Compare commits

..
529 changed files with 76304 additions and 17542 deletions
+38
View File
@@ -0,0 +1,38 @@
# Local real cc-connect validation template.
# Save real values in .env.cc-connect.local. That file is gitignored.
# Required for real OAuth, packaged OAuth, and Feishu E2E paths.
# Point at the auth.json that may be copied into an isolated managed CODEX_HOME.
# Use ~/.codex/auth.json only when that import is intentional.
CLAWX_REAL_CODEX_AUTH_JSON=
# Required for OpenAI API-key provider/model chat validation.
# The verifier maps this to child-process OPENAI_API_KEY without writing the value to reports.
CLAWX_REAL_OPENAI_API_KEY=
# Optional: override the model used by the real OpenAI API-key smoke.
# Leave empty to use the test default.
CLAWX_REAL_OPENAI_MODEL=
# Optional: set OPENAI_API_KEY directly instead when external tools need the standard name.
# OPENAI_API_KEY=
# Required for Feishu/Lark live channel lifecycle validation.
CLAWX_REAL_FEISHU_APP_ID=
CLAWX_REAL_FEISHU_APP_SECRET=
# A real user/open_id accepted by the bot. The lifecycle test verifies that
# cc-connect preserves this admin together with ClawX's local bridge admin.
CLAWX_REAL_FEISHU_ADMIN_FROM=
# Optional Feishu/Lark settings.
# Values for CLAWX_REAL_FEISHU_DOMAIN: feishu, lark, cn, global, or a full API base URL.
CLAWX_REAL_FEISHU_DOMAIN=feishu
CLAWX_REAL_FEISHU_ACCOUNT_ID=real_feishu_bot
CLAWX_REAL_FEISHU_ALLOW_FROM=
# Optional manual Feishu/Lark inbound delivery smoke.
# Set this only when a sandbox tenant chat can send the marker to the configured bot
# while the E2E test is waiting.
CLAWX_REAL_FEISHU_INBOUND_E2E=
CLAWX_REAL_FEISHU_INBOUND_MARKER=
CLAWX_REAL_FEISHU_INBOUND_TIMEOUT_MS=180000
+1 -1
View File
@@ -118,7 +118,7 @@ jobs:
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
run: pnpm exec vitest run tests/unit/attachment-open-with.test.ts tests/unit/attachment-open-with-native.test.ts
- name: Generate extension bridge
run: pnpm run ext:bridge
+1 -2
View File
@@ -13,7 +13,7 @@ jobs:
electron-e2e:
name: Electron E2E (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 20
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
@@ -23,7 +23,6 @@ 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'
+140 -8
View File
@@ -10,7 +10,7 @@ on:
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., 1.0.0)'
description: 'Version label for an unsigned smoke build (e.g., 1.0.0-beta.smoke)'
required: true
permissions:
@@ -31,6 +31,7 @@ jobs:
release:
needs: validate-release
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
@@ -114,21 +115,43 @@ jobs:
if: matrix.platform == 'mac'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CSC_IDENTITY_AUTO_DISCOVERY: ${{ github.event_name == 'workflow_dispatch' && 'false' || 'true' }}
CSC_LINK: ${{ github.event_name == 'push' && secrets.MAC_CERTS || '' }}
CSC_KEY_PASSWORD: ${{ github.event_name == 'push' && secrets.MAC_CERTS_PASSWORD || '' }}
APPLE_ID: ${{ github.event_name == 'push' && secrets.APPLE_ID || '' }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ github.event_name == 'push' && secrets.APPLE_APP_SPECIFIC_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ github.event_name == 'push' && secrets.APPLE_TEAM_ID || '' }}
run: |
ulimit -n 65536
echo "File descriptor limit: $(ulimit -n)"
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
unset CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID
fi
pnpm run package:mac
- name: Verify macOS packaged runtime resources
if: matrix.platform == 'mac'
run: |
pnpm run verify:packaged-runtime-resources -- --resources=release/mac/ClawX.app/Contents/Resources --platform=darwin --arch=x64
pnpm run verify:packaged-runtime-resources -- --resources=release/mac-arm64/ClawX.app/Contents/Resources --platform=darwin --arch=arm64
- name: Smoke native macOS packaged cc-connect runtime
if: matrix.platform == 'mac'
run: pnpm run smoke:cc-connect:packaged -- --allow-unsigned=${{ github.event_name == 'workflow_dispatch' && '1' || '0' }}
# Windows specific steps
- name: Build Windows
if: matrix.platform == 'win'
run: pnpm run package:win
- name: Verify Windows packaged runtime resources
if: matrix.platform == 'win'
run: pnpm run verify:packaged-runtime-resources -- --resources=release/win-unpacked/resources --platform=win32 --arch=x64
- name: Smoke native Windows packaged cc-connect runtime
if: matrix.platform == 'win'
run: pnpm run smoke:cc-connect:packaged
# Detect release channel from tag to skip code signing for alpha/beta builds
- name: Detect Windows release channel
if: matrix.platform == 'win'
@@ -276,6 +299,23 @@ jobs:
if: matrix.platform == 'linux'
run: pnpm run package:linux
- name: Verify Linux packaged runtime resources
if: matrix.platform == 'linux'
run: |
pnpm run verify:packaged-runtime-resources -- --resources=release/linux-unpacked/resources --platform=linux --arch=x64
pnpm run verify:packaged-runtime-resources -- --resources=release/linux-arm64-unpacked/resources --platform=linux --arch=arm64
- name: Smoke native Linux x64 packaged cc-connect runtime
if: matrix.platform == 'linux'
run: xvfb-run -a pnpm run smoke:cc-connect:packaged
- name: Upload native runtime smoke evidence
uses: actions/upload-artifact@v4
with:
name: runtime-smoke-${{ matrix.platform }}-native
path: artifacts/cc-connect/packaged-smoke-*.json
retention-days: 7
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
@@ -292,12 +332,103 @@ jobs:
!release/builder-debug.yml
retention-days: 7
runtime-smoke-macos-x64:
needs: validate-release
runs-on: macos-15-intel
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'pnpm'
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install
- name: Build native macOS x64 unpacked app
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
SKIP_PREINSTALLED_SKILLS: '1'
run: |
pnpm run package
node scripts/run-electron-builder.mjs --mac dir --x64 --publish never
- name: Verify and smoke native macOS x64 runtime
run: |
pnpm run verify:packaged-runtime-resources -- --resources=release/mac/ClawX.app/Contents/Resources --platform=darwin --arch=x64
pnpm run smoke:cc-connect:packaged -- --allow-unsigned=1
- name: Upload macOS x64 runtime smoke evidence
uses: actions/upload-artifact@v4
with:
name: runtime-smoke-macos-x64
path: artifacts/cc-connect/packaged-smoke-darwin-x64.json
retention-days: 7
runtime-smoke-linux-arm64:
needs: validate-release
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'pnpm'
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies and X virtual framebuffer
run: |
pnpm install
sudo apt-get update
sudo apt-get install -y xvfb
- name: Build native Linux arm64 unpacked app
env:
SKIP_PREINSTALLED_SKILLS: '1'
run: |
pnpm run package
node scripts/run-electron-builder.mjs --linux dir --arm64 --publish never
- name: Verify and smoke native Linux arm64 runtime
run: |
pnpm run verify:packaged-runtime-resources -- --resources=release/linux-arm64-unpacked/resources --platform=linux --arch=arm64
xvfb-run -a pnpm run smoke:cc-connect:packaged
- name: Upload Linux arm64 runtime smoke evidence
uses: actions/upload-artifact@v4
with:
name: runtime-smoke-linux-arm64
path: artifacts/cc-connect/packaged-smoke-linux-arm64.json
retention-days: 7
# ──────────────────────────────────────────────────────────────
# Job: Publish to GitHub Releases
# ──────────────────────────────────────────────────────────────
publish:
needs: release
needs: [release, runtime-smoke-macos-x64, runtime-smoke-linux-arm64]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download release artifacts only
@@ -389,8 +520,9 @@ jobs:
# releases/vX.Y.Z/ → permanent archive, never deleted
# ──────────────────────────────────────────────────────────────
upload-oss:
needs: release
needs: [release, runtime-smoke-macos-x64, runtime-smoke-linux-arm64]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download release artifacts only
-4
View File
@@ -21,16 +21,12 @@ 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.
+402 -97
View File
@@ -10,7 +10,8 @@
</p>
<p align="center">
<a href="#clawxを選ぶ理由">ClawXを選ぶ理由</a> •
<a href="#機能">機能</a> •
<a href="#なぜclawxなのか">なぜClawXなのか</a> •
<a href="#はじめに">はじめに</a> •
<a href="#アーキテクチャ">アーキテクチャ</a> •
<a href="#開発">開発</a> •
@@ -36,78 +37,150 @@
## 概要
**ClawX**は、強力なAIエージェントと日常のユーザーとの間のギャップを埋めます。[OpenClaw](https://github.com/OpenClaw)をベースに構築されており、コマンドラインによるAIオーケストレーションを、使いやすく美しいデスクトップ体験に変換します。ターミナルは必要ありません
**ClawX**は、強力なAIエージェントと日常のユーザーとの間のギャップを埋めます。[OpenClaw](https://github.com/OpenClaw)をベースに構築されており、コマンドラインによるAIオーケストレーションを、アクセスしやすく美しいデスクトップ体験に変換します。ターミナルは不要です
ワークフローの自動化、AI搭載チャネルの管理、インテリジェントなタスクのスケジューリングなど、ClawXはAIエージェントを効果的に活用するために必要なインターフェースを提供します。
ClawXはベストプラクティスに基づくモデルプロバイダーがあらかじめ設定されており、Windows多言語設定をネイティブにサポートしています。高度な設定は **設定 → 詳細設定 → 開発者モード** から調整できます。
ClawXはベストプラクティスモデルプロバイダーが事前設定されており、Windowsおよび多言語設定をネイティブにサポートしています。もちろん、**設定 → 詳細設定 → 開発者モード**から高度な設定を微調整することもできます。
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">完全なエンタープライズ版、専用サービスサポート、またはビジネスシナリオに合わせた導入支援が必要な場合は、<a href="mailto:public@valuecell.ai">public@valuecell.ai</a> までお問い合わせください。</strong></p>
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">完全なエンタープライズ版、専用サービスサポート、または御社のビジネスシナリオに合わせた導入支援が必要な場合は、<a href="mailto:public@valuecell.ai">public@valuecell.ai</a> までお問い合わせください。</strong></p>
---
## スクリーンショット
<table>
<tr>
<td align="center"><img src="resources/screenshot/jp/chat.png" alt="Chat"><br><em>チャット</em></td>
<td align="center"><img src="resources/screenshot/jp/cron.png" alt="Cron"><br><em>スケジュールタスク</em></td>
</tr>
<tr>
<td align="center"><img src="resources/screenshot/jp/skills.png" alt="Skills"><br><em>スキル</em></td>
<td align="center"><img src="resources/screenshot/jp/channels.png" alt="Channels"><br><em>チャネル</em></td>
</tr>
<tr>
<td align="center"><img src="resources/screenshot/jp/models.png" alt="Models"><br><em>モデル</em></td>
<td align="center"><img src="resources/screenshot/jp/settings.png" alt="Settings"><br><em>設定</em></td>
</tr>
</table>
<p align="center">
<img src="resources/screenshot/jp/chat.png" style="width: 100%; height: auto;">
</p>
## ClawXを選ぶ理由
<p align="center">
<img src="resources/screenshot/jp/cron.png" style="width: 100%; height: auto;">
</p>
AIエージェントの構築にコマンドラインの習得は不要であるべきです。ClawXはシンプルな哲学のもとに設計されました:**強力な技術には、あなたの時間を尊重するインターフェースがふさわしい。** ClawXは公式の **OpenClaw** コアを直接ベースに構築されています。別途インストールする必要はなく、ランタイムをアプリケーション内に組み込むことで、シームレスな「すべて込み」の体験を提供します。上流のOpenClawと緊密に連携し、公式の最新機能、安定性の改善、エコシステムとの互換性を利用できるようにしています。
<p align="center">
<img src="resources/screenshot/jp/skills.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/jp/channels.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/jp/models.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/jp/settings.png" style="width: 100%; height: auto;">
</p>
---
## なぜClawXなのか
AIエージェントの構築にコマンドラインの習得は不要であるべきです。ClawXはシンプルな哲学のもとに設計されました:**強力な技術には、あなたの時間を尊重するインターフェースがふさわしい。**
| 課題 | ClawXのソリューション |
|------|----------------------|
| 複雑なCLIセットアップ | ガイド付きセットアップウィザードによるワンクリックインストール |
| 設定ファイル | リアルタイム検証付きのビジュアル設定 |
| プロセス管理 | Gatewayライフサイクルの自動管理 |
| アプリ更新 | 起動時に更新を確認し、ダウンロードまたはインストール前に通知 |
| 複雑なCLIセットアップ | ワンクリックインストールとガイド付きセットアップウィザード |
| 設定ファイル | リアルタイムバリデーション付きのビジュアル設定 |
| プロセス管理 | ゲートウェイライフサイクルの自動管理 |
| アプリ更新 | 起動時に更新を確認し、ダウンロードインストール前に通知 |
| 複数のAIプロバイダー | 統合プロバイダー設定パネル |
| スキル/プラグインのインストール | オプションの拡張機能マーケットプレイスにも対応したローカル優先のスキル管理 |
| スキル/プラグインのインストール | 組み込みのスキルマーケットプレイスと管理機能 |
### 機能
### OpenClaw内蔵
- **🎯 ゼロ設定バリア**:直感的なグラフィカルインターフェースでセットアップを完了できます。ターミナルコマンド、YAMLファイル、環境変数の探索は不要です。
- **💬 インテリジェントチャットインターフェース**:複数セッションのコンテキストと履歴、シンタックスハイライト付きストリーミングMarkdown、CJK対応解析、テーブル、KaTeX数式、`@agent` による直接ルーティング、インライン `/skill` カード、ワークスペース優先のセッション、Markdown・`.docx``.pptx`・ローカルHTMLの読み取り専用プレビューに対応します。
- **📡 マルチチャネル管理**:複数アカウント、アカウント単位のAgent紐付け、既定アカウントの切り替え、Tencent公式個人WeChatチャネルプラグインを備えた独立したAIチャネルを設定・監視できます。
- **⏰ Cronベースの自動化**:繰り返しまたは1回限りのスケジュールを定義し、スケジュール済みプロンプトにスキルを挿入し、結果を外部チャネルへ配信できます。
- **🧩 拡張可能なスキルシステム**:Gatewayに依存せずスキルをローカルで管理できます。複数のOpenClawソースからスキルを検出し、`pdf``xlsx``docx``pptx` の文書処理スキルも利用できます。
- **🔐 セキュアなプロバイダー統合**OpenAI、Anthropic、Z.AI / GLMなどに接続し、認証情報をOSのネイティブキーチェーンに安全に保存できます。OAuth、カスタムプロバイダー、画像生成エンドポイント、互換性フォールバックにも対応します。
- **🌙 アダプティブテーマ**:ライト、ダーク、システム同期テーマを選択できます。
- **🚀 自動起動設定**:**設定 → 一般** で **システム起動時に自動起動** を有効にできます。
- **🔔 更新通知**:起動時に新しいバージョンを確認し、ダウンロードまたはインストールするかを選択できます。
ClawXは公式の**OpenClaw**コアを直接ベースに構築されています。別途インストールを必要とせず、アプリケーション内にランタイムを組み込むことで、シームレスな「バッテリー同梱」体験を提供します。
> 機能の詳細は [docs/ja-JP/features.md](docs/ja-JP/features.md) を参照してください
私たちはアップストリームのOpenClawプロジェクトとの厳密な整合性を維持することにコミットしており、公式リリースが提供する最新の機能、安定性の改善、エコシステムの互換性に常にアクセスできることを保証します
### 主なユースケース
開発者モードを有効にし、OpenClaw が active runtime の場合、サイドバーにはネイティブの Dreams ページも表示され、ClawX 内で OpenClaw の記憶レビュー、夢日記、基本メンテナンス操作を扱えます。詳細な診断が必要な場合は、そのページから完全版の OpenClaw Dreams UI も開けます。
- **🤖 パーソナルAIアシスタント**:質問への回答、メールの下書き、ドキュメントの要約、日常タスクの支援を行う汎用AIエージェントを、クリーンなデスクトップインターフェースから設定できます。
- **📊 自動モニタリング**:ニュースフィード、価格、特定のイベントを監視するスケジュールエージェントを設定し、結果を希望する通知チャネルへ届けられます。
- **💻 開発者の生産性向上**:AIを開発ワークフローに統合し、コードレビュー、ドキュメント生成、繰り返しのコーディング作業を行えます。
- **🔄 ワークフロー自動化**:複数のスキルをビジュアルな自動化パイプラインに組み合わせ、データ処理、コンテンツ変換、アクションの実行を行えます。
ClawX には runtime 抽象レイヤーもあります。OpenClaw は既定 runtime とロールバック経路のままで、**設定 → Gateway → Runtime** から任意の同梱 `cc-connect` runtime に切り替えられます。パッケージ版は cc-connect バイナリと OpenAI Codex ネイティブ CLI bundle の両方を app resources に含め、runtime 起動はグローバルインストール、PATH 上のバイナリ、起動時ダウンロードに依存しません。ClawX はアップグレード後も共有できる app config、credential、runtime data、skills、workspace を `~/.clawx`(または `CLAWX_DATA_HOME`)に保持し、`~/.cc-connect` を自動変更しません。GUI chat は cc-connect BridgePlatform 経由で Codex project agent に接続し、管理 project は cc-connect の Codex app-server stdio backend を使うため、リアルタイムの tool progress を共通 Chat execution graph へ直接反映できます。cc-connect の公開 history に channel session の tool packet がない場合、ClawX は所有する Agent の workspace に限定して一致するローカル Codex transcript から history を補完します。承認ボタンと cc-connect card の選択肢は実行グラフに表示され、応答はすべて cc-connect の公開 `card_action` プロトコルを通じて返されます。Runtime が生成した画像、ファイル、音声、動画の packet も BridgePlatform 経由で返り、Chat の添付として表示され続けます。各 Agent は既定でフルオートを使用し、Agent のモデル/runtime 設定で「承認を求める」(`suggest`)を個別に選択できます。新しい agent は `~/.clawx/workspaces/agents/<id>` を使い、既存の OpenClaw workspace は移動や所有権変更なしで元のパスを再利用できます。provider/model、native cron、enabled skills は管理された cc-connect/Codex runtime に同期されます。
Agent と channel の設定は `~/.clawx` を canonical source とします。cc-connect が active の間は保存しても `~/.openclaw/openclaw.json` を書き換えず、OpenClaw に戻すと Gateway 起動前に互換 projection を再生成します。
cc-connect mode では、Codex provider sync は OpenAI API key、OpenAI OAuth/Codex、Ollama、および Responses API を公開する OpenAI-compatible Custom provider をサポートします。Custom provider の header は環境変数参照として管理 config に書き込まれるため、secret や session header は永続化されません。Chat Completions として設定された Custom provider は、この経路が Codex の Responses wire API を使うため、chat 配信前に unsupported として報告されます。
OAuth provider account ごとに独立した管理 `CODEX_HOME` を持ちます。runtime 起動時にユーザーのグローバル Codex login を自動採用することはなく、選択した account に対する明示的な Codex OAuth import が必要です。
cc-connect はメッセージング platform bridge も担当します。cc-connect が active runtime の場合、channel status probe は OpenClaw Gateway に固定せず runtime abstraction 経由でルーティングされ、設定済み channel account はバインド先 agent を所有する cc-connect project にミラーされます。channel の保存や削除では cc-connect Management API で管理 config を reload し、可能な場合は完全な runtime restart なしで platform 変更を反映します。Developer Mode のサイドバーのページショートカットは cc-connect Web Admin を開き、OpenClaw Dreams ショートカットは OpenClaw runtime 専用のままです。
---
## 機能
### 🎯 ゼロ設定バリア
インストールから最初のAIインタラクションまで、すべてのセットアップを直感的なグラフィカルインターフェースで完了できます。ターミナルコマンド不要、YAMLファイル不要、環境変数の探索も不要です。
### 💬 インテリジェントチャットインターフェース
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリング(GitHub 風テーブルや KaTeX による LaTeX 数式 `$インライン$``$$ブロック$$``\(インライン\)``\[ブロック\]` を含む)に加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
コンポーザーから挿入した Skill は `/skill-name` 形式のチップとして表示され、チップをクリックすると右側のプレビュー側欄でその Skill の `SKILL.md` を開けます。
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
セッション側欄はワークスペース優先で整理され、既定ワークスペースを先頭に固定し、その他のワークスペースは自然順に並べます。各ワークスペースは折りたたみや追加読み込みができます。AI の返信中は行にスピナーが表示され、未確認の返信が完了すると青い点に変わり、会話を開くと相対アクティビティ時刻に戻ります。ホバーすると引き続き操作ボタンが表示されます。インポートしたワークスペースは側欄の見出しから名前を変更でき、新しい名前はチャット入力欄の下にも反映されます。見出しにホバーすると引き続きファイルシステムのパスを確認できます。選択中の会話に有効なワークスペースがある場合、新しいチャットはそれを引き継ぎ、最初の送信までは変更できます。編集可能な新規または未バインドのチャットでは、コンポーザーのワークスペースチップから最近使用したワークスペースと既存セッションのワークスペースの一覧を開き、既定ワークスペースへ戻すか別フォルダーを選べます。保存済みのワークスペースフォルダーが移動または削除されている場合、Chat はセッション作成を一時停止し、無効なパスを繰り返し再試行せずに既存のフォルダーを選ぶよう案内します。利用できない既定以外のグループには側欄で印が付き、確認後に削除できます。この操作ではグループ内の全セッションが完全に削除されます。OpenClaw が生成する UUID と日付のフォールバックタイトルは、そのセッション ID と一致する場合に限って欠落タイトルとして扱い、セッション名として保存せず、会話の最初のユーザーメッセージに置き換えて表示します。
各 Agent は `provider/model` の実行時設定を個別に上書きできます。上書きしていない Agent は引き続きグローバルの既定モデルを継承します。
Chat の右パネルにあるワークスペースとプレビューの各タブでは、`.docx``.pptx` ファイルを読み取り専用でプレビューできます。従来形式の `.doc``.ppt` はアプリ内ではプレビューせず、引き続き OS 経由で開きます。DOCX のページ区切りは Microsoft Word と異なる場合があり、PPTX プレビューではアニメーション、画面切り替え、メディア再生をサポートしません。20 MB を超える Office ファイルはアプリ内でプレビューされません。
### シングルページ Web ブラウザ
Chat の右パネルには、ワークスペース、プレビュー、変更、ウェブブラウザの 4 タブがあります。ウェブブラウザは初回利用時に 1 つのライブページを遅延作成し、パネルを閉じる、別のパネルタブを選ぶ、チャットセッションを切り替える、または ClawX の別ルートへ移動しても、ページを非表示にするだけで実行を継続します。そのため、非表示中もスクリプト、ネットワーク通信、音声、リソース消費が続く場合があります。専用の永続セッションはアプリ再起動後も Cookie とサイトストレージを保持しますが、起動ごとに `about:blank` から始まり、以前の URL、ページ状態、ナビゲーション履歴は復元しません。ページが favicon を提供する場合はタイトルの左側に表示され、favicon がない間は同じサイズのプレースホルダーでタイトル位置を維持します。アドレス編集中はアイコン領域全体が非表示になります。追加のブラウザタブやウィンドウ、ブックマーク、履歴の永続化、パスワードマネージャー、自動入力管理はありません。
トップレベルナビゲーションでは HTTP、HTTPS、および明示的に入力した標準 `file:///` URL を利用できます。通常のファイルシステムパスとその他のプロトコルは拒否されます。ローカルファイルを開くと、通常の Chromium セキュリティ規則の範囲で、読み取り可能な内容が埋め込みページに公開されます。また、`file:` URL に **システムブラウザで開く**を使うと、ブラウザではなく OS の関連付け済みアプリが起動する場合があります。許可されたポップアップ先は子ウィンドウを作らず現在のページを置き換えます。この同一ページへのフォールバックでは、`window.opener`、返されたウィンドウハンドル、空白ページを後から書き換えるスクリプト型ポップアップ、POST 本文や referrer、名前付きウィンドウ、ウィンドウ機能の完全な動作を維持できません。
ダウンロードには Electron と OS の既定動作がそのまま使われます。プラットフォームによってはネイティブの保存ダイアログが表示され、ユーザー操作が必要です。ClawX はカスタム保存先を指定せず、ダウンロードの進捗、履歴、管理 UI も提供しません。カメラとマイクはリクエストごとにネイティブの許可/拒否ダイアログを表示し、選択を記憶しません。クリップボードアクセスは許可され、位置情報、画面キャプチャ、通知、その他の権限は拒否されます。
**Cookie を消去**はブラウザセッション内の全オリジンの Cookie のみを削除し、キャッシュとサイトストレージを保持します。**サイトデータを消去**は全オリジンの HTTP/Chromium キャッシュ、Cache Storage、Local Storage、IndexedDB、Service Worker を削除し、Cookie とダウンロード済みファイルを保持します。ブラウザ通信は Electron/Chromium のシステムプロキシ解決に従います。ClawX クライアントのプロキシ設定はこのブラウザセッションへ同期されず、設定を変更しても再構成されません。
### 📡 マルチチャネル管理
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。
現在は各チャンネルで複数アカウントを扱え、Channels ページでアカウントの Agent 紐付けやデフォルトアカウント切替を直接管理できます。
カスタムのチャンネルアカウント ID には、ルーティング不一致を防ぐため OpenClaw 互換の正規形式(`[a-z0-9_-]`、英小文字、最大 64 文字、先頭は英小文字または数字)を必須にしています。
ClawX には Tencent 公式の個人 WeChat チャンネルプラグインも同梱されており、Channels ページからアプリ内 QR フローで直接 WeChat を連携できます。
### ⏰ Cronベースの自動化
AIタスクを自動的に実行するようスケジュール設定できます。トリガーを定義し、間隔を設定することで、手動介入なしにAIエージェントを24時間稼働させることができます。
定期タスク画面では外部配信を「送信アカウント」と「受信先ターゲット」の 2 段階セレクターで設定できるようになりました。対応チャネルでは、受信先候補をチャネルのディレクトリ機能や既知セッション履歴から自動検出するため、`jobs.json` を手で編集する必要はありません。タスクのメッセージ入力欄でも、メインのチャット入力と同じインライン `/skill` トークン記法でスキルを挿入できるようになりました(選択中のエージェントに応じて読み込み)。スケジュールされたプロンプトから直接スキルを起動できます。スケジュール選択は**繰り返し**と**1回のみ**のタブに分かれました。繰り返しは毎時・毎日・平日・毎週・カスタム(生の cron)の頻度を時刻/曜日コントロール付きで選べ、1回のみは選択した日付(曜日を表示)と時刻に一度だけ実行します。1回のみのタスクは未来の時刻を指定する必要があり、実行後はランタイムにより自動的に削除されます。
runtime が **今すぐ実行** を非同期で受け付ける場合、ClawX はトリガー確認をブロックせず、Cron カードに最新の完了結果が表示されるか、制限された停止条件に達するまで runtime 管理のジョブをバックグラウンド更新します。
### 🧩 拡張可能なスキルシステム
事前構築されたスキルでAIエージェントを拡張できます。統合 Skills ページはローカル優先で、管理ディレクトリや workspace のスキルをスキャンし、Gateway に依存せず有効/無効を切り替えられます。エンタープライズ拡張がある場合は、その拡張が提供する marketplace も表示できます。
ClawX はドキュメント処理スキル(`pdf``xlsx``docx``pptx`)もフル内容で同梱し、起動時に管理スキルディレクトリ(既定 `~/.openclaw/skills`)へ自動配備し、初回インストール時に既定で有効化します。
Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、workspace、追加スキルディレクトリ)から検出されたスキルを表示でき、各スキルの実際のパスを確認して実フォルダを直接開けます。OpenClaw 同梱の bundled skill については、コミュニティ版ではパッケージにも表示にも `skill-creator` のみを残し、dev 起動時と packaged 起動時の両方で他の bundled skill を物理的に削除します。さらに、削除済み bundled skill の古い `openclaw.json` エントリも一緒に掃除します。
cc-connect runtime が有効な場合、有効化されたローカル skills は app userData 配下の管理 Codex home にミラーされ、同梱 Codex agent がグローバル skill ディレクトリを読まずに同じ skill セットを使えます。
### 🔐 セキュアなプロバイダー統合
複数のAIプロバイダー(OpenAI、Anthropic、Z.AI / GLMなど)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuth(Codex サブスクリプション)の両方に対応しています。
開発者モードでは、専用の Image Generation ページで、独立した OpenAI 互換の画像生成エンドポイント(Base URL、API キー、`gpt-image-2` などのモデル名)を設定でき、画像生成だけ専用の `/v1/images/generations` サービスを使い、チャットは通常の OpenAI Provider のまま継続できます。
OpenAI-compatible ゲートウェイを **Custom プロバイダー** で使う場合、**設定 → AI Providers → Provider 編集** でカスタム `User-Agent` を設定でき、互換性が必要なエンドポイントで有効です。
プロバイダーの編集や切り替え時、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` プローブへ自動フォールバックします。
### 🌙 アダプティブテーマ
ライトモード、ダークモード、またはシステム同期テーマ。ClawXはあなたの好みに自動的に適応します。
### 🚀 自動起動設定
**設定 → 通用** から **システム起動時に自動起動** を有効化すると、ログイン後に ClawX が自動的に起動します。
### 🔔 更新通知
ClawX は起動時に新しいバージョンを自動確認できます。更新が見つかるとアプリ内通知を表示し、ダウンロードやインストールはユーザーが選択した後にのみ実行されます。
---
## はじめに
### システム要件
- **オペレーティングシステム**macOS 11以上、Windows 10以上、またはLinuxUbuntu 20.04以上)
- **メモリ**最低4GB RAM8GB推奨)
- **ストレージ**1GBの空きディスク容量
- **オペレーティングシステム**: macOS 11以上、Windows 10以上、またはLinuxUbuntu 20.04以上)
- **メモリ**: 最低4GB RAM8GB推奨)
- **ストレージ**: 1GBの空きディスク容量
### インストール
#### ビルド済みリリース(推奨)
[Releases](https://github.com/ValueCell-ai/ClawX/releases) ページから、お使いのプラットフォーム向けの最新リリースをダウンロードしてください。
[Releases](https://github.com/ValueCell-ai/ClawX/releases)ページから、お使いのプラットフォーム向けの最新リリースをダウンロードしてください。
#### ソースからビルド
@@ -116,124 +189,356 @@ AIエージェントの構築にコマンドラインの習得は不要である
git clone https://github.com/ValueCell-ai/ClawX.git
cd ClawX
# プロジェクト初期化
# プロジェクト初期化
pnpm run init
# 開発モードで起動
pnpm dev
```
### 初回起動
ClawXを初めて起動すると、**セットアップウィザード**がの手順を案内します
ClawXを初めて起動すると、**セットアップウィザード**が以下の手順をガイドします
1. **言語と地域**使用するロケールを設定
2. **AIプロバイダー**ブラウザまたはデバイスログイン対応したプロバイダーでは、APIキーまたはOAuthで追加
3. **スキルバンドル**一般的なユースケース向けの事前設定スキルを選択
4. **検証**メインインターフェースに入る前に設定をテスト
1. **言語と地域** 使用する言語・地域の設定
2. **AIプロバイダー** APIキーまたは OAuthブラウザ/デバイスログイン対応プロバイダーで追加
3. **スキルバンドル** 一般的なユースケース向けの事前設定スキルを選択
4. **検証** メインインターフェースに入る前に設定をテスト
サポートされている場合、ウィザードはシステム言語を初期選択し、対応していない場合は英語にフォールバックします。
> Web検索について:ClawXはAgentとGatewayの両方のポリシーレイヤーで、OpenClawの汎用 `web_search` ツールを無効にします。Moonshot(Kimi)検索も対象です。管理対象のブラウザ自動化と `web_fetch` は引き続き利用できます。
サポート対象のシステム言語がある場合、ウィザードはその言語を初期選択し、対応場合は英語にフォールバックします。
### プロキシ設定
ClawXには、Electron、OpenClaw Gateway、Telegramなどのチャネルがローカルプロキシクライアント経由でインターネットにアクセスする必要がある環境向け、組み込みプロキシ設定があります。
ClawXには、Electron、OpenClaw Gateway、任意の cc-connect/Codex runtime、またはTelegramなどのチャネルがローカルプロキシクライアントを介してインターネットにアクセスする必要がある環境向け、組み込みプロキシ設定が含まれています。
**設定 → Gateway → プロキシ**を開き、既定のプロキシ、バイパスルール、開発者モードでのHTTP・HTTPS・`ALL_PROXY` / SOCKSの上書きを設定します。ローカル設定の例は `http://127.0.0.1:7890` です。
**設定 → ゲートウェイ → プロキシ**を開いて以下を設定します:
> プロキシのフォールバック動作、Telegramとの同期、**OpenClaw Doctor**については [docs/ja-JP/proxy-settings.md](docs/ja-JP/proxy-settings.md) を参照してください。
- **プロキシサーバー**: すべてのリクエストのデフォルトプロキシ
- **バイパスルール**: 直接接続すべきホスト(セミコロン、カンマ、または改行で区切る)
- **開発者モード**では、オプションで以下をオーバーライドできます:
- **HTTP プロキシ**
- **HTTPS プロキシ**
- **ALL_PROXY / SOCKS**
推奨されるローカル設定例:
```text
プロキシサーバー: http://127.0.0.1:7890
```
注意事項:
- `host:port`のみの値はHTTPとして扱われます。
- 高度なプロキシフィールドが空の場合、ClawXは`プロキシサーバー`にフォールバックします。
- プロキシ設定を保存すると、Electronのネットワーク設定が即座に再適用され、ゲートウェイが自動的に再起動されます。
- cc-connect runtime モードでは、Codex 子プロセスが同じ `HTTP_PROXY``HTTPS_PROXY``ALL_PROXY`、バイパス環境値を継承します。
- ClawXはTelegramが有効な場合、プロキシをOpenClawのTelegramチャネル設定にも同期します。
- ClawXのプロキシが無効な状態では、Gatewayの通常再起動時に既存のTelegramチャネルプロキシ設定を保持します。
- OpenClaw設定のTelegramプロキシを明示的に消したい場合は、プロキシ無効の状態で一度「保存」を実行してください。
- **設定 → 詳細 → 開発者** の Runtime Doctor は、OpenClaw では `openclaw doctor --json` を実行します。cc-connect では同梱の `cc-connect doctor user-isolation``codex doctor --json` を組み合わせ、モード 0600 の監査レポートを ClawX 管理の runtime ディレクトリへ保存します。Doctor Fix は OpenClaw 専用です。
- Windows のパッケージ版では、同梱された `openclaw` CLI/TUI は端末入力を安定させるため、同梱の `node.exe` エントリーポイント経由で実行されます。
---
## アーキテクチャ
ClawXは **Host API統一レイヤーを備えたデュアルプロセスアーキテクチャ**を採用しています。React Rendererは単一クライアント抽象を呼び出し、Electron Mainがプロトコル選択、Gatewayのライフサイクル、ACP Chatのstdio bridgeを管理します
ClawXは**デュアルプロセス + Host API 統一アクセス**構成を採用しています。Renderer は単一クライアント抽象を呼び出し、プロトコル選択とライフサイクルは Main が管理します
- **プロセスモデル**Electron Mainがウィンドウ、Gateway監視、システム統合、更新を管理します。OpenClaw GatewayはAIオーケストレーション、チャネル、スキル機能を提供し、Rendererはローカルエンドポイントへ直接アクセスしません
- **設定の配信**Gateway実行中は `config.get` / `config.set` を使い、停止中または起動中は解決済みJSON5設定を更新します。通常のプロバイダー、Agent、スキル、モデル変更ではプロセスを置き換えず、認証情報は `secrets.reload` でホットリロードされます。ハートビートが10回連続で失敗した場合は、ライフサイクルで保護された復旧を要求します。
- **ACP Chat**Chatは [ACPAgent Client Protocol](https://agentclientprotocol.com) をMainが所有するstdio bridge経由で使用し、設定リロード後の認証済み履歴リプレイ、ページ移動中のストリーミング、Mainが検証したメディア・添付ファイル・ファイルアクティビティに対応します。
- **設計原則**:フロントエンドの単一入口、Mainによるトランスポート管理、再接続・タイムアウト・バックオフによるグレースフルリカバリ、安全なストレージ、CORSセーフな境界を採用しています。
Chat transport は active runtime に応じて切り替わりますが、Renderer の境界は 1 つに保たれます。OpenClaw Chat は Electron Main が所有する ACP stdio bridge を使用し、Renderer は型付き host event を受け取ってメモリ上の ACP timeline を描画します。cc-connect Chat は `RuntimeManager` から cc-connect BridgePlatform 経由で dispatch され、session history、progress、approval、generated media も同じ経路を通ります。両モードで Renderer は同じ Host API facade を使い、Codex を直接呼び出しません。非 Chat 機能も runtime provider 経由で dispatch され、OpenClaw 固有操作は OpenClaw adapter 内に限定されます
> プロセス図、設定の調整、ACPファイルアクティビティのセマンティクス、Gatewayのトラブルシューティングについては [docs/ja-JP/architecture.md](docs/ja-JP/architecture.md) を参照してください
別の会話やページを開いても、未完了の ACP 応答はストリーミングを継続します。完了前に戻ると最新のメモリ内 timeline が復元され、ライブ応答の表示が続きます。完了後は通常の ACP 履歴リプレイが引き続き唯一の正となります
ACP の assistant ターンにはターン全体の所要時間が表示されます。ライブ計時はクライアントが観測した prompt ライフサイクルに従い、アプリ内を移動しても継続します。履歴の所要時間は Electron Main が範囲を限定した OpenClaw transcript のタイムスタンプから算出し、ACP リプレイですでに復元されたターンだけに付与します。
ACP Chat は標準 ACP resource を添付ファイルとして表示します。ユーザーが選択した画像は、ホバー時のオーバーレイにファイル名を表示するサムネイルとして描画され、その他の利用可能な添付カードはファイル名に続いて、淡色で省略可能なソースパスを表示します。現在の OpenClaw ACP adapter が assistant のメディアを省略した場合も、明示的な assistant の `MEDIA:` ディレクティブを、元のディレクティブを表示せずに添付カードとして復元できます。現在の workspace 外を含む既存のローカルファイル参照は、プレビューまたはオープンのたびに Electron Main で正確な session と generation に対して再検証されます。AI が生成したプレビュー可能なローカル添付ファイル(20 MB 以下の `.docx``.pptx` を含む)は、読み取り専用のアプリ内プレビューを主要操作として維持し、対応アプリで開く操作と Finder、エクスプローラー、またはシステムのファイルマネージャーで表示する操作を副次メニューから利用できます。ローカル HTML 添付ファイルでは、そのメニューの先頭項目がファイル URL を右側のウェブブラウザで開きます。ここでも Office プレビューには同じ制限があります。`.doc``.ppt` はシステムアプリで開く形式のままで、DOCX のページ区切りは Microsoft Word と異なる場合があり、PPTX のアニメーション、画面切り替え、メディア再生はサポートされません。対応アプリの検出は macOS と Windows のみで利用でき、Linux または検出失敗時には通知せず、ファイルの場所を表示する操作だけに切り替わります。それ以外のローカルファイル(20 MB を超える Office ファイルを含む)はユーザーのクリック後にシステムアプリで開かれます。リモートの 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 ファイルでは、メニューの先頭項目がローカルファイル URL を右側の**ウェブブラウザ**で開き、そのタブを有効にします。削除された行には **Changes** 操作だけを残します。アプリ一覧、選択アプリで開く操作、ファイル位置の表示は、workspace ルートと相対パスから Electron Main が毎回個別に再検証します。ツール由来のパスが添付ファイルに変換されたり、Renderer に正規化済みのネイティブパスが渡されたりすることはありません。
- `write` はツールが宣言したとおり、作成および全行追加の差分として表示されます。対象パスがすでに存在する可能性がある場合も同様です。
- **Changes** は、ツールが宣言したアクティビティを時系列に並べたセッション単位の記録です。Git の出力でも、検証済みソースベースラインに対する差分でもありません。
- 各ファイルについて、Changes はアシスタントの各ターンに最大 1 つの diff エディターを表示します。安全に連結できるフラグメントは合成し、独立したフラグメントは 1 つのエディターに連結しますが、完全なファイルベースラインとの差分であるとはみなしません。
- シェルコマンド、スクリプト、ユーザー、IDE による副作用は検出されません。
- 完全な ACP リプレイからは記録済みのファイルアクティビティを復元できます。リプレイが不完全な場合、ClawX はフォールバック推論で欠落したアクティビティを補いません。
```
┌────────────────────────────────────────────────────────────────────┐
│ ClawX デスクトップアプリ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Electron メインプロセス │ │
│ │ • ウィンドウ&アプリケーションライフサイクル管理 │ │
│ │ • ゲートウェイプロセスの監視 │ │
│ │ • システム統合(トレイ、通知、キーチェーン) │ │
│ │ • 自動アップデートオーケストレーション │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ IPC(権威ある制御プレーン) │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ React レンダラープロセス │ │
│ │ • モダンなコンポーネントベースUI(React 19) │ │
│ │ • Zustandによるステート管理 │ │
│ │ • 統一 host-api/api-client 呼び出し │ │
│ │ • リッチなMarkdownレンダリング │ │
│ └──────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬─────────────────────────────────────┘
│ 型付き IPC リクエスト
┌─────────────────────────────────────────────────────────────────┐
│ Main Host Services と Runtime Manager │
│ │
│ • host:invoke 型付きサービスディスパッチ │
│ • 設定、ファイル、セッション、スキル、プロバイダー、診断サービス │
│ • Runtime 選択、transport、プロセス監視を所有 │
└──────────────────────────────┬──────────────────────────────────┘
│ Main 所有 WebSocket
┌─────────────────────────────────────────────────────────────────┐
│ OpenClaw Gateway 経路(図示) │
│ │
│ • AIエージェントランタイムとオーケストレーション │
│ • メッセージチャネル管理 │
│ • スキル/プラグイン実行環境 │
│ • プロバイダー抽象化レイヤー │
└─────────────────────────────────────────────────────────────────┘
```
### 設計原則
- **プロセス分離**: AIランタイムは別プロセスで動作し、重い計算処理中でもUIの応答性を確保します
- **フロントエンド呼び出しの単一入口**: Renderer は host-api/api-client を通じて呼び出し、下位プロトコルに依存しません
- **Mainによるトランスポート制御**: OpenClaw ACP/Gateway transport と cc-connect BridgePlatform dispatch は Electron Main が所有し、Renderer は型付き IPC で Main と通信します
- **拡張 IPC コントリビューション**: Main プロセス拡張は HTTP route ではなく、型付き IPC レジストリを通じて host-api action を提供します
- **グレースフルリカバリ**: 再接続・タイムアウト・バックオフで一時的障害を自動処理します
- **セキュアストレージ**: APIキーや機密データは、OSのネイティブセキュアストレージ機構を活用します
- **CORSセーフ設計**: Renderer はローカル Gateway や Host API HTTP エンドポイントを直接呼び出しません
### プロセスモデルと Gateway トラブルシューティング
- ClawX は Electron アプリのため、**1つのアプリインスタンスでも複数プロセス(main/renderer/zygote/utility)が表示される**のが正常です。
- 単一起動保護は Electron のロックに加え、`~/.clawx/locks` 配下のインストール横断 writer lock も使用します。ClawX は共有データ初期化、移行、runtime、scheduler の起動前にこのロックを取得し、所有権を確認できない場合は起動を拒否します。
- ローリングアップグレード中に旧版/新版が混在すると、単一起動保護の挙動が非対称になる場合があります。安定運用のため、デスクトップクライアントは可能な限り同一バージョンへ揃えてください。
- ただし OpenClaw Gateway の待受は常に**単一**であるべきです。`127.0.0.1:18789` を Listen しているプロセスは1つだけです。
- Gateway の readiness は `system-presence``health``status` などの OpenClaw コア信号を基準にし、memory、Dreams、チャネルの失敗はグローバルな Gateway 障害ではなく capability degradation として表示します。
- Listen プロセスの確認例:
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
- Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen`
- ウィンドウの閉じるボタン(`X`)は既定でトレイへ最小化する動作で、完全終了ではありません。完全終了する場合はトレイメニューの **Quit ClawX** を使用してください。
---
## ユースケース
### 🤖 パーソナルAIアシスタント
質問への回答、メールの下書き、ドキュメントの要約、日常タスクのサポートなど、汎用的なAIエージェントを設定できます。すべてクリーンなデスクトップインターフェースから操作できます。
### 📊 自動モニタリング
ニュースフィード、価格追跡、特定イベントの監視などを行うスケジュールエージェントを設定できます。結果はお好みの通知チャネルに配信されます。
### 💻 開発者の生産性向上
AI を開発ワークフローに統合できます。エージェントを使用して、コードレビュー、ドキュメント生成、反復的なコーディングタスクの自動化が可能です。
### 🔄 ワークフロー自動化
複数のスキルを連鎖させて、高度な自動化パイプラインを作成できます。データの処理、コンテンツの変換、アクションのトリガーを、すべてビジュアルにオーケストレーションできます。
---
## 開発
### 前提条件
- **Node.js**対応するメジャー系列の22.22.3以上、24.15.0以上、または25.9.0以上(Node 24 LTS推奨)
- **パッケージマネージャー**pnpm 9以上(npmも対応)
- **LinuxUbuntu/Debian**Electronの実行前に必要なシステムライブラリをインストールしてください。詳細は [docs/ja-JP/development.md](docs/ja-JP/development.md) を参照してください。
- **Node.js**: 対応するメジャー系列の 22.22.3以上、24.15.0以上、または25.9.0以上(Node 24 LTS推奨)
- **パッケージマネージャー**: pnpm 9以上(推奨)またはnpm
- **LinuxUbuntu/Debian**: Electron を実行する前に必要なシステムライブラリをインストールしてください:
```bash
sudo apt-get install -y libnss3 libgtk-3-0 libxss1 libxtst6 libatspi2.0-0 libnotify4 xdg-utils
```
Ubuntu 24.04以降では、一部のパッケージに `t64` サフィックスが付いています。上記コマンドを実行すると `apt` が自動的に適切なバリアントを選択します。
### よく使うコマンド
### プロジェクト構成
```ClawX/
├── electron/ # Electron メインプロセス
│ ├── services/ # 型付き Host API、Provider/Secrets/ランタイムサービス
│ │ ├── providers/ # provider/account モデル同期ロジック
│ │ └── secrets/ # OS キーチェーンと秘密情報管理
│ ├── shared/ # 共通 Provider スキーマ/定数
│ │ └── providers/
│ ├── main/ # アプリ入口、ウィンドウ、IPC 登録
│ ├── gateway/ # OpenClaw ゲートウェイプロセスマネージャー
│ ├── preload/ # セキュア IPC ブリッジ
│ └── utils/ # ユーティリティ(ストレージ、認証、パス)
├── src/ # React レンダラープロセス
│ ├── lib/ # フロントエンド統一 API とエラーモデル
│ ├── stores/ # Zustand ストア(settings/chat/gateway
│ ├── components/ # 再利用可能な UI コンポーネント
│ ├── pages/ # Setup/Dashboard/Chat/Channels/Skills/Cron/Settings
│ ├── i18n/ # ローカライズリソース
│ └── types/ # TypeScript 型定義
├── tests/
│ ├── e2e/ # Playwright による Electron E2E スモークテスト
│ └── unit/ # Vitest ユニット/統合寄りテスト
├── resources/ # 静的アセット(アイコン、画像)
└── scripts/ # ビルド/ユーティリティスクリプト
```
### 利用可能なコマンド
cc-connect の実環境検証はローカル env ファイルを読み込めますが、リポジトリ内の認証情報ファイルは gitignore されている必要があります。リポジトリ外の `--env-file` パスは利用でき、レポートには書き込まれません。`.env.cc-connect.local.example` は `.env.cc-connect.local` のフィールドテンプレートです。
```bash
pnpm run init # 依存関係をインストールし、バンドルランタイムをダウンロード
pnpm dev # ホットリロード付きで開発モードを起動
pnpm lint # ESLintを実行
pnpm typecheck # TypeScriptを検証
pnpm test # ユニットテストを実行
pnpm run test:e2e # Electron E2Eスモークテストを実行
pnpm build # 本番ビルドを実行
pnpm package # 現在のプラットフォーム向けにパッケージ化(:mac / :win / :linux
# 開発
pnpm run init # 依存関係のインストール + バンドルバイナリ(uv、agent-browser)のダウンロード
pnpm dev # ホットリロードで起動(不足時は同梱スキルを自動準備)
# コード品質
pnpm lint # ESLintを実行
pnpm typecheck # TypeScriptの型チェック
# テスト
pnpm test # ユニットテストを実行
pnpm run test:e2e # Electron E2E スモークテストを実行
pnpm run test:e2e:cc-connect:codex-oauth-lifecycle # 実認証情報なしで cc-connect Codex OAuth Host API の status/import/logout を検証
CLAWX_REAL_OAUTH_E2E=1 CLAWX_REAL_CODEX_AUTH_JSON="$HOME/.codex/auth.json" pnpm run test:e2e:cc-connect:real-oauth # 実 OAuth tool execution と Chat execution graph を検証
pnpm run test:e2e:headed # 表示付きウィンドウで Electron E2E を実行
pnpm run comms:replay # 通信リプレイ指標を算出
pnpm run comms:baseline # 通信ベースラインを更新
pnpm run comms:compare # リプレイ指標をベースライン閾値と比較
pnpm run verify:cc-connect:local-real # ローカル cc-connect 実環境検証の事前レポートを書き出す
pnpm run verify:cc-connect:local-real:run # 安全なローカル cc-connect 実環境検証を実行してレポートを書き出す
pnpm run verify:cc-connect:local-real:oauth # CLAWX_REAL_CODEX_AUTH_JSON に完全な refresh token フィールドがある場合、開発版 cc-connect の実 OAuth 総合スモークも実行
pnpm run verify:cc-connect:local-real:oauth-all # CLAWX_REAL_CODEX_AUTH_JSON に完全な refresh token フィールドがある場合、開発版とパッケージ版 cc-connect の実 OAuth スモークも実行
pnpm run verify:cc-connect:local-real:api-key # ローカル OpenAI-compatible API-key chat/abort スモークを実行し、認証情報がある場合は実 OpenAI API-key スモークも実行
pnpm run verify:cc-connect:local-real:feishu # 認証情報と CLAWX_REAL_CODEX_AUTH_JSON がある場合に実 Feishu/Lark ライフサイクルスモークも実行
pnpm run verify:cc-connect:local-real:feishu-inbound # サンドボックス tenant fixture が有効な場合に実 Feishu/Lark inbound marker スモークも実行
pnpm run verify:cc-connect:local-real:scheduled-cron # 実 native exec cron を実行し、Codex auth がある場合は public cc-connect session history で native prompt scheduling も検証
pnpm run verify:cc-connect:local-real:all # 利用可能なローカル cc-connect 実環境検証をすべて実行し、外部 gate handoff を書き出す
pnpm run verify:cc-connect:local-real:all-strict # リリース候補検証では全実認証情報と runtime parity coverage の PASS を必須にし、失敗前にも handoff を書き出す
pnpm run verify:cc-connect:local-real:replacement-ready # replacement readiness を必須にし、不足認証情報を別の事前失敗にはしない。失敗前にも handoff を書き出す
pnpm run verify:cc-connect:local-real:replacement-ready:check # 同じ readiness gate を実行し、前回のレポート成果物は上書きしない
pnpm run verify:cc-connect:local-real:packaged-oauth # CLAWX_REAL_CODEX_AUTH_JSON に完全な refresh token フィールドがある場合、パッケージ版 cc-connect の実 OAuth スモークも実行
pnpm run verify:cc-connect:local-real:external-gates:check # 残りの required external gates を非破壊で確認し、レポート成果物は上書きしない
pnpm run verify:cc-connect:local-real:external-gates # 残りの required external gates のみを実行し、3件すべて PASS の場合だけ成功
pnpm run verify:cc-connect:local-real:handoff # 残りの外部 gate 向けに認証情報を含まない handoff checklist を生成
# レポートは artifacts/cc-connect/local-real-validation-report.{json,md} に出力されます。
# :all、:all-strict、:replacement-ready、:external-gates、または :handoff は artifacts/cc-connect/local-real-external-gates.{md,json} に外部 gate handoff を出力します。
# JSON handoff は machine-readable で、sanitize 済みの status、env var 名、command、安全メモのみを含みます。
# runtimeMatrixStatus は pass/partial/fail の coverage と hard gate の終了状態を分けて表示します。
# --no-write、replacement-ready:check、または external-gates:check は非破壊の gate check に使えます。不足 precondition と次の command は秘密値なしで表示されます。
# validationGaps はローカル hard gate の不足と full parity に必要な follow-up evidence gap を分けて記録します。
# partial レポートには秘密値を含まない後続コマンドの Next Actions が含まれます。
# 実認証情報は、未追跡かつ gitignore 済みの .env.cc-connect.local、--env-file=<path>、
# または CLAWX_REAL_ENV_FILE / CLAWX_REAL_ENV_FILES で渡せます。明示的な process env が優先されます。
# API-key スモークでは、デフォルトモデルが利用できない場合に CLAWX_REAL_OPENAI_MODEL を設定できます。
# ビルド&パッケージ
pnpm run build:vite # フロントエンドのみビルド
pnpm build # フルプロダクションビルド(パッケージアセット含む)
pnpm package # 現在のプラットフォーム向けにパッケージ化(同梱プリインストールスキルを含む)
pnpm package:mac # macOS向けにパッケージ化
pnpm package:win # Windows向けにパッケージ化
pnpm package:linux # Linux向けにパッケージ化
pnpm run verify:runtime-bundles # ダウンロード済み cc-connect/Codex bundle の manifest とバイナリを検証
pnpm run verify:packaged-runtime-resources -- --resources=<path> --platform=<darwin|win32|linux> --arch=<x64|arm64> # 最終 Electron runtime resources を検証
pnpm run smoke:cc-connect:packaged # ネイティブ unpacked app を起動し、cc-connect の起動/状態/Cron/Doctor/ロールバック/クリーンアップを検証
```
> プロジェクト構成、完全なコマンド一覧、E2Eの並列実行ポリシー、パフォーマンス診断、通信回帰チェック、技術スタックについては [docs/ja-JP/development.md](docs/ja-JP/development.md) を参照してください。
ヘッドレス Linux では Electron テストに表示サーバーが必要です。`xvfb-run -a pnpm run test:e2e` を利用してください。
### 通信回帰チェック
PR が通信経路(Gateway イベント、ACP Chat bridge の送受信フロー、Channel 配信、トランスポートのフォールバック)に触れる場合は、次を実行してください。
```bash
pnpm run comms:replay
pnpm run comms:compare
```
CI の `comms-regression` が必須シナリオと閾値を検証します。
### 技術スタック
| レイヤー | 技術 |
|---------|------|
| ランタイム | Electron 40以上 |
| UIフレームワーク | React 19 + TypeScript |
| スタイリング | Tailwind CSS + shadcn/ui |
| ステート管理 | Zustand |
| ビルド | Vite + electron-builder |
| テスト | Vitest + Playwright |
| アニメーション | Framer Motion |
| アイコン | Lucide React |
---
## コントリビューション
コミュニティからの貢献を歓迎しますバグ修正、新機能、ドキュメントの改善、翻訳など、あらゆる貢献がClawXをより良くます。
コミュニティからのコントリビューションを歓迎しますバグ修正、新機能、ドキュメントの改善、翻訳など、あらゆる貢献がClawXをより良くするのに役立ちます。
### 貢献方法
### コントリビューション方法
1. リポジトリを**フォーク**する
2. フィーチャーブランチを**作成**する(`git checkout -b feature/amazing-feature`
3. 明確なメッセージで変更を**コミット**する
4. ブランチに**プッシュ**する
5. **Pull Request**を作成する
5. **プルリクエスト**を作成する
### ガイドライン
- 既存のコードスタイル(ESLint + Prettierに従う
- 既存のコードスタイルに従うESLint + Prettier
- 新機能にはテストを書く
- 必要に応じてドキュメントを更新する
- コミットはアトミックかつ説明的に保つ
---
## 謝辞
ClawXは次の優れたオープンソースプロジェクトの上に構築されています
ClawXは優れたオープンソースプロジェクトの上に構築されています
- [OpenClaw](https://github.com/OpenClaw) - AIエージェントランタイム
- [Electron](https://www.electronjs.org/) - クロスプラットフォームデスクトップフレームワーク
- [React](https://react.dev/) - UIコンポーネントライブラリ
- [shadcn/ui](https://ui.shadcn.com/) - 美しく設計されたコンポーネント
- [Zustand](https://github.com/pmndrs/zustand) - 軽量な状態管理
- [OpenClaw](https://github.com/OpenClaw) AIエージェントランタイム
- [Electron](https://www.electronjs.org/) クロスプラットフォームデスクトップフレームワーク
- [React](https://react.dev/) UIコンポーネントライブラリ
- [shadcn/ui](https://ui.shadcn.com/) 美しくデザインされたコンポーネント
- [Zustand](https://github.com/pmndrs/zustand) 軽量ステート管理
---
## コミュニティ
コミュニティに参加して、他のユーザーと交流し、サポートを受け、体験を共有しましょう。
コミュニティに参加して、他のユーザーとつながり、サポートを受け、体験を共有しましょう。
| 企業WeChat | Feishuグループ | Discord |
| 企業微信 | Feishuグループ | Discord |
| :---: | :---: | :---: |
| <img src="src/assets/community/wecom-qr.png" width="150" alt="WeChat QRコード" /> | <img src="src/assets/community/feishu-qr.png" width="150" alt="Feishu QRコード" /> | <img src="src/assets/community/20260212-185822.png" width="150" alt="Discord QRコード" /> |
### ClawXパートナープログラム
### ClawX パートナープログラム 🚀
ClawXをより多くのお客様、特にカスタムAIエージェントや自動化ニーズを持つお客様に紹介してくださるパートナーを募集しています。
ClawX パートナープログラムを開始します。特にカスタム AI エージェントや自動化ニーズを持つより多くの顧客に ClawX を紹介してくださるパートナーを募集しています。
パートナー見込みユーザーやプロジェクトとの接点づくりを担、ClawXチームは技術サポート、カスタマイズ、統合を全面的に提供します。AIツールや自動化に関心のあるお客様と仕事をされている方は、ぜひご一緒ください。
パートナーの皆さまには、見込みユーザーや案件との接点づくりを担っていただき、ClawX チームは技術サポート、カスタマイズ、統合を全面的に提供します。
詳細はDM、または [public@valuecell.ai](mailto:public@valuecell.ai) までお問い合わせください
AI ツールや自動化に関心のある顧客とお仕事をされている方は、ぜひご一緒できればうれしいです
## Star History
詳細は DM いただくか、[public@valuecell.ai](mailto:public@valuecell.ai) までメールでご連絡ください。
---
## スター履歴
<p align="center">
<img src="https://api.star-history.com/svg?repos=ValueCell-ai/ClawX&type=Date" alt="Star History Chart" />
<img src="https://api.star-history.com/svg?repos=ValueCell-ai/ClawX&type=Date" alt="スター履歴チャート" />
</p>
---
## ライセンス
ClawXは [MITライセンス](LICENSE) のもとで公開されています。本ソフトウェアは自由に使用、変更、配布できます。
ClawXは[MITライセンス](LICENSE)の下でリリースされています。本ソフトウェア使用、変更、配布は自由に行えます。
<hr>
---
<p align="center">
<sub>ValueCell Teamが❤️を込めて開発</sub>
+403 -73
View File
@@ -10,6 +10,7 @@
</p>
<p align="center">
<a href="#features">Features</a> •
<a href="#why-clawx">Why ClawX</a> •
<a href="#getting-started">Getting Started</a> •
<a href="#architecture">Architecture</a> •
@@ -36,64 +37,136 @@
## Overview
**ClawX** bridges the gap between powerful AI agents and everyday users. Built on top of [OpenClaw](https://github.com/OpenClaw), it transforms command-line AI orchestration into an accessible, beautiful desktop experience - no terminal required.
**ClawX** bridges the gap between powerful AI agents and everyday users. Built on top of [OpenClaw](https://github.com/OpenClaw), it transforms command-line AI orchestration into an accessible, beautiful desktop experienceno terminal required.
Whether you're automating workflows, managing AI-powered channels, or scheduling intelligent tasks, ClawX provides the interface you need to harness AI agents effectively.
ClawX comes pre-configured with best-practice model providers and natively supports Windows as well as multi-language settings. You can also fine-tune advanced configurations via **Settings -> Advanced -> Developer Mode**.
ClawX comes pre-configured with best-practice model providers and natively supports Windows as well as multi-language settings. Of course, you can also fine-tune advanced configurations via **Settings Advanced Developer Mode**.
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">For a full enterprise edition, dedicated service support, or tailored deployment guidance for your business scenario, contact us at <a href="mailto:public@valuecell.ai">public@valuecell.ai</a>.</strong></p>
## Screenshots
---
## Screenshot
<table>
<tr>
<td align="center"><img src="resources/screenshot/en/chat.png" alt="Chat"><br><em>Chat</em></td>
<td align="center"><img src="resources/screenshot/en/cron.png" alt="Cron"><br><em>Scheduled tasks</em></td>
</tr>
<tr>
<td align="center"><img src="resources/screenshot/en/skills.png" alt="Skills"><br><em>Skills</em></td>
<td align="center"><img src="resources/screenshot/en/channels.png" alt="Channels"><br><em>Channels</em></td>
</tr>
<tr>
<td align="center"><img src="resources/screenshot/en/models.png" alt="Models"><br><em>Models</em></td>
<td align="center"><img src="resources/screenshot/en/settings.png" alt="Settings"><br><em>Settings</em></td>
</tr>
</table>
<p align="center">
<img src="resources/screenshot/en/chat.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/en/cron.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/en/skills.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/en/channels.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/en/models.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/en/settings.png" style="width: 100%; height: auto;">
</p>
---
## Why ClawX
Building AI agents shouldn't require mastering the command line. ClawX was designed with a simple philosophy: **powerful technology deserves an interface that respects your time.** ClawX is built directly upon the official **OpenClaw** core. Instead of requiring a separate installation, we embed the runtime within the application for a seamless, battery-included experience. We stay closely aligned with upstream OpenClaw so you can benefit from the latest official capabilities, stability improvements, and ecosystem compatibility.
Building AI agents shouldn't require mastering the command line. ClawX was designed with a simple philosophy: **powerful technology deserves an interface that respects your time.**
| Challenge | ClawX Solution |
|-----------|----------------|
| Complex CLI setup | One-click installation with a guided setup wizard |
| Complex CLI setup | One-click installation with guided setup wizard |
| Configuration files | Visual settings with real-time validation |
| Process management | Automatic Gateway lifecycle management |
| Process management | Automatic gateway lifecycle management |
| App updates | Startup update checks with a prompt before downloading or installing |
| Multiple AI providers | Unified provider configuration panel |
| Skill/plugin installation | Local-first skill management with an optional extension-provided marketplace |
| Skill/plugin installation | Local-first skill management with optional extension-provided marketplace |
### Features
### OpenClaw Inside
- **🎯 Zero Configuration Barrier**: Complete setup through an intuitive graphical interface - no terminal commands, YAML files, or environment-variable hunting.
- **💬 Intelligent Chat Interface**: Multi-session context and history, streaming Markdown with syntax highlighting, CJK-aware parsing, tables, KaTeX math, direct `@agent` routing, inline `/skill` cards, workspace-first sessions, and read-only previews for Markdown, `.docx`, `.pptx`, and local HTML.
- **📡 Multi-Channel Management**: Configure and monitor independent AI channels with multiple accounts, per-account agent binding, default-account switching, and the bundled official Tencent personal WeChat channel plugin.
- **⏰ Cron-Based Automation**: Define recurring or one-time schedules, insert skills into scheduled prompts, and deliver results to external channels.
- **🧩 Extensible Skill System**: Manage skills locally without depending on the Gateway, discover skills from multiple OpenClaw sources, and use bundled document-processing skills for `pdf`, `xlsx`, `docx`, and `pptx`.
- **🔐 Secure Provider Integration**: Connect OpenAI, Anthropic, Z.AI / GLM, and other providers with credentials stored in the native system keychain; supports OAuth, custom providers, image-generation endpoints, and compatibility fallbacks.
- **🌙 Adaptive Theming**: Choose light mode, dark mode, or system-synchronized themes.
- **🚀 Startup Launch Control**: Enable **Launch at system startup** in **Settings -> General**.
- **🔔 Update Prompts**: Check for new versions at startup and choose whether to download or install them.
ClawX is built directly upon the official **OpenClaw** core. Instead of requiring a separate installation, we embed the runtime within the application to provide a seamless "battery-included" experience.
> For full feature details, see [docs/en-US/features.md](docs/en-US/features.md).
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.
### Typical Use Cases
When Developer Mode is enabled and OpenClaw is the active runtime, 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.
- **🤖 Personal AI Assistant**: Configure a general-purpose AI agent to answer questions, draft emails, summarize documents, and help with everyday tasks from a clean desktop interface.
- **📊 Automated Monitoring**: Schedule agents to monitor news feeds, track prices, or watch for specific events, with results delivered to your preferred notification channel.
- **💻 Developer Productivity**: Integrate AI into your development workflow for code review, documentation generation, and repetitive coding tasks.
- **🔄 Workflow Automation**: Chain multiple skills into visual automation pipelines that process data, transform content, and trigger actions.
ClawX also includes a runtime abstraction layer. OpenClaw remains the default runtime and rollback path, while **Settings → Gateway → Runtime** can switch to an optional bundled `cc-connect` runtime. Packaged builds include both the cc-connect binary and the native OpenAI Codex CLI bundle in app resources; runtime startup does not depend on global installs, PATH binaries, or app-time downloads. ClawX keeps upgrade-stable app config, credentials, runtime data, skills, and workspaces under `~/.clawx` (or `CLAWX_DATA_HOME`) instead of modifying `~/.cc-connect`. GUI chat connects through cc-connect BridgePlatform with Codex as the project agent; managed projects use cc-connect's Codex app-server backend over stdio so live tool progress can drive the shared Chat execution graph directly. When public cc-connect history omits tool packets for a channel-originated session, ClawX supplements that history from matching local Codex transcripts constrained to the owning Agent's workspace. Approval buttons and cc-connect card choices are rendered in that graph, and responses return through cc-connect's public `card_action` protocol. Runtime-generated image, file, audio, and video packets also return through BridgePlatform and remain visible as Chat attachments. Each Agent defaults to Full Auto and can independently select Ask for approval (`suggest`) in Agent model/runtime settings. New agents use `~/.clawx/workspaces/agents/<id>`; existing OpenClaw workspaces can be reused by reference without being moved or owned by ClawX. Provider/model selections, native cron tasks, and enabled skills are synchronized into the managed cc-connect/Codex runtime.
Agent and channel settings are canonical under `~/.clawx`. While cc-connect is active, saving them does not rewrite `~/.openclaw/openclaw.json`; switching back to OpenClaw rebuilds that compatibility projection before the Gateway starts.
In cc-connect mode, Codex provider sync supports OpenAI API key, OpenAI OAuth/Codex, Ollama, and Custom OpenAI-compatible providers that expose the Responses API. Custom provider headers are written as environment-variable references so secrets and session headers are not persisted in managed config files. Custom providers configured for Chat Completions are reported as unsupported before chat delivery because Codex accepts the Responses wire API for this path.
Each OAuth provider account has an isolated managed `CODEX_HOME`. An existing user-global Codex login is never adopted during runtime startup; importing it requires the explicit Codex OAuth import action for the selected account.
cc-connect also owns messaging platform bridges. When cc-connect is the active runtime, channel status probes are routed through the runtime abstraction instead of the OpenClaw Gateway, configured channel accounts are mirrored into the cc-connect project that owns their bound agent, and channel saves/deletes reload the managed cc-connect config through its Management API so platform changes take effect without a full runtime restart when possible. The Developer Mode sidebar page shortcut opens cc-connect Web Admin, while the OpenClaw Dreams shortcut remains OpenClaw-only.
---
## Features
### 🎯 Zero Configuration Barrier
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.
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, 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. 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 `.docx` and `.pptx` files. 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.
### Single-Page Web Browser
The Chat right panel has four tabs: Workspace, Preview, Changes, and Web Browser. Web Browser lazily creates one live page and keeps it running when you close the panel, select another panel tab, switch chat sessions, or visit another ClawX route; hidden pages may continue scripts, network activity, audio, and resource use. Its dedicated persistent session retains cookies and site storage across app restarts, but every new app run starts at `about:blank` without restoring the previous URL, page state, or navigation history. When a page provides a favicon, it appears beside the title; a same-size placeholder keeps the title aligned while no favicon is available, and the icon slot is hidden while editing the address. There are no additional browser tabs or windows, bookmarks, persisted history, password manager, or autofill management.
Top-level navigation accepts HTTP, HTTPS, and explicitly entered standard `file:///` URLs. Plain filesystem paths and other protocols are rejected. Opening a local file exposes its readable content to the embedded page under normal Chromium security rules, and using **Open in System Browser** for a `file:` URL may launch the OS-associated application instead of a browser. Allowed popup targets replace the current page rather than creating a child window; this same-page fallback cannot preserve `window.opener`, returned window handles, initially blank scripted popups, or full POST-body, referrer, named-window, and window-feature behavior.
Downloads keep Electron and the operating system defaults. Depending on the platform, this may present a native Save dialog and require user interaction; ClawX does not choose a custom path or provide download progress, history, or management UI. Camera and microphone access uses a native Allow/Deny prompt for every request and is never remembered. Clipboard access is allowed, while geolocation, display capture, notifications, and other permissions are denied.
**Clear Cookies** removes cookies for every origin in the browser session while preserving cache and site storage. **Clear Site Data** removes HTTP/Chromium cache, Cache Storage, Local Storage, IndexedDB, and Service Workers for every origin while preserving cookies and downloaded files. Browser traffic follows Electron/Chromium system-proxy resolution; ClawX client proxy settings are not synchronized to this browser session, and changing them does not reconfigure it.
### 📡 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.
For custom channel account IDs, ClawX enforces OpenClaw-compatible canonical IDs (`[a-z0-9_-]`, lowercase, max 64 chars, must start with a letter/number) to prevent routing mismatches.
ClawX now also bundles Tencent's official personal WeChat channel plugin, so you can link WeChat directly from the Channels page with an in-app QR flow.
### ⏰ Cron-Based Automation
Schedule AI tasks to run automatically. Define triggers, set intervals, and let your AI agents work around the clock without manual intervention.
The Cron page now lets you configure external delivery directly in the task form with separate sender-account and recipient-target selectors. For supported channels, recipient targets are discovered automatically from channel directories or known session history, so you no longer need to edit `jobs.json` by hand. The task message field also supports inserting skills with the same inline `/skill` token syntax as the main chat composer (scoped to the selected agent), so scheduled prompts can trigger skills directly. The schedule picker is split into **Recurring** and **Once** tabs: Recurring offers Hourly, Daily, Weekdays, Weekly, and Custom (raw cron) frequencies with inline time/weekday controls, while Once runs the task a single time at a chosen date (with weekday shown) and time. One-time tasks must be scheduled for a future moment and are automatically removed by the runtime once they finish.
When a runtime accepts **Run Now** asynchronously, ClawX keeps the trigger acknowledgement non-blocking and refreshes the runtime-owned job in the background until its latest completion result appears on the Cron card or a bounded stop condition is reached.
### 🧩 Extensible Skill System
Extend your AI agents with pre-built skills. The integrated Skills page is local-first: it scans managed/workspace skill directories, lets you enable or disable skills without depending on the Gateway, and can optionally expose an extension-provided marketplace in enterprise builds.
ClawX also pre-bundles full document-processing skills (`pdf`, `xlsx`, `docx`, `pptx`), deploys them automatically to the managed skills directory (default `~/.openclaw/skills`) on startup, and enables them by default on first install.
The Skills page can display skills discovered from multiple OpenClaw sources (managed dir, workspace, and extra skill dirs), and now shows each skill's actual location so you can open the real folder directly. For bundled OpenClaw skills, community builds now ship and expose only `skill-creator`; non-allowlisted bundled skills are physically trimmed in both dev and packaged startup, and any stale `openclaw.json` entries left behind for those removed bundled skills are pruned.
When cc-connect runtime is active, enabled local skills are mirrored into the managed Codex home under app user data so the bundled Codex agent can use the same skill set without reading global skill directories.
### 🔐 Secure Provider Integration
Connect to multiple AI providers (OpenAI, Anthropic, Z.AI / GLM, and more) with credentials stored securely in your system's native keychain. OpenAI supports both API key and browser OAuth (Codex subscription) sign-in.
In developer mode, the dedicated Image Generation page supports an independent OpenAI-compatible image-generation endpoint (Base URL, API key, and model name such as `gpt-image-2`) so image generation can use a dedicated `/v1/images/generations` service while chat continues using the normal OpenAI provider.
For **Custom** providers used with OpenAI-compatible gateways, you can set a custom `User-Agent` in **Settings → AI Providers → Edit Provider** for compatibility-sensitive endpoints.
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.
### 🌙 Adaptive Theming
Light mode, dark mode, or system-synchronized themes. ClawX adapts to your preferences automatically.
### 🚀 Startup Launch Control
In **Settings → General**, you can enable **Launch at system startup** so ClawX starts automatically after login.
### 🔔 Update Prompts
ClawX can automatically check for new versions on startup. When an update is available, it shows an in-app prompt; downloading and installing only happen after you choose the action.
---
## Getting Started
@@ -122,65 +195,312 @@ pnpm run init
# Start in development mode
pnpm dev
```
### First Launch
When you launch ClawX for the first time, the **Setup Wizard** will guide you through:
1. **Language & Region** - Configure your preferred locale
2. **AI Provider** - Add providers with API keys or OAuth for providers that support browser or device login
3. **Skill Bundles** - Select pre-configured skills for common use cases
4. **Verification** - Test your configuration before entering the main interface
1. **Language & Region** Configure your preferred locale
2. **AI Provider** Add providers with API keys or OAuth (for providers that support browser/device login)
3. **Skill Bundles** Select pre-configured skills for common use cases
4. **Verification** Test your configuration before entering the main interface
The wizard preselects your system language when it is supported, and falls back to English otherwise.
> Web search note: ClawX disables OpenClaw's general-purpose `web_search` tool at both the agent and Gateway policy layers. This includes Moonshot (Kimi) search; managed browser automation and `web_fetch` remain available.
> 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.
### Proxy Settings
ClawX includes built-in proxy settings for Electron, the OpenClaw Gateway, and channels such as Telegram that need to reach the internet through a local proxy client.
ClawX includes built-in proxy settings for environments where Electron, the OpenClaw Gateway, the optional cc-connect/Codex runtime, or channels such as Telegram need to reach the internet through a local proxy client.
Open **Settings -> Gateway -> Proxy** to configure the default proxy, bypass rules, and optional developer-mode overrides for HTTP, HTTPS, and `ALL_PROXY` / SOCKS. A local example is `http://127.0.0.1:7890`.
Open **Settings Gateway Proxy** and configure:
> For proxy fallback behavior, Telegram synchronization, and **OpenClaw Doctor**, see [docs/en-US/proxy-settings.md](docs/en-US/proxy-settings.md).
- **Proxy Server**: the default proxy for all requests
- **Bypass Rules**: hosts that should connect directly, separated by semicolons, commas, or new lines
- In **Developer Mode**, you can optionally override:
- **HTTP Proxy**
- **HTTPS Proxy**
- **ALL_PROXY / SOCKS**
Recommended local examples:
```text
Proxy Server: http://127.0.0.1:7890
```
Notes:
- A bare `host:port` value is treated as HTTP.
- If advanced proxy fields are left empty, ClawX falls back to `Proxy Server`.
- Saving proxy settings reapplies Electron networking immediately and restarts the Gateway automatically.
- In cc-connect runtime mode, Codex child processes inherit the same `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and bypass environment values.
- ClawX also syncs the proxy to OpenClaw's Telegram channel config when Telegram is enabled.
- Gateway restarts preserve an existing Telegram channel proxy if ClawX proxy is currently disabled.
- To explicitly clear Telegram channel proxy from OpenClaw config, save proxy settings with proxy disabled.
- In **Settings → Advanced → Developer**, Runtime Doctor runs `openclaw doctor --json` for OpenClaw. For cc-connect it combines bundled `cc-connect doctor user-isolation` with bundled `codex doctor --json` and stores a mode-0600 audit under the ClawX-managed runtime directory. Doctor Fix remains OpenClaw-only.
- On packaged Windows builds, the bundled `openclaw` CLI/TUI runs via the shipped `node.exe` entrypoint to keep terminal input behavior stable.
---
## Architecture
ClawX uses a **dual-process architecture with a unified Host API layer**: the React renderer calls one client abstraction, while Electron Main owns protocol selection, Gateway lifecycle, and the ACP Chat stdio bridge.
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:
- **Process model**: Electron Main owns the window, Gateway supervision, system integration, and updates; the OpenClaw Gateway provides AI orchestration, channel, and skill capabilities; the renderer does not access local endpoints directly.
- **Configuration delivery**: Main uses `config.get`/`config.set` while the Gateway is running and updates the resolved JSON5 config while it is stopped or starting; ordinary provider, agent, skill, and model changes do not replace the process, credentials are hot-reloaded through `secrets.reload`, and guarded recovery starts after ten consecutive heartbeat misses.
- **ACP Chat**: Chat uses [ACP (Agent Client Protocol)](https://agentclientprotocol.com) through a Main-owned stdio bridge, supporting authenticated history replay after config reloads, streaming across navigation, and Main-validated media, attachments, and file activity.
- **Design principles**: One frontend entry point, Main-owned transport, graceful recovery with reconnect/timeout/backoff, secure storage, and CORS-safe boundaries.
Chat transport follows the active runtime while preserving one renderer boundary. OpenClaw Chat uses an ACP stdio bridge owned by Electron Main; the renderer receives typed host events and renders an in-memory ACP timeline. cc-connect Chat is dispatched by `RuntimeManager` through cc-connect BridgePlatform, including session history, progress, approvals, and generated media. The renderer uses the same Host API facade in both modes and never invokes Codex directly. Non-Chat capabilities are also dispatched through runtime providers; OpenClaw-specific operations remain behind the OpenClaw adapter.
> For the process diagram, configuration coordination, ACP file activity semantics, and Gateway troubleshooting, see [docs/en-US/architecture.md](docs/en-US/architecture.md).
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 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, 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. 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 URL in the right-side Web Browser. 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; remote HTTP and HTTPS attachments open externally after a user click. Bare or inline prose paths 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 local file URL in the right-side Web Browser and activates that 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.
- Side effects made by shell commands, scripts, users, or IDEs are not detected.
- A full ACP replay can restore recorded file activity. If replay is incomplete, ClawX does not infer missing activity through fallback behavior.
```
┌──────────────────────────────────────────────────────────────────┐
│ ClawX Desktop App │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Electron Main Process │ │
│ │ • Window & application lifecycle management │ │
│ │ • Gateway process supervision │ │
│ │ • System integration (tray, notifications, keychain) │ │
│ │ • Auto-update orchestration │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ IPC (authoritative control plane) │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ React Renderer Process │ │
│ │ • Modern component-based UI (React 19) │ │
│ │ • State management with Zustand │ │
│ │ • Unified host-api/api-client calls │ │
│ │ • Rich Markdown rendering │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬───────────────────────────────────┘
│ Typed IPC requests
┌──────────────────────────────────────────────────────────────────┐
│ Main Host Services & Runtime Manager │
│ │
│ • host:invoke typed service dispatcher │
│ • Settings, files, sessions, skills, providers, diagnostics │
│ • Runtime selection, transport, and process supervision │
└──────────────────────────────┬───────────────────────────────────┘
│ Main-owned WebSocket
┌──────────────────────────────────────────────────────────────────┐
│ OpenClaw Gateway path (shown) │
│ │
│ • AI agent runtime and orchestration │
│ • Message channel management │
│ • Skill/plugin execution environment │
│ • Provider abstraction layer │
└──────────────────────────────────────────────────────────────────┘
```
### Design Principles
- **Process Isolation**: The AI runtime operates in a separate process, ensuring UI responsiveness even during heavy computation
- **Single Entry for Frontend Calls**: Renderer requests go through host-api/api-client; protocol details are hidden behind a stable interface
- **Main-Process Transport Ownership**: Electron Main owns OpenClaw ACP/Gateway transports and cc-connect BridgePlatform dispatch; the renderer talks to Main over typed IPC
- **Extension IPC Contributions**: Main-process extensions contribute host-api actions through the typed IPC registry instead of HTTP routes
- **Graceful Recovery**: Built-in reconnect, timeout, and backoff logic handles transient failures automatically
- **Secure Storage**: API keys and sensitive data leverage the operating system's native secure storage mechanisms
- **CORS-Safe by Design**: The renderer does not call local Gateway or Host API HTTP endpoints directly
### Process Model & Gateway Troubleshooting
- ClawX is an Electron app, so **one app instance normally appears as multiple OS processes** (main/renderer/zygote/utility). This is expected.
- Single-instance protection uses Electron's lock plus a cross-install writer lock under `~/.clawx/locks`. ClawX acquires that file lock before shared data initialization, migration, runtime, or scheduler startup and refuses to start if ownership cannot be established.
- 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.
- To verify the active listener:
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
- Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen`
- Clicking the window close button (`X`) hides ClawX to tray; it does **not** fully quit the app. Use tray menu **Quit ClawX** for complete shutdown.
---
## Use Cases
### 🤖 Personal AI Assistant
Configure a general-purpose AI agent that can answer questions, draft emails, summarize documents, and help with everyday tasks—all from a clean desktop interface.
### 📊 Automated Monitoring
Set up scheduled agents to monitor news feeds, track prices, or watch for specific events. Results are delivered to your preferred notification channel.
### 💻 Developer Productivity
Integrate AI into your development workflow. Use agents to review code, generate documentation, or automate repetitive coding tasks.
### 🔄 Workflow Automation
Chain multiple skills together to create sophisticated automation pipelines. Process data, transform content, and trigger actions—all orchestrated visually.
---
## Development
### Prerequisites
- **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+ (npm is also supported)
- **Linux (Ubuntu/Debian)**: Install required system libraries before running Electron; see [docs/en-US/development.md](docs/en-US/development.md)
- **Package Manager**: pnpm 9+ (recommended) or npm
- **Linux (Ubuntu/Debian)**: Install required system libraries before running Electron:
```bash
sudo apt-get install -y libnss3 libgtk-3-0 libxss1 libxtst6 libatspi2.0-0 libnotify4 xdg-utils
```
On Ubuntu 24.04+, some packages use a `t64` suffix; run the above command and `apt` will automatically select the correct variant.
### Common Commands
### Project Structure
```ClawX/
├── electron/ # Electron Main Process
│ ├── services/ # Typed host APIs, provider, secrets and runtime services
│ │ ├── providers/ # Provider/account model sync logic
│ │ └── secrets/ # OS keychain and secret storage
│ ├── shared/ # Shared provider schemas/constants
│ │ └── providers/
│ ├── main/ # App entry, windows, IPC registration
│ ├── gateway/ # OpenClaw Gateway process manager
│ ├── preload/ # Secure IPC bridge
│ └── utils/ # Utilities (storage, auth, paths)
├── src/ # React Renderer Process
│ ├── lib/ # Unified frontend API + error model
│ ├── stores/ # Zustand stores (settings/chat/gateway)
│ ├── components/ # Reusable UI components
│ ├── pages/ # Setup/Dashboard/Chat/Channels/Skills/Cron/Settings
│ ├── i18n/ # Localization resources
│ └── types/ # TypeScript type definitions
├── tests/
│ ├── e2e/ # Playwright Electron end-to-end smoke tests
│ └── unit/ # Vitest unit/integration-like tests
├── resources/ # Static assets (icons/images)
└── scripts/ # Build and utility scripts
```
### Available Commands
Real cc-connect verification can load local env files, but repo-local credential files must be gitignored; external `--env-file` paths are allowed without being written to reports. Use `.env.cc-connect.local.example` as the field template for `.env.cc-connect.local`.
```bash
pnpm run init # Install dependencies and download bundled runtimes
pnpm dev # Start in development mode with hot reload
pnpm lint # Run ESLint
pnpm typecheck # TypeScript validation
pnpm test # Run unit tests
pnpm run test:e2e # Run Electron E2E smoke tests
pnpm build # Full production build
pnpm package # Package for the current platform (:mac / :win / :linux)
# Development
pnpm run init # Install dependencies + download bundled binaries (uv, agent-browser)
pnpm dev # Start with hot reload (auto-prepares bundled skills if missing)
# Quality
pnpm lint # Run ESLint
pnpm typecheck # TypeScript validation
# Testing
pnpm test # Run unit tests
pnpm run test:e2e # Run Electron E2E smoke tests with Playwright
pnpm run test:e2e:cc-connect:codex-oauth-lifecycle # Verify cc-connect Codex OAuth Host API status/import/logout without real credentials
CLAWX_REAL_OAUTH_E2E=1 CLAWX_REAL_CODEX_AUTH_JSON="$HOME/.codex/auth.json" pnpm run test:e2e:cc-connect:real-oauth # Verify real OAuth tool execution and the Chat execution graph
pnpm run test:e2e:headed # Run Electron E2E tests with a visible window
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
pnpm run verify:cc-connect:local-real # Write a local cc-connect real-validation preflight report
pnpm run verify:cc-connect:local-real:run # Run safe local cc-connect real-validation checks and write the report
pnpm run verify:cc-connect:local-real:oauth # Also run dev cc-connect real OAuth comprehensive smoke when CLAWX_REAL_CODEX_AUTH_JSON has a complete refresh-token set
pnpm run verify:cc-connect:local-real:oauth-all # Also run dev and packaged cc-connect real OAuth smokes when CLAWX_REAL_CODEX_AUTH_JSON has a complete refresh-token set
pnpm run verify:cc-connect:local-real:api-key # Run local OpenAI-compatible API-key chat/abort smokes; also run real OpenAI API-key smoke when credentials are available
pnpm run verify:cc-connect:local-real:feishu # Also run real Feishu/Lark lifecycle smoke when credentials and CLAWX_REAL_CODEX_AUTH_JSON are available
pnpm run verify:cc-connect:local-real:feishu-inbound # Also run the manual real Feishu/Lark inbound marker smoke when the sandbox tenant fixture is enabled
pnpm run verify:cc-connect:local-real:scheduled-cron # Also run real native exec cron; with Codex auth, verify native prompt scheduling through public cc-connect session history
pnpm run verify:cc-connect:local-real:all # Run every available local real cc-connect validation path and write the external gate handoff
pnpm run verify:cc-connect:local-real:all-strict # Require all real credentials and runtime parity coverage for release-candidate validation; writes the handoff before failing
pnpm run verify:cc-connect:local-real:replacement-ready # Require replacement readiness without making missing credentials a separate preflight failure; writes the handoff before failing
pnpm run verify:cc-connect:local-real:replacement-ready:check # Same readiness gate without overwriting the last report artifacts
pnpm run verify:cc-connect:local-real:packaged-oauth # Also run packaged cc-connect real OAuth smoke when CLAWX_REAL_CODEX_AUTH_JSON has a complete refresh-token set
pnpm run verify:cc-connect:local-real:external-gates:check # Check remaining required external gates without overwriting report artifacts
pnpm run verify:cc-connect:local-real:external-gates # Run only the remaining required external gates and fail unless all three pass
pnpm run verify:cc-connect:local-real:handoff # Generate a credential-free handoff checklist for remaining external gates
# The report is written to artifacts/cc-connect/local-real-validation-report.{json,md};
# The external gate handoff is written to artifacts/cc-connect/local-real-external-gates.{md,json} by :all, :all-strict, :replacement-ready, :external-gates, or :handoff.
# The JSON handoff is machine-readable and contains only sanitized status, env-var names, commands, and safety notes.
# runtimeMatrixStatus shows pass/partial/fail coverage separately from hard-gate exit status.
# Use --no-write, replacement-ready:check, or external-gates:check for non-destructive gate checks; missing preconditions and next commands are printed without secret values.
# validationGaps records required local gate gaps separately from follow-up full-parity evidence gaps.
# partial reports include Next Actions with follow-up commands and no secret values.
# Real credentials can be supplied through untracked/gitignored .env.cc-connect.local,
# --env-file=<path>, or CLAWX_REAL_ENV_FILE / CLAWX_REAL_ENV_FILES; process env values win.
# API-key smoke can set CLAWX_REAL_OPENAI_MODEL when the default model is not available.
# Build & Package
pnpm run build:vite # Build frontend only
pnpm build # Full production build (with packaging assets)
pnpm package # Package for current platform (includes bundled preinstalled skills)
pnpm package:mac # Package for macOS
pnpm package:win # Package for Windows
pnpm package:linux # Package for Linux
pnpm run verify:runtime-bundles # Verify downloaded cc-connect/Codex bundle manifests and binaries
pnpm run verify:packaged-runtime-resources -- --resources=<path> --platform=<darwin|win32|linux> --arch=<x64|arm64> # Verify final Electron runtime resources
pnpm run smoke:cc-connect:packaged # Launch the native unpacked app and verify cc-connect start/status/Cron/Doctor/rollback/cleanup
```
> For the project structure, complete command list, E2E parallel policy, performance diagnostics, communication regression checks, and tech stack, see [docs/en-US/development.md](docs/en-US/development.md).
On headless Linux, run Electron tests under a display server such as `xvfb-run -a pnpm run test:e2e`.
### Communication Regression Checks
When a PR changes communication paths (gateway events, ACP Chat bridge send/receive flow, channel delivery, or transport fallback), run:
```bash
pnpm run comms:replay
pnpm run comms:compare
```
`comms-regression` in CI enforces required scenarios and threshold checks.
### Electron E2E Tests
The Playwright Electron suite launches the packaged renderer and main process
from `dist/` and `dist-electron/`, so it does not require manually running
`pnpm dev` first.
`pnpm run test:e2e` automatically:
- 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
- skips heavy startup side effects such as gateway auto-start, bundled skill
installation, tray creation, and CLI auto-install
The first two baseline specs cover:
- first-launch setup wizard visibility on a fresh profile
- 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`.
### Tech Stack
| Layer | Technology |
|-------|------------|
| Runtime | Electron 40+ |
| UI Framework | React 19 + TypeScript |
| Styling | Tailwind CSS + shadcn/ui |
| State | Zustand |
| Build | Vite + electron-builder |
| Testing | Vitest + Playwright |
| Animation | Framer Motion |
| Icons | Lucide React |
---
## Contributing
We welcome contributions from the community! Whether it's bug fixes, new features, documentation improvements, or translations, every contribution helps make ClawX better.
We welcome contributions from the community! Whether it's bug fixes, new features, documentation improvements, or translationsevery contribution helps make ClawX better.
### How to Contribute
@@ -197,15 +517,19 @@ We welcome contributions from the community! Whether it's bug fixes, new feature
- Update documentation as needed
- Keep commits atomic and descriptive
---
## Acknowledgments
ClawX is built on the shoulders of excellent open-source projects:
- [OpenClaw](https://github.com/OpenClaw) - The AI agent runtime
- [Electron](https://www.electronjs.org/) - Cross-platform desktop framework
- [React](https://react.dev/) - UI component library
- [shadcn/ui](https://ui.shadcn.com/) - Beautifully designed components
- [Zustand](https://github.com/pmndrs/zustand) - Lightweight state management
- [OpenClaw](https://github.com/OpenClaw) The AI agent runtime
- [Electron](https://www.electronjs.org/) Cross-platform desktop framework
- [React](https://react.dev/) UI component library
- [shadcn/ui](https://ui.shadcn.com/) Beautifully designed components
- [Zustand](https://github.com/pmndrs/zustand) Lightweight state management
---
## Community
@@ -215,25 +539,31 @@ Join our community to connect with other users, get support, and share your expe
| :---: | :---: | :---: |
| <img src="src/assets/community/wecom-qr.png" width="150" alt="WeChat QR Code" /> | <img src="src/assets/community/feishu-qr.png" width="150" alt="Feishu QR Code" /> | <img src="src/assets/community/20260212-185822.png" width="150" alt="Discord QR Code" /> |
### ClawX Partner Program
### ClawX Partner Program 🚀
We're launching the ClawX Partner Program and looking for partners who can help introduce ClawX to more clients, especially those with custom AI agent or automation needs.
Partners help connect us with potential users and projects, while the ClawX team provides full technical support, customization, and integration. If you work with clients interested in AI tools or automation, we'd love to collaborate.
Partners help connect us with potential users and projects, while the ClawX team provides full technical support, customization, and integration.
If you work with clients interested in AI tools or automation, we'd love to collaborate.
DM us or email [public@valuecell.ai](mailto:public@valuecell.ai) to learn more.
---
## Star History
<p align="center">
<img src="https://api.star-history.com/svg?repos=ValueCell-ai/ClawX&type=Date" alt="Star History Chart" />
</p>
---
## License
ClawX is released under the [MIT License](LICENSE). You're free to use, modify, and distribute this software.
<hr>
---
<p align="center">
<sub>Built with ❤️ by the ValueCell Team</sub>
+343 -98
View File
@@ -1,99 +1,148 @@
<p align="center">
<img src="src/assets/logo.svg" width="128" height="128" alt="ClawX Logo" />
<img src="src/assets/logo.svg" width="128" height="128" alt="ClawX Logo" />
</p>
<h1 align="center">ClawX</h1>
<p align="center">
<strong>Десктоп-интерфейс для AI-агентов OpenClaw</strong>
<strong>Десктоп-интерфейс для AI-агентов OpenClaw</strong>
</p>
<p align="center">
<a href="#почему-clawx">Почему ClawX</a> •
<a href="#быстрый-старт">Быстрый старт</a> •
<a href="#архитектура">Архитектура</a> •
<a href="#разработка">Разработка</a> •
<a href="#участие">Участие</a>
<a href="#возможности">Возможности</a> •
<a href="#почему-clawx">Почему ClawX</a> •
<a href="#быстрый-старт">Быстрый старт</a> •
<a href="#архитектура">Архитектура</a> •
<a href="#разработка">Разработка</a>
<a href="#участие">Участие</a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/platform-MacOS%20%7C%20Windows%20%7C%20Linux-blue" alt="Platform" />
<img src="https://img.shields.io/badge/electron-40+-47848F?logo=electron" alt="Electron" />
<img src="https://img.shields.io/badge/react-19-61DAFB?logo=react" alt="React" />
<a href="https://discord.com/invite/84Kex3GGAh" target="_blank">
<img src="https://img.shields.io/discord/1399603591471435907?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb" alt="chat on Discord" />
</a>
<img src="https://img.shields.io/github/downloads/ValueCell-ai/ClawX/total?color=%23027DEB" alt="Downloads" />
<img src="https://img.shields.io/badge/license-MIT-green" alt="License" />
<img src="https://img.shields.io/badge/platform-MacOS%20%7C%20Windows%20%7C%20Linux-blue" alt="Platform" />
<img src="https://img.shields.io/badge/electron-40+-47848F?logo=electron" alt="Electron" />
<img src="https://img.shields.io/badge/react-19-61DAFB?logo=react" alt="React" />
<a href="https://discord.com/invite/84Kex3GGAh" target="_blank">
<img src="https://img.shields.io/discord/1399603591471435907?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb" alt="chat on Discord" />
</a>
<img src="https://img.shields.io/github/downloads/ValueCell-ai/ClawX/total?color=%23027DEB" alt="Downloads" />
<img src="https://img.shields.io/badge/license-MIT-green" alt="License" />
</p>
<p align="center">
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.ja-JP.md">日本語</a> | Русский
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.ja-JP.md">日本語</a> | Русский
</p>
---
## Обзор
**ClawX** — это мост между мощными AI-агентами и повседневными пользователями. Построенный на базе [OpenClaw](https://github.com/OpenClaw), он превращает управление AI через командную строку в доступный и красивый десктопный интерфейс — терминал не нужен.
**ClawX** — это мост между мощными AI-агентами и повседневными пользователями. Построенный на базе [OpenClaw](https://github.com/OpenClaw), он превращает управление AI через командную строку в доступный и красивый десктоп-опыт — терминал не нужен.
Автоматизация рабочих процессов, управление AI-каналами или планирование интеллектуальных задач — ClawX предоставляет интерфейс для эффективного использования AI-агентов.
ClawX поставляется с предварительно настроенными провайдерами моделей, соответствующими лучшим практикам, и нативно поддерживает Windows и многоязычные настройки. Расширенные параметры можно настроить через **Настройки → Дополнительно → Режим разработчика**.
ClawX поставляется с предустановленными лучшими практиками для провайдеров моделей и нативно поддерживает Windows, а также многоязычные настройки. Вы можете тонко настроить расширенные параметры через **Настройки → Дополнительно → Режим разработчика**.
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">Для получения полной корпоративной версии, специализированной поддержки или индивидуального сопровождения внедрения под ваш бизнес-сценарий свяжитесь с нами по адресу <a href="mailto:public@valuecell.ai">public@valuecell.ai</a>.</strong></p>
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">Для получения полной корпоративной версии, специализированной поддержки или индивидуального сопровождения внедрения под ваш бизнес-сценарий, свяжитесь с нами по адресу <a href="mailto:public@valuecell.ai">public@valuecell.ai</a>.</strong></p>
---
## Скриншоты
<table>
<tr>
<td align="center"><img src="resources/screenshot/ru/chat.png" alt="Chat"><br><em>Чат</em></td>
<td align="center"><img src="resources/screenshot/ru/cron.png" alt="Cron"><br><em>Запланированные задачи</em></td>
</tr>
<tr>
<td align="center"><img src="resources/screenshot/ru/skills.png" alt="Skills"><br><em>Навыки</em></td>
<td align="center"><img src="resources/screenshot/ru/channels.png" alt="Channels"><br><em>Каналы</em></td>
</tr>
<tr>
<td align="center"><img src="resources/screenshot/ru/models.png" alt="Models"><br><em>Модели</em></td>
<td align="center"><img src="resources/screenshot/ru/settings.png" alt="Settings"><br><em>Настройки</em></td>
</tr>
</table>
<p align="center">
<img src="resources/screenshot/ru/chat.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/ru/cron.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/ru/skills.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/ru/channels.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/ru/models.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/ru/settings.png" style="width: 100%; height: auto;">
</p>
---
## Почему ClawX
Создание AI-агентов не должно требовать владения командной строкой. Философия ClawX проста: **мощные технологии заслуживают интерфейса, который уважает ваше время.** ClawX построен непосредственно на официальном ядре **OpenClaw**. Вместо отдельной установки среда выполнения встроена в приложение, что обеспечивает бесшовный опыт «всё включено». Мы поддерживаем тесное соответствие с upstream-проектом OpenClaw, чтобы вы всегда имели доступ к официальным новейшим возможностям, улучшениям стабильности и совместимости с экосистемой.
Создание AI-агентов не должно требовать владения командной строкой. Философия ClawX проста: **мощные технологии заслуживают интерфейса, который уважает ваше время.**
| Проблема | Решение ClawX |
|----------|---------------|
| Сложная настройка через CLI | Установка в один клик с мастером настройки |
| Конфигурационные файлы | Визуальные настройки с проверкой в реальном времени |
| Управление процессами | Автоматическое управление жизненным циклом Gateway |
| Обновления приложения | Проверка обновлений при запуске с запросом перед скачиванием или установкой |
| Редактирование конфигурационных файлов | Визуальные настройки с проверкой в реальном времени |
| Управление процессами | Автоматическое управление жизненным циклом шлюза |
| Несколько AI-провайдеров | Единая панель настройки провайдеров |
| Установка навыков/плагинов | Локальное управление навыками с опциональным маркетплейсом от расширения |
| Установка навыков/плагинов | Встроенный маркетплейс и управление навыками |
### Возможности
### OpenClaw внутри
- **🎯 Нулевой порог настройки**: Весь процесс выполняется через интуитивный графический интерфейс — без терминальных команд, YAML-файлов и поиска переменных окружения.
- **💬 Интеллектуальный интерфейс чата**: Несколько контекстов и история разговоров, потоковый Markdown с подсветкой синтаксиса, разбором CJK, таблицами и формулами KaTeX, прямая маршрутизация через `@agent`, встроенные карточки `/skill`, рабочие пространства с приоритетом и предпросмотр только для чтения Markdown, `.docx`, `.pptx` и локального HTML.
- **📡 Управление несколькими каналами**: Настраивайте и отслеживайте независимые AI-каналы с несколькими аккаунтами, привязкой агента к аккаунту, переключением аккаунта по умолчанию и встроенным официальным плагином личного WeChat от Tencent.
- **⏰ Автоматизация по расписанию**: Создавайте повторяющиеся или одноразовые расписания, вставляйте навыки в запланированные запросы и доставляйте результаты во внешние каналы.
- **🧩 Расширяемая система навыков**: Управляйте навыками локально без зависимости от Gateway, обнаруживайте навыки из нескольких источников OpenClaw и используйте встроенные навыки обработки документов для `pdf`, `xlsx`, `docx` и `pptx`.
- **🔐 Безопасная интеграция провайдеров**: Подключайте OpenAI, Anthropic, Z.AI / GLM и другие провайдеры; учётные данные хранятся в нативном системном хранилище ключей. Поддерживаются OAuth, пользовательские провайдеры, эндпоинты генерации изображений и совместимые резервные проверки.
- **🌙 Адаптивные темы**: Выбирайте светлую, тёмную или синхронизированную с системой тему.
- **🚀 Управление автозапуском**: Включите **Запускать при старте системы** в разделе **Настройки → Общие**.
- **🔔 Уведомления об обновлениях**: Проверяйте новые версии при запуске и сами решайте, скачивать или устанавливать обновление.
ClawX построен непосредственно на официальном ядре **OpenClaw**. Вместо отдельной установки мы встраиваем среду выполнения в приложение для бесшовного опыта "всё включено".
> Полное описание возможностей доступно в [docs/ru-RU/features.md](docs/ru-RU/features.md).
Мы стремимся поддерживать строгое соответствие с проектом OpenClaw, чтобы вы всегда имели доступ к новейшим возможностям, улучшениям стабильности и совместимости с экосистемой.
### Типичные сценарии использования
---
- **🤖 Персональный AI-ассистент**: Настройте универсального AI-агента для ответов на вопросы, составления писем, резюмирования документов и помощи с повседневными задачами через чистый десктопный интерфейс.
- **📊 Автоматизированный мониторинг**: Планируйте агентов для отслеживания новостных лент, цен или определённых событий и доставляйте результаты в предпочитаемый канал уведомлений.
- **💻 Производительность разработчика**: Интегрируйте AI в рабочий процесс разработки для проверки кода, генерации документации и автоматизации повторяющихся задач.
- **🔄 Автоматизация рабочих процессов**: Объединяйте несколько навыков в визуальные конвейеры, которые обрабатывают данные, преобразуют контент и запускают действия.
## Возможности
### 🎯 Нулевой порог настройки
Весь процесс — от установки до первого взаимодействия с AI — выполняется через интуитивный графический интерфейс. Без терминальных команд, без YAML-файлов, без поиска переменных окружения.
### 💬 Интеллектуальный интерфейс чата
Общайтесь с AI-агентами через современный чат. Поддержка нескольких контекстов разговора, истории сообщений, рендеринга Markdown (включая таблицы GitHub-flavored и математические формулы LaTeX через KaTeX: `$строчные$`, `$$блочные$$`, `\(строчные\)` и `\[блочные\]`) и прямая маршрутизация через `@agent` в главном поле ввода для мультиагентных конфигураций.
Навыки, вставляемые из поля ввода, отображаются как чипы `/skill-name`; нажмите на чип, чтобы открыть боковую панель предпросмотра и прочитать `SKILL.md` соответствующего навыка.
При выборе другого агента через `@agent` ClawX переключается непосредственно в контекст этого агента вместо ретрансляции через агента по умолчанию. Рабочие пространства агентов по умолчанию разделены, но более строгая изоляция зависит от настроек песочницы OpenClaw.
Каждый агент может переопределить свои настройки `provider/model`; агенты без переопределения продолжают наследовать глобальную модель по умолчанию.
### Одностраничный веб-браузер
На правой панели Chat находятся четыре вкладки: «Рабочая область», «Просмотр», «Изменения» и «Веб-браузер». При первом использовании веб-браузер лениво создаёт одну активную страницу и не останавливает её при закрытии панели, выборе другой вкладки панели, переключении сессии чата или переходе на другой маршрут ClawX; скрытая страница может продолжать выполнять скрипты, обращаться к сети, воспроизводить звук и расходовать ресурсы. Выделенная постоянная сессия сохраняет cookie и хранилища сайтов после перезапуска приложения, но каждый новый запуск начинается с `about:blank` без восстановления предыдущего URL, состояния страницы или истории переходов. Если страница предоставляет favicon, он отображается рядом с заголовком; пока favicon недоступен, заполнитель того же размера сохраняет положение заголовка, а при редактировании адреса вся область значка скрывается. Дополнительных вкладок или окон браузера, закладок, сохраняемой истории, менеджера паролей и управления автозаполнением нет.
Навигация верхнего уровня принимает HTTP, HTTPS и явно введённые стандартные URL `file:///`. Обычные пути файловой системы и другие протоколы отклоняются. Открытие локального файла предоставляет встроенной странице доступ к его читаемому содержимому в рамках обычных правил безопасности Chromium; команда **Открыть в системном браузере** для URL `file:` может запустить связанное с файлом приложение ОС, а не браузер. Разрешённая цель всплывающего окна заменяет текущую страницу, а не создаёт дочернее окно. Такой переход в той же странице не сохраняет `window.opener`, возвращаемые дескрипторы окон, сценарии с первоначально пустым окном, а также полную семантику тела POST, referrer, именованных окон и параметров окна.
Для загрузок сохраняется стандартное поведение Electron и операционной системы. В зависимости от платформы может появиться нативный диалог сохранения, требующий действий пользователя; ClawX не задаёт собственный путь и не предоставляет интерфейс прогресса, истории или управления загрузками. Для каждого запроса камеры или микрофона показывается нативный диалог разрешения или запрета, а решение не запоминается. Доступ к буферу обмена разрешён; геолокация, захват экрана, уведомления и остальные разрешения отклоняются.
**Очистить файлы cookie** удаляет только cookie всех источников в сессии браузера, сохраняя кэш и хранилища сайтов. **Очистить данные сайта** удаляет HTTP/Chromium-кэш, Cache Storage, Local Storage, IndexedDB и Service Workers всех источников, сохраняя cookie и загруженные файлы. Трафик браузера использует системное разрешение прокси Electron/Chromium; настройки клиентского прокси ClawX не синхронизируются с этой сессией браузера, и их изменение не перенастраивает её.
### 📡 Управление несколькими каналами
Настраивайте и отслеживайте несколько AI-каналов одновременно. Каждый канал работает независимо, позволяя запускать специализированных агентов для разных задач.
Каждый канал теперь поддерживает несколько учётных записей, привязку агента к учётной записи и переключение канала по умолчанию прямо на странице Каналы.
Для пользовательских идентификаторов учётных записей каналов ClawX требует совместимый с OpenClaw канонический формат (`[a-z0-9_-]`, строчные буквы, максимум 64 символа, должен начинаться с буквы или цифры) для предотвращения ошибок маршрутизации.
ClawX также включает официальный плагин личного WeChat от Tencent, позволяя подключить WeChat напрямую со страницы Каналы через встроенный QR-код.
### ⏰ Автоматизация по расписанию
Планируйте автоматический запуск AI-задач. Определяйте триггеры, устанавливайте интервалы и позволяйте AI-агентам работать круглосуточно без ручного вмешательства.
На странице Cron теперь можно настроить внешнюю доставку непосредственно в форме задачи с отдельными селекторами учётной записи отправителя и цели получателя. Для поддерживаемых каналов цели получателей автоматически обнаруживаются из каталогов каналов или известной истории сессий, поэтому больше не нужно редактировать `jobs.json` вручную. Поле сообщения задачи также поддерживает вставку навыков с помощью того же синтаксиса встроенных токенов `/skill`, что и в основном окне чата (с учётом выбранного агента), поэтому запланированные подсказки могут запускать навыки напрямую. Выбор расписания разделён на вкладки **Повтор** и **Однократно**: повтор предлагает частоты «Ежечасно», «Ежедневно», «По будням», «Еженедельно» и «Свой» (произвольный cron) со встроенными элементами выбора времени/дня недели, а однократно запускает задачу один раз в выбранную дату (с показом дня недели) и время. Однократные задачи должны быть запланированы на будущее и автоматически удаляются средой выполнения после завершения.
### 🧩 Расширяемая система навыков
Расширяйте возможности AI-агентов готовыми навыками. Просматривайте, устанавливайте и управляйте навыками через встроенную панель — менеджеры пакетов не нужны.
ClawX также предварительно упаковывает полные навыки обработки документов (`pdf`, `xlsx`, `docx`, `pptx`), автоматически развёртывает их в управляемый каталог навыков (по умолчанию `~/.openclaw/skills`) при запуске и включает по умолчанию при первой установке.
На странице Навыки отображаются навыки из нескольких источников OpenClaw (управляемый каталог, workspace и дополнительные каталоги навыков), а также показывается фактическое расположение каждого навыка для прямого открытия папки.
### 🔐 Безопасная интеграция провайдеров
Подключайтесь к нескольким AI-провайдерам (OpenAI, Anthropic, Z.AI / GLM и др.) с учётными данными, безопасно хранящимися в системной связке ключей. OpenAI поддерживает как API-ключи, так и OAuth через браузер (подписка Codex).
Для провайдеров **Custom**, используемых с OpenAI-совместимыми шлюзами, вы можете установить пользовательский `User-Agent` в **Настройки → AI Провайдеры → Редактировать провайдера** для совместимости с чувствительными эндпоинтами.
Z.AI (CN / Global) соответствует встроенному провайдеру OpenClaw `zai` (`ZAI_API_KEY`). Модель по умолчанию — `glm-5.2`. Пресет Code Plan переключает на эндпоинты Coding Plan (`…/api/coding/paas/v4`); также доступны обычные API (`…/api/paas/v4`). CN и Global взаимоисключающие, так как используют один и тот же runtime-ключ OpenClaw.
Когда совместимый шлюз отклоняет `/models` по причинам, не связанным с аутентификацией, ClawX автоматически переключается на легковесный зонд `/chat/completions` или `/responses` при проверке API-ключа.
### 🌙 Адаптивные темы
Светлая тема, тёмная тема или синхронизация с системой. ClawX автоматически адаптируется к вашим предпочтениям.
### 🚀 Управление автозапуском
В **Настройки → Общие** вы можете включить **Запускать при старте системы**, чтобы ClawX автоматически запускался после входа в систему.
---
## Быстрый старт
@@ -125,69 +174,255 @@ pnpm dev
### Первый запуск
При первом запуске ClawX **Мастер настройки** проведёт вас через следующие шаги:
При первом запуске ClawX **Мастер настройки** проведёт вас через:
1. **Язык и регион** — настройка предпочитаемой локали
2. **AI-провайдер** — добавление провайдеров с API-ключами или OAuth для провайдеров, поддерживающих вход через браузер или устройство
1. **Язык и регион** — настройка предпочтительного языка и региона
2. **AI-провайдер** — добавление провайдеров с API-ключами или OAuth (для провайдеров, поддерживающих вход через браузер/устройство)
3. **Пакеты навыков** — выбор предустановленных навыков для распространённых сценариев
4. **Проверка** — тестирование конфигурации перед входом в основной интерфейс
Мастер предварительно выбирает системный язык, если он поддерживается, иначе переключается на английский.
> Примечание о веб-поиске: ClawX отключает универсальный инструмент OpenClaw `web_search` на уровнях политик агента и Gateway. Это также относится к поиску Moonshot (Kimi); управляемая автоматизация браузера и `web_fetch` остаются доступными.
### Настройки прокси
ClawX включает встроенные настройки прокси для Electron, OpenClaw Gateway и таких каналов, как Telegram, которым требуется доступ в интернет через локальный прокси-клиент.
ClawX включает встроенные настройки прокси для сред, где Electron, шлюз OpenClaw или каналы вроде Telegram должны выходить в интернет через локальный прокси-клиент.
Откройте **Настройки → Gateway → Прокси**, чтобы настроить прокси по умолчанию, правила обхода и дополнительные переопределения HTTP, HTTPS и `ALL_PROXY` / SOCKS в режиме разработчика. Пример локального адреса: `http://127.0.0.1:7890`.
Откройте **Настройки → Шлюз → Прокси** и настройте:
> Подробности о резервном поведении прокси, синхронизации с Telegram и **OpenClaw Doctor** см. в [docs/ru-RU/proxy-settings.md](docs/ru-RU/proxy-settings.md).
- **Прокси-сервер**: прокси по умолчанию для всех запросов
- **Правила обхода**: хосты, которые должны подключаться напрямую, разделённые точкой с запятой, запятыми или новыми строками
- В **Режиме разработчика** можно дополнительно переопределить:
- **HTTP Прокси**
- **HTTPS Прокси**
- **ALL_PROXY / SOCKS**
Рекомендуемые примеры локальных настроек:
```text
Прокси-сервер: http://127.0.0.1:7890
```
Примечания:
- Значение `host:port` рассматривается как HTTP.
- Если расширенные поля прокси пусты, ClawX использует `Прокси-сервер`.
- Сохранение настроек прокси немедленно повторно применяет сеть Electron и автоматически перезапускает шлюз.
- ClawX также синхронизирует прокси с конфигурацией канала Telegram в OpenClaw, когда Telegram включён.
- При перезапуске шлюза существующий прокси канала Telegram сохраняется, если прокси ClawX отключен.
- Чтобы явно очистить прокси Telegram из конфигурации OpenClaw, сохраните настройки прокси с отключенным прокси.
- В **Настройки → Дополнительно → Разработчик** можно запустить **OpenClaw Doctor** для выполнения `openclaw doctor --json` и просмотра диагностического вывода, не покидая приложение.
- В упакованных сборках Windows встроенный `openclaw` CLI/TUI запускается через поставляемый `node.exe` для стабильного поведения ввода в терминале.
---
## Архитектура
ClawX использует **двухпроцессную архитектуру с унифицированным уровнем Host API**: React Renderer обращается к единой абстракции клиента, а Electron Main управляет выбором протокола, жизненным циклом Gateway и stdio bridge для ACP Chat.
ClawX использует **двухпроцессную архитектуру с унифицированным уровнем Host API**. Рендерер обращается к единой абстракции клиента, а Electron Main управляет выбором протокола и жизненным циклом процессов:
- **Модель процессов**: Electron Main управляет окном, наблюдением за Gateway, системной интеграцией и обновлениями; OpenClaw Gateway предоставляет возможности AI-оркестрации, каналов и навыков; Renderer не обращается к локальным эндпоинтам напрямую.
- **Доставка конфигурации**: изменения среды выполнения используют авторитетный снимок `config.set`, поэтому обычные изменения провайдера, агента, навыка и модели не заменяют процесс Gateway; учётные данные обновляются без перезапуска через `secrets.reload`.
- **ACP Chat**: Chat использует [ACP (Agent Client Protocol)](https://agentclientprotocol.com) через stdio bridge под управлением Main, поддерживая аутентифицированное воспроизведение истории после перезагрузки конфигурации, потоковую выдачу при навигации и медиа, вложения и файловые операции, проверенные Main.
- **Принципы проектирования**: единая точка входа фронтенда, транспорт под управлением Main, корректное восстановление с переподключением/таймаутом/повтором, безопасное хранение и границы, защищённые от CORS.
```
┌─────────────────────────────────────────────────────────────────┐
│ Десктоп-приложение ClawX │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Главный процесс Electron │ │
│ │ • Управление жизненным циклом окна и приложения │ │
│ │ • Наблюдение за процессом шлюза │ │
│ │ • Интеграция с системой (трей, уведомления, связка ключей)│ │
│ │ • Оркестрация автообновлений │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ IPC (авторитетная плоскость управления) │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Процесс рендерера React │ │
│ │ • Современный UI на компонентах (React 19) │ │
│ │ • Управление состоянием с Zustand │ │
│ │ • Унифицированные вызовы host-api/api-client │ │
│ │ • Рендеринг Markdown │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────────┘
Стратегия транспорта, управляемая Main
(Сначала WS, затем HTTP, затем IPC)
┌──────────────────────────────────────────────────────────────────┐
│ Host API и прокси-уровень Main │
│ │
│ • hostapi:fetch (прокси Main, избегает CORS в dev/prod) │
│ • gateway:httpProxy (Рендерер не вызывает Gateway HTTP напрямую)│
│ • Унифицированное отображение ошибок и повторные попытки │
└──────────────────────────────┬───────────────────────────────────┘
Резерв WS / HTTP / IPC
┌─────────────────────────────────────────────────────────────────┐
│ Шлюз OpenClaw │
│ │
│ • Среда выполнения AI-агентов и оркестрация │
│ • Управление каналами сообщений │
│ • Среда выполнения навыков/плагинов │
│ • Уровень абстракции провайдеров │
└─────────────────────────────────────────────────────────────────┘
```
> Схема процессов, координация конфигурации, семантика файловых операций ACP и устранение неполадок Gateway описаны в [docs/ru-RU/architecture.md](docs/ru-RU/architecture.md).
### Принципы проектирования
- **Изоляция процессов**: Среда выполнения AI работает в отдельном процессе, обеспечивая отзывчивость UI даже при тяжёлых вычислениях
- **Единая точка входа для фронтенда**: Запросы рендерера проходят через host-api/api-client; детали протокола скрыты за стабильным интерфейсом
- **Владение транспортом в Main**: Electron Main управляет использованием WS/HTTP и откатом к IPC для надёжности
- **Корректное восстановление**: Встроенная логика переподключения, таймаутов и отката автоматически обрабатывает временные сбои
- **Безопасное хранение**: API-ключи и конфиденциальные данные используют нативные механизмы безопасного хранения ОС
- **Безопасность CORS**: Локальный HTTP-доступ проксируется через Main, предотвращая CORS-проблемы на стороне рендерера
### Модель процессов и устранение неполадок шлюза
- ClawX — это приложение Electron, поэтому **один экземпляр приложения обычно отображается как несколько процессов ОС** (main/renderer/zygote/utility). Это нормально.
- Защита единственного экземпляра использует блокировку Electron плюс локальный файл блокировки процессов, предотвращая дублирование запуска приложения в средах с нестабильным IPC/сессионной шиной.
- При последовательных обновлениях смешанные старые/новые версии могут иметь асимметричное поведение защиты. Для лучшей надёжности обновите все десктоп-клиенты до одной версии.
- Слушатель шлюза OpenClaw должен быть **единственным владельцем**: только один процесс должен слушать `127.0.0.1:18789`.
- Для проверки активного слушателя:
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
- Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen`
- Нажатие кнопки закрытия окна (`X`) скрывает ClawX в трей; это **не** полностью закрывает приложение. Используйте меню трея **Quit ClawX** для полного завершения.
---
## Варианты использования
### 🤖 Персональный AI-ассистент
Настройте универсального AI-агента, который может отвечать на вопросы, составлять письма, резюмировать документы и помогать с повседневными задачами — всё через чистый десктоп-интерфейс.
### 📊 Автоматизированный мониторинг
Настройте запланированных агентов для отслеживания новостных лент, цен или определённых событий. Результаты доставляются в ваш предпочтительный канал уведомлений.
### 💻 Производительность разработчика
Интегрируйте AI в рабочий процесс разработки. Используйте агентов для проверки кода, генерации документации или автоматизации повторяющихся задач кодирования.
### 🔄 Автоматизация рабочих процессов
Связывайте несколько навыков для создания сложных конвейеров автоматизации. Обрабатывайте данные, преобразовывайте контент и запускайте действия — всё визуально оркестрируется.
---
## Разработка
### Требования
- **Node.js**: 22.22.3+, 24.15.0+ или 25.9.0+ в пределах соответствующей основной версии (рекомендуется Node 24 LTS)
- **Менеджер пакетов**: pnpm 9+ (npm также поддерживается)
- **Linux (Ubuntu/Debian)**: перед запуском Electron установите необходимые системные библиотеки; см. [docs/ru-RU/development.md](docs/ru-RU/development.md)
- **Менеджер пакетов**: pnpm 9+ (рекомендуется) или npm
### Основные команды
### Структура проекта
```bash
pnpm run init # Установить зависимости и скачать встроенные среды выполнения
pnpm dev # Запустить режим разработки с горячей перезагрузкой
pnpm lint # Запустить ESLint
pnpm typecheck # Проверить типы TypeScript
pnpm test # Запустить модульные тесты
pnpm run test:e2e # Запустить дымовые E2E-тесты Electron
pnpm build # Выполнить полную production-сборку
pnpm package # Упаковать для текущей платформы (:mac / :win / :linux)
```
ClawX/
├── electron/ # Главный процесс Electron
│ ├── api/ # Маршрутизатор API и обработчики Main
│ │ └── routes/ # Модули маршрутов RPC/HTTP прокси
│ ├── services/ # Службы провайдеров, секретов и среды выполнения
│ │ ├── providers/ # Логика синхронизации моделей provider/account
│ │ └── secrets/ # Связка ключей ОС и хранилище секретов
│ ├── shared/ # Общие схемы провайдеров и константы
│ │ └── providers/
│ ├── main/ # Точка входа приложения, окна, регистрация IPC
│ ├── gateway/ # Менеджер процесса шлюза OpenClaw
│ ├── preload/ # Безопасный IPC-мост
│ └── utils/ # Утилиты (хранилище, аутентификация, пути)
├── src/ # Процесс рендерера React
│ ├── lib/ # Унифицированный фронтенд API и модель ошибок
│ ├── stores/ # Хранилища Zustand (settings/chat/gateway)
│ ├── components/ # Переиспользуемые UI-компоненты
│ ├── pages/ # Setup/Dashboard/Chat/Channels/Skills/Cron/Settings
│ ├── i18n/ # Ресурсы локализации
│ └── types/ # Определения типов TypeScript
├── tests/
│ ├── e2e/ # Сквозные дымовые тесты Playwright Electron
│ └── unit/ # Модульные/интеграционные тесты Vitest
├── resources/ # Статические ресуры (иконки, изображения)
└── scripts/ # Скрипты сборки и утилит
```
> Структура проекта, полный список команд, политика параллельности E2E, диагностика производительности, проверки регрессий коммуникаций и технологический стек описаны в [docs/ru-RU/development.md](docs/ru-RU/development.md).
### Доступные команды
```bash
# Разработка
pnpm run init # Установить зависимости + скачать uv
pnpm dev # Запуск с горячей перезагрузкой (автоподготовка упакованных навыков при отсутствии)
# Качество кода
pnpm lint # Запустить ESLint
pnpm typecheck # Проверка типов TypeScript
# Тестирование
pnpm test # Запустить модульные тесты
pnpm run test:e2e # Запустить E2E дымовые тесты Electron с Playwright
pnpm run test:e2e:headed # Запустить E2E тесты Electron с видимым окном
pnpm run comms:replay # Вычислить метрики повторного воспроизведения коммуникаций
pnpm run comms:baseline # Обновить базовый снимок коммуникаций
pnpm run comms:compare # Сравнить метрики воспроизведения с базовыми порогами
# Сборка и упаковка
pnpm run build:vite # Собрать только фронтенд
pnpm build # Полная production-сборка (с ресурсами упаковки)
pnpm package # Упаковать для текущей платформы (включает предустановленные навыки)
pnpm package:mac # Упаковать для macOS
pnpm package:win # Упаковать для Windows
pnpm package:linux # Упаковать для Linux
```
На headless Linux запускайте тесты Electron под сервером отображения, например `xvfb-run -a pnpm run test:e2e`.
### Проверка регрессии коммуникаций
Когда PR изменяет пути коммуникации (события шлюза, поток отправки/получения чата, доставка каналов или откат транспорта), запустите:
```bash
pnpm run comms:replay
pnpm run comms:compare
```
`comms-regression` в CI проверяет обязательные сценарии и пороги.
### E2E-тесты Electron
Сьют Playwright Electron запускает упакованный рендерер и главный процесс из `dist/` и `dist-electron/`, поэтому не требует предварительного ручного запуска `pnpm dev`.
`pnpm run test:e2e` автоматически:
- собирает рендерер и пакеты Electron с `pnpm run build:vite`
- запускает Electron в изолированном режиме E2E с временным `HOME`
- использует временный каталог `userData` ClawX
- пропускает тяжёлые побочные эффекты запуска, такие как автозапуск шлюза, установку упакованных навыков, создание трея и автоустановку CLI
Первые два базовых спецификации покрывают:
- видимость мастера настройки при первом запуске на чистом профиле
- пропуск настройки и навигация на страницу Models внутри приложения Electron
Добавляйте будущие потоки Electron в `tests/e2e/` и переиспользуйте общий fixture в `tests/e2e/fixtures/electron.ts`.
### Технологический стек
| Уровень | Технология |
|----------------|-------------------------------|
| Среда выполнения | Electron 40+ |
| UI-фреймворк | React 19 + TypeScript |
| Стилизация | Tailwind CSS + shadcn/ui |
| Состояние | Zustand |
| Сборка | Vite + electron-builder |
| Тестирование | Vitest + Playwright |
| Анимация | Framer Motion |
| Иконки | Lucide React |
---
## Участие
Мы приветствуем вклад сообщества! Исправления ошибок, новые функции, улучшения документации и переводы помогают сделать ClawX лучше.
Мы приветствуем вклад сообщества! Исправления багов, новые функции, улучшения документации или переводы — каждый вклад делает ClawX лучше.
### Как внести вклад
1. **Сделайте форк** репозитория
2. **Создайте** ветку функции (`git checkout -b feature/amazing-feature`)
3. **Зафиксируйте** изменения с понятными сообщениями
4. **Отправьте** изменения в свою ветку
4. **Отправьте** в свою ветку
5. **Откройте** Pull Request
### Руководящие принципы
@@ -197,44 +432,54 @@ pnpm package # Упаковать для текущей платформ
- Обновляйте документацию по мере необходимости
- Держите коммиты атомарными и описательными
---
## Благодарности
ClawX построен на основе следующих отличных проектов с открытым исходным кодом:
ClawX построен на плечах отличных проектов с открытым исходным кодом:
- [OpenClaw](https://github.com/OpenClaw) - Среда выполнения AI-агентов
- [Electron](https://www.electronjs.org/) - Кроссплатформенный десктоп-фреймворк
- [React](https://react.dev/) - Библиотека UI-компонентов
- [shadcn/ui](https://ui.shadcn.com/) - Красиво спроектированные компоненты
- [Zustand](https://github.com/pmndrs/zustand) - Лёгкое управление состоянием
- [OpenClaw](https://github.com/OpenClaw) Среда выполнения AI-агентов
- [Electron](https://www.electronjs.org/) Кроссплатформенный десктоп-фреймворк
- [React](https://react.dev/) Библиотека UI-компонентов
- [shadcn/ui](https://ui.shadcn.com/) Красиво спроектированные компоненты
- [Zustand](https://github.com/pmndrs/zustand) Легковесное управление состоянием
---
## Сообщество
Присоединяйтесь к нашему сообществу, чтобы общаться с другими пользователями, получать поддержку и делиться опытом.
| WeChat Enterprise | Группа Feishu | Discord |
| WeChat Enterprise | Feishu Group | Discord |
| :---: | :---: | :---: |
| <img src="src/assets/community/wecom-qr.png" width="150" alt="WeChat QR Code" /> | <img src="src/assets/community/feishu-qr.png" width="150" alt="Feishu QR Code" /> | <img src="src/assets/community/20260212-185822.png" width="150" alt="Discord QR Code" /> |
### Партнёрская программа ClawX
### Партнёрская программа ClawX 🚀
Мы запускаем Партнёрскую программу ClawX и ищем партнёров, которые помогут представить ClawX большему числу клиентов, особенно клиентам с потребностями в кастомных AI-агентах или автоматизации.
Мы запускаем Партнёрскую программу ClawX и ищем партнёров, которые могут помочь представить ClawX большему числу клиентов, особенно тем, у кого есть потребности в кастомных AI-агентах или автоматизации.
Партнёры помогают связывать нас с потенциальными пользователями и проектами, а команда ClawX предоставляет полную техническую поддержку, кастомизацию и интеграцию. Если вы работаете с клиентами, заинтересованными в AI-инструментах или автоматизации, мы будем рады сотрудничеству.
Партнёры помогают связывать нас с потенциальными пользователями и проектами, а команда ClawX предоставляет полную техническую поддержку, кастомизацию и интеграцию.
Напишите нам в DM или на [public@valuecell.ai](mailto:public@valuecell.ai), чтобы узнать больше.
Если вы работаете с клиентами, заинтересованными в AI-инструментах или автоматизации, мы будем рады сотрудничеству.
Напишите нам в DM или на [public@valuecell.ai](mailto:public@valuecell.ai) для получения дополнительной информации.
---
## История звёзд
<p align="center">
<img src="https://api.star-history.com/svg?repos=ValueCell-ai/ClawX&type=Date" alt="Star History Chart" />
<img src="https://api.star-history.com/svg?repos=ValueCell-ai/ClawX&type=Date" alt="Star History Chart" />
</p>
---
## Лицензия
ClawX выпускается под [лицензией MIT](LICENSE). Вы можете свободно использовать, изменять и распространять это программное обеспечение.
ClawX выпускается под [лицензией MIT](LICENSE). Вы можете свободно использовать, модифицировать и распространять это программное обеспечение.
<hr>
---
<p align="center">
<sub>Создано с ❤️ командой ValueCell</sub>
<sub>Создано с ❤️ командой ValueCell</sub>
</p>
+366 -61
View File
@@ -10,6 +10,7 @@
</p>
<p align="center">
<a href="#功能特性">功能特性</a> •
<a href="#为什么选择-clawx">为什么选择 ClawX</a> •
<a href="#快速上手">快速上手</a> •
<a href="#系统架构">系统架构</a> •
@@ -44,25 +45,39 @@ ClawX 预置了最佳实践的模型供应商配置,原生支持 Windows 平
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">如需完整的企业版、专属服务支持或面向您业务场景的定制化落地辅导,请联系 <a href="mailto:public@valuecell.ai">public@valuecell.ai</a>。</strong></p>
---
## 截图预览
<table>
<tr>
<td align="center"><img src="resources/screenshot/zh/chat.png" alt="Chat"><br><em>聊天界面</em></td>
<td align="center"><img src="resources/screenshot/zh/cron.png" alt="Cron"><br><em>定时任务</em></td>
</tr>
<tr>
<td align="center"><img src="resources/screenshot/zh/skills.png" alt="Skills"><br><em>技能管理</em></td>
<td align="center"><img src="resources/screenshot/zh/channels.png" alt="Channels"><br><em>频道管理</em></td>
</tr>
<tr>
<td align="center"><img src="resources/screenshot/zh/models.png" alt="Models"><br><em>模型配置</em></td>
<td align="center"><img src="resources/screenshot/zh/settings.png" alt="Settings"><br><em>设置</em></td>
</tr>
</table>
<p align="center">
<img src="resources/screenshot/zh/chat.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/zh/cron.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/zh/skills.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/zh/channels.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/zh/models.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/zh/settings.png" style="width: 100%; height: auto;">
</p>
---
## 为什么选择 ClawX
构建 AI 智能体不应该需要精通命令行。ClawX 的设计理念很简单:**强大的技术值得拥有一个尊重用户时间的界面**。ClawX 直接基于官方 OpenClaw 核心构建。无需单独安装,我们将运行时嵌入应用内部,提供开箱即用的无缝体验,并致力于与上游 OpenClaw 项目保持严格同步,确保你始终可以使用官方发布的最新功能、稳定性改进和生态兼容性。
构建 AI 智能体不应该需要精通命令行。ClawX 的设计理念很简单:**强大的技术值得拥有一个尊重用户时间的界面**
| 痛点 | ClawX 解决方案 |
|------|----------------|
@@ -73,26 +88,86 @@ ClawX 预置了最佳实践的模型供应商配置,原生支持 Windows 平
| 多 AI 供应商切换 | 统一的供应商配置面板 |
| 技能/插件安装复杂 | 内置技能市场与管理界面 |
### 功能特性
### 内置 OpenClaw 核心
- **🎯 零配置门槛**:从安装到第一次 AI 对话,全程指引式图形界面,无需终端命令、YAML 配置或环境变量
- **💬 智能聊天界面**:多会话上下文与历史记录,流式 Markdown 渲染(语法高亮、CJK 排版、表格、KaTeX 公式)、`@agent` 直接路由与 `/技能` 内联卡片,工作空间优先的会话侧边栏,以及 Markdown、`.docx``.pptx` 和本地 HTML 的只读预览。
- **📡 多频道管理**:同时配置和监控多个 AI 频道,每个频道独立运行并支持多账号;内置腾讯官方个人微信渠道插件。
- **⏰ 定时任务自动化**:可视化定义触发器与时间间隔,让 AI 智能体 7×24 小时自动运行;支持周期(每小时/每天/工作日/每周/自定义 cron)与单次执行,并可将结果自动投递到外部频道。
- **🧩 可扩展技能系统**:本地优先的技能管理,扫描托管与 workspace 技能目录,无需依赖 Gateway 即可启用或停用技能;预装文档处理技能(`pdf``xlsx``docx``pptx`)。
- **🔐 安全的供应商集成**:支持 OpenAI、Anthropic、Z.AI / GLM 等供应商,凭证经系统原生密钥链安全存储;提供自定义 Provider、OAuth 登录、图像生成端点与兼容网关的降级探测。
- **🌙 自适应主题**:支持浅色、深色与跟随系统主题。
- **🚀 开机启动控制**:在 设置 → 通用 中开启开机自动启动。
- **🔔 更新提示**:启动时自动检查新版本,由你决定是否下载或安装更新。
ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们将运行时嵌入应用内部,提供开箱即用的无缝体验
> 对于功能细节的完整说明,请参阅 [docs/zh-CN/features.md](docs/zh-CN/features.md)
我们致力于与上游 OpenClaw 项目保持严格同步,确保你始终可以使用官方发布的最新功能、稳定性改进和生态兼容性
### 典型使用场景
打开开发者模式且当前 runtime 为 OpenClaw 时,侧边栏还会提供原生 Dreams 页面,可在 ClawX 内查看 OpenClaw 记忆回顾、梦境日记,并执行基础维护操作;需要更深诊断时仍可从该页面打开完整 OpenClaw Dreams UI。
- **🤖 个人 AI 助手**:配置一个通用 AI 智能体,可以回答问题、撰写邮件、总结文档并协助处理日常任务——全部通过简洁的桌面界面完成
- **📊 自动化监控**:设置定时智能体来监控新闻动态、追踪价格变动或监听特定事件,结果将推送到你偏好的通知渠道。
- **💻 开发者效率工具**:将 AI 融入你的开发工作流,使用智能体进行代码审查、生成文档或自动化重复性编码任务
- **🔄 工作流自动化**:将多个技能串联起来,创建复杂的自动化流水线——处理数据、转换内容、触发操作,全部通过可视化方式编排。
ClawX 现在也包含 runtime 抽象层。OpenClaw 仍是默认 runtime 和回滚路径,你可以在 **设置 → 网关 → Runtime** 切换到可选的内置 `cc-connect` runtime。打包产物会同时内置 cc-connect 二进制和 OpenAI Codex 原生 CLI bundleruntime 启动不依赖全局安装、PATH 二进制或运行时下载。ClawX 会把可跨升级复用的 app 配置、凭据、runtime 数据、skills 和 workspace 放在 `~/.clawx`(或 `CLAWX_DATA_HOME`),不会自动修改 `~/.cc-connect`。GUI chat 会通过 cc-connect BridgePlatform 连接到 Codex project agent;托管 project 固定使用 cc-connect 的 Codex app-server stdio backend,让实时工具进度可以直接驱动共用的 Chat execution graph。当 cc-connect 公共历史缺少频道会话的工具数据包时,ClawX 会从匹配的本地 Codex transcript 补全历史,并将匹配范围限制在该会话所属 Agent 的 workspace。审批按钮和 cc-connect card 选项都会显示在执行图中,响应统一通过 cc-connect 公共 `card_action` 协议返回。Runtime 生成的图片、文件、音频和视频包也通过 BridgePlatform 返回,并持续显示为 Chat 附件。每个 Agent 默认使用全自动模式,也可以在 Agent 的模型/runtime 设置中独立选择“需要审批”(`suggest`)。新 agent 使用 `~/.clawx/workspaces/agents/<id>`;已有 OpenClaw workspace 可以按原路径复用,ClawX 不移动也不接管它。Provider/model、原生 cron 任务和已启用 skills 会同步到托管的 cc-connect/Codex runtime
Agent 和频道设置以 `~/.clawx` 为唯一 canonical 数据源。cc-connect 处于启用状态时,保存设置不会改写 `~/.openclaw/openclaw.json`;切回 OpenClaw 后,Gateway 启动前会重新生成这份兼容投影
在 cc-connect 模式下,Codex provider 同步支持 OpenAI API Key、OpenAI OAuth/Codex、Ollama,以及暴露 Responses API 的 OpenAI-compatible Custom provider。Custom provider header 会以环境变量引用写入托管配置,避免持久化密钥或 session header。配置为 Chat Completions 的 Custom provider 会在 chat 投递前被明确标记为不支持,因为这条路径使用 Codex 的 Responses wire API。
每个 OAuth provider account 都有独立的托管 `CODEX_HOME`。runtime 启动不会自动采用用户全局 Codex 登录;必须对选中的 account 显式执行 Codex OAuth 导入。
cc-connect 也负责消息平台桥接。当 cc-connect 是当前 runtime 时,频道状态探测会通过 runtime 抽象层路由,而不是继续固定查询 OpenClaw Gateway;已配置的频道账号会同步到其绑定 agent 所属的 cc-connect project,频道保存/删除会通过 cc-connect Management API reload 托管配置,在可行时无需完整重启 runtime 就让 platform 变更生效;开发者模式侧边栏的页面入口会打开 cc-connect Web AdminOpenClaw Dreams 入口仍只在 OpenClaw runtime 下显示。
---
## 功能特性
### 🎯 零配置门槛
从安装到第一次 AI 对话,全程通过直观的图形界面完成。无需终端命令,无需 YAML 文件,无需到处寻找环境变量。
### 💬 智能聊天界面
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、Markdown 富文本渲染(包括 GitHub 风格表格以及由 KaTeX 渲染的 LaTeX 数学公式:`$行内$``$$块级$$``\(行内\)``\[块级\]`),以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。
从输入框插入的技能会以 `/技能名` 卡片形式显示;点击卡片可在右侧预览栏打开并阅读该技能的 `SKILL.md`
当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
会话侧边栏现在以工作空间优先组织:默认工作空间固定在最上方,其它工作空间按自然顺序排列,每个工作空间都可折叠或继续加载更多会话。AI 回复期间,会话行显示加载指示器;未查看的回复完成后显示蓝点;打开会话后恢复显示相对活跃时间,悬停时仍会露出操作按钮。导入的工作空间可从侧边栏标题处重命名,新名称会同步显示在对话输入框下方,同时悬浮标题仍可查看文件系统路径。如果当前所选会话存在有效工作空间,新对话会继承该工作空间,并在首次发送前保持可编辑。对于可编辑的新对话或未绑定对话,输入框的工作空间卡片会打开一个小菜单,列出最近使用及现有会话中的工作空间,并可切回默认工作空间或选择其它目录。如果保存的工作空间文件夹已被移动或删除,Chat 会暂停创建会话并提示选择现有文件夹,而不会持续重试失效路径。不可用的非默认工作空间会在侧边栏显示标记,并可在确认后删除;该操作会永久删除分组中的全部会话。OpenClaw 生成的 UUID 加日期兜底标题只有在与该会话 ID 匹配时才会被视为缺失标题,随后改用会话的首条用户消息展示,而不会被持久化为会话名称。
每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。
Chat 右侧面板的工作空间和预览选项卡支持以只读方式预览 `.docx``.pptx` 文件。旧版 `.doc``.ppt` 文件不会在应用内预览,而是继续通过操作系统打开。DOCX 的分页效果可能与 Microsoft Word 不同;PPTX 预览不支持动画、切换效果或媒体播放。超过 20 MB 的 Office 文件不会在应用内预览。
### 单页面 Web 浏览器
Chat 右侧面板包含四个选项卡:工作空间、预览、变更和网页浏览器。网页浏览器会在首次使用时延迟创建一个实时页面;关闭面板、切换面板选项卡、切换聊天会话或前往 ClawX 的其它路由时,页面只会隐藏并继续运行,因此脚本、网络活动、音频和资源占用都可能持续。专用持久会话会在应用重启后保留 Cookie 和站点存储,但每次启动都从 `about:blank` 开始,不恢复上次的 URL、页面状态或导航历史。页面提供网站图标时,图标会显示在标题左侧;没有图标时,同尺寸占位图标会保持标题对齐,编辑地址时则隐藏整个图标位。该功能不提供额外浏览器标签页或窗口、书签、持久化历史、密码管理器或自动填充管理。
顶层导航支持 HTTP、HTTPS 和明确输入的标准 `file:///` URL;普通文件系统路径及其它协议会被拒绝。打开本地文件会在 Chromium 的常规安全规则下向嵌入页面暴露其中可读取的内容;对 `file:` URL 使用**在系统浏览器中打开**时,操作系统也可能改用文件关联应用,而不是浏览器。允许的弹窗目标会替换当前页面,不会创建子窗口;这种同页面回退无法保留 `window.opener`、返回的窗口句柄、先打开空白页再写入内容的脚本弹窗,也不能完整保持 POST 请求体、referrer、命名窗口和窗口特性行为。
下载完全沿用 Electron 和操作系统的默认行为。根据平台不同,系统可能显示原生“保存”对话框并需要用户操作;ClawX 不会指定自定义路径,也不提供下载进度、历史或管理界面。摄像头和麦克风权限会对每次请求显示原生“允许/拒绝”提示,且不会记住选择。剪贴板访问允许使用;地理位置、屏幕捕获、通知及其它权限均会被拒绝。
**清除 Cookie**会删除浏览器会话中所有来源的 Cookie,同时保留缓存和站点存储。**清除网站数据**会删除所有来源的 HTTP/Chromium 缓存、Cache Storage、Local Storage、IndexedDB 和 Service Worker,同时保留 Cookie 与已下载文件。浏览器流量使用 Electron/Chromium 的系统代理解析;ClawX 客户端代理设置不会同步到该浏览器会话,修改这些设置也不会重新配置它。
### 📡 多频道管理
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。
现在每个频道支持多个账号,并可在 Channels 页面直接完成账号绑定到 Agent 与默认账号切换。
对于自定义频道账号 ID,ClawX 现在会强制校验 OpenClaw 兼容的规范格式(`[a-z0-9_-]`、小写、最长 64 位、且必须以字母或数字开头),避免路由匹配异常。
ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Channels 页面通过内置二维码流程完成微信连接。
### ⏰ 定时任务自动化
调度 AI 任务自动执行。定义触发器、设置时间间隔,让 AI 智能体 7×24 小时不间断工作。
现在定时任务页面已经可以直接配置外部投递,统一拆成“发送账号”和“接收目标”两个下拉选择。对于已支持的通道,接收目标会从通道目录能力或已知会话历史中自动发现,不需要再手动修改 `jobs.json`。任务的消息输入框也支持像主对话框那样以内联 `/skill` 令牌的方式插入技能(按所选智能体范围加载),让定时提示词可以直接触发技能。调度选择器现在分为**周期**和**单次**两个选项卡:周期支持每小时、每天、工作日、每周、自定义(原始 cron)等频率,并内置时间/星期选择;单次则在所选日期(显示星期)和时间执行一次。单次任务必须设置为未来时间,并会在执行完成后由运行时自动清除。
当 runtime 异步接受**立即运行**时,ClawX 会保持触发确认非阻塞,并在后台刷新 runtime 自己管理的任务,直到 Cron 卡片显示最新完成结果或达到有界停止条件。
### 🧩 可扩展技能系统
通过预构建的技能扩展 AI 智能体的能力。集成的 Skills 页面采用“本地优先”方式:会扫描托管目录与 workspace 技能目录,并且无需依赖 Gateway 即可启用或停用技能;在企业扩展接管时,也可以显示扩展提供的 marketplace。
ClawX 还会内置预装完整的文档处理技能(`pdf``xlsx``docx``pptx`),在启动时自动部署到托管技能目录(默认 `~/.openclaw/skills`),并在首次安装时默认启用。
Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、workspace、额外技能目录),并显示每个技能的实际路径,便于直接打开真实安装位置。对于 OpenClaw 自带的 bundled skills,社区版现在在打包产物里只保留并展示 `skill-creator`;开发模式和打包版启动时都会直接清理其它 bundled skill,同时把这些已删除 bundled skill 在 `openclaw.json` 中残留的旧配置一并移除。
当 cc-connect runtime 处于启用状态时,ClawX 会把已启用的本地 skills 镜像到 app userData 下托管的 Codex home 中,让内置 Codex agent 使用同一套技能,而不读取全局 skill 目录。
### 🔐 安全的供应商集成
连接多个 AI 供应商(OpenAI、Anthropic、Z.AI / GLM 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuthCodex 订阅)登录。
在开发者模式下,独立的“图像生成”页面支持配置 OpenAI 兼容生图端点(Base URL、API Key 和模型名,例如 `gpt-image-2`),生图请求会走专用的 `/v1/images/generations` 服务,聊天仍继续使用正常的 OpenAI Provider。
如果你通过 **自定义(CustomProvider** 对接 OpenAI-compatible 网关,可以在 **设置 → AI Providers → 编辑 Provider** 中配置自定义 `User-Agent`,以提高兼容性。
编辑或切换 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` 探测。
### 🌙 自适应主题
支持浅色模式、深色模式或跟随系统主题。ClawX 自动适应你的偏好设置。
### 🚀 开机启动控制
**设置 → 通用** 中,你可以开启 **开机自动启动**,让 ClawX 在系统登录后自动启动。
### 🔔 更新提示
ClawX 可以在启动时自动检查新版本。发现更新后会显示应用内提示;只有在你选择操作后,才会下载或安装更新。
---
## 快速上手
@@ -108,7 +183,7 @@ ClawX 预置了最佳实践的模型供应商配置,原生支持 Windows 平
从 [Releases](https://github.com/ValueCell-ai/ClawX/releases) 页面下载适用于你平台的最新版本。
#### 从源码开始
#### 从源码构建
```bash
# 克隆仓库
@@ -130,54 +205,277 @@ pnpm dev
3. **技能包** 选择适用于常见场景的预配置技能
4. **验证** 在进入主界面前测试你的配置
> Web search 说明:ClawX 会在 Agent 和 Gateway 两层策略中禁用 OpenClaw 的通用 `web_search` 工具
> 这也包括 Moonshot(Kimi)搜索;受管浏览器自动化和 `web_fetch` 仍然可用。
如果系统语言在支持列表中,向导会默认选中该语言;否则回退到英文
> MoonshotKimi)说明:ClawX 默认保持开启 Kimi 的 web search。
> 当配置 Moonshot 后,ClawX 也会将 OpenClaw 配置中的 Kimi web search 同步到中国区端点(`https://api.moonshot.cn/v1`)。
### 代理设置
ClawX 内置了代理设置,适用于需要通过本地代理客户端访问外网的场景,包括 Electron 本身、OpenClaw Gateway,以及 Telegram 这类频道的联网请求。
ClawX 内置了代理设置,适用于需要通过本地代理客户端访问外网的场景,包括 Electron 本身、OpenClaw Gateway、可选的 cc-connect/Codex runtime,以及 Telegram 这类频道的联网请求。
打开 **设置 → 网关 → 代理**,配置以下内容:
- **代理服务器**:所有请求默认使用的代理,填写例如 `http://127.0.0.1:7890`
- **代理服务器**:所有请求默认使用的代理
- **绕过规则**:需要直连的主机,使用分号、逗号或换行分隔
-**开发者模式** 下,还可以单独覆盖:HTTP 代理、HTTPS 代理、ALL_PROXY / SOCKS
-**开发者模式** 下,还可以单独覆盖:
- **HTTP 代理**
- **HTTPS 代理**
- **ALL_PROXY / SOCKS**
> 开发者模式覆盖项、Telegram 代理同步与 **OpenClaw Doctor** 等详细行为说明,请参阅 [docs/zh-CN/proxy-settings.md](docs/zh-CN/proxy-settings.md)。
本地代理的常见填写示例:
```text
代理服务器: http://127.0.0.1:7890
```
说明:
- 只填写 `host:port` 时,会按 HTTP 代理处理。
- 高级代理项留空时,会自动回退到“代理服务器”。
- 保存代理设置后,Electron 网络层会立即重新应用代理,并自动重启 Gateway。
- 在 cc-connect runtime 模式下,Codex 子进程会继承同一组 `HTTP_PROXY``HTTPS_PROXY``ALL_PROXY` 和绕过规则环境变量。
- 如果启用了 TelegramClawX 还会把代理同步到 OpenClaw 的 Telegram 频道配置中。
- 当 ClawX 代理处于关闭状态时,Gateway 的常规重启会保留已有的 Telegram 频道代理配置。
- 如果你要明确清空 OpenClaw 中的 Telegram 代理,请在关闭代理后点一次“保存代理设置”。
-**设置 → 高级 → 开发者** 中,Runtime Doctor 会在 OpenClaw 模式执行 `openclaw doctor --json`;在 cc-connect 模式组合执行内置的 `cc-connect doctor user-isolation``codex doctor --json`,并把权限为 0600 的审计报告写入 ClawX 托管 runtime 目录。Doctor Fix 仍只支持 OpenClaw。
- 在 Windows 打包版本中,内置的 `openclaw` CLI/TUI 会通过随包分发的 `node.exe` 入口运行,以保证终端输入行为稳定。
---
## 系统架构
ClawX 采用 **双进程 + Host API 统一接入架构**:React 渲染进程只通过统一的 host-api/api-client 抽象与后端交互,协议选择、Gateway 生命周期与 ACP Chat stdio bridge 全部由 Electron 主进程统一管理
ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用统一客户端抽象,协议选择与进程生命周期由 Electron 主进程统一管理
- **进程模型**:Electron 主进程负责窗口、网关进程监控、系统集成与自动更新;OpenClaw Gateway 作为独立运行时进程提供 AI 编排、频道和技能能力;渲染层不直接访问本地端点
- **配置交付**Gateway 运行时由 Main 使用 `config.get` / `config.set`,停止或启动中则更新解析后的 JSON5 配置;普通 Provider/Agent/Skill/模型修改不会替换进程,凭据通过 `secrets.reload` 热更新;连续 10 次心跳无响应后才会请求受生命周期保护的自动恢复。
- **ACP Chat**Chat UI 基于 ACP ([Agent Client Protocol](https://agentclientprotocol.com)) 与 OpenClaw 交互,从而在高速迭代的 OpenClaw 前找到相对稳定的聊天协议面。ACP 走 Main 持有的 stdio bridge,支持配置热重载后的历史回放认证、跨页面持续流式输出,以及由 Main 验证和加载的媒体/附件/文件活动(Changes)展示。
- **设计原则**:前端调用单一入口、Main 掌控传输策略、优雅恢复(重连/超时/退避)、安全存储与 CORS 安全。
Chat 传输会随当前 runtime 切换,但 Renderer 始终只经过同一个边界。OpenClaw Chat 使用由 Electron Main 持有的 ACP stdio bridgeRenderer 接收类型化 host events 并渲染内存中的 ACP timelinecc-connect Chat 则由 `RuntimeManager` 通过 cc-connect BridgePlatform 分派,包括 session history、progress、approval 与 generated media。两种模式都使用同一套 Host API facadeRenderer 不会直接调用 Codex。非 Chat 能力也通过 runtime provider 分派,OpenClaw 专属操作只保留在 OpenClaw adapter 内
> 完整架构说明(进程图、配置协调、ACP 文件活动语义与 Gateway 排障)请参阅 [docs/zh-CN/architecture.md](docs/zh-CN/architecture.md)
打开其它会话或页面时,尚未完成的 ACP 回复仍会继续流式接收。若在回复完成前返回,ClawX 会恢复最新的内存 timeline 并继续显示实时输出;回复完成后,普通 ACP 历史回放仍是唯一事实来源
ACP assistant 回合会显示整轮耗时。Live 计时跟随客户端观测到的 prompt 生命周期,并在应用内导航后保持连续;历史耗时由 Electron Main 根据有界的 OpenClaw transcript 时间戳计算,而且只能标注 ACP 回放已经恢复出的回合。
ACP Chat 会将标准 ACP resource 渲染为附件。用户选择的图片会显示为缩略图,并在悬停蒙层中显示文件名;其它可用的附件卡片会显示文件名,以及灰色、可截断的来源路径。当前 OpenClaw ACP adapter 遗漏 assistant 媒体时,显式的 assistant `MEDIA:` 指令也可恢复为附件卡片,且不会显示原始指令。现有本地文件引用(包括当前 workspace 外的路径)在每次预览或打开前,都会由 Electron Main 按精确的 session 和 generation 重新验证。AI 生成且可预览的本地附件(包括不超过 20 MB 的 `.docx``.pptx` 文件)会保留主要的只读应用内预览操作,并提供次级菜单,可通过兼容应用打开,或在 Finder、文件资源管理器或系统文件管理器中显示。对于本地 HTML 附件,该菜单第一项会在右侧网页浏览器中打开文件 URL。Office 预览在此处也有相同限制:`.doc``.ppt` 仍通过系统应用打开,DOCX 的分页效果可能与 Microsoft Word 不同,PPTX 的动画、切换效果和媒体播放不受支持。兼容应用发现仅在 macOS 和 Windows 上可用;在 Linux 上或发现失败时,会静默降级为仅显示文件位置。其它本地文件(包括超过 20 MB 的 Office 文件)会在用户点击后通过系统应用打开;远程 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 文件,菜单第一项会在右侧**网页浏览器**中打开本地文件 URL 并激活该选项卡;已删除的活动行只保留 **Changes** 操作。应用列表、指定应用打开和显示文件位置都会由 Electron Main 根据 workspace 根目录与相对路径分别重新验证;工具路径不会因此变成附件,Renderer 也不会获得规范化系统路径。
- `write` 按工具声明的语义显示:视为创建,并展示为全部新增的差异,即使该路径可能已经存在。
- **Changes** 是按时间顺序记录工具声明活动的会话级记录,不是 Git 输出,也不是相对于已验证源码基线的差异。
- 对每个文件,Changes 在每轮助手回复中最多展示一个 diff 编辑器。可安全串联的片段会合并,独立片段会拼接到同一个编辑器中,但不会被描述为基于完整文件基线的差异。
- Shell 命令、脚本、用户或 IDE 产生的副作用不会被检测。
- 完整的 ACP 回放可以恢复已记录的文件活动;如果回放不完整,ClawX 不会通过回退推断来补造缺失活动。
```
┌───────────────────────────────────────────────────────────────────┐
│ ClawX 桌面应用 │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Electron 主进程 │ │
│ │ • 窗口与应用生命周期管理 │ │
│ │ • 网关进程监控 │ │
│ │ • 系统集成(托盘、通知、密钥链) │ │
│ │ • 自动更新编排 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ IPC (权威控制面) │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ React 渲染进程 │ │
│ │ • 现代组件化 UI(React 19 │ │
│ │ • Zustand 状态管理 │ │
│ │ • 统一 host-api/api-client 调用 │ │
│ │ • Markdown 富文本渲染 │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬───────────────────────────────────┘
│ 类型化 IPC 请求
┌─────────────────────────────────────────────────────────────────┐
│ 主进程 Host Services 与 Runtime Manager │
│ │
│ • host:invoke 类型化服务分发 │
│ • 设置、文件、会话、技能、供应商、诊断服务 │
│ • Runtime 选择、传输与进程监控 │
└──────────────────────────────┬──────────────────────────────────┘
│ 主进程持有 WebSocket
┌─────────────────────────────────────────────────────────────────┐
│ OpenClaw 网关路径(图示) │
│ │
│ • AI 智能体运行时与编排 │
│ • 消息频道管理 │
│ • 技能/插件执行环境 │
│ • 供应商抽象层 │
└─────────────────────────────────────────────────────────────────┘
```
### 设计原则
- **进程隔离**:AI 运行时在独立进程中运行,确保即使在高负载计算期间 UI 也能保持响应
- **前端调用单一入口**:渲染层统一走 host-api/api-client,不感知底层协议细节
- **主进程掌控传输策略**OpenClaw ACP/Gateway 传输与 cc-connect BridgePlatform 分派都由 Electron Main 持有,渲染进程通过类型化 IPC 调用 Main
- **扩展 IPC 贡献点**:主进程扩展通过类型化 IPC 注册表贡献 host-api action,而不是挂载 HTTP route
- **优雅恢复**:内置重连、超时、退避逻辑,自动处理瞬时故障
- **安全存储**:API 密钥和敏感数据利用操作系统原生的安全存储机制
- **CORS 安全**:渲染进程不直接请求本地 Gateway 或 Host API HTTP 端点
### 进程模型与 Gateway 排障
- ClawX 基于 Electron,**单个应用实例出现多个系统进程是正常现象**main/renderer/zygote/utility)。
- 单实例保护同时使用 Electron 自带锁和 `~/.clawx/locks` 下的跨安装 writer lock。ClawX 会在共享数据初始化、迁移、runtime 或 scheduler 启动前取得文件锁;无法确认所有权时会拒绝启动。
- 滚动升级期间若新旧版本混跑,单实例保护仍可能出现不对称行为。为保证稳定性,建议桌面客户端尽量统一升级到同一版本。
- 但 OpenClaw Gateway 监听应始终保持**单实例**:`127.0.0.1:18789` 只能有一个监听者。
- Gateway readiness 以 OpenClaw 的 `system-presence``health``status` 等核心信号为准;memory、Dreams 或频道失败会显示为能力降级,而不是全局 Gateway 故障。
- 可用以下命令确认监听进程:
- macOS/Linux`lsof -nP -iTCP:18789 -sTCP:LISTEN`
- WindowsPowerShell):`Get-NetTCPConnection -LocalPort 18789 -State Listen`
- 点击窗口关闭按钮(`X`)默认只是最小化到托盘,并不会完全退出应用。请在托盘菜单中选择 **Quit ClawX** 执行完整退出。
---
## 使用场景
### 🤖 个人 AI 助手
配置一个通用 AI 智能体,可以回答问题、撰写邮件、总结文档并协助处理日常任务——全部通过简洁的桌面界面完成。
### 📊 自动化监控
设置定时智能体来监控新闻动态、追踪价格变动或监听特定事件。结果将推送到你偏好的通知渠道。
### 💻 开发者效率工具
将 AI 融入你的开发工作流。使用智能体进行代码审查、生成文档或自动化重复性编码任务。
### 🔄 工作流自动化
将多个技能串联起来,创建复杂的自动化流水线。处理数据、转换内容、触发操作——全部通过可视化方式编排。
---
## 开发指南
### 前置要求
- **Node.js**22.22.3+ / 24.15.0+(推荐) / 25.9.0+
- **包管理器**pnpm 9+
- **LinuxUbuntu/Debian**:运行 Electron 前先安装系统库,见 [docs/zh-CN/development.md](docs/zh-CN/development.md)
- **Node.js**对应主版本范围内的 22.22.3+24.15.0+ 25.9.0+(推荐 Node 24 LTS
- **包管理器**pnpm 9+(推荐)或 npm
- **LinuxUbuntu/Debian**:运行 Electron 前,请先安装所需系统库
```bash
sudo apt-get install -y libnss3 libgtk-3-0 libxss1 libxtst6 libatspi2.0-0 libnotify4 xdg-utils
```
在 Ubuntu 24.04+ 上,部分软件包使用 `t64` 后缀,运行上述命令后 `apt` 会自动选择正确版本。
### 项目结构
```ClawX/
├── electron/ # Electron 主进程
│ ├── services/ # 类型化 Host API、Provider、Secrets 与运行时服务
│ │ ├── providers/ # Provider/account 模型同步逻辑
│ │ └── secrets/ # 系统钥匙串与密钥存储
│ ├── shared/ # 共享 Provider schema/常量
│ │ └── providers/
│ ├── main/ # 应用入口、窗口、IPC 注册
│ ├── gateway/ # OpenClaw 网关进程管理
│ ├── preload/ # 安全 IPC 桥接
│ └── utils/ # 工具模块(存储、认证、路径)
├── src/ # React 渲染进程
│ ├── lib/ # 前端统一 API 与错误模型
│ ├── stores/ # Zustand 状态仓库(settings/chat/gateway
│ ├── components/ # 可复用 UI 组件
│ ├── pages/ # Setup/Dashboard/Chat/Channels/Skills/Cron/Settings
│ ├── i18n/ # 国际化资源
│ └── types/ # TypeScript 类型定义
├── tests/
│ ├── e2e/ # Playwright Electron 端到端冒烟测试
│ └── unit/ # Vitest 单元/集成型测试
├── resources/ # 静态资源(图标、图片)
└── scripts/ # 构建与工具脚本
```
### 常用命令
cc-connect 真实验证可以加载本地 env 文件,但仓库内的凭据文件必须被 gitignore;仓库外 `--env-file` 路径可以使用且不会写入报告。`.env.cc-connect.local.example` 是 `.env.cc-connect.local` 的字段模板。
```bash
pnpm run init # 初始化开发环境(安装依赖并下载捆绑运行时)
pnpm dev # 以热重载模式启动
pnpm lint # ESLint 检查
pnpm typecheck # TypeScript 类型检查
pnpm test # 单元测试
pnpm run test:e2e # Electron E2E 冒烟测试
pnpm build # 完整生产构建
pnpm package # 为当前平台打包(可用 :mac / :win / :linux 后缀)
# 开发
pnpm run init # 安装依赖并下载捆绑二进制(uv、agent-browser
pnpm dev # 以热重载模式启动(若缺失会自动准备预装技能包)
# 代码质量
pnpm lint # 运行 ESLint 检查
pnpm typecheck # TypeScript 类型检查
# 测试
pnpm test # 运行单元测试
pnpm run test:e2e # 运行 Electron E2E 冒烟测试
pnpm run test:e2e:cc-connect:codex-oauth-lifecycle # 无需真实凭证验证 cc-connect Codex OAuth Host API 状态/导入/登出
CLAWX_REAL_OAUTH_E2E=1 CLAWX_REAL_CODEX_AUTH_JSON="$HOME/.codex/auth.json" pnpm run test:e2e:cc-connect:real-oauth # 验证真实 OAuth 工具执行和 Chat execution graph
pnpm run test:e2e:headed # 以可见窗口运行 Electron E2E 测试
pnpm run comms:replay # 计算通信回放指标
pnpm run comms:baseline # 刷新通信基线快照
pnpm run comms:compare # 将回放指标与基线阈值对比
pnpm run verify:cc-connect:local-real # 写入本地 cc-connect 真实验证前置报告
pnpm run verify:cc-connect:local-real:run # 执行安全的本地 cc-connect 真实验证检查并写入报告
pnpm run verify:cc-connect:local-real:oauth # CLAWX_REAL_CODEX_AUTH_JSON 包含完整 refresh token 字段时额外执行开发版 cc-connect 真实 OAuth 综合冒烟
pnpm run verify:cc-connect:local-real:oauth-all # CLAWX_REAL_CODEX_AUTH_JSON 包含完整 refresh token 字段时额外执行开发版和打包版 cc-connect 真实 OAuth 冒烟
pnpm run verify:cc-connect:local-real:api-key # 执行本地 OpenAI-compatible API-key chat/abort 冒烟;有真实凭证时额外执行真实 OpenAI API-key 冒烟
pnpm run verify:cc-connect:local-real:feishu # 有凭证和 CLAWX_REAL_CODEX_AUTH_JSON 时额外执行真实飞书/Lark 生命周期冒烟
pnpm run verify:cc-connect:local-real:feishu-inbound # 沙箱租户入站 fixture 启用时额外执行真实飞书/Lark inbound marker 冒烟
pnpm run verify:cc-connect:local-real:scheduled-cron # 执行真实原生 exec cron;有 Codex auth 时通过 cc-connect public session history 验证原生 prompt 调度
pnpm run verify:cc-connect:local-real:all # 执行所有可用的本地 cc-connect 真实验证路径,并写入外部门禁交接清单
pnpm run verify:cc-connect:local-real:all-strict # 发布候选验证要求所有真实凭证和 runtime parity 覆盖都通过;失败前也会写入交接清单
pnpm run verify:cc-connect:local-real:replacement-ready # 要求 replacement readiness 通过,但不把缺失凭证单独作为前置失败;失败前也会写入交接清单
pnpm run verify:cc-connect:local-real:replacement-ready:check # 同样检查 readiness,但不覆盖上一次报告产物
pnpm run verify:cc-connect:local-real:packaged-oauth # CLAWX_REAL_CODEX_AUTH_JSON 包含完整 refresh token 字段时额外执行打包版 cc-connect 真实 OAuth 冒烟
pnpm run verify:cc-connect:local-real:external-gates:check # 非破坏性检查剩余 required external gates,不覆盖报告产物
pnpm run verify:cc-connect:local-real:external-gates # 只运行剩余 required external gates,三项全部通过才成功
pnpm run verify:cc-connect:local-real:handoff # 生成不含凭证的剩余外部门禁交接清单
# 报告写入 artifacts/cc-connect/local-real-validation-report.{json,md}
# :all、:all-strict、:replacement-ready、:external-gates 或 :handoff 会把外部门禁交接清单写入 artifacts/cc-connect/local-real-external-gates.{md,json}。
# JSON 交接清单可供机器读取,只包含清洗后的状态、环境变量名、命令和安全说明。
# runtimeMatrixStatus 会把 pass/partial/fail 覆盖状态和硬门禁退出状态分开展示。
# 使用 --no-write、replacement-ready:check 或 external-gates:check 做非破坏性门禁检查;缺失前置条件和下一步命令会以不含密钥值的形式打印。
# validationGaps 会区分本地硬门禁缺口和完整替代所需的 follow-up 证据缺口。
# partial 报告会包含 Next Actions,列出后续命令且不写入密钥值。
# 真实凭证可通过未跟踪且已 gitignore 的 .env.cc-connect.local、--env-file=<path>、
# 或 CLAWX_REAL_ENV_FILE / CLAWX_REAL_ENV_FILES 提供;显式进程环境变量优先。
# API-key 冒烟在默认模型不可用时可设置 CLAWX_REAL_OPENAI_MODEL。
# 构建与打包
pnpm run build:vite # 仅构建前端
pnpm build # 完整生产构建(含打包资源)
pnpm package # 为当前平台打包(包含预装技能资源)
pnpm package:mac # 为 macOS 打包
pnpm package:win # 为 Windows 打包
pnpm package:linux # 为 Linux 打包
pnpm run verify:runtime-bundles # 校验下载的 cc-connect/Codex bundle manifest 与二进制
pnpm run verify:packaged-runtime-resources -- --resources=<路径> --platform=<darwin|win32|linux> --arch=<x64|arm64> # 校验最终 Electron runtime resources
pnpm run smoke:cc-connect:packaged # 启动当前平台 unpacked app,验证 cc-connect 启动/状态/Cron/Doctor/回滚/清理
```
> 项目结构、技术栈、完整命令列表、E2E 并行策略、性能诊断与通信回归检查等细节,请参阅 [docs/zh-CN/development.md](docs/zh-CN/development.md)
在无头 Linux 环境下,Electron 测试需要显示服务;可使用 `xvfb-run -a pnpm run test:e2e`
### 通信回归检查
当 PR 涉及通信链路(Gateway 事件、ACP Chat bridge 收发流程、Channel 投递、传输回退)时,建议执行:
```bash
pnpm run comms:replay
pnpm run comms:compare
```
CI 中的 `comms-regression` 会校验必选场景与阈值。
### 技术栈
| 层级 | 技术 |
|------|------|
| 运行时 | Electron 40+ |
| UI 框架 | React 19 + TypeScript |
| 样式 | Tailwind CSS + shadcn/ui |
| 状态管理 | Zustand |
| 构建工具 | Vite + electron-builder |
| 测试 | Vitest + Playwright |
| 动画 | Framer Motion |
| 图标 | Lucide React |
---
## 参与贡献
@@ -186,8 +484,10 @@ pnpm package # 为当前平台打包(可用 :mac / :win / :linux 后
### 如何贡献
1. **Fork** 本仓库
2. **创建** 功能分支(`git checkout -b feature/amazing-feature`),进行开发
3. **提交** 清晰描述的变更,**推送** 到你的分支,并**创建** Pull Request
2. **创建** 功能分支(`git checkout -b feature/amazing-feature`
3. **提交** 清晰描述的变更
4. **推送** 到你的分支
5. **创建** Pull Request
### 贡献规范
@@ -196,6 +496,7 @@ pnpm package # 为当前平台打包(可用 :mac / :win / :linux 后
- 按需更新文档
- 保持提交原子化且描述清晰
---
## 致谢
@@ -207,6 +508,7 @@ ClawX 构建于以下优秀的开源项目之上:
- [shadcn/ui](https://ui.shadcn.com/) 精美设计的组件库
- [Zustand](https://github.com/pmndrs/zustand) 轻量级状态管理
---
## 社区
@@ -220,10 +522,13 @@ ClawX 构建于以下优秀的开源项目之上:
我们正在启动 ClawX 合作伙伴计划,寻找能够帮助我们将 ClawX 介绍给更多客户的合作伙伴,尤其是那些有定制化 AI 智能体或自动化需求的客户。
合作伙伴负责帮助我们连接潜在用户和项目,ClawX 团队则提供完整的技术支持、定制开发与集成服务。如果你服务的客户对 AI 工具或自动化方案感兴趣,欢迎与我们合作。
合作伙伴负责帮助我们连接潜在用户和项目,ClawX 团队则提供完整的技术支持、定制开发与集成服务。
如果你服务的客户对 AI 工具或自动化方案感兴趣,欢迎与我们合作。
欢迎私信我们,或发送邮件至 [public@valuecell.ai](mailto:public@valuecell.ai) 了解更多。
---
## Stars 历史
@@ -231,13 +536,13 @@ ClawX 构建于以下优秀的开源项目之上:
<img src="https://api.star-history.com/svg?repos=ValueCell-ai/ClawX&type=Date" alt="Stars 历史图表" />
</p>
---
## 许可证
ClawX 基于 [MIT 许可证](LICENSE) 发布。你可以自由地使用、修改和分发本软件。
<hr>
---
<p align="center">
<sub>由 ValueCell 团队用 ❤️ 打造</sub>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

-113
View File
@@ -1,113 +0,0 @@
# ClawX Architecture
This document provides the detailed version of the Architecture section in the README.
ClawX uses a **dual-process architecture with a unified Host API layer**. The renderer calls one client abstraction, while protocol selection and process lifecycle are managed by Electron Main:
OpenClaw configuration delivery is also managed by Electron Main. While the Gateway is running, ClawX uses the authoritative snapshot returned by `config.get` as its baseline and commits changes with `config.set`. While the Gateway is stopped or starting, the same coordinator updates the resolved JSON5 configuration file without starting the Gateway. Ordinary provider, agent, channel, binding, skill, and model changes therefore do not replace the Gateway process. Full restarts are reserved for process-launch environment changes such as proxy settings and explicit user actions. Confirmed process exits and WebSocket closes retain their existing automatic reconnect paths. The first nine consecutive WebSocket heartbeat misses remain diagnostic-only so brief pong delays do not interrupt long-running work; a pong or any incoming message resets the count, while a tenth consecutive miss requests guarded automatic Gateway recovery when the lifecycle is in an auto-recoverable running state. After authentication configuration is written to SQLite, ClawX calls OpenClaw's `secrets.reload` so running agents can read new credentials without a process restart.
Chat uses an ACP stdio bridge owned by Electron Main. Main passes the same app-managed Gateway token to this local child through its private process environment, so ACP history replay remains authenticated when the runtime configuration reloads. The renderer receives typed host events and renders an in-memory ACP timeline. The Gateway remains responsible for non-Chat capabilities such as providers, models, skills, workspace, settings, diagnostics, and media configuration.
### ACP Semantic Authority
ACP is the preferred semantic authority for every Chat meaning and context that it exposes. This includes session identity and routing where applicable, workspace and execution `cwd`, prompt and timeline state, and standard resource or attachment semantics. When ACP provides a value or event, Main and Renderer must use it rather than replace it with a Gateway snapshot, transcript inference, local configuration, or a parallel projection.
A bypass is allowed only when upstream ACP has no equivalent. Such a compatibility path must be narrow, bounded, session- and generation-scoped, and documented with its rationale, source of truth, limits, reconciliation behavior, and removal condition in the relevant Harness reference or rule. It must not silently become a competing authority.
### ACP History Authority and Bounded Transcript Supplements
ACP `session/load` replay is the primary authority for Chat history. ClawX does not persist a second ACP ledger, reduced timeline, replay cache, or reconstructed tool history. Some OpenClaw capabilities do not yet have fully corresponding ACP implementations; for example, assistant media may be omitted from ACP and Gateway processing may remove assistant `MEDIA:` directives from the visible live reply. ClawX therefore keeps only bounded, marked, memory-only compatibility supplements:
- Asynchronous image-generation completions may be restored only when the same session has proven `image_generate` context and the completion evidence is trusted or approved transcript evidence.
- General attachments may be recovered from canonical persisted assistant `__openclaw.media` facts or explicit line-leading assistant `MEDIA:` directives. This recovers attachment references and declared metadata, not the surrounding assistant message.
- Main may add metadata-only whole-turn timing from bounded transcript JSONL records because ACP replay does not provide the original event timestamps. It can annotate only an already restored ACP turn.
- If a cron session has completely empty ACP replay, Main's typed cron-history API may provide the scheduled prompt and completion summary. When an identified run summary contains OpenClaw's truncation marker, Main may recover the final assistant text from that run's transcript only when the transcript is longer and shares the complete persisted summary prefix.
Historical reads are bounded to the newest 1000 transcript messages. A successful live prompt performs one immediate read and one retry after 1500 ms. Every supplement is scoped to the exact session, ACP generation, operation, and live user turn where applicable; stale, missing, duplicate, or ambiguous matches are discarded. These paths must not reconstruct ordinary assistant messages, thoughts, tools, plans, permissions, file activity, missing turns, or a parallel Chat history, and Main must not manufacture native ACP events from transcript evidence. Standard ACP resources remain preferred, and these compatibility exceptions should be removed when upstream emits equivalent content.
An unfinished ACP response continues 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 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 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 the 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.
- Side effects made by shell commands, scripts, users, or IDEs are not detected.
- A full ACP replay can restore recorded file activity. If replay is incomplete, ClawX does not infer missing activity through fallback behavior.
```
┌──────────────────────────────────────────────────────────────────┐
│ ClawX Desktop App │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Electron Main Process │ │
│ │ • Window and application lifecycle management │ │
│ │ • Gateway process supervision │ │
│ │ • System integration (tray, notifications, keychain) │ │
│ │ • Auto-update orchestration │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬───────────────────────────────────┘
│ IPC (authoritative control plane)
┌──────────────────────────────────────────────────────────────────┐
│ React Renderer Process │
│ • Modern component-based UI (React 19) │
│ • State management with Zustand │
│ • Unified host-api/api-client calls │
│ • Markdown assistant replies, literal user input │
└──────────────────────────────┬───────────────────────────────────┘
│ Typed IPC requests
┌──────────────────────────────────────────────────────────────────┐
│ Main Host Services and Gateway Manager │
│ • host:invoke typed service dispatcher │
│ • Settings, files, sessions, skills, providers, diagnostics │
│ • Main-owned Gateway WebSocket and process supervision │
└──────────────────────────────┬───────────────────────────────────┘
│ Main-owned WebSocket
┌──────────────────────────────────────────────────────────────────┐
│ OpenClaw Gateway │
│ • AI agent runtime and orchestration │
│ • Message channel management │
│ • Skill/plugin execution environment │
│ • Provider abstraction layer │
└──────────────────────────────────────────────────────────────────┘
```
### Design Principles
- **Process Isolation**: The AI runtime operates in a separate process, keeping the UI responsive even during heavy computation.
- **Single Entry for Frontend Calls**: Renderer requests go through `host-api` / `api-client`; protocol details are hidden behind a stable interface.
- **Main-Process Transport Ownership**: Electron Main owns the ACP Chat stdio bridge and Gateway transports; the renderer talks to Main over typed IPC.
- **Extension IPC Contributions**: Main-process extensions contribute host-api actions through the typed IPC registry instead of HTTP routes.
- **Graceful Recovery**: Built-in reconnect, timeout, and backoff logic handles transient failures automatically.
- **Secure Storage**: API keys and sensitive data use the operating system's native secure storage mechanisms.
- **CORS-Safe by Design**: The renderer does not call local Gateway or Host API HTTP endpoints directly.
### Process Model and Gateway Troubleshooting
- ClawX is an Electron app, so **one app instance normally appears as multiple OS processes** (main/renderer/zygote/utility). This is expected.
- Single-instance protection uses Electron's lock plus a local process-file lock fallback, preventing duplicate app launches in environments where desktop IPC or the session bus is unstable.
- During rolling upgrades, mixed old and 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 or channel failures are shown as capability degradation rather than global Gateway failure.
- To verify the active listener:
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
- Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen`
- Clicking the window close button (`X`) hides ClawX to the tray; it does not fully quit the app. Use **Quit ClawX** in the tray menu for a complete shutdown.
-130
View File
@@ -1,130 +0,0 @@
# ClawX Development Guide
This document provides the detailed version of the Development section in the README.
### Prerequisites
- **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+ (npm is also supported)
- **Linux (Ubuntu/Debian)**: Install the required system libraries before running Electron:
```bash
sudo apt-get install -y libnss3 libgtk-3-0 libxss1 libxtst6 libatspi2.0-0 libnotify4 xdg-utils
```
On Ubuntu 24.04+, some packages use a `t64` suffix; `apt` automatically selects the correct variant when you run the command above.
### Project Structure
```text
ClawX/
├── electron/ # Electron Main Process
│ ├── services/ # Typed Host API, provider, secrets, and runtime services
│ │ ├── providers/ # Provider/account model sync logic
│ │ └── secrets/ # OS keychain and secret storage
│ ├── shared/ # Shared provider schemas/constants
│ │ └── providers/
│ ├── main/ # App entry, windows, and IPC registration
│ ├── gateway/ # OpenClaw Gateway process manager
│ ├── preload/ # Secure IPC bridge
│ └── utils/ # Utilities for storage, auth, and paths
├── src/ # React Renderer Process
│ ├── lib/ # Unified frontend API and error model
│ ├── stores/ # Zustand stores (settings/chat/gateway)
│ ├── components/ # Reusable UI components
│ ├── pages/ # Setup/Dashboard/Chat/Channels/Skills/Cron/Settings
│ ├── i18n/ # Localization resources
│ └── types/ # TypeScript type definitions
├── tests/
│ ├── e2e/ # Playwright Electron end-to-end smoke tests
│ └── unit/ # Vitest unit and integration-like tests
├── resources/ # Static assets (icons and images)
└── scripts/ # Build and utility scripts
```
### Available Commands
```bash
# Development
pnpm run init # Install dependencies and download bundled binaries (uv, agent-browser)
pnpm dev # Start with hot reload (auto-prepares bundled skills if missing)
# Quality
pnpm lint # Run ESLint
pnpm typecheck # TypeScript validation
# Testing
pnpm test # Run unit tests
pnpm run test:e2e # Run Electron E2E smoke tests
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 the communication baseline snapshot
pnpm run comms:compare # Compare replay metrics against baseline thresholds
# Build and Package
pnpm run build:vite # Build the frontend only
pnpm build # Full production build with packaging assets
pnpm package # Package for the current platform with bundled skills
pnpm package:mac # Package for macOS
pnpm package:win # Package for Windows
pnpm package:linux # Package for Linux
```
On headless Linux, Electron tests need a display service. Use `xvfb-run -a pnpm run test:e2e`.
Electron E2E functional tests 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 and scroll interaction. It writes versioned metrics plus Renderer and Main CPU profiles under the Playwright `test-results/` directory. Renderer profiles cover the production store/render path and frame pacing. The streaming Main profile measures Main-to-Renderer IPC fanout, while 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 such as Gateway events, the ACP Chat bridge send/receive flow, channel delivery, or transport fallback, run:
```bash
pnpm run comms:replay
pnpm run comms:compare
```
The `comms-regression` CI job enforces required scenarios and threshold checks.
### Electron E2E Tests
The Playwright Electron suite launches the packaged renderer and Main process from `dist/` and `dist-electron/`, so it does not require manually running `pnpm dev` first.
`pnpm run test:e2e` automatically:
- 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
The first baseline specs cover:
- first-launch Setup Wizard visibility on a fresh profile
- 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`. 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 |
|-------|------------|
| Runtime | Electron 40+ |
| UI Framework | React 19 + TypeScript |
| Styling | Tailwind CSS + shadcn/ui |
| State | Zustand |
| Build | Vite + electron-builder |
| Testing | Vitest + Playwright |
| Animation | Framer Motion |
| Icons | Lucide React |
-83
View File
@@ -1,83 +0,0 @@
# ClawX Features
This document provides the detailed version of the Features section in the README.
### Zero Configuration Barrier
Complete the entire setup from installation to your first AI conversation through an intuitive graphical interface. No terminal commands, YAML files, or environment-variable hunting are required.
### Intelligent Chat Interface
Communicate with AI agents through a modern chat experience. ClawX supports multiple conversation contexts and message history, with 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\]`). User input remains literal text. The main composer also supports direct `@agent` routing for multi-agent setups. Fenced code preserves source line breaks, soft-wraps long lines, and provides a localized copy action after streaming completes.
Skills inserted from the composer appear as `/skill-name` cards. Click a card to open the preview sidebar and read that skill's `SKILL.md`.
When you target another agent with `@agent`, ClawX switches directly to that agent's own conversation context instead of relaying through the default agent. Agent workspaces stay separate by default, while stronger runtime isolation depends on OpenClaw sandbox settings.
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 session 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 a valid workspace is selected, a new chat inherits it while remaining editable until the first send. Editable new or unbound chats expose a workspace chip in the composer. Its menu lists recent and known-session workspaces, lets you return to the default workspace, or choose another folder. If a saved workspace folder was moved or deleted, Chat pauses session creation and asks 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 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 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 press 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 contains Workspace, Preview, and Changes tabs. It no longer provides 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. 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 supports multiple accounts, per-account agent binding, and switching the channel default account directly from the Channels page.
For custom channel account IDs, ClawX enforces OpenClaw-compatible canonical IDs: `[a-z0-9_-]`, lowercase, a maximum of 64 characters, and starting with a letter or number. This prevents routing mismatches.
ClawX also bundles Tencent's official personal WeChat channel plugin, so you can link WeChat directly from the Channels page through an in-app QR flow.
### Cron-Based Automation
Schedule AI tasks to run automatically. Define triggers and set intervals so AI agents can work around the clock.
The Cron page lets you configure external delivery directly in the task form with separate sender-account and recipient-target selectors. For supported channels, recipient targets are discovered automatically from channel directories or known session history, so you no longer need to edit `jobs.json` by hand. The task message field supports inserting skills with the same inline `/skill` token syntax as the main chat composer, scoped to the selected agent, so scheduled prompts can trigger skills directly.
The schedule picker is split into **Recurring** and **Once** tabs. Recurring offers Hourly, Daily, Weekdays, Weekly, and Custom raw cron frequencies with inline time and weekday controls. Once runs the task a single time at a chosen date, with the weekday shown, and time. One-time tasks must be scheduled for a future moment and are automatically removed by the runtime once they finish.
### Extensible Skill System
Extend your AI agents with pre-built skills. The integrated Skills page is local-first: it scans managed and workspace skill directories and lets you enable or disable skills without depending on the Gateway. Enterprise extensions may also expose an extension-provided marketplace.
ClawX pre-bundles full document-processing skills (`pdf`, `xlsx`, `docx`, `pptx`), deploys them automatically to the managed skills directory (default `~/.openclaw/skills`) on startup, and enables them by default on first install.
The Skills page can display skills discovered from multiple OpenClaw sources, including the managed directory, workspace, and extra skill directories. It shows each skill's actual location so you can open the real folder directly. For bundled OpenClaw skills, community builds ship and expose only `skill-creator`; non-allowlisted bundled skills are physically trimmed in both development and packaged startup, and stale `openclaw.json` entries for removed bundled skills are pruned.
### Secure Provider Integration
Connect to multiple AI providers, including OpenAI, Anthropic, and Z.AI / GLM, with credentials stored securely in the native system keychain. OpenAI supports both API keys and browser OAuth for Codex subscriptions.
In Developer Mode, the dedicated Image Generation page supports an independent OpenAI-compatible image-generation endpoint with a Base URL, API key, and model name such as `gpt-image-2`. Image generation can therefore use a dedicated `/v1/images/generations` service while chat continues using the normal OpenAI provider.
For **Custom** providers used with OpenAI-compatible gateways, you can set a custom `User-Agent` in **Settings -> AI Providers -> Edit Provider** for compatibility-sensitive endpoints.
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, such as `gpt-5.x` -> 272k. Rows saved by older versions are backfilled on startup so OpenClaw can compact long sessions before they fail with "Context overflow" errors. When no compaction configuration exists, ClawX seeds `agents.defaults.compaction.mode = "safeguard"` and `reserveTokensFloor = 50000`; rows or configurations you authored yourself are never modified, except that a missing `reserveTokensFloor` may be backfilled.
Z.AI (CN / Global) maps to OpenClaw's built-in `zai` provider (`ZAI_API_KEY`). The 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-authentication reasons, ClawX automatically falls back to a lightweight `/chat/completions` or `/responses` probe using the configured model during API-key validation.
### Adaptive Theming
Choose light mode, dark mode, or a system-synchronized theme. ClawX adapts to your preferences automatically.
### Startup Launch Control
In **Settings -> General**, enable **Launch at system startup** so ClawX starts automatically after login.
### Update Prompts
ClawX checks for new versions on startup. When an update is available, it shows an in-app prompt; downloading and installing happen only after you choose the action.
-12
View File
@@ -1,12 +0,0 @@
# ClawX Proxy Settings
This document provides the detailed version of the Proxy Settings section in the README.
- A bare `host:port` value is treated as an HTTP proxy.
- If advanced proxy fields are left empty, ClawX falls back to **Proxy Server**.
- Saving proxy settings reapplies Electron networking immediately and restarts the Gateway automatically.
- When Telegram is enabled, ClawX also syncs the proxy to OpenClaw's Telegram channel configuration.
- When the ClawX proxy is disabled, a normal Gateway restart preserves an existing Telegram channel proxy.
- To explicitly clear the Telegram proxy from OpenClaw configuration, disable the proxy and save the proxy settings once.
- In **Settings -> Advanced -> Developer**, you can run **OpenClaw Doctor**, which executes `openclaw doctor --json` and displays the diagnostic output in the app.
- In packaged Windows builds, the bundled `openclaw` CLI/TUI runs through the shipped `node.exe` entry point to keep terminal input behavior stable.
-113
View File
@@ -1,113 +0,0 @@
# ClawXのアーキテクチャ
このドキュメントは、READMEの「アーキテクチャ」セクションの詳細版です。
ClawXは **統合Host APIレイヤーを備えたデュアルプロセスアーキテクチャ**を採用しています。Rendererは単一のクライアント抽象を呼び出し、プロトコル選択とプロセスライフサイクルはElectron Mainが管理します。
OpenClawの設定配信もElectron Mainが管理します。Gateway実行中は`config.get`が返す権威あるスナップショットを基準にし、変更を`config.set`でコミットします。Gatewayが停止中または起動中の場合は、同じコーディネーターが解決済みJSON5設定ファイルを更新しますが、これを理由にGatewayを起動することはありません。そのため、通常のプロバイダー、Agent、チャネル、バインディング、スキル、モデルの変更ではGatewayプロセスを置き換えません。完全な再起動は、プロキシなどのプロセス起動環境の変更と、ユーザーによる明示的な操作に限られます。確認済みのプロセス終了とWebSocket切断では、既存の自動再接続経路が引き続き使用されます。WebSocketハートビートの連続9回までの欠落は診断のみとし、短いpong遅延で長時間実行中の処理を中断しません。pongまたは任意の受信メッセージでカウントをリセットし、10回連続で欠落した場合に、ライフサイクルが自動復旧可能なrunning状態であれば、保護されたGateway自動復旧を要求します。認証設定をSQLiteへ書き込んだ後はOpenClawの`secrets.reload`を呼び出し、実行中のAgentがプロセス再起動なしで新しい認証情報を読み取れるようにします。
ChatはElectron Mainが所有するACP stdio bridgeを使用します。Mainはアプリが管理するGateway tokenをプライベートなプロセス環境経由でローカルの子プロセスへ渡すため、ランタイム設定の再読み込み後もACP履歴リプレイの認証が維持されます。Rendererは型付きhost eventを受け取り、メモリ上のACP timelineを描画します。Gatewayはproviders、models、skills、workspace、settings、diagnostics、media configurationなどの非Chat機能を引き続き担当します。
### ACPのセマンティック権威
ACPが提供するすべてのChatの意味とコンテキストでは、`session/load`履歴だけでなくACPを優先的なセマンティック権威として扱います。該当する場合のセッションIDとルーティング、ワークスペースと実行`cwd`、promptとtimelineの状態、標準resourceや添付ファイルのセマンティクスが含まれます。ACPが値やイベントを提供する場合、MainとRendererはGatewayスナップショット、transcriptからの推論、ローカル設定、別の並列投影で置き換えず、ACPの結果を使用します。
上流ACPに相当する機能がない場合に限り、ACPを迂回できます。その互換性パスは狭く有界で、sessionとgenerationに紐付ける必要があります。また、理由、情報源、制限、調整方法、削除条件を該当するHarness referenceまたはruleに記録し、競合する権威へ暗黙に発展させてはいけません。
### ACP履歴の権威と有界なtranscript補足
ACP `session/load` のリプレイがChat履歴の主要な権威です。ClawXは第二のACP ledger、縮約timeline、リプレイキャッシュ、再構成したツール履歴を永続化しません。OpenClawの一部の機能にはまだ完全に対応するACP実装がありません。たとえば、assistantメディアがACPから省略されたり、Gateway処理によってassistantの`MEDIA:`ディレクティブが表示中のライブ返信から削除されたりする場合があります。そのため、ClawXは有界で印付き、メモリのみの互換性補足だけを保持します。
- 非同期の画像生成完了は、同じセッションに確認済みの`image_generate`コンテキストがあり、完了の証拠が信頼できるか、承認済みのtranscript証拠である場合に限り復元できます。
- 一般の添付ファイルは、永続化されたassistantの`__openclaw.media`事実、または行頭にある明示的なassistant `MEDIA:`ディレクティブから復元できます。復元されるのは添付ファイルの参照と宣言されたメタデータだけで、周囲のassistantメッセージは復元しません。
- ACPリプレイには元のイベントタイムスタンプがないため、Mainは有界のtranscript JSONLレコードからメタデータのみのターン全体の時間を追加できます。これはACPですでに復元されたターンにだけ付与できます。
- cronセッションのACPリプレイが完全に空の場合、Mainの型付きcron履歴APIがスケジュール済みプロンプトと完了サマリーを提供できます。識別された実行サマリーにOpenClawの切り詰めマーカーがある場合、対応するrunのtranscriptがより長く、永続化されたサマリーの完全な接頭辞を共有するときに限り、Mainは最終assistantテキストを復元できます。
履歴の読み取りは最新のtranscriptメッセージ1000件に制限されます。成功したライブpromptでは直ちに1回読み取り、1500ms後に1回だけ再試行します。すべての補足は、正確なsession、ACP generation、操作、必要に応じてライブユーザーターンに紐付けられます。古い、欠落した、重複した、または曖昧な一致は破棄されます。これらの経路で通常のassistantメッセージ、thought、tool、plan、permission、ファイルアクティビティ、欠落したターン、別のChat履歴を再構成してはいけません。Mainはtranscriptの証拠からネイティブACPイベントを生成しません。標準ACP resourceが優先され、上流が同等の内容を提供した場合はこれらの互換性例外を削除します。
別の会話やページを開いても、未完了のACP応答はストリーミングを継続します。完了前に戻ると最新のメモリ内timelineを復元し、ライブ応答の表示を続けます。完了後は通常のACP履歴リプレイが正の情報源です。
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添付では、そのメニューの先頭項目から右側のPreviewタブでファイルを開けます。
Officeプレビューには同じ制限があります。`.doc``.ppt`はシステムアプリで開き、DOCXのページ区切りはMicrosoft Wordと異なる場合があり、PPTXのアニメーション、画面切り替え、メディア再生はサポートされません。対応アプリの検出はmacOSとWindowsでのみ利用でき、Linuxまたは検出失敗時は通知なしにファイル位置の表示だけへ切り替わります。その他のローカルファイル(20 MBを超えるOfficeファイルを含む)は、クリック後にシステムアプリで開きます。ユーザーが選択したフォルダー添付は送信後も利用でき、クリックするとシステムのファイルマネージャーで開きます。ClawXはその内容を読み取ったりプレビューしたりしません。リモートHTTP/HTTPS添付はクリック後に外部で開きます。正規のメディア情報を伴わない通常の文章中のパスは添付として扱われません。
ACP Chatは、ランタイムが画像生成メディアを信頼できる構造化メディアとして配信した場合、生成画像のプレビューも表示できます。信頼できるOpenClaw internal-UI配信と画像生成タスクに紐付いた最終返信では、テキストだけの失敗説明を含む元のユーザー向け完了テキストを保持し、汎用画像キャプションに置き換えません。OpenClawの履歴リプレイ中、assistant画像の`MEDIA:`マーカーは、同じセッションで画像生成タスクの開始が記録されている場合に限りインライン画像へ昇格します。プレビューは任意のRendererファイルシステムアクセスではなく、Electron Mainのホストメディア処理で読み込みます。標準ACPの画像とresourceコンテンツが引き続き優先され、そのまま描画されます。
### ACPファイルアクティビティのセマンティクス
- ファイルアクティビティは、成功して完了したOpenClawの`write``edit``apply_patch`呼び出しから投影されます。ツール認識は公式OpenClaw Chat UIに従い、完了した呼び出しだけに絞る処理はClawX固有です。
- 作成・変更された行は、プレビュー可能なassistant添付と同じファイルカードと**Open with**メニューを使い、状態と任意の`+/-`概要を保持します。HTMLではメニューの先頭項目が右側の**Preview**タブでファイルを開きます。削除行には **Changes** 操作だけを残します。アプリ一覧、選択アプリで開く操作、表示位置の要求は、workspaceルートと相対パスからElectron Mainが個別に再検証します。ツール由来のパスは添付にならず、Rendererへ正規化済みのネイティブパスも公開されません。
- `write` はツールの宣言どおり、対象パスがすでに存在する可能性があっても、作成および全行追加の差分として表示されます。
- **Changes** はツールが宣言したアクティビティを時系列に記録するセッション単位の記録です。Gitの出力でも、検証済みソースベースラインとの差分でもありません。
- 各ファイルについて、Changesはassistantの各ターンに最大1つのdiffエディターを表示します。安全に連結できる断片は合成し、独立した断片は1つのエディターに連結しますが、完全なファイルベースラインとの差分とはみなしません。
- シェルコマンド、スクリプト、ユーザー、IDEによる副作用は検出されません。
- 完全なACPリプレイから記録済みのファイルアクティビティを復元できます。リプレイが不完全でも、ClawXはフォールバック推論で欠落を補いません。
```
┌──────────────────────────────────────────────────────────────────┐
│ ClawX デスクトップアプリ │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Electron メインプロセス │ │
│ │ • ウィンドウとアプリケーションのライフサイクル管理 │ │
│ │ • Gatewayプロセスの監視 │ │
│ │ • システム統合(トレイ、通知、キーチェーン) │ │
│ │ • 自動更新のオーケストレーション │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬───────────────────────────────────┘
│ IPC(権威ある制御プレーン)
┌──────────────────────────────────────────────────────────────────┐
│ React Rendererプロセス │
│ • モダンなコンポーネントベースUI(React 19) │
│ • Zustandによる状態管理 │
│ • 統一host-api/api-client呼び出し │
│ • assistant返信はMarkdown、ユーザー入力はプレーンテキスト │
└──────────────────────────────┬───────────────────────────────────┘
│ 型付きIPCリクエスト
┌──────────────────────────────────────────────────────────────────┐
│ Main Host ServicesとGateway Manager │
│ • host:invoke型付きサービスディスパッチ │
│ • 設定、ファイル、セッション、スキル、プロバイダー、診断 │
│ • Main所有のGateway WebSocketとプロセス監視 │
└──────────────────────────────┬───────────────────────────────────┘
│ Main所有WebSocket
┌──────────────────────────────────────────────────────────────────┐
│ OpenClaw Gateway │
│ • AIエージェントランタイムとオーケストレーション │
│ • メッセージチャネル管理 │
│ • スキル/プラグイン実行環境 │
│ • プロバイダー抽象化レイヤー │
└──────────────────────────────────────────────────────────────────┘
```
### 設計原則
- **プロセス分離**:AIランタイムは別プロセスで動作し、重い計算中もUIの応答性を保ちます。
- **フロントエンド呼び出しの単一入口**:Rendererのリクエストは`host-api` / `api-client`を経由し、プロトコルの詳細は安定したインターフェースの背後に隠されます。
- **Mainプロセスによるトランスポート管理**Electron MainがACP Chat stdio bridgeとGatewayトランスポートを所有し、Rendererは型付きIPCでMainと通信します。
- **拡張IPCの貢献点**Mainプロセス拡張はHTTP routeではなく、型付きIPCレジストリを通じてhost-api actionを提供します。
- **グレースフルリカバリ**:再接続、タイムアウト、バックオフを内蔵し、一時的な障害を自動処理します。
- **セキュアストレージ**:APIキーや機密データにはOSのネイティブな安全な保存機構を使用します。
- **CORSセーフ設計**RendererはローカルGatewayやHost API HTTPエンドポイントを直接呼び出しません。
### プロセスモデルとGatewayのトラブルシューティング
- ClawXはElectronアプリのため、**1つのアプリインスタンスでも複数のOSプロセスが表示される**main/renderer/zygote/utility)のは正常です。
- 単一起動保護にはElectronのロックに加えてローカルのプロセスファイルロックのフォールバックを使用し、デスクトップIPCやセッションバスが不安定な環境での二重起動を防ぎます。
- ローリングアップグレード中に旧版と新版が混在すると、単一起動保護が非対称になる場合があります。安定性のため、すべてのデスクトップクライアントを同じバージョンへ更新してください。
- OpenClaw Gatewayのリスナーは**単一所有者**である必要があります。`127.0.0.1:18789`をListenするプロセスは1つだけにしてください。
- Gatewayのreadinessは`system-presence``health``status`などOpenClawのコア信号を基準にします。メモリまたはチャネルの失敗は、Gateway全体の障害ではなく機能低下として表示されます。
- アクティブなリスナーは次のコマンドで確認できます。
- macOS/Linux`lsof -nP -iTCP:18789 -sTCP:LISTEN`
- WindowsPowerShell):`Get-NetTCPConnection -LocalPort 18789 -State Listen`
- ウィンドウの閉じるボタン(`X`)はClawXをトレイに隠すだけで、完全終了ではありません。完全終了にはトレイメニューの **Quit ClawX** を使用してください。
-130
View File
@@ -1,130 +0,0 @@
# ClawX開発ガイド
このドキュメントは、READMEの「開発」セクションの詳細版です。
### 前提条件
- **Node.js**:対応するメジャー系列の22.22.3以上、24.15.0以上、または25.9.0以上(Node 24 LTS推奨)
- **パッケージマネージャー**:pnpm 9以上(npmも対応)
- **LinuxUbuntu/Debian**Electronを実行する前に必要なシステムライブラリをインストールしてください。
```bash
sudo apt-get install -y libnss3 libgtk-3-0 libxss1 libxtst6 libatspi2.0-0 libnotify4 xdg-utils
```
Ubuntu 24.04以降では一部のパッケージに`t64`サフィックスが付きます。上記コマンドを実行すると、`apt`が適切なバリアントを自動選択します。
### プロジェクト構成
```text
ClawX/
├── electron/ # Electron Mainプロセス
│ ├── services/ # 型付きHost API、プロバイダー、秘密情報、ランタイムサービス
│ │ ├── providers/ # プロバイダー/アカウントのモデル同期ロジック
│ │ └── secrets/ # OSキーチェーンと秘密情報の保存
│ ├── shared/ # 共有プロバイダースキーマ/定数
│ │ └── providers/
│ ├── main/ # アプリ入口、ウィンドウ、IPC登録
│ ├── gateway/ # OpenClaw Gatewayプロセスマネージャー
│ ├── preload/ # セキュアIPCブリッジ
│ └── utils/ # ストレージ、認証、パスのユーティリティ
├── src/ # React Rendererプロセス
│ ├── lib/ # フロントエンド統合APIとエラーモデル
│ ├── stores/ # Zustandストア(settings/chat/gateway
│ ├── components/ # 再利用可能なUIコンポーネント
│ ├── pages/ # Setup/Dashboard/Chat/Channels/Skills/Cron/Settings
│ ├── i18n/ # ローカライズリソース
│ └── types/ # TypeScript型定義
├── tests/
│ ├── e2e/ # Playwright Electron E2Eスモークテスト
│ └── unit/ # Vitestユニット/統合系テスト
├── resources/ # 静的アセット(アイコン、画像)
└── scripts/ # ビルドとユーティリティのスクリプト
```
### 利用可能なコマンド
```bash
# 開発
pnpm run init # 依存関係をインストールし、同梱バイナリ(uv、agent-browser)をダウンロード
pnpm dev # ホットリロードで起動(不足時は同梱スキルを自動準備)
# 品質
pnpm lint # ESLintを実行
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 # リプレイ指標をベースラインの閾値と比較
# ビルドとパッケージ
pnpm run build:vite # フロントエンドのみをビルド
pnpm build # パッケージアセットを含む本番ビルド
pnpm package # 現在のプラットフォーム向けにパッケージ化(同梱スキルを含む)
pnpm package:mac # macOS向けにパッケージ化
pnpm package:win # Windows向けにパッケージ化
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`プロジェクトを使用し、ホストのパフォーマンスプロファイルはその後単独で実行されます。新しいE2Eテストは既定で並列です。実際のクリップボードなどマシン全体で共有されるリソースを使う場合は、`tests/e2e/parallel-policy.ts`の`E2E_EXCLUSIVE_TAG`を適用してください。
独占前提を必要としない通常のspecだけを実行する場合は、`pnpm exec playwright test <spec> --project=parallel --no-deps`を使用します。
### Electronパフォーマンス診断
`pnpm run perf:chat`は、ストリーミングとリッチな静的Markdownサイドバー/スクロール操作を対象に、分離された合成ACP負荷を実行します。Playwrightの`test-results/`ディレクトリにバージョン付きメトリクスとRenderer/Main CPUプロファイルを書き込みます。Rendererプロファイルは本番のstore/render経路とフレームペーシングを対象とします。ストリーミングMainプロファイルはMainからRendererへのIPC fanoutを測定し、操作用MainプロファイルはRenderer操作中にMainがアイドル状態を保つかを示します。どちらも上流のOpenClaw/ACPサブプロセスやGPUプロセスの経路は含みません。
CPUプロファイルはChrome DevToolsで開けます。アーティファクトには生成されたfixtureテキストだけが含まれ、製品テレメトリーではありません。結果はハードウェアに依存するため、単一のクロスプラットフォーム絶対閾値ではなく、同じマシンで繰り返した結果を比較してください。
実際の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ターゲットを選びます。WebSocket trace自体を測定する場合を除き、`CLAWX_GATEWAY_WS_TRACE`は設定しないでください。
ClawXは既定でChromiumのハードウェアアクセラレーションを有効にし、長い文書、スクロール、レイアウトアニメーションでGPUコンポジットとラスタライズを利用します。グラフィックスドライバーに問題がある場合のトラブルシューティングには、Chromium標準の`--disable-gpu`コマンドラインスイッチを使用できます。
### 通信回帰チェック
Gatewayイベント、ACP Chat bridgeの送受信フロー、チャネル配信、トランスポートフォールバックなどの通信経路をPRで変更した場合は、次を実行してください。
```bash
pnpm run comms:replay
pnpm run comms:compare
```
CIの`comms-regression`ジョブが必須シナリオと閾値を検証します。
### Electron E2Eテスト
Playwright Electronスイートは`dist/`と`dist-electron/`からパッケージ済みのRendererとMainプロセスを起動するため、事前に`pnpm dev`を手動実行する必要はありません。
`pnpm run test:e2e`は自動的に次を行います。
- `pnpm run build:vite`でRendererとElectronのバンドルをビルド
- 一時的な`HOME`を使ってElectronを分離E2Eモードで起動
- 一時的なClawX `userData`ディレクトリを使用
- OS全体のリソースとパフォーマンステストを隔離しながら、通常のspecファイルを並列実行
- Gateway自動起動、同梱スキルのインストール、トレイ作成、CLI自動インストールなど、重い起動副作用をスキップ
最初のベースラインspecは次を対象とします。
- 新しいプロファイルでの初回起動Setup Wizardの表示
- セットアップをスキップし、Electronアプリ内でModelsページへ移動できること
今後のElectronフローは`tests/e2e/`に追加し、`tests/e2e/fixtures/electron.ts`の共有fixtureを再利用してください。固定の書き込みパス、ポート、ネイティブキーチェーン、その他の外部共有状態を避けてテストを並列安全に保ちます。分離できない場合は`E2E_EXCLUSIVE_TAG`を使用してください。
### 技術スタック
| レイヤー | 技術 |
|---------|------|
| ランタイム | Electron 40+ |
| UIフレームワーク | React 19 + TypeScript |
| スタイリング | Tailwind CSS + shadcn/ui |
| 状態管理 | Zustand |
| ビルド | Vite + electron-builder |
| テスト | Vitest + Playwright |
| アニメーション | Framer Motion |
| アイコン | Lucide React |
-83
View File
@@ -1,83 +0,0 @@
# ClawXの機能
このドキュメントは、READMEの「機能」セクションの詳細版です。
### ゼロ設定バリア
インストールから最初のAI会話まで、直感的なグラフィカルインターフェースですべてのセットアップを完了できます。ターミナルコマンド、YAMLファイル、環境変数の探索は不要です。
### インテリジェントチャットインターフェース
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキストとメッセージ履歴に対応し、エージェントの返信は、シンタックスハイライト付きのフェンスコード、CJK対応の解析、GitHub風テーブル、KaTeXによるLaTeX数式(`$インライン$``$$ブロック$$``\(インライン\)``\[ブロック\]`)を含むストリーミングMarkdownとして描画されます。ユーザー入力は常にプレーンテキストとして扱われます。マルチエージェント構成では、メインのコンポーザーから `@agent` で対象エージェントへ直接ルーティングできます。フェンスコードはソースの改行を保持し、長い行をソフトラップし、ストリーミング完了後にローカライズされたコピー操作を提供します。
コンポーザーから挿入したスキルは `/skill-name` カードとして表示されます。カードをクリックすると右側のプレビューサイドバーが開き、そのスキルの `SKILL.md` を読めます。
`@agent` で別のエージェントを指定すると、ClawXはデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。エージェントのワークスペースは既定で分離されますが、より強い実行時分離はOpenClawのsandbox設定に依存します。
セッションサイドバーはワークスペース優先で構成されます。既定のワークスペースが先頭に固定され、その他のワークスペースは自然な順序で並びます。各ワークスペースは折りたたんだり、セッションを追加読み込みしたりできます。AIが返信中のセッション行にはスピナーが表示され、未確認の返信が完了すると青い点が表示されます。会話を開いた後は相対的なアクティビティ時刻が表示され、ホバーすると行の操作が表示されます。インポートしたワークスペースはサイドバーの見出しから名前を変更できます。カスタム名はチャットコンポーザーにも反映され、見出しにホバーするとファイルシステムのパスを確認できます。
有効なワークスペースが選択されている場合、新しいチャットは最初の送信まで編集可能な状態でそのワークスペースを引き継ぎます。編集可能な新規チャットまたは未バインドのチャットでは、コンポーザーのワークスペースチップから、最近使用したワークスペースと既知のセッションワークスペースの一覧を開けます。既定のワークスペースへ戻ることも、別のフォルダーを選ぶこともできます。保存済みのワークスペースフォルダーが移動または削除された場合、Chatはセッション作成を一時停止し、無効なパスを繰り返し再試行せず、既存のフォルダーを選ぶよう案内します。利用できない既定以外のグループにはサイドバーで印が付き、確認後に削除できます。この操作ではグループ内のすべてのセッションが完全に削除されます。セッション行の削除と画面遷移は完全削除が成功した後にのみ行われます。失敗した場合は会話と確認ダイアログが保持され、再試行できます。OpenClawが生成するUUIDと日付のフォールバックタイトルは、セッションIDと一致する場合に限り欠落タイトルとして扱われ、セッション名として保存せず、会話の最初のユーザーメッセージに置き換えて表示します。
各エージェントは自身の `provider/model` 実行時設定を上書きできます。上書きしていないエージェントはグローバルの既定モデルを引き続き継承します。
Chat右側パネルのWorkspaceとPreviewタブでは、Markdown、`.docx``.pptx`を読み取り専用でプレビューできます。Markdownのプレビューは静的レンダリングモードで、シンタックスハイライト、ソフトラップ、コピー可能なフェンスコード、CJK対応解析、KaTeX数式をサポートします。プレビューのヘッダーから選択中のファイルをClawXの表示領域全体へ拡大できます。同じボタンまたはEscapeキーでパネルへ戻れます。従来形式の`.doc``.ppt`はアプリ内ではなくOS経由で開きます。DOCXのページ区切りはMicrosoft Wordと異なる場合があり、PPTXプレビューではアニメーション、画面切り替え、メディア再生をサポートしません。20 MBを超えるOfficeファイルはアプリ内でプレビューされません。
### ローカルHTMLプレビュー
Chat右側パネルにはWorkspace、Preview、Changesタブがあります。汎用Webブラウザ、ホーム画面、アドレスバーは提供されません。許可されたローカル`.html``.htm`の添付ファイル、ファイルアクティビティ、Workspaceファイルは既定でPreviewに開きます。ファイル操作ではClawX内蔵のPreviewまたはシステムアプリを選択でき、Previewのヘッダーから現在のHTMLファイルをシステムブラウザで開くこともできます。
すべてのリンクはクリックできません。ClawXが描画するリンクは通常のテキストとして表示され、HTML Preview内のリンクからもリンク装飾とポインター操作が除去されます。HTML Previewはフォーム、スクリプトによる移動、リダイレクト、ページ内移動、ポップアップ、ダウンロード、ネットワーク要求、デバイス権限もブロックします。自己完結したローカルHTMLは表示できますが、選択中の文書から移動することはできません。
### マルチチャネル管理
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。
各チャネルは複数アカウント、アカウント単位のAgent紐付け、Channelsページからの既定アカウント切り替えに対応しています。
カスタムチャネルアカウントIDには、ルーティング不一致を防ぐため、OpenClaw互換の正規形式(`[a-z0-9_-]`、小文字、最大64文字、先頭は英字または数字)を必須としています。
ClawXにはTencent公式の個人WeChatチャネルプラグインも同梱されており、Channelsページからアプリ内QRフローでWeChatを直接連携できます。
### Cronベースの自動化
AIタスクを自動的に実行するようスケジュール設定できます。トリガーと間隔を定義し、AIエージェントを常時稼働させられます。
Cronページでは、送信アカウントと受信先ターゲットを別々に選択して、タスクフォームから外部配信を直接設定できます。対応チャネルでは、受信先ターゲットがチャネルディレクトリまたは既知のセッション履歴から自動検出されるため、`jobs.json`を手動編集する必要はありません。タスクメッセージ欄では、メインのチャットコンポーザーと同じインライン `/skill` トークン構文で、選択したエージェントのスキルを挿入できます。これにより、スケジュール済みプロンプトからスキルを直接起動できます。
スケジュール選択は**繰り返し**と**1回のみ**のタブに分かれています。繰り返しでは毎時、毎日、平日、毎週、カスタム(生のcron)を時刻・曜日コントロール付きで選択できます。1回のみでは、曜日が表示された指定日と時刻に一度だけ実行します。1回のみのタスクは未来の時刻を指定する必要があり、完了後はランタイムによって自動削除されます。
### 拡張可能なスキルシステム
事前構築されたスキルでAIエージェントを拡張できます。統合Skillsページはローカル優先で、管理ディレクトリとworkspaceのスキルディレクトリをスキャンし、Gatewayに依存せずスキルを有効化・無効化できます。エンタープライズ拡張では、拡張機能が提供するマーケットプレイスを表示することもできます。
ClawXはドキュメント処理スキル(`pdf``xlsx``docx``pptx`)を完全な形で同梱し、起動時に管理スキルディレクトリ(既定は`~/.openclaw/skills`)へ自動配備し、初回インストール時に既定で有効化します。
Skillsページでは、管理ディレクトリ、workspace、追加スキルディレクトリなど、複数のOpenClawソースから検出されたスキルを表示できます。各スキルの実際の場所も表示されるため、実フォルダーを直接開けます。OpenClaw同梱のbundled skillについて、コミュニティ版では`skill-creator`だけをパッケージと画面に残します。許可リストにないbundled skillは開発時とパッケージ版の起動時に物理的に削除され、削除済みスキルに対応する古い`openclaw.json`エントリも整理されます。
### セキュアなプロバイダー統合
OpenAI、Anthropic、Z.AI / GLMなど複数のAIプロバイダーに接続でき、認証情報はOSのネイティブキーチェーンに安全に保存されます。OpenAIはAPIキーとブラウザOAuth(Codexサブスクリプション)の両方に対応しています。
開発者モードの専用Image Generationページでは、Base URL、APIキー、`gpt-image-2`などのモデル名を指定して、独立したOpenAI互換の画像生成エンドポイントを設定できます。画像生成は専用の`/v1/images/generations`サービスを使い、チャットは通常のOpenAIプロバイダーを使い続けられます。
OpenAI互換ゲートウェイで **Custom** プロバイダーを使う場合、互換性が必要なエンドポイント向けに **設定 → AI Providers → Providerを編集** からカスタム `User-Agent` を設定できます。
プロバイダーを編集または切り替える際、ClawXは `input: ["text", "image"]` など既存のモデル単位の能力メタデータを保持します。新しく選択したCustomプロバイダーのモデルにはOpenClaw onboarding互換の画像入力推論を適用し、不明なモデルはテキスト専用として扱います。
Customプロバイダーのモデル行には、モデルファミリーから推定した明示的な `contextWindow`(例:`gpt-5.x` → 272k)も付与されます。旧バージョンで保存された行は起動時に補完されるため、OpenClawは長いセッションが「Context overflow」エラーになる前に圧縮できます。圧縮設定がない場合、ClawXは `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`プローブへ自動フォールバックします。
### アダプティブテーマ
ライトモード、ダークモード、システム同期テーマを選択できます。ClawXは設定に自動的に適応します。
### 自動起動設定
**設定 → 一般**で **システム起動時に自動起動** を有効にすると、ログイン後にClawXが自動的に起動します。
### 更新通知
ClawXは起動時に新しいバージョンを確認します。更新が利用可能になるとアプリ内プロンプトを表示し、選択した場合にのみダウンロードとインストールを実行します。
-12
View File
@@ -1,12 +0,0 @@
# ClawXのプロキシ設定
このドキュメントは、READMEの「プロキシ設定」セクションの詳細版です。
- `host:port` だけの値はHTTPプロキシとして扱われます。
- 高度なプロキシ項目が空の場合、ClawXは **プロキシサーバー** にフォールバックします。
- プロキシ設定を保存すると、Electronのネットワーク設定が即座に再適用され、Gatewayが自動的に再起動します。
- Telegramが有効な場合、ClawXはプロキシをOpenClawのTelegramチャネル設定にも同期します。
- ClawXのプロキシが無効な状態で通常のGateway再起動が行われても、既存のTelegramチャネルプロキシは保持されます。
- OpenClaw設定からTelegramプロキシを明示的に削除するには、プロキシを無効にしてプロキシ設定を一度保存してください。
- **設定 → 詳細設定 → 開発者**では **OpenClaw Doctor** を実行できます。`openclaw doctor --json` を実行し、診断結果をアプリ内に表示します。
- Windowsのパッケージ版では、同梱の`openclaw` CLI/TUIは同梱の`node.exe`エントリーポイント経由で実行され、ターミナル入力の安定性を保ちます。
-113
View File
@@ -1,113 +0,0 @@
# Архитектура ClawX
Этот документ содержит подробную версию раздела «Архитектура» из README.
ClawX использует **двухпроцессную архитектуру с унифицированным уровнем Host API**. Renderer обращается к единой абстракции клиента, а Electron Main управляет выбором протокола и жизненным циклом процессов.
Доставка конфигурации OpenClaw также управляется Electron Main. Когда Gateway запущен, ClawX использует авторитетный снимок из `config.get` как основу и применяет изменения через `config.set`. Когда Gateway остановлен или запускается, тот же координатор обновляет разрешённый JSON5-файл конфигурации, не запуская Gateway из-за этого обновления. Поэтому обычные изменения провайдера, агента, канала, привязки, навыка и модели не заменяют процесс Gateway. Полные перезапуски остаются только для изменений среды запуска процесса, например прокси, и явных действий пользователя. Подтверждённые завершения процесса и закрытия WebSocket используют существующие пути автоматического переподключения. Первые девять последовательных пропусков WebSocket heartbeat являются только диагностикой, поэтому краткая задержка pong не прерывает долгую операцию; pong или любое входящее сообщение сбрасывает счётчик, а при десятом последовательном пропуске запрашивается защищённое автоматическое восстановление Gateway, если его жизненный цикл находится в состоянии running с разрешённым автовосстановлением. После записи конфигурации аутентификации в SQLite ClawX вызывает `secrets.reload` OpenClaw, чтобы работающие агенты получили новые учётные данные без перезапуска процесса.
Chat использует ACP stdio bridge, принадлежащий Electron Main. Main передаёт тому же локальному дочернему процессу управляемый приложением Gateway token через приватное окружение процесса, поэтому после перезагрузки конфигурации среды выполнения воспроизведение истории ACP остаётся аутентифицированным. Renderer получает типизированные host events и отображает находящуюся в памяти ACP timeline. Gateway продолжает отвечать за возможности вне Chat: providers, models, skills, workspace, settings, diagnostics и media configuration.
### Семантический авторитет ACP
ACP является предпочтительным семантическим источником для каждого значения и контекста Chat, которые он предоставляет, а не только для истории `session/load`. Сюда относятся, где применимо, идентификатор сессии и маршрутизация, рабочее пространство и исполняемый `cwd`, состояние prompt и timeline, а также семантика стандартных resource и вложений. Если ACP предоставляет значение или событие, Main и Renderer должны использовать его, а не заменять снимком Gateway, выводом из transcript, локальной конфигурацией или параллельной проекцией.
Обход ACP разрешён только тогда, когда в upstream нет эквивалентной возможности. Такой путь совместимости должен быть узким, ограниченным и привязанным к session и generation. В соответствующем Harness reference или rule необходимо указать причину, источник истины, ограничения, поведение согласования и условие удаления; обход не должен незаметно стать конкурирующим источником истины.
### Авторитет истории ACP и ограниченные дополнения из transcript
Воспроизведение ACP `session/load` является главным источником истории Chat. ClawX не сохраняет второй ACP ledger, сокращённую timeline, кэш воспроизведения или восстановленную историю инструментов. Некоторые возможности OpenClaw пока не имеют полного соответствия в ACP. Например, media assistant может отсутствовать в ACP, а обработка Gateway может удалять директивы assistant `MEDIA:` из видимого потокового ответа. Поэтому ClawX хранит только ограниченные, помеченные дополнения совместимости в памяти:
- Асинхронное завершение генерации изображения можно восстановить только при наличии подтверждённого контекста `image_generate` в той же сессии и доверенного либо разрешённого transcript-доказательства.
- Обычные вложения можно восстановить из канонических сохранённых фактов assistant `__openclaw.media` или явных директив assistant `MEDIA:` в начале строки. Восстанавливаются только ссылки на вложения и объявленные метаданные, но не окружающее сообщение assistant.
- Поскольку ACP replay не содержит исходных временных меток событий, Main может добавить метаданные длительности всего хода из ограниченных записей transcript JSONL. Они могут быть привязаны только к уже восстановленному ACP-ходу.
- Если ACP replay cron-сессии полностью пуст, типизированный API истории cron в Main может предоставить запланированный запрос и сводку завершения. Если сводка идентифицированного запуска содержит маркер усечения OpenClaw, Main может восстановить финальный текст assistant из transcript этого запуска только когда transcript длиннее и содержит полный сохранённый префикс сводки.
Историческое чтение ограничено последними 1000 сообщениями transcript. Успешный live prompt выполняет одно немедленное чтение и одну повторную попытку через 1500 мс. Каждое дополнение привязано к точной session, ACP generation, операции и, где применимо, текущему пользовательскому ходу; устаревшие, отсутствующие, дублирующиеся и неоднозначные совпадения отбрасываются. Эти пути не должны восстанавливать обычные сообщения assistant, thoughts, tools, plans, permissions, файловые операции, пропущенные ходы или параллельную историю Chat. Main не создаёт нативные события ACP из transcript-доказательств. Стандартные ACP resources остаются предпочтительными, а после появления эквивалентного контента upstream эти исключения совместимости должны быть удалены.
Незавершённый ответ ACP продолжает потоковую выдачу при открытии другого разговора или страницы. Возврат до завершения восстанавливает последнюю timeline в памяти и продолжает отображение ответа. После завершения обычное воспроизведение истории ACP остаётся источником истины.
Ходы assistant в ACP показывают длительность всего хода. Живой таймер следует за наблюдаемым клиентом жизненным циклом prompt и сохраняется при навигации внутри приложения. Историческая длительность вычисляется Electron Main по ограниченным временным меткам transcript OpenClaw и добавляется только к ходу, уже восстановленному ACP replay.
ACP Chat отображает стандартные ACP resources как вложения. Выбранные пользователем изображения показываются как миниатюры с именем файла при наведении, а другие доступные карточки вложений содержат имя файла и приглушённый обрезаемый исходный путь. Если текущий OpenClaw ACP adapter не передаёт media assistant, канонические сохранённые факты media OpenClaw и явные директивы assistant `MEDIA:` также могут быть восстановлены как карточки вложений без отображения метаданных, предназначенных только для transcript.
Существующие локальные ссылки на файлы, включая пути за пределами активного рабочего пространства, перед каждым предпросмотром или открытием повторно проверяются Electron Main для точной session и generation. Локальные вложения, созданные AI и доступные для предпросмотра, включая `.docx` и `.pptx` размером до 20 МБ, сохраняют основное действие предпросмотра только для чтения внутри приложения и дополнительное меню для открытия совместимым приложением или показа в Finder, File Explorer либо системном файловом менеджере. Для локальных HTML-вложений первый пункт меню открывает файл во вкладке Preview справа.
Для Office действуют те же ограничения: `.doc` и `.ppt` открываются системным приложением, разбиение DOCX на страницы может отличаться от Microsoft Word, а анимации, переходы и воспроизведение медиа в PPTX не поддерживаются. Поиск совместимых приложений доступен только в macOS и Windows; в Linux или при ошибке поиска происходит незаметный переход к действию показа расположения. Остальные локальные файлы, включая Office-файлы размером более 20 МБ, открываются системным приложением после нажатия пользователя. Выбранные пользователем папки остаются доступными после отправки и открываются системным файловым менеджером; ClawX не читает и не просматривает их содержимое. Вложения HTTP и HTTPS открываются внешне после нажатия. Обычные пути в тексте без канонических media-фактов не считаются вложениями.
ACP Chat также может показывать предпросмотр сгенерированных изображений, когда среда выполнения доставляет media генерации как доверенные структурированные данные. Доверенные OpenClaw internal-UI доставки и финальные ответы, связанные с задачей генерации, сохраняют исходный пользовательский текст завершения, включая текстовое описание ошибки, вместо замены на общий заголовок изображения. При историческом воспроизведении OpenClaw маркеры assistant `MEDIA:` переводятся в встроенный просмотр изображения только после зарегистрированного запуска задачи генерации в той же сессии. ClawX загружает предпросмотр через обработку media на стороне Electron Main, а не через произвольный доступ Renderer к файловой системе. Стандартные изображения и ресурсы ACP остаются предпочтительным путём и отображаются напрямую.
### Семантика файловых операций ACP
- Файловые операции проецируются из успешных завершённых вызовов OpenClaw `write`, `edit` и `apply_patch`. Распознавание инструментов соответствует официальному OpenClaw Chat UI; фильтрация только завершённых вызовов специфична для ClawX.
- Строки созданных и изменённых файлов используют ту же оболочку карточки и меню **Open with**, что и предпросматриваемые вложения assistant, сохраняя статус и необязательную сводку `+/-`. Для HTML первый пункт меню открывает файл во вкладке **Preview** справа. Удалённые строки сохраняют только действие **Changes**. Каждый запрос списка приложений, выбора приложения и показа расположения заново проверяется Electron Main по корню рабочего пространства и относительному пути. Пути из инструментов не становятся вложениями и не раскрывают Renderer канонические системные пути.
- `write` отображается так, как его объявляет инструмент: как создание с разницей из всех добавленных строк, даже если путь уже может существовать.
- **Changes** — это хронологическая запись объявленной инструментом активности на уровне сессии. Это не вывод Git и не проверенная разница относительно исходной базы.
- Для каждого файла Changes отображает не более одного diff-редактора на ход assistant. Последовательные фрагменты объединяются, если это безопасно; независимые фрагменты объединяются в один редактор без утверждения, что это полная разница относительно базовой версии файла.
- Побочные эффекты shell-команд, скриптов, пользователей или IDE не обнаруживаются.
- Полное ACP replay может восстановить записанные файловые операции. При неполном replay ClawX не выводит пропущенную активность через fallback.
```
┌──────────────────────────────────────────────────────────────────┐
│ Десктопное приложение ClawX │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Главный процесс Electron │ │
│ │ • Управление жизненным циклом окна и приложения │ │
│ │ • Наблюдение за процессом Gateway │ │
│ │ • Интеграция с системой (трей, уведомления, связка ключей) │ │
│ │ • Оркестрация автообновлений │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬───────────────────────────────────┘
│ IPC (авторитетная плоскость управления)
┌──────────────────────────────────────────────────────────────────┐
│ Процесс Renderer на React │
│ • Современный компонентный UI (React 19) │
│ • Управление состоянием с Zustand │
│ • Унифицированные вызовы host-api/api-client │
│ • Ответы assistant в Markdown, ввод пользователя как обычный текст│
└──────────────────────────────┬───────────────────────────────────┘
│ Типизированные IPC-запросы
┌──────────────────────────────────────────────────────────────────┐
│ Main Host Services и Gateway Manager │
│ • Типизированный диспетчер сервисов host:invoke │
│ • Настройки, файлы, сессии, навыки, провайдеры, диагностика │
│ • WebSocket Gateway и наблюдение за процессом принадлежат Main │
└──────────────────────────────┬───────────────────────────────────┘
│ WebSocket под управлением Main
┌──────────────────────────────────────────────────────────────────┐
│ OpenClaw Gateway │
│ • Среда выполнения и оркестрация AI-агентов │
│ • Управление каналами сообщений │
│ • Среда выполнения навыков/плагинов │
│ • Уровень абстракции провайдеров │
└──────────────────────────────────────────────────────────────────┘
```
### Принципы проектирования
- **Изоляция процессов**: AI-среда выполнения работает в отдельном процессе, сохраняя отзывчивость UI даже при тяжёлых вычислениях.
- **Единая точка входа для фронтенда**: запросы Renderer проходят через `host-api` / `api-client`, а детали протокола скрыты за стабильным интерфейсом.
- **Транспорт принадлежит Main**: Electron Main владеет ACP Chat stdio bridge и транспортами Gateway; Renderer общается с Main через типизированный IPC.
- **Расширения через IPC**: расширения Main-процесса добавляют действия host-api через типизированный IPC-реестр, а не через HTTP routes.
- **Корректное восстановление**: встроенные переподключение, таймауты и backoff автоматически обрабатывают временные сбои.
- **Безопасное хранение**: API-ключи и конфиденциальные данные используют нативные механизмы безопасного хранения ОС.
- **CORS-безопасность**: Renderer не вызывает напрямую локальные HTTP-эндпоинты Gateway или Host API.
### Модель процессов и устранение неполадок Gateway
- ClawX — приложение Electron, поэтому **один экземпляр обычно отображается как несколько процессов ОС** (main/renderer/zygote/utility). Это нормально.
- Защита единственного экземпляра использует блокировку Electron и резервный локальный файл блокировки процесса, предотвращая дублирование запуска при нестабильном desktop IPC или сессионной шине.
- При последовательном обновлении смешанные старые и новые версии могут вести себя асимметрично. Для надёжности обновляйте все десктопные клиенты до одной версии.
- Слушатель OpenClaw Gateway должен иметь **единственного владельца**: только один процесс должен слушать `127.0.0.1:18789`.
- Готовность Gateway определяется основными сигналами OpenClaw, такими как `system-presence`, `health` и `status`. Ошибки памяти или каналов отображаются как снижение возможностей, а не как общий сбой Gateway.
- Проверить активный слушатель можно командами:
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
- Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen`
- Нажатие кнопки закрытия окна (`X`) скрывает ClawX в трее, но не завершает приложение. Для полного завершения используйте **Quit ClawX** в меню трея.
-130
View File
@@ -1,130 +0,0 @@
# Руководство по разработке ClawX
Этот документ содержит подробную версию раздела «Разработка» из README.
### Требования
- **Node.js**: 22.22.3+, 24.15.0+ или 25.9.0+ в пределах соответствующей основной версии (рекомендуется Node 24 LTS)
- **Менеджер пакетов**: pnpm 9+ (npm также поддерживается)
- **Linux (Ubuntu/Debian)**: перед запуском Electron установите необходимые системные библиотеки:
```bash
sudo apt-get install -y libnss3 libgtk-3-0 libxss1 libxtst6 libatspi2.0-0 libnotify4 xdg-utils
```
В Ubuntu 24.04+ некоторые пакеты используют суффикс `t64`; после выполнения команды `apt` автоматически выберет подходящий вариант.
### Структура проекта
```text
ClawX/
├── electron/ # Главный процесс Electron
│ ├── services/ # Типизированные Host API, провайдеры, секреты и runtime-сервисы
│ │ ├── providers/ # Логика синхронизации моделей provider/account
│ │ └── secrets/ # Связка ключей ОС и хранилище секретов
│ ├── shared/ # Общие схемы провайдеров и константы
│ │ └── providers/
│ ├── main/ # Точка входа приложения, окна и регистрация IPC
│ ├── gateway/ # Менеджер процесса OpenClaw Gateway
│ ├── preload/ # Безопасный IPC-мост
│ └── utils/ # Утилиты для хранилища, аутентификации и путей
├── src/ # Процесс Renderer на React
│ ├── lib/ # Унифицированный фронтенд API и модель ошибок
│ ├── stores/ # Хранилища Zustand (settings/chat/gateway)
│ ├── components/ # Переиспользуемые UI-компоненты
│ ├── pages/ # Setup/Dashboard/Chat/Channels/Skills/Cron/Settings
│ ├── i18n/ # Ресурсы локализации
│ └── types/ # Определения типов TypeScript
├── tests/
│ ├── e2e/ # Сквозные дымовые тесты Playwright Electron
│ └── unit/ # Модульные и интеграционные тесты Vitest
├── resources/ # Статические ресурсы (иконки и изображения)
└── scripts/ # Скрипты сборки и утилит
```
### Доступные команды
```bash
# Разработка
pnpm run init # Установить зависимости и скачать встроенные бинарные файлы (uv, agent-browser)
pnpm dev # Запуск с горячей перезагрузкой (автоподготовка bundled skills при отсутствии)
# Качество
pnpm lint # Запустить ESLint
pnpm typecheck # Проверить типы TypeScript
# Тестирование
pnpm test # Запустить модульные тесты
pnpm run test:e2e # Запустить дымовые E2E-тесты Electron
pnpm run test:e2e:headed # Запустить E2E-тесты Electron с видимым окном
pnpm run perf:chat # Получить синтетические CPU-профили Chat Renderer/Main
pnpm run profile:main # Запустить собранное приложение с Main inspector на порту 9229
pnpm run comms:replay # Рассчитать метрики повторного воспроизведения коммуникаций
pnpm run comms:baseline # Обновить снимок базовой линии коммуникаций
pnpm run comms:compare # Сравнить метрики с порогами базовой линии
# Сборка и упаковка
pnpm run build:vite # Собрать только фронтенд
pnpm build # Полная production-сборка с ресурсами упаковки
pnpm package # Упаковать для текущей платформы со встроенными навыками
pnpm package:mac # Упаковать для macOS
pnpm package:win # Упаковать для Windows
pnpm package:linux # Упаковать для Linux
```
В headless Linux тестам Electron нужен сервер отображения. Используйте `xvfb-run -a pnpm run test:e2e`.
Функциональные E2E-тесты Electron локально и в CI по умолчанию используют два worker-процесса Playwright. Обычную параллельную группу можно настроить через `CLAWX_E2E_WORKERS=<положительное целое>`. Тесты, затрагивающие глобальное состояние ОС, используют однопоточный проект `exclusive`, а профили производительности хоста запускаются отдельно после них. Новые E2E-тесты по умолчанию параллельны; при использовании реального буфера обмена или другого общего ресурса машины применяйте `E2E_EXCLUSIVE_TAG` из `tests/e2e/parallel-policy.ts`.
Для запуска отдельного обычного spec без эксклюзивного предварительного этапа используйте `pnpm exec playwright test <spec> --project=parallel --no-deps`.
### Диагностика производительности Electron
`pnpm run perf:chat` запускает изолированные синтетические ACP-нагрузки для потоковой выдачи и взаимодействия с боковой панелью и прокруткой в статическом Markdown-документе. В каталог Playwright `test-results/` записываются версионированные метрики и CPU-профили Renderer и Main. Профили Renderer охватывают production store/render-путь и плавность кадров. Потоковый профиль Main измеряет IPC fanout от Main к Renderer, а профиль интеракций показывает, остаётся ли Main свободным во время действий Renderer. Ни один профиль не включает процессы upstream OpenClaw/ACP или путь GPU-процесса.
CPU-профиль можно открыть в Chrome DevTools. Артефакты содержат только сгенерированный fixture-текст и не являются телеметрией продукта. Результаты зависят от оборудования, поэтому сравнивайте повторные запуски на одной машине, а не применяйте единый абсолютный порог для разных платформ.
Для записи реального 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. Не устанавливайте `CLAWX_GATEWAY_WS_TRACE`, если измеряется не сам WebSocket trace.
ClawX по умолчанию оставляет аппаратное ускорение Chromium включённым, чтобы длинные документы, прокрутка и анимации layout использовали GPU-композицию и растеризацию. При проблемах с графическим драйвером можно использовать встроенный переключатель Chromium `--disable-gpu` как резервный вариант диагностики.
### Проверки регрессии коммуникаций
Если PR изменяет пути коммуникации, включая события Gateway, поток отправки/получения ACP Chat, доставку каналов или транспортный fallback, выполните:
```bash
pnpm run comms:replay
pnpm run comms:compare
```
Задача CI `comms-regression` проверяет обязательные сценарии и пороги.
### E2E-тесты Electron
Набор Playwright Electron запускает упакованные Renderer и Main-процессы из `dist/` и `dist-electron/`, поэтому заранее вручную запускать `pnpm dev` не требуется.
`pnpm run test:e2e` автоматически:
- собирает Renderer и бандлы Electron через `pnpm run build:vite`
- запускает Electron в изолированном E2E-режиме с временным `HOME`
- использует временный каталог `userData` ClawX
- запускает обычные spec-файлы параллельно, изолируя тесты глобальных ресурсов ОС и производительности
- пропускает тяжёлые побочные эффекты запуска, такие как автозапуск Gateway, установка bundled skills, создание трея и автоустановка CLI
Первые базовые spec покрывают:
- видимость Setup Wizard при первом запуске на чистом профиле
- пропуск настройки и переход на страницу Models внутри приложения Electron
Добавляйте будущие сценарии Electron в `tests/e2e/` и переиспользуйте общий fixture из `tests/e2e/fixtures/electron.ts`. Сохраняйте тесты безопасными для параллельного запуска: избегайте фиксированных доступных для записи путей, портов, нативных хранилищ ключей и другого внешнего общего состояния. Если изоляция невозможна, используйте `E2E_EXCLUSIVE_TAG`.
### Технологический стек
| Уровень | Технология |
|---------|------------|
| Среда выполнения | Electron 40+ |
| UI-фреймворк | React 19 + TypeScript |
| Стилизация | Tailwind CSS + shadcn/ui |
| Состояние | Zustand |
| Сборка | Vite + electron-builder |
| Тестирование | Vitest + Playwright |
| Анимация | Framer Motion |
| Иконки | Lucide React |
-83
View File
@@ -1,83 +0,0 @@
# Возможности ClawX
Этот документ содержит подробную версию раздела «Возможности» из README.
### Нулевой порог настройки
Весь процесс от установки до первого разговора с AI выполняется через интуитивный графический интерфейс. Терминальные команды, YAML-файлы и поиск переменных окружения не требуются.
### Интеллектуальный интерфейс чата
Общайтесь с AI-агентами через современный чат. ClawX поддерживает несколько контекстов разговоров и историю сообщений, а ответы агента отображаются как потоковый Markdown с подсветкой синтаксиса для fenced code, разбором CJK, таблицами GitHub-flavored и формулами LaTeX через KaTeX (`$строчные$`, `$$блочные$$`, `\(строчные\)` и `\[блочные\]`). Пользовательский ввод всегда остаётся обычным текстом. В многoагентных конфигурациях основное поле ввода поддерживает прямую маршрутизацию через `@agent`. Fenced code сохраняет исходные переводы строк, переносит длинные строки и предоставляет локализованное действие копирования после завершения потоковой выдачи.
Навыки, вставленные из композитора, отображаются как карточки `/skill-name`. Нажмите карточку, чтобы открыть боковую панель предпросмотра и прочитать `SKILL.md` этого навыка.
При выборе другого агента через `@agent` ClawX напрямую переключается в контекст этого агента, не передавая запрос через агента по умолчанию. Рабочие пространства агентов по умолчанию разделены, а более строгая изоляция среды выполнения зависит от настроек sandbox OpenClaw.
Боковая панель сессий организована по принципу «сначала рабочие пространства»: рабочее пространство по умолчанию находится вверху, остальные сортируются естественным образом, а каждое рабочее пространство можно свернуть или загрузить для него дополнительные сессии. Во время ответа AI в строке сессии отображается индикатор загрузки, после завершения непросмотренного ответа появляется синяя точка, а после открытия разговора снова показывается относительное время активности. При наведении отображаются действия строки. Импортированные рабочие пространства можно переименовать из заголовка боковой панели. Пользовательское имя отображается в композиторе чата, а при наведении на заголовок по-прежнему виден путь в файловой системе.
Если выбрано доступное рабочее пространство, новый чат наследует его и остаётся редактируемым до первой отправки. В редактируемых новых или ещё не привязанных чатах чип рабочего пространства в композиторе открывает меню с недавно использованными и известными из сессий рабочими пространствами. Можно вернуться к рабочему пространству по умолчанию или выбрать другую папку. Если сохранённая папка была перемещена или удалена, Chat приостанавливает создание сессии и предлагает выбрать существующую папку вместо повторных попыток использовать недоступный путь. Недоступные группы, кроме группы по умолчанию, отмечаются в боковой панели и могут быть удалены после подтверждения; это навсегда удаляет все сессии группы. Строка сессии удаляется, а навигация меняется только после успешного окончательного удаления. При ошибке удаления разговор и подтверждение остаются открытыми для повторной попытки. Синтетические резервные заголовки OpenClaw с UUID и датой считаются отсутствующими только при совпадении с ID сессии, после чего заменяются первым сообщением пользователя и не сохраняются как имя сессии.
Каждый агент может переопределить собственную настройку среды выполнения `provider/model`. Агенты без переопределения продолжают наследовать глобальную модель по умолчанию.
Вкладки Workspace и Preview на правой панели Chat предоставляют предпросмотр только для чтения файлов Markdown, `.docx` и `.pptx`. Предпросмотр Markdown в статическом режиме использует ту же подсветку синтаксиса, перенос длинных строк, копирование fenced code, разбор CJK и поддержку формул KaTeX. В заголовке Preview можно развернуть выбранный файл на весь видимый экран ClawX; тем же элементом управления или клавишей Escape можно вернуться на панель. Устаревшие `.doc` и `.ppt` по-прежнему открываются через операционную систему, а не внутри приложения. Разбиение DOCX на страницы может отличаться от Microsoft Word; предпросмотр PPTX не поддерживает анимации, переходы и воспроизведение медиа. Office-файлы размером более 20 МБ не просматриваются внутри приложения.
### Предпросмотр локального HTML
На правой панели Chat есть вкладки Workspace, Preview и Changes. Универсального веб-браузера, домашней страницы и адресной строки больше нет. Разрешённые локальные вложения `.html` и `.htm`, файловые операции и файлы рабочего пространства по умолчанию открываются в Preview. В действиях файла можно выбрать встроенный Preview или системное приложение, а заголовок Preview может открыть текущий HTML-файл в системном браузере.
Все ссылки некликабельны. Ссылки, отображаемые ClawX, выглядят как обычный текст; в HTML Preview также удаляются оформление ссылок и взаимодействие указателем. HTML Preview блокирует формы, переходы из скриптов, перенаправления, переходы внутри страницы, всплывающие окна, загрузки, сетевые запросы и разрешения устройств. Самодостаточный локальный HTML отображается, но не может покинуть выбранный документ.
### Управление несколькими каналами
Настраивайте и отслеживайте несколько AI-каналов одновременно. Каждый канал работает независимо, позволяя запускать специализированных агентов для разных задач.
Каждый канал поддерживает несколько аккаунтов, привязку агента к аккаунту и переключение аккаунта канала по умолчанию прямо на странице Channels.
Для пользовательских ID аккаунтов каналов ClawX требует совместимый с OpenClaw канонический формат: `[a-z0-9_-]`, строчные буквы, максимум 64 символа, начало с буквы или цифры. Это предотвращает ошибки маршрутизации.
ClawX также включает официальный плагин личного WeChat от Tencent, поэтому WeChat можно подключить прямо на странице Channels через встроенный QR-код.
### Автоматизация по расписанию
Планируйте автоматический запуск AI-задач. Определяйте триггеры и интервалы, чтобы AI-агенты могли работать круглосуточно.
На странице Cron внешнюю доставку можно настроить непосредственно в форме задачи с отдельными селекторами аккаунта отправителя и цели получателя. Для поддерживаемых каналов цели получателей автоматически обнаруживаются из каталогов каналов или известной истории сессий, поэтому больше не нужно вручную редактировать `jobs.json`. Поле сообщения задачи поддерживает вставку навыков тем же синтаксисом встроенных токенов `/skill`, что и основной композитор чата, с учётом выбранного агента. Запланированные запросы могут запускать навыки напрямую.
Выбор расписания разделён на вкладки **Повтор** и **Однократно**. Повтор предлагает частоты «Ежечасно», «Ежедневно», «По будням», «Еженедельно» и «Свой» (произвольный cron) со встроенным выбором времени и дня недели. Однократно запускает задачу один раз в выбранные дату и время с отображением дня недели. Одноразовые задачи должны быть запланированы на будущее и автоматически удаляются средой выполнения после завершения.
### Расширяемая система навыков
Расширяйте возможности AI-агентов готовыми навыками. Встроенная страница Skills работает по принципу local-first: сканирует управляемые каталоги и каталоги рабочего пространства и позволяет включать или отключать навыки без зависимости от Gateway. В корпоративных сборках расширение также может предоставить собственный маркетплейс.
ClawX предварительно упаковывает полные навыки обработки документов (`pdf`, `xlsx`, `docx`, `pptx`), автоматически развёртывает их в управляемый каталог навыков (по умолчанию `~/.openclaw/skills`) при запуске и включает их по умолчанию при первой установке.
На странице Skills можно показывать навыки из нескольких источников OpenClaw, включая управляемый каталог, workspace и дополнительные каталоги навыков. Для каждого навыка отображается фактическое расположение, чтобы папку можно было открыть напрямую. Для bundled skills OpenClaw в community-сборках упаковывается и отображается только `skill-creator`; bundled skills, отсутствующие в списке разрешённых, физически удаляются при запуске в режиме разработки и в packaged-сборках, а устаревшие записи для удалённых навыков в `openclaw.json` очищаются.
### Безопасная интеграция провайдеров
Подключайтесь к нескольким AI-провайдерам, включая OpenAI, Anthropic и Z.AI / GLM; учётные данные безопасно хранятся в нативном системном хранилище ключей. OpenAI поддерживает API-ключи и браузерный OAuth для подписок Codex.
В режиме разработчика отдельная страница Image Generation поддерживает независимый OpenAI-совместимый эндпоинт генерации изображений с Base URL, API-ключом и именем модели, например `gpt-image-2`. Поэтому генерация изображений может использовать отдельный сервис `/v1/images/generations`, а чат продолжает использовать обычный OpenAI-провайдер.
Для **Custom**-провайдеров, работающих с OpenAI-совместимыми шлюзами, можно задать собственный `User-Agent` в разделе **Настройки → AI-провайдеры → Редактировать провайдер** для эндпоинтов с требованиями к совместимости.
При редактировании или переключении провайдера ClawX сохраняет существующие метаданные возможностей модели, например `input: ["text", "image"]`. Для новых моделей Custom-провайдера используется совместимая с OpenClaw onboarding логика определения поддержки изображений; неизвестные модели считаются текстовыми.
Строки моделей Custom-провайдера также получают явный `contextWindow`, рассчитанный по семейству модели, например `gpt-5.x` → 272k. Строки, сохранённые старыми версиями, дополняются при запуске, чтобы OpenClaw мог сжимать длинные сессии до ошибки «Context overflow». Если настройки сжатия отсутствуют, ClawX создаёт `agents.defaults.compaction.mode = "safeguard"` и `reserveTokensFloor = 50000`. Созданные пользователем строки и конфигурации не изменяются, кроме возможного дополнения отсутствующего `reserveTokensFloor`.
Z.AI (CN / 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` с настроенной моделью.
### Адаптивные темы
Выбирайте светлую, тёмную или синхронизированную с системой тему. ClawX автоматически адаптируется к вашим предпочтениям.
### Управление автозапуском
В разделе **Настройки → Общие** включите **Запускать при старте системы**, чтобы ClawX автоматически запускался после входа в систему.
### Запросы на обновление
ClawX проверяет наличие новых версий при запуске. Если обновление доступно, приложение показывает запрос; скачивание и установка выполняются только после вашего выбора.
-12
View File
@@ -1,12 +0,0 @@
# Настройки прокси ClawX
Этот документ содержит подробную версию раздела «Настройки прокси» из README.
- Значение в формате `host:port` рассматривается как HTTP-прокси.
- Если расширенные поля прокси пусты, ClawX использует **Прокси-сервер**.
- Сохранение настроек прокси немедленно повторно применяет сетевые настройки Electron и автоматически перезапускает Gateway.
- При включённом Telegram ClawX также синхронизирует прокси с конфигурацией канала Telegram в OpenClaw.
- Если прокси ClawX отключён, обычный перезапуск Gateway сохраняет существующий прокси канала Telegram.
- Чтобы явно удалить прокси Telegram из конфигурации OpenClaw, отключите прокси и один раз сохраните настройки прокси.
- В разделе **Настройки → Дополнительно → Разработчик** можно запустить **OpenClaw Doctor**. Он выполняет `openclaw doctor --json` и показывает диагностический вывод в приложении.
- В упакованных сборках Windows встроенный `openclaw` CLI/TUI запускается через поставляемую точку входа `node.exe`, чтобы сохранить стабильное поведение ввода в терминале.
+865
View File
@@ -0,0 +1,865 @@
# ClawX Runtime Abstraction and cc-connect Replacement Specification
Status: implementation contract
Updated: 2026-07-12
Default runtime: `openclaw`
Optional runtime: `cc-connect` behind Developer Mode
## 1. Objective
ClawX must expose one runtime layer whose OpenClaw and cc-connect providers
support the same product surfaces. OpenClaw remains the default and rollback
path. cc-connect is accepted as a replacement only when chat, sessions,
history, tools, provider credentials, Feishu/Lark, native cron, usage,
skills, diagnostics, and packaged startup are proven through the cc-connect
process rather than through ClawX-to-Codex shortcuts.
The non-negotiable execution boundary is:
```text
Renderer -> Host API -> RuntimeManager -> CcConnectRuntimeProvider
-> cc-connect Bridge/Management API -> cc-connect -> Codex
```
ClawX may supply the Codex binary path, provider environment, `CODEX_HOME`,
workspace, skills, and credentials to cc-connect. It must not spawn Codex for
chat, parse Codex files as the production real-time event transport, or invoke
Codex session commands directly.
## 2. Version and packaging decision
The original prototype pinned `cc-connect@1.3.2`. That package contains only a
CLI wrapper, install script, and documentation; its postinstall downloads a
release binary into `node_modules/cc-connect/bin`. Declaring the dependency is
therefore insufficient for Electron packaging.
The replacement implementation targets stable `cc-connect@1.4.1` because its
published runtime surface includes Bridge REST session management and a broader
Management API. The exact binary, not upstream `main`, is the release contract.
Every upgrade must run the contract probe before application code adopts a new
endpoint.
Packaging requirements:
- Pin `cc-connect` exactly in `devDependencies`.
- `scripts/bundle-cc-connect.mjs` downloads release assets for macOS x64/arm64,
Linux x64/arm64, and Windows x64.
- Verify `--version`, executable permission, SHA-256, platform, architecture,
source URL, and package version in `manifest.json`.
- Copy the verified binary to `process.resourcesPath/cc-connect/`; never run
postinstall or download a binary at application runtime.
- Bundle the pinned Codex CLI in `process.resourcesPath/codex/`; cc-connect is
the only process allowed to launch it for runtime work.
- `afterPack` must reject a target whose copied cc-connect or Codex resource is
missing, stale, corrupted, non-executable, or inconsistent with its manifest.
- Final unpacked artifacts must pass
`pnpm run verify:packaged-runtime-resources -- --resources=<resources> --platform=<platform> --arch=<arch>`.
Windows and Linux require exact packaged-binary SHA equality. macOS also
requires exact SHA before signing; when `codesign` rewrites Mach-O metadata,
the final verifier requires the source bundle SHA, all Mach-O section
payloads, architecture/version, and `codesign --verify --strict` to agree.
- macOS, Windows, and Linux packaged jobs must run a resource/startup/cleanup
smoke before release readiness can be claimed.
- `.github/workflows/release.yml` runs the final resource verifier for macOS
x64/arm64, Windows x64, and Linux x64/arm64 before uploading release
artifacts. The same release gate runs the full packaged smoke natively on
macOS arm64, Windows x64, and Linux x64, with dedicated `macos-15-intel` and
`ubuntu-24.04-arm` jobs for macOS x64 and Linux arm64. Publishing depends on
all five jobs. A local run cannot replace observed CI evidence. Runner labels
follow the [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners).
- A manual `Release` workflow dispatch is evidence-only: it disables macOS
signing discovery and never creates a GitHub Release, uploads to OSS, or runs
final promotion. Publishing remains tag-only. Use an alpha/beta version label
for manual smoke so Windows also skips SignPath. Manual macOS smoke explicitly
records that signature validation was skipped; tag builds still require strict
signature verification before publishing.
Primary upstream contracts:
- [cc-connect usage](https://github.com/chenhg5/cc-connect/blob/v1.4.1/docs/usage.md)
- [Management API](https://github.com/chenhg5/cc-connect/blob/v1.4.1/docs/management-api.md)
- [Bridge protocol](https://github.com/chenhg5/cc-connect/blob/v1.4.1/docs/bridge-protocol.md)
## 3. Durable data and locking
All ClawX-owned persistent state uses one upgrade-stable root. Stable, beta,
dev, and multiple installations may share it, but only one writer may run at a
time.
```text
~/.clawx/
state/
data-version.json
migration-journal.jsonl
locks/
writer.lock
app/
settings.json
clawx-providers.json
runtime-config.json
cc-connect-agent-bindings.json
cc-connect-session-metadata.json
credentials/
index.json
secrets.enc
oauth/<provider-account-id>/codex-home/
skills/
installed/
configs.json
workspaces/
agents/<agent-id>/
runtimes/
cc-connect/{config,data,media,events,logs}
openclaw/projection-state.json
system/electron/
logs/
backups/
cache/
```
`resolveClawXDataRoot()` and `getClawXDataLayout()` are the only path-building
entry points. Production defaults to `~/.clawx`; `CLAWX_DATA_HOME` is the
supported override. Electron `userData` becomes `~/.clawx/system/electron` and
application logs use `~/.clawx/logs`.
`writer.lock` is created atomically and contains pid, owner token, app version,
channel, executable, start time, and heartbeat time. A second installation
shows the current owner and exits before the data layout, migrations, runtime
manager, or scheduler can start. Failure to acquire or inspect the lock is
fail-closed; ClawX never falls back to an uncoordinated shared-root writer.
Stale lock recovery requires both a dead pid and an expired heartbeat;
`force: true` deletion is forbidden.
Migrations are version-gated, journaled, additive, backed up, and atomic. An
older application that cannot understand the current data version refuses to
write. Existing Electron data is imported into `~/.clawx`; existing
`~/.openclaw` remains external compatibility data and is never moved or
deleted.
`tests/e2e/clawx-data-layout-migration.spec.ts` exercises this production
startup order without the flat `CLAWX_USER_DATA_DIR` test compatibility
override. It supplies an isolated legacy `--user-data-dir` before Main startup,
uses an isolated `CLAWX_DATA_HOME`, launches Electron, and verifies `app/`,
`system/electron`, the data version, migration journal, and retained legacy
source on every CI platform without reading the developer's real userData. It
then changes the legacy settings and launches again to prove the canonical
`app/` state wins across upgrades and repeated migration attempts.
`tests/e2e/clawx-shared-root-single-writer.spec.ts` launches two real Electron
processes against the same root, proves the duplicate cannot replace the live
owner or create a window, captures first-writer UI evidence, then closes the
owner and proves a successor process acquires the released lock.
`app/runtime-config.json` is the canonical Agent, binding, channel-account, and
OpenClaw-compatible runtime metadata document. Sensitive channel fields are
removed before this file is written and are hydrated from
`credentials/secrets.enc` only in Main-process memory. `~/.openclaw/openclaw.json`
is an import/export compatibility projection, not the cc-connect state owner.
The compatibility file is imported only when canonical state does not yet
exist. Shared saves never use its mtime to overwrite canonical state. While
cc-connect is active they do not write the projection; the OpenClaw adapter
rebuilds it, including vault-backed channel secrets, immediately before
OpenClaw start or restart.
## 4. Runtime contracts
```ts
type RuntimeKind = 'openclaw' | 'cc-connect'
interface RuntimeProvider {
kind: RuntimeKind
start(): Promise<void>
stop(): Promise<void>
restart(): Promise<void>
getStatus(): RuntimeStatus
checkHealth(options?: RuntimeHealthOptions): Promise<RuntimeHealth>
rpc<T>(method: string, params?: unknown): Promise<T>
sendMessageWithMedia(payload: RuntimeSendPayload): Promise<RuntimeSendResult>
abortRun(payload: RuntimeAbortPayload): Promise<RuntimeAbortResult>
resolveApproval(payload: RuntimeApprovalResponse): Promise<void>
listSessions(query?: RuntimeSessionQuery): Promise<RuntimeSessionPage>
loadHistory(query: RuntimeHistoryQuery): Promise<RuntimeHistoryPage>
deleteSession(payload: RuntimeSessionMutation): Promise<void>
listUsage(query?: RuntimeUsageQuery): Promise<RuntimeUsagePage>
listLogs(query?: RuntimeLogQuery): Promise<RuntimeLogPage>
runDoctor(mode: 'diagnose' | 'fix'): Promise<RuntimeDoctorResult>
listCapabilities(): RuntimeCapabilities
listOperationCapabilities(): RuntimeOperationCapabilities
}
```
`RuntimeStatus` retains Gateway-compatible process states and adds
`runtimeKind`, version, config directory, capabilities, operation capabilities,
and scoped health. `gateway:*` IPC/event names remain compatibility aliases,
but their data is always supplied by the active provider.
Operation support is `native`, `proxy`, `degraded`, or `unsupported`.
`degraded` means the command remains callable but has a documented parity or
blast-radius limitation. For cc-connect v1.4.1, `chat.abort` is native: ClawX
sends the public `/stop` command over BridgePlatform for the selected session.
The whole runtime is restarted only as a disconnected-Bridge fallback when the
stop command cannot be delivered. Settings displays degraded and unsupported
operations separately from top-level capability availability.
Before a runtime status has published operation capabilities, renderer helpers
retain compatibility with legacy Gateway status. Once the operation map is
present, any undeclared method is treated as unsupported; this makes contract
drift visible instead of allowing an unreviewed runtime call to pass through.
OpenClaw-specific auth, proxy mutation, Doctor Fix, Skills implementation,
Dreams, memory repair, and Control UI remain inside the OpenClaw adapter.
Shared services must not call `GatewayManager` or write `~/.openclaw` when
cc-connect is active.
## 5. Agent, provider, model, and credential ownership
Provider Account is the stable credential identity. Agent bindings reference an
account explicitly instead of encoding identity in `provider/model` strings.
```ts
interface AgentRuntimeBinding {
agentId: string
providerAccountId: string
model: string
workspaceId: string
}
```
`agents.updateRuntimeBinding({ id, providerAccountId, model })` is the canonical
Host API. The old model-only method is a compatibility adapter and fails when
multiple accounts make the reference ambiguous.
Each cc-connect project resolves credential identity from the Agent's provider
account binding and resolves model independently from that Agent's explicit
`provider/model` override or the canonical default. Project model overrides
replace only cc-connect/Codex model arguments; they never replace or merge the
bound account's OAuth home or API-key environment.
Credential rules:
- Browser OAuth acquisition writes only the ClawX-owned provider account and
encrypted secret. Runtime projection is dispatched through the active
`RuntimeProvider`: cc-connect materializes its account-scoped managed
`CODEX_HOME`, while OpenClaw retains its existing auth/config projection. A
cc-connect OAuth success must never write OpenClaw config or schedule an
OpenClaw Gateway restart.
- A successful cc-connect browser re-login (`reason=oauth`) replaces that
account's managed Codex auth with the newly acquired vault secret. Ordinary
runtime startup keeps managed auth first so Codex refresh-token rotation is
not rolled back by an older vault snapshot.
- API keys and reusable OAuth recovery material are encrypted with Electron
`safeStorage` in `credentials/secrets.enc`.
- Channel account secrets share the encrypted vault under account-scoped IDs;
`credentials/index.json` contains IDs only, never secret values.
- Every OpenAI OAuth account owns a complete account-level `CODEX_HOME` under
`credentials/oauth/<account-id>/codex-home`; auth files are mode `0600`.
- OAuth homes are not symlinked or copied between accounts. Agents may share an
account by binding to the same account-level home.
- A pre-account shared managed Codex home is moved once to the selected default
OAuth account and then removed; it is never copied to a second account.
- Runtime profile construction never consumes user-global `~/.codex/auth.json`.
That file is inspected only for redacted status and copied only after the user
explicitly invokes `importCodexOAuth` for a matching account.
- API-key projects receive account-specific environment variables. Secrets are
never written literally to generated TOML or exposed to Renderer.
- Provider/model/account changes detach the old runtime session and create a
new cc-connect/Codex session on the next turn while preserving visible ClawX
history.
- Missing or incomplete credentials block only bound Agents. Access-token
expiry does not invalidate a complete managed OAuth home because
cc-connect/Codex owns refresh-token rotation there; a failed refresh is
surfaced on that Agent's runtime turn and can be recovered with browser
re-login, without changing another Agent's credentials.
- Validation may import a complete token set with an expired access or ID token
into an isolated managed `CODEX_HOME`. The verifier records only sanitized JWT
expiry metadata; only a successful real cc-connect -> Codex turn proves that
refresh-token rotation worked. Passing the static precondition alone is not
refresh evidence.
- Proxy variables are supplied to cc-connect and inherited by its children;
localhost, `127.0.0.1`, and `::1` are always added to `NO_PROXY`.
Initial verified matrix: OpenAI API key, OpenAI Codex OAuth, OpenAI-compatible
Responses, and Ollama. Unsupported providers return a stable capability error
without mutating OpenClaw config.
`providers.profile` and `models.profile` are read-only runtime operations. While
cc-connect is running they return the ClawX-managed public profile together
with each managed project's public Management API `/providers` and `/models`
state. They never reuse the sync path and therefore never restart cc-connect.
The adapter maps only provider name, active state, model, base URL, model list,
and current model; unknown Management fields and secret-like fields never cross
the Host API.
Provider/model writes remain ClawX-owned: ClawX updates the account-scoped
Codex profile and cc-connect project config, then reloads or restarts through
the runtime provider.
## 6. Workspace, skills, and plugins
New Agents use `~/.clawx/workspaces/agents/<agent-id>`. If an existing OpenClaw
Agent has a valid configured workspace, ClawX records that path as
`external-openclaw` and reuses it without copying or moving data.
Each cc-connect project receives exactly that Agent workspace as `work_dir`.
No code path may default to `process.cwd()`, the ClawX source checkout, or app
resources. Agent deletion removes only `clawx-managed` workspaces.
When a new Agent requests workspace inheritance, ClawX may read bootstrap files
from the existing OpenClaw main workspace, but writes the new Agent under the
ClawX-managed root. It never changes or assumes ownership of the source path.
ClawX owns one Skill Registry. OpenClaw receives its normal skills projection;
cc-connect receives the same enabled skills through its project/Codex skills
surface. The acceptance test must invoke a real installed skill through chat,
not only compare copied files.
Plugin reuse means shared ClawX capability, account, binding, and UI metadata.
OpenClaw JS plugins remain OpenClaw-specific. cc-connect channels are generated
as native `projects.platforms` entries and do not load OpenClaw plugins.
## 7. Chat, events, tools, approvals, and cancellation
GUI Chat registers as a cc-connect Bridge adapter. cc-connect invokes Codex and
emits all run activity over Bridge. The normalized envelope is:
OpenClaw and cc-connect intentionally use different provider-owned Chat
transports behind the same ClawX route. OpenClaw uses the Main-owned ACP
session transport introduced by the OpenClaw runtime. cc-connect renders the
Runtime Chat implementation and sends through `RuntimeManager` -> active
`RuntimeProvider` -> BridgePlatform. Renderer routing follows the active
runtime status, not only the pending Settings selection. As defense in depth,
Main rejects ACP load, prompt, cancel, and permission requests whenever
cc-connect is active; typed media sends remain dispatched through the active
runtime provider.
The adapter follows the pinned cc-connect Web Admin client lifecycle: after
`register_ack` it sends a JSON `ping` every 25 seconds, reconnects after 3
seconds when the socket drops, and stops both timers during an intentional
runtime stop. This is required for scheduler and long-running Agent replies
that cross cc-connect's approximately 90-second idle disconnect window.
```ts
interface RuntimeEventEnvelope {
schemaVersion: 1
eventId: string
runtimeKind: RuntimeKind
project: string
sessionKey: string
runtimeSessionId: string
runId: string
turnId: string
seq: number
timestamp: string
type: RuntimeEventType
payload: unknown
}
```
Required event types are `run.started`, `assistant.delta`,
`reasoning.summary.delta`, `tool.started`, `tool.updated`, `tool.completed`,
`command.output`, `patch.completed`, `approval.requested`,
`approval.resolved`, `usage.recorded`, and `run.ended`.
Pinned cc-connect v1.4.1 has two materially different Codex backends. Its
default `exec` backend does not map Codex 0.137 `custom_tool_call` records such
as `apply_patch` to `EventToolUse`; a real OAuth probe created the requested
file while cc-connect reported `tools=0`. ClawX therefore configures every
managed Codex project with `backend = "app_server"` and
`app_server_url = "stdio://"`. cc-connect remains the process owner and starts
the bundled Codex app-server inside the Agent workspace.
The Bridge adapter registers `progress_style = "card"` and
`supports_progress_card_payload = true`. cc-connect then sends the public
`__cc_connect_progress_card_v1__:` payload through `preview_start` and
`update_message`; ClawX maps typed `thinking`, `tool_use`, `tool_result`, and
`error` entries to the shared runtime graph. cc-connect v1.4.1 emits a
`fileChange` start but no corresponding result, so a successful or failed final
Bridge reply closes any still-open tool with
`meta.inferredFromRunCompletion = true`. Explicit tool results always win and
are never replaced by the inferred terminal event.
Plain-text previews use the same normalized `assistant.delta` contract. ClawX
emits the initial `preview_start` immediately, applies each `update_message` as
an in-place replacement, and clears only that transient assistant text when
cc-connect sends `delete_message`. Structured progress is intentionally kept as
semantic thinking/tool lifecycle in the execution graph; deleting cc-connect's
temporary platform message must not erase the completed tool relationship.
The opt-in real OAuth E2E proves the full path: GUI send -> RuntimeManager ->
cc-connect Bridge -> cc-connect-owned Codex app-server -> Patch -> progress
payload -> Main runtime event -> Renderer execution graph. It asserts
`transport=stdio`, cc-connect `tools=1`, the managed workspace file, both tool
lifecycle events, real approval request/resolution, and the visible graph. It
writes sanitized evidence under
`artifacts/cc-connect/real-oauth-tool-events.{png,json}` plus
`artifacts/cc-connect/real-oauth-approval-request.png`. These screenshots keep
the tool type, approval controls, lifecycle state, generated filename, and
assistant result visible while masking the isolated managed workspace path.
Reading Codex JSONL as a real-time event source, wrapping Codex stdout, or
spawning a second Codex bridge remains forbidden. Section 8 documents the sole
bounded historical exception for Channel tool packets omitted by public
cc-connect history.
The local-real verifier performs runtime checks with real filesystem paths but
replaces repository, home, and temporary roots with `<repo>`, `<home>`, and
`<tmp>` before persisting JSON or Markdown. A passing evidence row must not
publish a developer's worktree, credential-home, or isolated runtime path.
Only Codex-provided reasoning summaries are shown. Hidden chain-of-thought is
never requested or inferred. `eventId` deduplicates; `runId + seq` orders and
detects gaps. Bridge reconnect must replay missing events through
cc-connect-owned history once the upstream protocol exposes them. ClawX must
not scan Codex transcript files to reconstruct real-time tool activity.
The app-server backend surfaces approval requests as Bridge `buttons`. ClawX
stores the run-correlated `session_key`, `reply_ctx`, project, and only the
actions offered by cc-connect. `chat.approval.respond` validates the requested
action against that pending set and sends cc-connect's public `card_action`
packet; Renderer never talks to Codex and cannot inject an arbitrary action.
Deterministic Electron E2E proves request rendering, GUI click, Host API/runtime
RPC dispatch, the exact Bridge packet, and resumed assistant delivery. The
opt-in real OAuth E2E additionally runs the Main Agent in `suggest` mode and
proves the same flow through bundled cc-connect 1.4.1 and bundled Codex: a real
Patch approval is rendered, allowed, resolved by cc-connect, and followed by a
workspace write and final assistant response.
The same validated path handles non-approval runtime choices from cc-connect
cards. Action rows, list buttons, and select options are parsed from the public
card schema; only `perm:`, `askq:`, `cmd:`, `nav:`, and `act:` values are
eligible. Select options complete the current Chat run when cc-connect returns
the updated state card, while navigation/button cards can continue the same
interaction until cc-connect emits a reply or the user aborts. The real bundled
`/lang -> card -> card_action -> card` E2E verifies the live language through
the public Management project API and preserves the runtime PID. Pinned v1.4.1
does not persist manual `/lang` selections to `config.toml`: its save callback
is registered only for automatic language detection, so ClawX does not infer a
durable write that the runtime did not perform.
Permission mode is Agent-owned runtime metadata in
`~/.clawx/app/agent-bindings.json`, alongside but independent from the Agent's
provider-account binding. `full-auto` remains the default; `suggest` selects
cc-connect app-server's `on-request` approval policy and read-only sandbox.
Saving the mode refreshes the managed project config without writing OpenClaw
configuration. Only these two safe product modes are exposed; ClawX does not
offer cc-connect's sandbox-bypassing mode.
Pinned cc-connect v1.4.1 has no dedicated incoming Bridge cancellation packet
or per-run cancellation Management endpoint, but its public `/stop` command is
session-scoped. `chat.abort` immediately ends the correlated ClawX run, sends
`/stop` through BridgePlatform for that session, and suppresses replies correlated
to the aborted run. Codex app-server does not implement cc-connect's graceful
`CancelTurn` interface, so cc-connect closes only that session's Codex child
while preserving its stored AgentSessionID for resume; the cc-connect process
and other Agent sessions remain running. If Bridge is disconnected and `/stop`
cannot be delivered, ClawX restarts the owned runtime as an explicit fallback.
The real local OpenAI-compatible E2E proves upstream stream closure, no late
assistant rendering, and an unchanged cc-connect PID.
## 8. Sessions and history
Session inventory, ordinary user/assistant history, and deletion use
cc-connect's public Management/Bridge session endpoints. ClawX does not read or
mutate cc-connect session JSON files. User-assigned titles are ClawX UI metadata
stored atomically in `app/cc-connect-session-metadata.json`; deleting a public
session deletes its title in the same Host API operation. On first use, labels
from the old ClawX-owned `.clawx-supplemental-history.json` are imported without
copying its history payload.
The production Bridge adapter contains no parser for cc-connect session JSON or
Codex transcripts. It retains only messages observed on the current public
Bridge connection for immediate event delivery; durable list/delete and
authoritative ordinary messages always come from the provider's public
Management session client.
Pinned cc-connect can omit historical tool packets from public history for
Channel-originated sessions even though Codex recorded and executed those
tools. After public history has loaded, the provider may apply one degraded,
best-effort compatibility supplement that contributes only tool calls and
their results. It cannot create or replace user, assistant, system, attachment,
approval, real-time event, or usage records.
Candidate Codex JSONL files must match the owning Agent workspace. A stale or
exact `agent_session_id` does not bypass that check. Fallback discovery is
bounded to recent public user-turn text and the timestamp of that exact user
record, nearby transcript date directories, bounded path/file caches, and
truncated tool output. Missing, stale, cross-workspace, or ambiguous evidence
leaves public history unchanged. This exception does not satisfy replacement
readiness and must be removed when the pinned cc-connect runtime exposes
durable public Channel tool history.
ClawX owns logical session identity and display metadata; cc-connect owns
runtime sessions and message history. Public session responses carry the
logical/runtime binding, while `cc-connect-session-metadata.json` stores only
optional display labels and never copies runtime credentials or message
history.
cc-connect Session REST/Management APIs are the only production source for
list, create, ordinary message history, switch, and delete. The bounded
Channel tool supplement above is the only historical content exception. Rename
uses an official endpoint if the pinned binary exposes it; otherwise ClawX
stores only the display label in its logical index and does not rewrite
cc-connect private JSON. Hard delete is reported successful only after the
runtime API confirms deletion.
Runtime or provider switching preserves visible historical turns and detaches
the old backend binding. The first subsequent message creates a new runtime
session and includes a clearly identified continuation context once. OpenClaw
internal session ids are never passed to cc-connect.
Required cases include active, named, cross-Agent, Channel, Cron, restart,
rename, hard delete, and pagination. Session ids must not collide across
projects or provider accounts.
## 9. Token usage
Usage is a runtime contract, not a dashboard file scan.
```ts
interface RuntimeUsageRecord {
id: string
runtimeKind: RuntimeKind
logicalSessionId: string
runtimeSessionId: string
turnId: string
agentId: string
providerAccountId?: string
provider: string
model: string
timestamp: string
status: 'available' | 'missing' | 'error'
inputTokens: number
cachedInputTokens: number
outputTokens: number
reasoningTokens: number
totalTokens: number
costUsd?: number
}
```
Pinned cc-connect v1.4.1 does not currently expose per-turn token usage through
its documented Bridge or Management API, and an actual binary probe confirms
that enabling `reply_footer` does not add machine-readable usage to Bridge
replies. Therefore this acceptance row is **upstream-blocked**, not complete.
Production ClawX derives turn identity only from cc-connect public session
history. When that history has no usage payload, each assistant turn is
returned with `status: 'missing'` and zero counters so callers can distinguish
"the turn exists but usage is unavailable" from "there is no history". ClawX
does not fill those counters from private cc-connect stores or Codex JSONL.
Test code may use a managed transcript or provider response as an oracle, but
that evidence cannot close the exact-usage runtime-contract row.
`RuntimeProvider.listUsage` is the only Host API usage source. The OpenClaw
adapter owns its existing structured transcript scan; the cc-connect adapter
owns public Management session/history reads and emits one normalized record
per assistant turn. `usage-api` does not call `listSessions`/`loadHistory`
itself and does not know either runtime's storage layout. Runtime records carry
logical and runtime session ids, a stable turn identity, Agent/provider/model
attribution, status, counters, and optional cost/content compatibility fields.
The upstream audit was refreshed on 2026-07-26. npm still marks `1.4.1` as
`latest`; `1.5.0-beta.2` is the newest prerelease. The beta.2 release contains
only a Codex model-visibility fix on top of beta.1 and does not publish a usage
API. The stable and prerelease source trees parse Codex
`thread/tokenUsage/updated` into an internal `ContextUsageReporter`, but the
documented Management and Bridge session detail responses still expose only
message role/content/timestamp. When context display is enabled, the runtime
renders a lossy `[ctx: ~N%]` footer to the platform instead of a structured
per-turn payload. ClawX must not parse that display string or reach into
cc-connect's internal agent/session state. This is why upgrading to the beta or
enabling `reply_footer` does not close the contract.
Upstream PR [cc-connect#1428](https://github.com/chenhg5/cc-connect/pull/1428)
proposes an opt-in Bridge `usage` observer. It is useful directionally, but its
current head is conflicting and is not included in stable v1.4.1 or prerelease
v1.5.0-beta.2.
Its unversioned event contains `session_key`, `turn_id`, input/output/cache
counts and user metadata, but omits `project`, provider/model identity,
reasoning tokens, durable history semantics and replay after reconnect. Those
omissions prevent reliable multi-Agent attribution and historical dashboard
reconstruction, so ClawX must not implement production parity against that
unmerged schema. A future release may use the observer design provided the
published contract addresses these fields or exposes an equivalent durable
Management history field.
Completion requires a pinned cc-connect release to expose a versioned usage
event or history field containing project, session/turn, provider/model, and
token counts, plus documented reconnect/replay behavior or durable history.
ClawX must then map that public payload to `RuntimeUsageRecord`, add a real
API-key/OAuth oracle comparison, and remove the checked-in E2E `fixme`.
`cachedInputTokens` is a subset of input and `reasoningTokens` is a subset of
output. If total is absent, calculate `input + output`; never add cache again.
Cost is shown only when runtime/provider returns an explicit historical value.
Dashboard defaults to the active runtime and offers OpenClaw, cc-connect, and
combined filters.
The shared parser enforces this total rule for both adapters. Public payloads
may expose cache-read/cache-write and reasoning counters independently for
display, but inferred `totalTokens` remains `inputTokens + outputTokens` so
cache and reasoning subsets are never counted twice.
## 10. Channels and Feishu/Lark
Channel account metadata lives under `~/.clawx/app`; app secrets live in the
encrypted credential vault. Generated cc-connect TOML references environment
variables. Connect, disconnect, and delete mean config projection plus
Management API reload/status when the pinned binary lacks per-platform
lifecycle endpoints.
Feishu/Lark replacement evidence requires:
```text
tenant message -> cc-connect platform -> bound project/Agent/workspace
-> Codex -> cc-connect -> tenant reply
```
Both China Feishu and global Lark domain mappings are tested. Status is read
from project platform detail, not inferred from process state. Channel-created
sessions must appear in ClawX history and usage under the bound Agent.
Channel mutations require account-scoped authorization. Runtime hooks may be
used as an evidence collector, not as a second message processor.
Current live-credential evidence proves the Feishu platform reaches
`connected`/`running` through cc-connect, survives Host API disconnect/connect
reload, preserves both the ClawX desktop administrator and configured Channel
administrators, removes the account from managed config on delete, and cleans
up the runtime process. The same real test proves an existing OpenClaw channel
file is a read-only import source: non-secret account metadata is owned by the
canonical runtime config, the app secret is absent from that document and from
plaintext vault bytes, and neither import nor cc-connect-mode delete changes
the compatibility file. Sanitized machine evidence is written to
`artifacts/cc-connect/real-feishu-lifecycle.json`. A tenant-originated inbound
marker and its reply remain a separate manual gate; lifecycle success alone
does not claim message-delivery parity.
## 11. Cron
For the first replacement milestone, cc-connect native cron-expression jobs
are the only supported schedule kind. `at`, `every`, and manual run remain
explicitly unsupported unless the pinned stable binary exposes equivalent
native operations. ClawX must not maintain a second prompt scheduler.
GUI and Channel `/cron` operate the same cc-connect scheduler and store:
- Channel create/update/enable/disable/delete is visible in GUI.
- GUI mutations are visible through Channel `/cron`.
- Scheduled prompt execution returns to the configured Channel through
cc-connect.
- `admin_from` contains ClawX admins and explicit `cron-manager` role members;
other allow-listed users cannot mutate jobs.
- Jobs carry project, session key, workspace, schedule, enabled state, and
runtime ownership.
For prompt/exec jobs without external delivery, ClawX uses the managed local
LINE placeholder session key because cc-connect Cron resolves the first session
key segment as a configured platform. Agent/account/workspace ownership still
comes from the job's project. `clawx:<agent>:<session>` remains a Bridge session
key and must not be passed to the native scheduler. Announce jobs use the real
target platform and recipient key.
Capability metadata exposes `scheduleKinds: ['cron']`, Channel commands, and
the actual support state of manual execution. Unsupported operations are
non-mutating.
cc-connect manual execution is asynchronous: `POST /api/v1/cron/{id}/exec`
acknowledges that a run was triggered, but does not mean the run completed.
ClawX observes completion through the runtime-owned Cron list and maps the
official `last_run` and `last_error` fields to `CronJob.lastRun`; Go's zero
timestamp means the job has never run and is not exposed as a completed run.
Validation must wait for a successful `lastRun` before using public
session/history as delivery evidence.
The Cron UI keeps trigger acknowledgement non-blocking. After the immediate
list refresh, its store observes an unchanged run in the background with a
bounded exponential-backoff refresh until `lastRun` changes, the runtime
auto-removes the job, the user deletes it, the selected runtime changes, or the
job timeout elapses. A repeated trigger supersedes the prior observation. This
polling only observes the runtime-owned scheduler; it never executes the job in
ClawX.
Current real-runtime evidence covers both native scheduler paths with the
bundled cc-connect binary. An enabled exec job fired on an actual minute tick
and wrote its marker from the configured `work_dir`. A Codex OAuth prompt job
also fired on an actual minute tick, entered cc-connect through the managed
project, and exposed its prompt and assistant reply through the public
session-summary/history APIs. The evidence command is
`pnpm run verify:cc-connect:local-real:scheduled-cron`; it does not claim live
tenant-channel delivery, which remains a separate Feishu/Lark credential gate.
Both jobs preserve the cc-connect PID, remain visible through Host API and the
Cron page until cleanup, require delete success plus a second Host API list that
proves the job is absent, and write sanitized machine/visual evidence to
`artifacts/cc-connect/real-scheduled-{exec,prompt}-cron.{json,png}`. The prompt
artifact records only public session keys and success flags; it never records
OAuth material, Management tokens, or temporary absolute paths.
The bundled-runtime E2E also registers a simulated Feishu transport through the
public Bridge protocol and proves Channel `/cron add`, list, disable, enable,
and delete as the projected managed admin are reflected by Host API Cron
operations. A GUI-created announce
job targeting the same Feishu session is visible from Channel `/cron`, and the
runtime PID remains unchanged. Sanitized evidence is written to
`artifacts/cc-connect/real-channel-cron-bridge.json`. This verifies cc-connect
core/platform command routing and one shared native scheduler; it does not
replace live Feishu tenant inbound or scheduled-reply evidence.
The probe advertises Bridge `card` and `buttons` capabilities. Pinned
cc-connect v1.4.1 returns `/cron add` as a usable text acknowledgement and the
`/cron` list as a real card; the test invokes its disable, enable, and delete
callbacks through `card_action` and verifies each mutation through Host API.
Non-approval standalone-button and upstream-triggered delete-message evidence
remain separate from this card/action proof. Preview/update now have an
independent local-real proof: the bundled cc-connect v1.4.1 engine runs against
a deterministic Codex app-server protocol boundary, emits public
`preview_start`/`update_message`, and drives the GUI execution graph plus final
assistant reply. Sanitized evidence is written to
`artifacts/cc-connect/real-rich-progress-bridge.{json,png}`. This proves the
runtime/Bridge/UI integration without claiming a real OpenAI credential; real
OAuth remains a separate gate. Real media is covered independently: the bundled
`cc-connect send` CLI targets an active managed session and emits public Bridge
image/file/audio/video packets. The adapter copies decoded bytes under
`runtimes/cc-connect/media/outgoing/bridge`, session history merges these
runtime-owned attachments with Management API history, renderer final-event
deduplication uses each message id, and Chat keeps `gateway-media` cards visible
even when surrounding process narration is folded into the execution graph.
The real local OpenAI-compatible E2E verifies exact bytes, image preview, all
four GUI cards, and writes sanitized evidence to
`artifacts/cc-connect/real-cli-media-bridge.{json,png}`.
## 12. Health, Doctor, and logs
Runtime ready requires a live process, Management API, Bridge registration,
loaded projects, executable Agent binary, valid required workspace, and scoped
credential checks. A single expired Agent account degrades that Agent rather
than the whole runtime.
`checkHealth({ probe: true })` verifies the child is still alive, the Bridge
WebSocket is currently registered, and every projected project is readable
through Management API. Infrastructure probe failures return `ok: false` with
the failed component; account support/auth diagnostics stay project-scoped so
one invalid account does not mark unrelated Agents unhealthy.
Message preflight resolves the target Agent from the logical session key and
checks that project's provider profile. An invalid default account therefore
does not block an Agent with a valid explicit binding, and an invalid explicit
binding blocks only that Agent before any Bridge message is sent.
Agent create, rename, model/account binding, Channel binding, and delete
operations notify the active runtime. In cc-connect mode they rebuild or
restart cc-connect projects without invoking OpenClaw auth/model projection;
OpenClaw keeps its existing projection and reload behavior.
Skills are sourced from the shared ClawX/OpenClaw-compatible skill registry and
mirrored into every distinct Codex home used by current cc-connect projects.
Runtime start, skill enable/disable, and ClawHub install/uninstall all refresh
every project home, so account isolation does not split skill availability.
Startup order is data lock/version, managed config, skills, binary validation,
process, Management API, Bridge, projects, health, ready. Intentional stop
drains or cancels runs before terminating the process tree. Unexpected crashes
use bounded backoff and eventually enter error state.
Bridge registration is part of startup, not a background best effort. If the
process starts but Bridge registration fails, the provider closes registered
and in-flight WebSockets, terminates the managed process tree, reports `error`,
and leaves no child running. Stop/restart closes sockets that are still waiting
for `register_ack` and suppresses any reconnect scheduled by that close.
Main captures cc-connect stdout/stderr, redacts scoped provider/channel secrets
and common bearer/API-key forms before emission, keeps a bounded in-memory tail,
and writes mode-0600 `runtimes/cc-connect/logs/runtime.log` with size rotation.
Runtime diagnostics combine that stream, matching ClawX manager lines, and a
redacted managed config. Renderer never reads the process pipe or log path
directly.
cc-connect Doctor runs native `doctor user-isolation` against managed config
with an explicit managed `--out` path, then runs bundled `codex doctor --json`
inside the main project's managed `CODEX_HOME`. The adapter writes a
mode-0600 composite JSON audit under `runtimes/cc-connect/audits`; it never uses
the native default `~/.cc-connect/audits`. A Codex project without
`run_as_user` legitimately produces no native user-isolation file, which is
recorded as `auditGenerated: false` rather than treated as missing evidence.
The Codex Doctor subprocess is a provider-owned diagnostic exception only: it
accepts no prompt, creates no chat/session/tool run, and cannot replace or
bypass BridgePlatform delivery.
`doctor.fix` is unsupported in cc-connect mode and is hidden/disabled.
Runtime-neutral Settings strings must not report an OpenClaw Doctor result for
cc-connect.
cc-connect stdout, stderr, structured events, and doctor audits are captured
under `~/.clawx/logs/runtimes/cc-connect` with rotation and pre-write secret
redaction. Diagnostics use the active provider and must not include OpenClaw
gateway logs as cc-connect runtime logs.
## 13. Migration and rollback
Migration steps:
1. Create and lock `~/.clawx` layout.
2. Import ClawX application settings and provider accounts from legacy
Electron userData.
3. Register existing OpenClaw workspaces as external paths.
4. Encrypt provider secrets and create account-level OAuth homes.
5. Move ClawX-owned cc-connect data from legacy userData into the new runtime
directory.
6. Build logical session projection without modifying runtime stores.
7. Start the selected runtime only after migration commits.
Rollback means selecting OpenClaw, stopping cc-connect, and preserving its
managed data. Rollback never deletes credentials, sessions, workspace, or
cc-connect config. A migration failure restores the backup and leaves the prior
data version writable by the prior application.
## 14. Delivery phases and evidence gates
| Phase | Goal and implementation | Required verification | Impact |
| --- | --- | --- | --- |
| A. Contract and dependency | Pin/probe stable cc-connect; add runtime contracts and API client | Binary contract test, bundle manifest, type/unit tests | Shared types; no behavior switch |
| B. Data root and credentials | Add layout, fail-closed pre-write lock, migrations, encrypted vault, OAuth homes | vN to vN+1 and rollback packaged run; real two-Electron ownership/handover; secret scan | All persistent paths and runtime/scheduler startup |
| C. Workspace and skills | Registry, OpenClaw reuse, project `work_dir`, shared skill projection | Two-Agent isolation; real skill invocation; source-checkout negative test | Agent create/delete and files |
| D. Bridge chat/events | Official Bridge send, tools, approvals, cancellation, replay | Real API-key and OAuth tool-heavy chats; disconnect/replay; screenshots | Core communication path |
| E. Sessions and usage | Official APIs, logical binding, per-turn usage | Named/cross-Agent/Channel/restart/delete; token oracle comparison | Sidebar, history, Models |
| F. Channels and cron | Feishu/Lark full path; one native scheduler for GUI and Channel | Tenant inbound/reply; Channel/GUI Cron bidirectional CRUD and scheduled reply | Channel and Cron surfaces |
| G. Health and diagnostics | Scoped health, native doctor, real logs | Crash, port conflict, expired auth, doctor audit, log redaction | Settings and diagnostics |
| H. Packaging and release | Offline resources and platform smoke | Source bundle integrity; `afterPack` target verification; final macOS x64/arm64, Windows x64, Linux x64/arm64 resource checks; native Electron/Host API/runtime startup, Cron/Doctor, rollback, PID/port/process cleanup | Build/release only |
Every phase must produce code-level route evidence and actual runtime evidence
under `artifacts/cc-connect/<run-id>/`:
- `api/`: sanitized requests and responses.
- `logs/`: ClawX, cc-connect, Bridge, doctor, and scheduler excerpts.
- `screenshots/`: ClawX and Channel UI evidence.
- `fs/`: sanitized manifests, workspace trees, and migration checks.
- `report.json`: acceptance row, command, status, evidence paths, and gaps.
Mock-only evidence cannot close a real-runtime row. Opt-in credentials may stay
outside normal CI, but replacement readiness remains partial until the latest
report contains PASS evidence for real OAuth, external OpenAI API key, Feishu
inbound/reply, native Channel Cron, and packaged target platforms.
Deterministic Electron evidence covers same-account browser re-login projection
and protects Codex-refreshed managed auth from stale-vault rollback. A live
expired-token refresh failure followed by browser re-login still requires an
explicit real OAuth fixture and remains an external validation row.
## 15. Acceptance and explicit non-parity
cc-connect replacement is complete only when:
- No cc-connect Chat, session, tool, approval, cancellation, or usage path
launches or talks to Codex outside cc-connect.
- No shared cc-connect service writes OpenClaw config or cc-connect private
session files.
- GUI Chat, Feishu/Lark, and native Cron all execute through cc-connect and the
bound Agent/account/workspace.
- OpenAI OAuth and API-key modes pass real end-to-end tests with account
isolation.
- Session/history/title/delete and token usage match the shared runtime
contract across Agent and Channel cases.
- Skills are actually invoked, health/Doctor/logs are runtime-aware, and
packaged applications run offline.
- Required logs and screenshots exist and sensitive-data scans pass.
Accepted non-parity for the first milestone:
- cc-connect remains behind Developer Mode.
- cc-connect Doctor Fix does not replace OpenClaw Doctor Fix.
- Only native cron expressions are supported; `at` and `every` are not
emulated.
- Real credential and all-platform release checks remain opt-in until a
separate CI policy decision.
-110
View File
@@ -1,110 +0,0 @@
# ClawX 系统架构
本文档是 README「系统架构」一节的详细说明。
ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用统一客户端抽象,协议选择与进程生命周期由 Electron 主进程统一管理:
OpenClaw 配置交付也统一由 Electron Main 管理。Gateway 运行时,ClawX 以 `config.get` 返回的权威快照为基线,并通过 `config.set` 提交修改;Gateway 停止或启动中时,同一个协调器只更新解析后的 JSON5 配置文件,不会因此启动 Gateway。因此,普通的 Provider、Agent、Channel、绑定、Skill 和模型修改不会替换 Gateway 进程。完整重启仅保留给代理等进程启动环境变化和用户显式操作。已确认的进程退出与 WebSocket 关闭继续使用现有的自动重连路径。连续前 9 次 WebSocket 心跳无响应只更新诊断,不会因短暂的 pong 延迟中断长时间运行的任务;收到 pong 或任意消息会重置计数,连续第 10 次无响应时,只有在生命周期处于可自动恢复的 running 状态时,才会请求受保护的 Gateway 自动恢复。认证配置写入 SQLite 后,ClawX 会调用 OpenClaw 的 `secrets.reload`,让运行中的 Agent 无需重启即可读取新凭据。
Chat 使用由 Electron Main 持有的 ACP stdio bridge。Main 通过私有进程环境把同一份应用管理的 Gateway token 传给本地子进程,因此运行时配置重载后 ACP 历史回放仍能完成认证。Renderer 接收类型化 host events,并渲染内存中的 ACP timeline。Gateway 仍负责 providers、models、skills、workspace、settings、diagnostics 和 media configuration 等非 Chat 能力。
### ACP 语义权威
对于 ACP 能够提供的每一种 Chat 语义和上下文,ACP 都是优先的语义权威。这包括适用时的 session identity 与路由、工作空间和执行 `cwd`、prompt 与 timeline 状态,以及标准 resource 或附件语义。ACP 提供值或事件时,Main 和 Renderer 必须使用 ACP 的结果,不得用 Gateway 快照、transcript 推断、本地配置或另一套并行投影替代。
只有在上游 ACP 没有对应能力时,才允许绕过 ACP。此类兼容性路径必须保持狭窄、有界,并绑定 session 和 generation;同时必须在相关 Harness reference 或 rule 中记录其原因、事实来源、限制、协调行为和移除条件,不得悄悄演变为竞争性的权威来源。
### ACP 历史权威与有界 transcript 补充
ACP `session/load` 回放是 Chat 历史的首要事实来源。ClawX 不会持久化第二套 ACP ledger、精简 timeline、回放缓存或重建的工具历史。OpenClaw 的部分能力目前还没有完全对应的 ACP 实现;例如,assistant 媒体可能不会出现在 ACP 中,Gateway 处理也可能从可见的实时回复中移除 assistant `MEDIA:` 指令。因此,ClawX 只保留有界、带标记、仅存于内存的兼容性补充路径:
- 只有在同一 session 中存在已确认的 `image_generate` 上下文,且完成证据可信或来自获准 transcript 证据时,才可以恢复异步图像生成结果。
- 普通附件可以从持久化的 assistant `__openclaw.media` 规范事实或明确的行首 assistant `MEDIA:` 指令中恢复。这只恢复附件引用和声明的元数据,不恢复周围的 assistant 消息。
- 由于 ACP 回放不提供原始事件时间戳,Main 可以从有界的 transcript JSONL 记录中补充仅包含元数据的整轮耗时,但只能标注已经由 ACP 回放恢复出的回合。
- 如果 cron session 的 ACP 回放完全为空,Main 的类型化 cron-history API 可以提供计划提示词和完成摘要。当已识别的运行摘要带有 OpenClaw 截断标记时,只有在对应 run 的 transcript 更长且共享完整的已持久化摘要前缀时,Main 才可以恢复最终 assistant 文本。
历史读取最多读取最近 1000 条 transcript 消息。一次成功的实时 prompt 会立即读取一次,并在 1500ms 后重试一次。每个补充路径都必须绑定精确的 session、ACP generation、补充操作,并在适用时绑定当前的用户回合;过期、缺失、重复或有歧义的匹配都会被丢弃。这些路径不得重建普通 assistant 消息、thought、tool、plan、permission、文件活动、缺失回合或另一套 Chat 历史,Main 也不得根据 transcript 伪造原生 ACP 事件。标准 ACP resource 仍是首选;上游提供等价内容后,这些兼容性例外应当移除。
打开其它会话或页面时,尚未完成的 ACP 回复仍会继续流式接收。若在回复完成前返回,ClawX 会恢复最新的内存 timeline 并继续显示实时输出;回复完成后,普通 ACP 历史回放仍是唯一事实来源。
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 编辑器。可安全串联的片段会合并,独立片段会拼接到同一个编辑器中,但不会被描述为基于完整文件基线的差异。
- Shell 命令、脚本、用户或 IDE 产生的副作用不会被检测。
- 完整的 ACP 回放可以恢复已记录的文件活动;如果回放不完整,ClawX 不会通过回退推断来补造缺失活动。
```
┌───────────────────────────────────────────────────────────────────┐
│ ClawX 桌面应用 │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Electron 主进程 │ │
│ │ • 窗口与应用生命周期管理 │ │
│ │ • 网关进程监控 │ │
│ │ • 系统集成(托盘、通知、密钥链) │ │
│ │ • 自动更新编排 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ IPC (权威控制面) │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ React 渲染进程 │ │
│ │ • 现代组件化 UI(React 19 │ │
│ │ • Zustand 状态管理 │ │
│ │ • 统一 host-api/api-client 调用 │ │
│ │ • 回复使用 Markdown,用户输入按原文显示 │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬───────────────────────────────────┘
│ 类型化 IPC 请求
┌─────────────────────────────────────────────────────────────────┐
│ 主进程 Host Services 与 Gateway Manager │
│ │
│ • host:invoke 类型化服务分发 │
│ • 设置、文件、会话、技能、供应商、诊断服务 │
│ • 主进程持有 Gateway WebSocket 并负责进程监控 │
└──────────────────────────────┬──────────────────────────────────┘
│ 主进程持有 WebSocket
┌─────────────────────────────────────────────────────────────────┐
│ OpenClaw 网关 │
│ │
│ • AI 智能体运行时与编排 │
│ • 消息频道管理 │
│ • 技能/插件执行环境 │
│ • 供应商抽象层 │
└─────────────────────────────────────────────────────────────────┘
```
### 设计原则
- **进程隔离**:AI 运行时在独立进程中运行,确保即使在高负载计算期间 UI 也能保持响应
- **前端调用单一入口**:渲染层统一走 host-api/api-client,不感知底层协议细节
- **主进程掌控传输策略**ACP Chat stdio bridge 与 Gateway 传输都由 Electron Main 持有,渲染进程通过类型化 IPC 调用 Main
- **扩展 IPC 贡献点**:主进程扩展通过类型化 IPC 注册表贡献 host-api action,而不是挂载 HTTP route
- **优雅恢复**:内置重连、超时、退避逻辑,自动处理瞬时故障
- **安全存储**:API 密钥和敏感数据利用操作系统原生的安全存储机制
- **CORS 安全**:渲染进程不直接请求本地 Gateway 或 Host API HTTP 端点
### 进程模型与 Gateway 排障
- ClawX 基于 Electron,**单个应用实例出现多个系统进程是正常现象**main/renderer/zygote/utility)。
- 单实例保护同时使用 Electron 自带锁与本地进程文件锁回退机制,可在桌面会话总线异常时避免重复启动。
- 滚动升级期间若新旧版本混跑,单实例保护仍可能出现不对称行为。为保证稳定性,建议桌面客户端尽量统一升级到同一版本。
- 但 OpenClaw Gateway 监听应始终保持**单实例**:`127.0.0.1:18789` 只能有一个监听者。
- Gateway readiness 以 OpenClaw 的 `system-presence``health``status` 等核心信号为准;memory 或频道失败会显示为能力降级,而不是全局 Gateway 故障。
- 可用以下命令确认监听进程:
- macOS/Linux`lsof -nP -iTCP:18789 -sTCP:LISTEN`
- WindowsPowerShell):`Get-NetTCPConnection -LocalPort 18789 -State Listen`
- 点击窗口关闭按钮(`X`)默认只是最小化到托盘,并不会完全退出应用。请在托盘菜单中选择 **Quit ClawX** 执行完整退出。
-107
View File
@@ -1,107 +0,0 @@
# ClawX 开发文档
本文档是 README「开发指南」一节的详细说明。
### 前置要求
- **Node.js**:对应主版本范围内的 22.22.3+、24.15.0+ 或 25.9.0+(推荐 Node 24 LTS
- **包管理器**pnpm 9+(推荐)或 npm
- **LinuxUbuntu/Debian**:运行 Electron 前,请先安装所需系统库:
```bash
sudo apt-get install -y libnss3 libgtk-3-0 libxss1 libxtst6 libatspi2.0-0 libnotify4 xdg-utils
```
在 Ubuntu 24.04+ 上,部分软件包使用 `t64` 后缀,运行上述命令后 `apt` 会自动选择正确版本。
### 项目结构
```ClawX/
├── electron/ # Electron 主进程
│ ├── services/ # 类型化 Host API、Provider、Secrets 与运行时服务
│ │ ├── providers/ # Provider/account 模型同步逻辑
│ │ └── secrets/ # 系统钥匙串与密钥存储
│ ├── shared/ # 共享 Provider schema/常量
│ │ └── providers/
│ ├── main/ # 应用入口、窗口、IPC 注册
│ ├── gateway/ # OpenClaw 网关进程管理
│ ├── preload/ # 安全 IPC 桥接
│ └── utils/ # 工具模块(存储、认证、路径)
├── src/ # React 渲染进程
│ ├── lib/ # 前端统一 API 与错误模型
│ ├── stores/ # Zustand 状态仓库(settings/chat/gateway
│ ├── components/ # 可复用 UI 组件
│ ├── pages/ # Setup/Dashboard/Chat/Channels/Skills/Cron/Settings
│ ├── i18n/ # 国际化资源
│ └── types/ # TypeScript 类型定义
├── tests/
│ ├── e2e/ # Playwright Electron 端到端冒烟测试
│ └── unit/ # Vitest 单元/集成型测试
├── resources/ # 静态资源(图标、图片)
└── scripts/ # 构建与工具脚本
```
### 常用命令
```bash
# 开发
pnpm run init # 安装依赖并下载捆绑二进制(uv、agent-browser
pnpm dev # 以热重载模式启动(若缺失会自动准备预装技能包)
# 代码质量
pnpm lint # 运行 ESLint 检查
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 # 将回放指标与基线阈值对比
# 构建与打包
pnpm run build:vite # 仅构建前端
pnpm build # 完整生产构建(含打包资源)
pnpm package # 为当前平台打包(包含预装技能资源)
pnpm package:mac # 为 macOS 打包
pnpm package:win # 为 Windows 打包
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 投递、传输回退)时,建议执行:
```bash
pnpm run comms:replay
pnpm run comms:compare
```
CI 中的 `comms-regression` 会校验必选场景与阈值。
### 技术栈
| 层级 | 技术 |
|------|------|
| 运行时 | Electron 40+ |
| UI 框架 | React 19 + TypeScript |
| 样式 | Tailwind CSS + shadcn/ui |
| 状态管理 | Zustand |
| 构建工具 | Vite + electron-builder |
| 测试 | Vitest + Playwright |
| 动画 | Framer Motion |
| 图标 | Lucide React |
-54
View File
@@ -1,54 +0,0 @@
# ClawX 功能特性
本文档是 README「功能特性」一节的详细说明。
### 🎯 零配置门槛
从安装到第一次 AI 对话,全程通过直观的图形界面完成。无需终端命令,无需 YAML 文件,无需到处寻找环境变量。
### 💬 智能聊天界面
通过现代化的聊天体验与 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 与默认账号切换。
对于自定义频道账号 ID,ClawX 现在会强制校验 OpenClaw 兼容的规范格式(`[a-z0-9_-]`、小写、最长 64 位、且必须以字母或数字开头),避免路由匹配异常。
ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Channels 页面通过内置二维码流程完成微信连接。
### ⏰ 定时任务自动化
调度 AI 任务自动执行。定义触发器、设置时间间隔,让 AI 智能体 7×24 小时不间断工作。
现在定时任务页面已经可以直接配置外部投递,统一拆成“发送账号”和“接收目标”两个下拉选择。对于已支持的通道,接收目标会从通道目录能力或已知会话历史中自动发现,不需要再手动修改 `jobs.json`。任务的消息输入框也支持像主对话框那样以内联 `/skill` 令牌的方式插入技能(按所选智能体范围加载),让定时提示词可以直接触发技能。调度选择器现在分为**周期**和**单次**两个选项卡:周期支持每小时、每天、工作日、每周、自定义(原始 cron)等频率,并内置时间/星期选择;单次则在所选日期(显示星期)和时间执行一次。单次任务必须设置为未来时间,并会在执行完成后由运行时自动清除。
### 🧩 可扩展技能系统
通过预构建的技能扩展 AI 智能体的能力。集成的 Skills 页面采用“本地优先”方式:会扫描托管目录与 workspace 技能目录,并且无需依赖 Gateway 即可启用或停用技能;在企业扩展接管时,也可以显示扩展提供的 marketplace。
ClawX 还会内置预装完整的文档处理技能(`pdf``xlsx``docx``pptx`),在启动时自动部署到托管技能目录(默认 `~/.openclaw/skills`),并在首次安装时默认启用。
Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、workspace、额外技能目录),并显示每个技能的实际路径,便于直接打开真实安装位置。对于 OpenClaw 自带的 bundled skills,社区版现在在打包产物里只保留并展示 `skill-creator`;开发模式和打包版启动时都会直接清理其它 bundled skill,同时把这些已删除 bundled skill 在 `openclaw.json` 中残留的旧配置一并移除。
### 🔐 安全的供应商集成
连接多个 AI 供应商(OpenAI、Anthropic、Z.AI / GLM 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuthCodex 订阅)登录。
在开发者模式下,独立的“图像生成”页面支持配置 OpenAI 兼容生图端点(Base URL、API Key 和模型名,例如 `gpt-image-2`),生图请求会走专用的 `/v1/images/generations` 服务,聊天仍继续使用正常的 OpenAI Provider。
如果你通过 **自定义(CustomProvider** 对接 OpenAI-compatible 网关,可以在 **设置 → AI Providers → 编辑 Provider** 中配置自定义 `User-Agent`,以提高兼容性。
编辑或切换 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` 探测。
### 🌙 自适应主题
支持浅色模式、深色模式或跟随系统主题。ClawX 自动适应你的偏好设置。
### 🚀 开机启动控制
**设置 → 通用** 中,你可以开启 **开机自动启动**,让 ClawX 在系统登录后自动启动。
### 🔔 更新提示
ClawX 可以在启动时自动检查新版本。发现更新后会显示应用内提示;只有在你选择操作后,才会下载或安装更新。
-12
View File
@@ -1,12 +0,0 @@
# ClawX 代理设置
本文档是 README「代理设置」一节的详细说明。
- 只填写 `host:port` 时,会按 HTTP 代理处理。
- 高级代理项留空时,会自动回退到“代理服务器”。
- 保存代理设置后,Electron 网络层会立即重新应用代理,并自动重启 Gateway。
- 如果启用了 TelegramClawX 还会把代理同步到 OpenClaw 的 Telegram 频道配置中。
- 当 ClawX 代理处于关闭状态时,Gateway 的常规重启会保留已有的 Telegram 频道代理配置。
- 如果你要明确清空 OpenClaw 中的 Telegram 代理,请在关闭代理后点一次“保存代理设置”。
-**设置 → 高级 → 开发者** 中,可以直接运行 **OpenClaw Doctor**,执行 `openclaw doctor --json` 并在应用内查看诊断输出。
- 在 Windows 打包版本中,内置的 `openclaw` CLI/TUI 会通过随包分发的 `node.exe` 入口运行,以保证终端输入行为稳定。
+12
View File
@@ -68,6 +68,10 @@ mac:
to: bin
- from: resources/cli/posix/
to: cli/
- from: build/cc-connect/darwin-${arch}/
to: cc-connect/
- from: build/codex/darwin-${arch}/
to: codex/
category: public.app-category.productivity
icon: resources/icons/icon.icns
target:
@@ -119,6 +123,10 @@ win:
to: bin
- from: resources/cli/win32/
to: cli/
- from: build/cc-connect/win32-${arch}/
to: cc-connect/
- from: build/codex/win32-${arch}/
to: codex/
icon: resources/icons/icon.ico
target:
- target: nsis
@@ -147,6 +155,10 @@ linux:
to: bin
- from: resources/cli/posix/
to: cli/
- from: build/cc-connect/linux-${arch}/
to: cc-connect/
- from: build/codex/linux-${arch}/
to: codex/
icon: resources/icons
target:
- target: AppImage
+4 -1
View File
@@ -14,7 +14,10 @@ class DiagnosticsExtension implements HostApiProviderExtension {
}
getHostApiContributions(ctx: ExtensionContext): HostApiContribution[] {
const diagnostics = createDiagnosticsApi({ gatewayManager: ctx.gatewayManager });
const diagnostics = createDiagnosticsApi({
gatewayManager: ctx.gatewayManager,
runtimeManager: ctx.runtimeManager,
});
const actions: Record<string, RuntimeHostAction> = {
gatewaySnapshot: () => diagnostics.gatewaySnapshot(),
acpTrace: () => diagnostics.acpTrace(),
+2
View File
@@ -1,5 +1,6 @@
import type { BrowserWindow } from 'electron';
import type { GatewayManager } from '../gateway/manager';
import type { RuntimeManager } from '../runtime/manager';
import type { HostApiContribution, HostApiContributionRegistrar } from '../main/ipc/host-contract';
import type {
MarketplaceSearchParams,
@@ -12,6 +13,7 @@ import type {
export interface ExtensionContext {
gatewayManager: GatewayManager;
runtimeManager: RuntimeManager;
getMainWindow: () => BrowserWindow | null;
hostApi: HostApiContributionRegistrar;
}
+10
View File
@@ -203,6 +203,16 @@ export function normalizeGatewayChatRuntimeEvent(payload: unknown): ChatRuntimeE
phase: readString(data.phase),
status: readString(data.status),
message: readString(data.message),
actions: Array.isArray(data.actions)
? data.actions.flatMap((action) => {
if (!action || typeof action !== 'object') return [];
const record = action as Record<string, unknown>;
const value = readString(record.action);
if (!value) return [];
const label = readString(record.label);
return [{ action: value, ...(label ? { label } : {}) }];
})
: undefined,
}
: null;
}
+333
View File
@@ -0,0 +1,333 @@
/**
* 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
@@ -1,289 +0,0 @@
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();
}
+14 -11
View File
@@ -1,6 +1,6 @@
import { app } from 'electron';
import path from 'path';
import { existsSync, readFileSync, mkdirSync, readdirSync, symlinkSync } from 'fs';
import { existsSync, readFileSync, mkdirSync, readdirSync, rmSync, symlinkSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
@@ -33,8 +33,7 @@ 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, removeTrustedOfficialPluginInstallRecord, resolvePluginNpmPackagePath } from '../utils/plugin-install';
import { safeRmSync } from '../utils/safe-fs';
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe, buildCandidateSources, repairTrustedOfficialPluginInstallRecords, removeTrustedOfficialPluginInstallRecord, syncTrustedOfficialPluginInstallRecord, resolvePluginNpmPackagePath } from '../utils/plugin-install';
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';
@@ -121,7 +120,7 @@ function cleanupStaleBuiltInExtensions(): void {
if (existsSync(fsPath(extDir))) {
logger.info(`[plugin] Removing stale built-in extension copy: ${ext}`);
try {
safeRmSync(fsPath(extDir));
rmSync(fsPath(extDir), { recursive: true, force: true });
} catch (err) {
logger.warn(`[plugin] Failed to remove stale extension ${ext}:`, err);
}
@@ -193,9 +192,10 @@ 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 });
safeRmSync(fsPath(targetDir));
rmSync(fsPath(targetDir), { recursive: true, force: true });
cpSyncSafe(bundledDir, targetDir);
fixupPluginManifest(targetDir);
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
} catch (err) {
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin:`, err);
succeeded = false;
@@ -204,6 +204,7 @@ 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,6 +218,7 @@ 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;
}
@@ -226,6 +228,7 @@ 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;
@@ -255,7 +258,7 @@ function cleanupUnconfiguredChannelPlugins(configuredChannels: string[]): boolea
logger.info(`[plugin] Removing unconfigured channel plugin: ${channelType} (${dirName})`);
try {
safeRmSync(fsPath(targetDir));
rmSync(fsPath(targetDir), { recursive: true, force: true });
} catch (err) {
logger.warn(`[plugin] Failed to remove unconfigured channel plugin ${channelType}:`, err);
succeeded = false;
@@ -264,7 +267,7 @@ function cleanupUnconfiguredChannelPlugins(configuredChannels: string[]): boolea
return succeeded;
}
async function cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels: string[]): Promise<void> {
function cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels: string[]): void {
const configuredSet = new Set(configuredChannels);
for (const [channelType, { dirName }] of Object.entries(CHANNEL_PLUGIN_MAP)) {
if (configuredSet.has(channelType)) continue;
@@ -272,7 +275,7 @@ async function cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels
// 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);
removeTrustedOfficialPluginInstallRecord(dirName);
}
}
@@ -536,9 +539,9 @@ 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.
await measureAsync(timingsMs, 'trustedPluginInstallSyncMs', async () => {
await cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels);
await repairTrustedOfficialPluginInstallRecords();
measureSync(timingsMs, 'trustedPluginInstallSyncMs', () => {
cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels);
repairTrustedOfficialPluginInstallRecords();
});
} catch (err) {
logger.warn('Failed to auto-upgrade plugins:', err);
+2 -4
View File
@@ -27,6 +27,7 @@ export function dispatchProtocolEvent(
if (normalized) {
emitter.emit('chat:runtime-event', normalized);
}
emitter.emit('notification', { method: event, params: payload });
break;
}
case 'channel.status':
@@ -52,17 +53,14 @@ 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;
+195 -3
View File
@@ -50,6 +50,11 @@ import { GatewayLifecycleController, LifecycleSupersededError } from './lifecycl
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,
GATEWAY_STARTUP_SLOW_STAGE_MS,
@@ -190,15 +195,22 @@ 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 upgradeSnapshotCleanupAttempted = false;
private reloadPolicy: GatewayReloadPolicy = { ...DEFAULT_GATEWAY_RELOAD_POLICY };
private reloadPolicyLoadedAt = 0;
private reloadPolicyRefreshPromise: Promise<void> | null = null;
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 = 10;
private static readonly HEARTBEAT_MAX_MISSES = 4;
public static readonly RESTART_COOLDOWN_MS = 5_000;
private static readonly GATEWAY_READY_FALLBACK_PROBE_DELAYS_MS = [1_500, 3_000, 5_000, 8_000, 12_000, 30_000] as const;
private 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;
@@ -242,6 +254,7 @@ 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 });
@@ -330,6 +343,8 @@ 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();
@@ -672,6 +687,138 @@ 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
*/
@@ -682,7 +829,12 @@ 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 {
@@ -889,6 +1041,7 @@ export class GatewayManager extends EventEmitter {
}
private recordGatewayAlive(): void {
this.clearInitialReadyHeartbeatRecoveryTimer();
this.diagnostics.lastAliveAt = Date.now();
this.diagnostics.consecutiveHeartbeatMisses = 0;
}
@@ -1135,7 +1288,7 @@ export class GatewayManager extends EventEmitter {
}
/**
* Observe Gateway control-plane responsiveness and recover after a sustained outage.
* Start ping interval to keep connection alive
*/
private startPing(): void {
this.connectionMonitor.startPing({
@@ -1159,7 +1312,16 @@ export class GatewayManager extends EventEmitter {
logger.warn('Gateway heartbeat recovery skipped (lifecycle is not in auto-recoverable running state)');
return;
}
logger.warn('Gateway heartbeat recovery: restarting persistently unresponsive gateway process');
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);
});
@@ -1167,6 +1329,36 @@ export class GatewayManager extends EventEmitter {
});
}
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 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;
}
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;
}
private async cleanupOpenClawUpgradeSnapshot(): Promise<void> {
if (this.upgradeSnapshotCleanupAttempted) return;
this.upgradeSnapshotCleanupAttempted = true;
+63
View File
@@ -0,0 +1,63 @@
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
@@ -0,0 +1,101 @@
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();
}
}
}
+1 -10
View File
@@ -9,7 +9,6 @@ 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';
@@ -23,17 +22,9 @@ 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(record)) {
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
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);
+99 -37
View File
@@ -5,7 +5,9 @@
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 { RuntimeManager } from '../runtime/manager';
import { OpenClawRuntimeProvider } from '../runtime/openclaw-provider';
import { CcConnectRuntimeProvider } from '../runtime/cc-connect-provider';
import { registerIpcHandlers } from './ipc-handlers';
import { HostApiRegistry } from './ipc/host-invoke';
import { createTray } from './tray';
@@ -54,19 +56,38 @@ import { deviceOAuthManager } from '../utils/device-oauth';
import { browserOAuthManager } from '../utils/browser-oauth';
import { whatsAppLoginManager } from '../utils/whatsapp-login';
import { syncAllProviderAuthToRuntime } from '../services/providers/provider-runtime-sync';
import { getClawXDataLayout, initializeClawXDataLayout } from '../utils/clawx-data-layout';
import { migrateLegacyProviderSecretsToVault } from '../services/secrets/secret-store';
import { migrateLegacyClawXData } from '../utils/clawx-data-migration';
const WINDOWS_APP_USER_MODEL_ID = 'app.clawx.desktop';
const isE2EMode = process.env.CLAWX_E2E === '1';
const requestedUserDataDir = process.env.CLAWX_USER_DATA_DIR?.trim();
const enforceWriterLockInE2E = process.env.CLAWX_E2E_ENFORCE_WRITER_LOCK === '1';
const requestedRemoteDebuggingPort = process.env.CLAWX_REMOTE_DEBUGGING_PORT?.trim();
const legacyElectronUserDataDir = app.getPath('userData');
const clawXDataLayout = getClawXDataLayout();
if (requestedRemoteDebuggingPort) {
app.commandLine.appendSwitch('remote-debugging-port', requestedRemoteDebuggingPort);
}
if (isE2EMode && requestedUserDataDir) {
app.setPath('userData', requestedUserDataDir);
}
app.setPath('userData', clawXDataLayout.electronUserDataDir);
// 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);
@@ -89,12 +110,17 @@ if (!gotElectronLock) {
}
let releaseProcessInstanceFileLock: () => void = () => {};
let gotFileLock = true;
if (gotElectronLock && !isE2EMode) {
if (gotElectronLock && (!isE2EMode || enforceWriterLockInE2E)) {
try {
const fileLock = acquireProcessInstanceFileLock({
userDataDir: app.getPath('userData'),
lockName: 'clawx',
force: true, // Electron lock already guarantees exclusivity; force-clean orphan/recycled-PID locks
userDataDir: clawXDataLayout.locksDir,
lockName: 'writer',
lockPath: clawXDataLayout.writerLockPath,
metadata: {
appVersion: app.getVersion(),
channel: process.env.CLAWX_RELEASE_CHANNEL?.trim() || (app.isPackaged ? 'stable' : 'dev'),
executable: process.execPath,
},
});
gotFileLock = fileLock.acquired;
releaseProcessInstanceFileLock = fileLock.release;
@@ -110,14 +136,28 @@ if (gotElectronLock && !isE2EMode) {
app.exit(0);
}
} catch (error) {
console.warn('[ClawX] Failed to acquire process instance file lock; continuing with Electron single-instance lock only', error);
gotFileLock = false;
console.error('[ClawX] Failed to acquire process instance file lock; refusing to start a shared-root writer', error);
app.exit(1);
}
}
const gotTheLock = gotElectronLock && gotFileLock;
if (gotTheLock) {
try {
// No shared-root state may be created or migrated until this process owns
// the cross-install writer lock.
initializeClawXDataLayout(clawXDataLayout);
} catch (error) {
releaseProcessInstanceFileLock();
throw error;
}
}
// Global references
let mainWindow: BrowserWindow | null = null;
let gatewayManager!: GatewayManager;
let runtimeManager!: RuntimeManager;
let clawHubService!: ClawHubService;
const hostApiRegistry = new HostApiRegistry();
const webBrowserGuestRegistry = new WebBrowserGuestRegistry();
@@ -305,6 +345,17 @@ async function initialize(): Promise<void> {
logger.debug(
`Runtime: platform=${process.platform}/${process.arch}, electron=${process.versions.electron}, node=${process.versions.node}, packaged=${app.isPackaged}, pid=${process.pid}, ppid=${process.ppid}`
);
const legacyMigration = await migrateLegacyClawXData({
legacyElectronUserDataDir,
layout: clawXDataLayout,
});
if (legacyMigration.copied.length > 0) {
logger.info(`Imported ${legacyMigration.copied.length} legacy ClawX data path(s) into ${clawXDataLayout.root}`);
}
const migratedSecretCount = await migrateLegacyProviderSecretsToVault();
if (migratedSecretCount > 0) {
logger.info(`Migrated ${migratedSecretCount} provider credential account(s) into the encrypted ClawX vault`);
}
webBrowserSession = configureWebBrowserSession({
registry: webBrowserGuestRegistry,
@@ -357,6 +408,7 @@ async function initialize(): Promise<void> {
// Register IPC handlers
registerIpcHandlers(
gatewayManager,
runtimeManager,
clawHubService,
window,
hostApiRegistry,
@@ -364,6 +416,7 @@ async function initialize(): Promise<void> {
webBrowserGuestRegistry,
);
await runtimeManager.getActiveKind();
loadMainWindow(window);
// Create system tray
@@ -374,6 +427,7 @@ async function initialize(): Promise<void> {
// Initialize extension system
await extensionRegistry.initialize({
gatewayManager,
runtimeManager,
getMainWindow: () => mainWindow,
hostApi: {
register: (extensionId, contributions) => (
@@ -450,44 +504,44 @@ async function initialize(): Promise<void> {
// Bridge gateway and host-side events before any auto-start logic runs, so
// renderer subscribers observe the full startup lifecycle.
gatewayManager.on('status', (status: { state: string }) => {
runtimeManager.on('status', (status: { state: string; runtimeKind?: string }) => {
sendMainWindowEvent('gateway:status-changed', status);
if (status.state === 'running' && !isE2EMode) {
if (status.runtimeKind === 'openclaw' && status.state === 'running' && !isE2EMode) {
void ensureClawXContext().catch((error) => {
logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error);
});
}
});
gatewayManager.on('error', (error) => {
runtimeManager.on('error', (error) => {
sendMainWindowEvent('gateway:error', { message: error.message });
});
gatewayManager.on('notification', (notification) => {
runtimeManager.on('notification', (notification) => {
sendMainWindowEvent('gateway:notification', notification);
});
gatewayManager.on('gateway:health', (data) => {
runtimeManager.on('gateway:health', (data) => {
sendMainWindowEvent('gateway:health-changed', data);
});
gatewayManager.on('gateway:presence', (data) => {
runtimeManager.on('gateway:presence', (data) => {
sendMainWindowEvent('gateway:presence-changed', data);
});
gatewayManager.on('chat:message', (data) => {
runtimeManager.on('chat:message', (data) => {
sendMainWindowEvent('gateway:chat-message', data);
});
gatewayManager.on('chat:runtime-event', (data) => {
runtimeManager.on('chat:runtime-event', (data) => {
sendMainWindowEvent('chat:runtime-event', data);
});
gatewayManager.on('channel:status', (data) => {
runtimeManager.on('channel:status', (data) => {
sendMainWindowEvent('gateway:channel-status', data);
});
gatewayManager.on('exit', (code) => {
runtimeManager.on('exit', (code) => {
sendMainWindowEvent('gateway:exit', { code });
});
@@ -531,12 +585,14 @@ async function initialize(): Promise<void> {
const gatewayAutoStart = await getSetting('gatewayAutoStart');
if (!isE2EMode && gatewayAutoStart) {
try {
await syncAllProviderAuthToRuntime();
logger.debug('Auto-starting Gateway...');
await gatewayManager.start();
logger.info('Gateway auto-start succeeded');
if (await runtimeManager.getActiveKind() === 'openclaw') {
await syncAllProviderAuthToRuntime();
}
logger.debug(`Auto-starting ${await runtimeManager.getActiveKind()} runtime...`);
await runtimeManager.start();
logger.info('Runtime auto-start succeeded');
} catch (error) {
logger.error('Gateway auto-start failed:', error);
logger.error('Runtime auto-start failed:', error);
mainWindow?.webContents.send('gateway:error', String(error));
}
} else if (isE2EMode) {
@@ -589,7 +645,10 @@ if (gotTheLock) {
}
gatewayManager = new GatewayManager();
registerOpenClawConfigCoordinator(gatewayManager);
runtimeManager = new RuntimeManager({
openclaw: new OpenClawRuntimeProvider(gatewayManager),
ccConnect: new CcConnectRuntimeProvider(),
});
clawHubService = new ClawHubService();
// Register builtin extensions and load manifest
@@ -659,8 +718,8 @@ if (gotTheLock) {
void extensionRegistry.teardownAll();
const stopPromise = gatewayManager.stop().catch((err) => {
logger.warn('gatewayManager.stop() error during quit:', err);
const stopPromise = runtimeManager.stop().catch((err) => {
logger.warn('runtimeManager.stop() error during quit:', err);
});
const timeoutPromise = new Promise<'timeout'>((resolve) => {
setTimeout(() => resolve('timeout'), 5000);
@@ -668,14 +727,16 @@ if (gotTheLock) {
void Promise.race([stopPromise.then(() => 'stopped' as const), timeoutPromise]).then((result) => {
if (result === 'timeout') {
logger.warn('Gateway shutdown timed out during app quit; proceeding with forced quit');
void gatewayManager.forceTerminateOwnedProcessForQuit().then((terminated) => {
if (terminated) {
logger.warn('Forced gateway process termination completed after quit timeout');
}
}).catch((err) => {
logger.warn('Forced gateway termination failed after quit timeout:', err);
});
logger.warn('Runtime shutdown timed out during app quit; proceeding with forced quit');
if (runtimeManager.getActiveProvider().kind === 'openclaw') {
void gatewayManager.forceTerminateOwnedProcessForQuit().then((terminated) => {
if (terminated) {
logger.warn('Forced gateway process termination completed after quit timeout');
}
}).catch((err) => {
logger.warn('Forced gateway termination failed after quit timeout:', err);
});
}
}
markQuitCleanupCompleted(quitLifecycleState);
app.quit();
@@ -689,6 +750,7 @@ if (gotTheLock) {
logger.error(`${reason}:`, error);
try {
void gatewayManager?.stop().catch(() => { /* ignore */ });
void runtimeManager?.stop().catch(() => { /* ignore */ });
} catch {
// ignore — stop() may not be callable if state is corrupted
}
@@ -708,4 +770,4 @@ if (gotTheLock) {
}
// Export for testing
export { mainWindow, gatewayManager };
export { mainWindow, gatewayManager, runtimeManager };
+143 -57
View File
@@ -8,6 +8,7 @@ import { homedir } from 'node:os';
import { join, extname, basename, resolve, sep, relative } from 'node:path';
import { syncMacTrafficLightPosition } from './traffic-light-layout';
import { GatewayManager } from '../gateway/manager';
import { RuntimeManager } from '../runtime/manager';
import { ClawHubService } from '../gateway/clawhub';
import {
type ProviderConfig,
@@ -25,9 +26,11 @@ 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';
import { getCcConnectMediaDir, getOpenClawMediaDir } from '../utils/runtime-media-paths';
import { getProviderService } from '../services/providers/provider-service';
import {
getOpenClawProviderKey,
@@ -40,6 +43,7 @@ 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';
@@ -63,7 +67,7 @@ import { createMediaApi } from '../services/media-api';
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 { createUsageApi, getRecentTokenHistoryForRuntime } from '../services/usage-api';
import { createWebBrowserApi } from '../services/web-browser-api';
import type { WebBrowserGuestRegistry } from './web-browser-policy';
import {
@@ -75,11 +79,14 @@ import {
} from './ipc/request-helpers';
import { createMenu } from './menu';
const gatewayRpcBackpressure = new GatewayRpcBackpressure();
/**
* Register all IPC handlers
*/
export function registerIpcHandlers(
gatewayManager: GatewayManager,
runtimeManager: RuntimeManager,
clawHubService: ClawHubService,
mainWindow: BrowserWindow,
hostApiRegistry: HostApiRegistry,
@@ -87,11 +94,12 @@ export function registerIpcHandlers(
registry: WebBrowserGuestRegistry,
): void {
// Unified request protocol (non-breaking: legacy channels remain available)
registerUnifiedRequestHandlers(gatewayManager);
registerUnifiedRequestHandlers(gatewayManager, runtimeManager);
// Typed host invoke handlers (new renderer facade; legacy channels remain available)
registerTypedHostHandlers(
gatewayManager,
runtimeManager,
clawHubService,
mainWindow,
hostApiRegistry,
@@ -100,13 +108,13 @@ export function registerIpcHandlers(
);
// Gateway handlers
registerGatewayHandlers(gatewayManager);
registerGatewayHandlers(runtimeManager);
// OpenClaw handlers
registerOpenClawHandlers();
// Provider handlers
registerProviderHandlers(gatewayManager);
registerProviderHandlers(gatewayManager, runtimeManager);
// Shell handlers
registerShellHandlers();
@@ -121,7 +129,7 @@ export function registerIpcHandlers(
registerSettingsHandlers(gatewayManager);
// Usage handlers
registerUsageHandlers();
registerUsageHandlers(runtimeManager);
// Cron task handlers (proxy to Gateway RPC)
registerCronHandlers(gatewayManager);
@@ -138,6 +146,7 @@ export function registerIpcHandlers(
function registerTypedHostHandlers(
gatewayManager: GatewayManager,
runtimeManager: RuntimeManager,
clawHubService: ClawHubService,
mainWindow: BrowserWindow,
hostApiRegistry: HostApiRegistry,
@@ -153,7 +162,7 @@ function registerTypedHostHandlers(
openWith: attachmentOpenWith,
});
hostApiRegistry.registerCoreServices({
app: createAppApi(),
app: createAppApi(runtimeManager),
openclaw: createOpenClawApi(),
shell: createShellApi(),
webBrowser: createWebBrowserApi({ browserSession, registry }),
@@ -161,28 +170,34 @@ function registerTypedHostHandlers(
window: createWindowApi(mainWindow),
updates: createUpdatesApi(appUpdater),
uv: createUvApi(),
settings: createSettingsApi(gatewayManager),
gateway: createGatewayApi(gatewayManager),
settings: createSettingsApi(gatewayManager, runtimeManager),
gateway: createGatewayApi(runtimeManager, gatewayRpcBackpressure, gatewayManager),
logs: createLogsApi(),
channels: createChannelsApi({ gatewayManager, mainWindow }),
agents: createAgentsApi({ gatewayManager }),
providers: createProvidersApi({ gatewayManager, mainWindow }),
channels: createChannelsApi({ gatewayManager, runtimeManager, mainWindow }),
agents: createAgentsApi({ gatewayManager, runtimeManager }),
providers: createProvidersApi({ gatewayManager, runtimeManager, mainWindow }),
files: createFilesApi({
runtimeManager,
attachmentAccess,
openWith: attachmentOpenWith,
stagedAttachments,
}),
media: createMediaApi({ attachmentAccess }),
sessions: createSessionsApi(),
chat: createChatApi({ gatewayManager, mainWindow, acpSessionAccessRegistry }),
cron: createCronApi({ gatewayManager }),
skills: createSkillsApi({ clawHubService, gatewayManager }),
usage: createUsageApi(),
media: createMediaApi({ runtimeManager, attachmentAccess }),
sessions: createSessionsApi(runtimeManager),
chat: createChatApi({
gatewayManager,
runtimeManager,
mainWindow,
acpSessionAccessRegistry,
}),
cron: createCronApi({ gatewayManager, runtimeManager }),
skills: createSkillsApi({ clawHubService, gatewayManager, runtimeManager }),
usage: createUsageApi(runtimeManager),
});
registerHostInvokeHandler(hostApiRegistry);
}
function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
function registerUnifiedRequestHandlers(gatewayManager: GatewayManager, runtimeManager: RuntimeManager): void {
const providerService = getProviderService();
const handleProxySettingsChange = async () => {
const settings = await getAllSettings();
@@ -293,7 +308,11 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
}
}
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
try {
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
} catch (err) {
console.warn('Failed to sync openclaw provider config:', err);
}
data = { success: true };
} catch (error) {
@@ -308,10 +327,14 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
try {
const existing = await providerService.getLegacyProvider(providerId);
if (existing?.type) {
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
}
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);
}
}
data = { success: true };
} catch (error) {
data = { success: false, error: String(error) };
@@ -332,7 +355,11 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
const provider = await providerService.getLegacyProvider(providerId);
const providerType = provider?.type || providerId;
const ock = getOpenClawProviderKey(providerType, providerId);
await saveProviderKeyToOpenClaw(ock, apiKey);
try {
await saveProviderKeyToOpenClaw(ock, apiKey);
} catch (err) {
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
}
data = { success: true };
} catch (error) {
data = { success: false, error: String(error) };
@@ -378,7 +405,11 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
}
}
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
try {
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
} catch (err) {
console.warn('Failed to sync openclaw config after provider update:', err);
}
data = { success: true };
} catch (error) {
@@ -408,8 +439,12 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
const provider = await providerService.getLegacyProvider(providerId);
const providerType = provider?.type || providerId;
const ock = getOpenClawProviderKey(providerType, providerId);
if (ock) {
await removeProviderFromOpenClaw(ock);
try {
if (ock) {
await removeProviderFromOpenClaw(ock);
}
} catch (err) {
console.warn('Failed to completely remove provider from OpenClaw:', err);
}
data = { success: true };
} catch (error) {
@@ -426,7 +461,11 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
await providerService.setDefaultLegacyProvider(providerId);
const provider = await providerService.getLegacyProvider(providerId);
if (provider) {
await syncDefaultProviderToRuntime(providerId, gatewayManager);
try {
await syncDefaultProviderToRuntime(providerId, gatewayManager);
} catch (err) {
console.warn('Failed to set OpenClaw default model:', err);
}
}
data = { success: true };
@@ -508,12 +547,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
}
case 'usage': {
if (request.action === 'recentTokenHistory') {
const payload = request.payload as { limit?: number } | number | undefined;
const limit = typeof payload === 'number' ? payload : payload?.limit;
const safeLimit = typeof limit === 'number' && Number.isFinite(limit)
? Math.max(Math.floor(limit), 1)
: undefined;
data = await getRecentTokenUsageHistory(safeLimit);
data = await getRecentTokenHistoryForRuntime(request.payload, runtimeManager);
break;
}
return {
@@ -686,16 +720,21 @@ function registerCronHandlers(gatewayManager: GatewayManager): void {
/**
* Gateway-related IPC handlers
*/
function registerGatewayHandlers(gatewayManager: GatewayManager): void {
function registerGatewayHandlers(runtimeManager: RuntimeManager): void {
// Get Gateway status
ipcMain.handle('gateway:status', () => {
return gatewayManager.getStatus();
return runtimeManager.getStatus();
});
// Gateway RPC call
ipcMain.handle('gateway:rpc', async (_, method: string, params?: unknown, timeoutMs?: number) => {
try {
const result = await gatewayManager.rpc(method, params, timeoutMs);
const result = await gatewayRpcBackpressure.run(
method,
params,
timeoutMs,
(rpcMethod, rpcParams, rpcTimeoutMs) => runtimeManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
);
return { success: true, result };
} catch (error) {
logger.warn(`[gateway:rpc] ${method} failed (timeoutMs=${timeoutMs ?? 30000}): ${String(error)}`);
@@ -773,7 +812,10 @@ function registerWhatsAppHandlers(mainWindow: BrowserWindow): void {
/**
* Provider-related IPC handlers
*/
function registerProviderHandlers(gatewayManager: GatewayManager): void {
function registerProviderHandlers(
gatewayManager: GatewayManager,
runtimeManager: RuntimeManager,
): void {
const providerService = getProviderService();
const legacyProviderChannelsWarned = new Set<string>();
const logLegacyProviderChannel = (channel: string): void => {
@@ -784,6 +826,23 @@ 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', async ({ provider, accountId }) => {
try {
if (await runtimeManager.getActiveKind() !== 'openclaw') return;
logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`);
gatewayManager.debouncedRestart(8000);
} catch (error) {
logger.warn('[IPC] Failed to resolve active runtime after browser OAuth success:', error);
}
});
// Get all providers with key info
ipcMain.handle('provider:list', async () => {
logLegacyProviderChannel('provider:list');
@@ -810,12 +869,20 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
await providerService.setLegacyProviderApiKey(config.id, trimmedKey);
// Also write to OpenClaw auth-profiles.json so the gateway can use it
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
try {
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
} catch (err) {
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
}
}
}
// Sync the provider configuration to openclaw.json so Gateway knows about it
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
try {
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
} catch (err) {
console.warn('Failed to sync openclaw provider config:', err);
}
return { success: true };
} catch (error) {
@@ -828,11 +895,17 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
logLegacyProviderChannel('provider:delete');
try {
const existing = await providerService.getLegacyProvider(providerId);
if (existing?.type) {
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
}
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);
}
}
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
@@ -848,7 +921,11 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
// Also write to OpenClaw auth-profiles.json
const provider = await providerService.getLegacyProvider(providerId);
const providerType = provider?.type || providerId;
await syncProviderApiKeyToRuntime(providerType, providerId, apiKey);
try {
await syncProviderApiKeyToRuntime(providerType, providerId, apiKey);
} catch (err) {
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
}
return { success: true };
} catch (error) {
@@ -897,7 +974,11 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
}
// Sync the provider configuration to openclaw.json so Gateway knows about it
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
try {
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
} catch (err) {
console.warn('Failed to sync openclaw config after provider update:', err);
}
return { success: true };
} catch (error) {
@@ -928,7 +1009,11 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
// Keep OpenClaw auth-profiles.json in sync with local key storage
const provider = await providerService.getLegacyProvider(providerId);
await syncDeletedProviderApiKeyToRuntime(provider, providerId);
try {
await syncDeletedProviderApiKeyToRuntime(provider, providerId);
} catch (err) {
console.warn('Failed to completely remove provider from OpenClaw:', err);
}
return { success: true };
} catch (error) {
@@ -955,7 +1040,11 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
await providerService.setDefaultLegacyProvider(providerId);
// Update OpenClaw config to use this provider's default model
await syncDefaultProviderToRuntime(providerId, gatewayManager);
try {
await syncDefaultProviderToRuntime(providerId, gatewayManager);
} catch (err) {
console.warn('Failed to set OpenClaw default model:', err);
}
return { success: true };
} catch (error) {
@@ -1150,12 +1239,9 @@ function registerSettingsHandlers(gatewayManager: GatewayManager): void {
return { success: true, settings };
});
}
function registerUsageHandlers(): void {
ipcMain.handle('usage:recentTokenHistory', async (_, limit?: number) => {
const safeLimit = typeof limit === 'number' && Number.isFinite(limit)
? Math.max(Math.floor(limit), 1)
: undefined;
return await getRecentTokenUsageHistory(safeLimit);
function registerUsageHandlers(runtimeManager: RuntimeManager): void {
ipcMain.handle('usage:recentTokenHistory', async (_, payload?: number | { limit?: number; runtimeKind?: unknown }) => {
return await getRecentTokenHistoryForRuntime(payload, runtimeManager);
});
}
/**
@@ -1239,7 +1325,7 @@ function getMimeType(ext: string): string {
return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream';
}
const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound');
const OPENCLAW_OUTBOUND_DIR = join(getOpenClawMediaDir(), 'outbound');
// ── File preview (sandboxed) ──────────────────────────────────────────
//
@@ -1308,14 +1394,14 @@ function isPathInside(child: string, parent: string): boolean {
*/
function getFilePreviewWriteRoots(): string[] {
const roots: string[] = [];
const openclawDir = join(homedir(), '.openclaw');
roots.push(resolve(openclawDir));
roots.push(resolve(join(homedir(), '.openclaw')));
roots.push(resolve(getCcConnectMediaDir()));
try {
roots.push(resolve(app.getPath('userData')));
} catch {
// ignore — userData should always exist
}
roots.push(resolve(OUTBOUND_DIR));
roots.push(resolve(OPENCLAW_OUTBOUND_DIR));
return roots;
}
+113 -86
View File
@@ -1,30 +1,50 @@
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
const LOCK_SCHEMA = 'clawx-instance-lock';
const LOCK_VERSION = 1;
const LEGACY_LOCK_VERSION = 1;
const STRUCTURED_LOCK_VERSION = 2;
export interface StructuredLockContent {
schema: string;
version: number;
pid: number;
ownerToken?: string;
appVersion?: string;
channel?: string;
executable?: string;
startedAt?: string;
heartbeatAt?: string;
}
export interface ProcessInstanceFileLock {
acquired: boolean;
lockPath: string;
ownerPid?: number;
ownerFormat?: 'legacy' | 'structured' | 'unknown';
ownerDetails?: StructuredLockContent;
release: () => void;
}
export interface ProcessInstanceLockMetadata {
appVersion: string;
channel: string;
executable: string;
startedAt?: string;
}
export interface ProcessInstanceFileLockOptions {
userDataDir: string;
lockName: string;
pid?: number;
isPidAlive?: (pid: number) => boolean;
/**
* When true, unconditionally remove any existing lock file before attempting
* to acquire. Use this when an external mechanism (e.g. Electron's
* `requestSingleInstanceLock`) already guarantees that no other real instance
* is running, so a surviving lock file can only be stale (orphan child
* process, PID recycling on Windows, etc.).
*/
/** Legacy escape hatch. New shared-data-root callers must not use it. */
force?: boolean;
lockPath?: string;
metadata?: ProcessInstanceLockMetadata;
heartbeatIntervalMs?: number;
heartbeatExpiryMs?: number;
}
function defaultPidAlive(pid: number): boolean {
@@ -32,51 +52,35 @@ function defaultPidAlive(pid: number): boolean {
process.kill(pid, 0);
return true;
} catch (error) {
const errno = (error as NodeJS.ErrnoException).code;
return errno !== 'ESRCH';
return (error as NodeJS.ErrnoException).code !== 'ESRCH';
}
}
type ParsedLockOwner =
| { kind: 'legacy'; pid: number }
| { kind: 'structured'; pid: number }
| { kind: 'structured'; pid: number; details: StructuredLockContent }
| { kind: 'unknown' };
interface StructuredLockContent {
schema: string;
version: number;
pid: number;
}
function parsePositivePid(raw: string): number | undefined {
if (!/^\d+$/.test(raw)) {
return undefined;
}
if (!/^\d+$/.test(raw)) return undefined;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return undefined;
}
return parsed;
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
function parseStructuredLockContent(raw: string): StructuredLockContent | undefined {
try {
const parsed = JSON.parse(raw) as Partial<StructuredLockContent>;
if (
parsed?.schema === LOCK_SCHEMA
&& parsed?.version === LOCK_VERSION
&& typeof parsed?.pid === 'number'
parsed.schema === LOCK_SCHEMA
&& (parsed.version === LEGACY_LOCK_VERSION || parsed.version === STRUCTURED_LOCK_VERSION)
&& typeof parsed.pid === 'number'
&& Number.isFinite(parsed.pid)
&& parsed.pid > 0
) {
return {
schema: parsed.schema,
version: parsed.version,
pid: parsed.pid,
};
return parsed as StructuredLockContent;
}
} catch {
// ignore parse errors
// Unknown content is never removed automatically.
}
return undefined;
}
@@ -85,111 +89,135 @@ function readLockOwner(lockPath: string): ParsedLockOwner {
try {
const raw = readFileSync(lockPath, 'utf8').trim();
const legacyPid = parsePositivePid(raw);
if (legacyPid !== undefined) {
return { kind: 'legacy', pid: legacyPid };
}
if (legacyPid !== undefined) return { kind: 'legacy', pid: legacyPid };
const structured = parseStructuredLockContent(raw);
if (structured) {
return { kind: 'structured', pid: structured.pid };
}
if (structured) return { kind: 'structured', pid: structured.pid, details: structured };
} catch {
// ignore read errors
// Missing and unreadable lock files have unknown ownership.
}
return { kind: 'unknown' };
}
function writeLockAtomic(lockPath: string, content: string): void {
const temporaryPath = `${lockPath}.${process.pid}.${randomUUID()}.tmp`;
try {
writeFileSync(temporaryPath, content, { encoding: 'utf8', mode: 0o600 });
renameSync(temporaryPath, lockPath);
} catch (error) {
rmSync(temporaryPath, { force: true });
throw error;
}
}
function heartbeatExpired(owner: ParsedLockOwner, expiryMs: number): boolean {
if (owner.kind !== 'structured') return true;
if (!owner.details.heartbeatAt) return true;
const heartbeat = Date.parse(owner.details.heartbeatAt);
return !Number.isFinite(heartbeat) || Date.now() - heartbeat > expiryMs;
}
export function acquireProcessInstanceFileLock(
options: ProcessInstanceFileLockOptions,
): ProcessInstanceFileLock {
const pid = options.pid ?? process.pid;
const isPidAlive = options.isPidAlive ?? defaultPidAlive;
const lockPath = options.lockPath ?? join(options.userDataDir, `${options.lockName}.instance.lock`);
const heartbeatExpiryMs = options.heartbeatExpiryMs ?? 30_000;
mkdirSync(dirname(lockPath), { recursive: true });
mkdirSync(options.userDataDir, { recursive: true });
const lockPath = join(options.userDataDir, `${options.lockName}.instance.lock`);
// When force mode is enabled, unconditionally remove any existing lock file
// before attempting acquisition. This is safe because an external mechanism
// (Electron's requestSingleInstanceLock) already guarantees exclusivity.
if (options.force && existsSync(lockPath)) {
const staleOwner = readLockOwner(lockPath);
try {
rmSync(lockPath, { force: true });
} catch {
// best-effort; fall through to normal acquisition
}
if (staleOwner.kind !== 'unknown') {
console.info(
`[ClawX] Force-cleaned stale instance lock (pid=${staleOwner.pid}, format=${staleOwner.kind})`,
);
}
rmSync(lockPath, { force: true });
}
let ownerPid: number | undefined;
let ownerFormat: ProcessInstanceFileLock['ownerFormat'] = 'unknown';
let ownerDetails: StructuredLockContent | undefined;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const fd = openSync(lockPath, 'wx');
const ownerToken = randomUUID();
const startedAt = options.metadata?.startedAt ?? new Date().toISOString();
const structuredContent: StructuredLockContent | undefined = options.metadata
? {
schema: LOCK_SCHEMA,
version: STRUCTURED_LOCK_VERSION,
pid,
ownerToken,
appVersion: options.metadata.appVersion,
channel: options.metadata.channel,
executable: options.metadata.executable,
startedAt,
heartbeatAt: startedAt,
}
: undefined;
try {
// Keep writing legacy numeric format for broad backward compatibility.
// Parser accepts both legacy numeric and structured JSON formats.
writeFileSync(fd, String(pid), 'utf8');
writeFileSync(fd, structuredContent ? JSON.stringify(structuredContent) : String(pid), 'utf8');
} finally {
closeSync(fd);
}
let released = false;
const heartbeatTimer = structuredContent
? setInterval(() => {
const currentOwner = readLockOwner(lockPath);
if (currentOwner.kind !== 'structured' || currentOwner.details.ownerToken !== ownerToken) return;
structuredContent.heartbeatAt = new Date().toISOString();
try {
writeLockAtomic(lockPath, JSON.stringify(structuredContent));
} catch {
// A missed heartbeat never transfers ownership.
}
}, options.heartbeatIntervalMs ?? 5_000)
: undefined;
heartbeatTimer?.unref();
return {
acquired: true,
lockPath,
release: () => {
if (released) return;
released = true;
if (heartbeatTimer) clearInterval(heartbeatTimer);
try {
const currentOwner = readLockOwner(lockPath);
if (currentOwner.kind === 'unknown' || currentOwner.pid !== pid) return;
if (
(currentOwner.kind === 'legacy' || currentOwner.kind === 'structured')
&& currentOwner.pid !== pid
) {
return;
}
if (currentOwner.kind === 'unknown') {
return;
}
currentOwner.kind === 'structured'
&& currentOwner.details.ownerToken
&& currentOwner.details.ownerToken !== ownerToken
) return;
rmSync(lockPath, { force: true });
} catch {
// best-effort
// Best effort during shutdown.
}
},
};
} catch (error) {
const errno = (error as NodeJS.ErrnoException).code;
if (errno !== 'EEXIST') {
break;
}
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') break;
const owner = readLockOwner(lockPath);
if (owner.kind === 'legacy' || owner.kind === 'structured') {
ownerPid = owner.pid;
ownerFormat = owner.kind;
ownerDetails = owner.kind === 'structured' ? owner.details : undefined;
} else {
ownerPid = undefined;
ownerFormat = 'unknown';
ownerDetails = undefined;
}
const shouldTreatAsStale =
(owner.kind === 'legacy' || owner.kind === 'structured')
&& !isPidAlive(owner.pid);
if (shouldTreatAsStale && existsSync(lockPath)) {
const stale = (owner.kind === 'legacy' || owner.kind === 'structured')
&& !isPidAlive(owner.pid)
&& heartbeatExpired(owner, heartbeatExpiryMs);
if (stale && existsSync(lockPath)) {
try {
rmSync(lockPath, { force: true });
continue;
} catch {
// If deletion fails, treat as held lock.
// Treat an undeletable stale lock as held.
}
}
break;
}
}
@@ -199,8 +227,7 @@ export function acquireProcessInstanceFileLock(
lockPath,
ownerPid,
ownerFormat,
release: () => {
// no-op when lock wasn't acquired
},
ownerDetails,
release: () => {},
};
}
+34 -72
View File
@@ -3,19 +3,11 @@ import {
WEB_BROWSER_INITIAL_URL,
WEB_BROWSER_PARTITION,
WEB_BROWSER_USER_AGENT,
normalizeWebBrowserHtmlFileUrl,
normalizeWebBrowserTopLevelUrl,
} 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;
@@ -75,7 +67,7 @@ export function isExpectedWebBrowserAttachment(
return params.partition === WEB_BROWSER_PARTITION
&& params.src === WEB_BROWSER_INITIAL_URL
&& params.useragent === WEB_BROWSER_USER_AGENT
&& params.allowpopups !== true
&& params.allowpopups === true
&& params.preload === '';
}
@@ -143,70 +135,48 @@ export function installWebBrowserGuestPolicy(
}
guest.setUserAgent(WEB_BROWSER_USER_AGENT);
let committedHtmlUrl = normalizeWebBrowserHtmlFileUrl(guest.getURL());
let restoringCommittedUrl = false;
const blockPageNavigation = (
details: Electron.Event<Electron.WebContentsWillFrameNavigateEventParams>,
const rejectDisallowedNavigation = (
details: Electron.Event<Electron.WebContentsWillNavigateEventParams>,
): 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)) {
if (!details.isMainFrame || normalizeWebBrowserTopLevelUrl(details.url) !== null) {
return;
}
logger.warn(`[WebBrowser] Stopped invalid programmatic navigation to ${details.url}`);
guest.stop();
logger.warn(`[WebBrowser] Blocked top-level navigation to ${details.url}`);
details.preventDefault();
};
const blockRedirect = (
const rejectDisallowedRedirect = (
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) {
if (!details.isMainFrame || normalizeWebBrowserTopLevelUrl(details.url) !== null) {
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);
});
logger.warn(`[WebBrowser] Blocked top-level redirect to ${details.url}`);
details.preventDefault();
};
// Same-tab fallback cannot preserve window.opener, returned window handles, or full POST/referrer fidelity.
guest.setWindowOpenHandler(({ url }) => {
const target = normalizeWebBrowserTopLevelUrl(url);
if (!target || !registry.owns(guest)) {
logger.warn(`[WebBrowser] Blocked popup target ${url}`);
return DENY_WINDOW_OPEN;
}
try {
void guest.loadURL(target).catch((error) => {
logger.warn(`[WebBrowser] Failed to load popup target ${target}:`, error);
});
} catch (error) {
logger.warn(`[WebBrowser] Failed to load popup target ${target}:`, error);
}
return DENY_WINDOW_OPEN;
});
let cleaned = false;
const cleanup = (): void => {
if (cleaned) {
@@ -214,12 +184,8 @@ export function installWebBrowserGuestPolicy(
}
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('will-navigate', rejectDisallowedNavigation);
guest.off('will-redirect', rejectDisallowedRedirect);
guest.off('destroyed', cleanup);
if (!guest.isDestroyed()) {
guest.setWindowOpenHandler(() => DENY_WINDOW_OPEN);
@@ -229,12 +195,8 @@ export function installWebBrowserGuestPolicy(
}
};
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.on('will-navigate', rejectDisallowedNavigation);
guest.on('will-redirect', rejectDisallowedRedirect);
guest.once('destroyed', cleanup);
cleanupGuestPolicy = cleanup;
};
+106 -22
View File
@@ -1,56 +1,140 @@
import {
dialog,
session,
type BrowserWindow,
type MessageBoxOptions,
type MessageBoxReturnValue,
type Session,
} from 'electron';
import { WEB_BROWSER_PERMISSION_LABELS } from '@shared/i18n/resources';
import { resolveSupportedLanguage } from '@shared/language';
import {
WEB_BROWSER_PARTITION,
WEB_BROWSER_USER_AGENT,
normalizeWebBrowserHtmlFileUrl,
} from '@shared/web-browser';
import { logger } from '../utils/logger';
import { getSetting } from '../utils/store';
import type { WebBrowserGuestRegistry } from './web-browser-policy';
const CLIPBOARD_PERMISSIONS = new Set([
'clipboard-read',
'clipboard-sanitized-write',
'deprecated-sync-clipboard-read',
]);
const DOWNLOAD_OBSERVED_SESSIONS = new WeakSet<Session>();
export interface ConfigureWebBrowserSessionOptions {
registry: WebBrowserGuestRegistry;
getMainWindow: () => BrowserWindow | null;
getLanguage?: () => Promise<string | undefined>;
showMessageBox?: (
window: BrowserWindow,
options: MessageBoxOptions,
) => Promise<MessageBoxReturnValue>;
}
export function configureWebBrowserSession(
_options: ConfigureWebBrowserSessionOptions,
options: ConfigureWebBrowserSessionOptions,
): Session {
const browserSession = session.fromPartition(WEB_BROWSER_PARTITION, { cache: true });
const getLanguage = options.getLanguage ?? (() => getSetting('language'));
// Resolve the method at request time so Electron E2E tests can replace the native dialog after startup.
const showMessageBox = options.showMessageBox
?? ((window, messageOptions) => dialog.showMessageBox(window, messageOptions));
// Keep a deterministic identity even though this session may load only local HTML.
// The macOS UA is fixed on every platform for stable website compatibility and deterministic requests.
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.setPermissionCheckHandler((_contents, permission) => (
CLIPBOARD_PERMISSIONS.has(permission)
));
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 });
},
);
browserSession.setPermissionRequestHandler((contents, permission, callback, details) => {
let callbackCalled = false;
const respond = (allowed: boolean): void => {
if (callbackCalled) return;
callbackCalled = true;
callback(allowed);
};
if (CLIPBOARD_PERMISSIONS.has(permission)) {
respond(true);
return;
}
if (permission === 'geolocation') {
// ClawX has no location service, so websites cannot receive a meaningful location.
respond(false);
return;
}
if (permission !== 'media' || !options.registry.owns(contents)) {
respond(false);
return;
}
const mediaDetails = details as Electron.MediaAccessPermissionRequest;
const mediaTypes = new Set(mediaDetails.mediaTypes ?? []);
const requestsCamera = mediaTypes.has('video');
const requestsMicrophone = mediaTypes.has('audio');
if (!requestsCamera && !requestsMicrophone) {
respond(false);
return;
}
const mainWindow = options.getMainWindow();
if (!mainWindow) {
respond(false);
return;
}
void (async () => {
try {
const language = resolveSupportedLanguage(await getLanguage());
const labels = WEB_BROWSER_PERMISSION_LABELS[language];
const capability = requestsCamera && requestsMicrophone
? labels.cameraAndMicrophone
: requestsCamera
? labels.camera
: labels.microphone;
const origin = mediaDetails.securityOrigin || mediaDetails.requestingUrl;
if (!options.registry.owns(contents)) {
respond(false);
return;
}
const result = await showMessageBox(mainWindow, {
type: 'question',
title: labels.title,
message: labels.message
.replace('{{origin}}', origin)
.replace('{{capability}}', capability),
buttons: [labels.allow, labels.deny],
defaultId: 0,
cancelId: 1,
noLink: true,
});
respond(result.response === 0 && options.registry.owns(contents));
} catch (error) {
logger.warn('[WebBrowser] Native media permission dialog failed:', error);
respond(false);
}
})();
});
if (!DOWNLOAD_OBSERVED_SESSIONS.has(browserSession)) {
DOWNLOAD_OBSERVED_SESSIONS.add(browserSession);
browserSession.on('will-download', (event) => {
event.preventDefault();
// Preserve Electron's default save location and UI by observing without cancelling or setting a path.
browserSession.on('will-download', (_event, item) => {
item.once('done', (_doneEvent, state) => {
if (state === 'interrupted') {
logger.warn('[WebBrowser] Download interrupted');
}
});
});
}
// This isolated browser Session intentionally does not mirror client proxy settings or recycle connections.
return browserSession;
}
@@ -0,0 +1,115 @@
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { app } from 'electron';
import { getClawXDataLayout, resolveClawXDataRoot } from '../utils/clawx-data-layout';
export type CcConnectPermissionMode = 'suggest' | 'full-auto';
type AgentBinding = {
providerAccountId?: string;
permissionMode?: CcConnectPermissionMode;
updatedAt: string;
};
type AgentBindingDocument = {
schema: 'clawx-agent-bindings';
version: 1;
agents: Record<string, AgentBinding>;
};
function bindingsPath(): string {
const layout = getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData')));
return join(layout.appDir, 'agent-bindings.json');
}
async function readDocument(): Promise<AgentBindingDocument> {
try {
const parsed = JSON.parse(await readFile(bindingsPath(), 'utf8')) as Partial<AgentBindingDocument>;
if (parsed.schema === 'clawx-agent-bindings' && parsed.version === 1 && parsed.agents) {
return parsed as AgentBindingDocument;
}
} catch {
// Missing or malformed bindings start empty and are replaced atomically on write.
}
return { schema: 'clawx-agent-bindings', version: 1, agents: {} };
}
async function writeDocument(document: AgentBindingDocument): Promise<void> {
const path = bindingsPath();
await mkdir(dirname(path), { recursive: true });
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
await writeFile(temporaryPath, `${JSON.stringify(document, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
await rename(temporaryPath, path);
}
export async function listCcConnectAgentProviderBindings(): Promise<Record<string, string>> {
const document = await readDocument();
return Object.fromEntries(Object.entries(document.agents).flatMap(([agentId, binding]) => (
binding.providerAccountId ? [[agentId, binding.providerAccountId]] : []
)));
}
export async function listCcConnectAgentPermissionModes(): Promise<Record<string, CcConnectPermissionMode>> {
const document = await readDocument();
return Object.fromEntries(Object.entries(document.agents).flatMap(([agentId, binding]) => (
binding.permissionMode === 'suggest' || binding.permissionMode === 'full-auto'
? [[agentId, binding.permissionMode]]
: []
)));
}
export async function setCcConnectAgentProviderBinding(
agentId: string,
providerAccountId: string | null,
): Promise<void> {
const normalizedAgentId = agentId.trim();
if (!normalizedAgentId) throw new Error('agentId is required');
const document = await readDocument();
const normalizedAccountId = providerAccountId?.trim();
if (normalizedAccountId) {
document.agents[normalizedAgentId] = {
...document.agents[normalizedAgentId],
providerAccountId: normalizedAccountId,
updatedAt: new Date().toISOString(),
};
} else {
const existing = document.agents[normalizedAgentId];
if (existing?.permissionMode) {
document.agents[normalizedAgentId] = {
permissionMode: existing.permissionMode,
updatedAt: new Date().toISOString(),
};
} else {
delete document.agents[normalizedAgentId];
}
}
await writeDocument(document);
}
export async function setCcConnectAgentPermissionMode(
agentId: string,
permissionMode: CcConnectPermissionMode,
): Promise<void> {
const normalizedAgentId = agentId.trim();
if (!normalizedAgentId) throw new Error('agentId is required');
if (permissionMode !== 'suggest' && permissionMode !== 'full-auto') {
throw new Error('permissionMode must be suggest or full-auto');
}
const document = await readDocument();
document.agents[normalizedAgentId] = {
...document.agents[normalizedAgentId],
permissionMode,
updatedAt: new Date().toISOString(),
};
await writeDocument(document);
}
export async function deleteCcConnectAgentBinding(agentId: string): Promise<void> {
const normalizedAgentId = agentId.trim();
if (!normalizedAgentId) return;
const document = await readDocument();
if (!(normalizedAgentId in document.agents)) return;
delete document.agents[normalizedAgentId];
await writeDocument(document);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
import { chmod, mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { getCcConnectManagedDir } from './cc-connect-paths';
function safeName(value: string): string {
return encodeURIComponent(value.trim() || 'default').replace(/%/g, '_');
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
export async function ensureCcConnectCodexLauncher(options: {
accountId: string;
codexHomeDir: string;
codexPath: string;
envAliases?: Record<string, string>;
}): Promise<string> {
const launchersDir = join(getCcConnectManagedDir(), 'config', 'launchers');
await mkdir(launchersDir, { recursive: true });
const baseName = `codex-${safeName(options.accountId)}`;
if (process.platform === 'win32') {
const path = join(launchersDir, `${baseName}.cmd`);
const content = [
'@echo off',
`set "CODEX_HOME=${options.codexHomeDir}"`,
...Object.entries(options.envAliases ?? {}).map(([target, source]) => `set "${target}=%${source}%"`),
`"${options.codexPath.replace(/"/g, '""')}" %*`,
'',
].join('\r\n');
await writeFile(path, content, { encoding: 'utf8', mode: 0o700 });
return path;
}
const path = join(launchersDir, baseName);
const content = [
'#!/bin/sh',
`export CODEX_HOME=${shellQuote(options.codexHomeDir)}`,
...Object.entries(options.envAliases ?? {}).map(([target, source]) => `export ${target}="\${${source}}"`),
`exec ${shellQuote(options.codexPath)} "$@"`,
'',
].join('\n');
await writeFile(path, content, { encoding: 'utf8', mode: 0o700 });
await chmod(path, 0o700);
return path;
}
@@ -0,0 +1,452 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import type { RawMessage } from '@shared/chat/types';
const MAX_TRANSCRIPT_SEARCH_DEPTH = 6;
const MAX_TOOL_OUTPUT_CHARS = 16_000;
const TRANSCRIPT_TURN_MATCH_WINDOW_MS = 2 * 60_000;
const MAX_TRANSCRIPT_FILE_CACHE_ENTRIES = 512;
const MAX_TRANSCRIPT_PATH_CACHE_ENTRIES = 2_048;
const MAX_FALLBACK_TURN_HINTS = 20;
const MAX_FALLBACK_DIRECTORIES = 12;
const MAX_FALLBACK_CANDIDATE_FILES = 64;
const MAX_FALLBACK_FILE_BYTES = 8 * 1024 * 1024;
const MAX_FALLBACK_TOTAL_BYTES = 32 * 1024 * 1024;
type CachedTranscriptFile = {
mtimeMs: number;
size: number;
jsonl: string;
turnMetadata?: {
sessionTimestamp?: number;
sessionWorkDir?: string;
userTurns: Array<{
content: string;
timestamp?: number;
}>;
};
toolMessages?: RawMessage[];
};
const transcriptFileCache = new Map<string, CachedTranscriptFile>();
const transcriptPathBySessionId = new Map<string, string>();
function setBoundedCache<K, V>(cache: Map<K, V>, key: K, value: V, maxEntries: number): void {
cache.delete(key);
cache.set(key, value);
while (cache.size > maxEntries) {
const oldestKey = cache.keys().next().value;
if (oldestKey === undefined) break;
cache.delete(oldestKey);
}
}
export type CcConnectTranscriptTurnHint = {
content: string;
timestamp: number;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function parseTimestamp(value: unknown): number | undefined {
if (typeof value !== 'string' || !value.trim()) return undefined;
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function parseToolArguments(value: unknown): unknown {
if (typeof value !== 'string') return value ?? {};
const trimmed = value.trim();
if (!trimmed) return {};
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
function displayToolName(name: string): string {
switch (name) {
case 'exec_command':
return 'Bash';
case 'apply_patch':
return 'Patch';
case 'web_search':
case 'web_search_call':
return 'Web Search';
default:
return name || 'tool';
}
}
function toolOutputIsError(output: string): boolean {
const exitCode = output.match(/\bProcess exited with code (\d+)\b/i)?.[1];
return exitCode !== undefined && Number(exitCode) !== 0;
}
function toolOutputText(value: unknown): string {
if (typeof value === 'string') return value;
return JSON.stringify(value ?? '');
}
function truncateToolOutput(output: string): string {
return output.length > MAX_TOOL_OUTPUT_CHARS
? `${output.slice(0, MAX_TOOL_OUTPUT_CHARS)}\n… output truncated by ClawX`
: output;
}
async function readTranscriptFile(path: string): Promise<CachedTranscriptFile | null> {
const metadata = await stat(path).catch(() => null);
if (!metadata) return null;
const cached = transcriptFileCache.get(path);
if (cached && cached.mtimeMs === metadata.mtimeMs && cached.size === metadata.size) {
setBoundedCache(transcriptFileCache, path, cached, MAX_TRANSCRIPT_FILE_CACHE_ENTRIES);
return cached;
}
const jsonl = await readFile(path, 'utf8').catch(() => '');
const entry = {
mtimeMs: metadata.mtimeMs,
size: metadata.size,
jsonl,
};
setBoundedCache(transcriptFileCache, path, entry, MAX_TRANSCRIPT_FILE_CACHE_ENTRIES);
return entry;
}
async function findTranscriptFile(
directory: string,
agentSessionId: string,
depth = 0,
): Promise<string | undefined> {
if (depth > MAX_TRANSCRIPT_SEARCH_DEPTH) return undefined;
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (!entry.isFile()) continue;
if (entry.name.endsWith('.jsonl') && entry.name.includes(agentSessionId)) {
return join(directory, entry.name);
}
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const match = await findTranscriptFile(join(directory, entry.name), agentSessionId, depth + 1);
if (match) return match;
}
return undefined;
}
function transcriptDateParts(timestamp: number, utc: boolean): [string, string, string] {
const date = new Date(timestamp);
const year = utc ? date.getUTCFullYear() : date.getFullYear();
const month = (utc ? date.getUTCMonth() : date.getMonth()) + 1;
const day = utc ? date.getUTCDate() : date.getDate();
return [String(year), String(month).padStart(2, '0'), String(day).padStart(2, '0')];
}
function transcriptCandidateDateParts(timestamp: number): Array<[string, string, string]> {
const candidates = [
transcriptDateParts(timestamp - 24 * 60 * 60_000, false),
transcriptDateParts(timestamp, false),
transcriptDateParts(timestamp + 24 * 60 * 60_000, false),
transcriptDateParts(timestamp, true),
];
return Array.from(new Map(candidates.map((parts) => [parts.join('/'), parts])).values());
}
function transcriptTurnMetadata(file: CachedTranscriptFile): NonNullable<CachedTranscriptFile['turnMetadata']> {
if (file.turnMetadata) return file.turnMetadata;
let sessionTimestamp: number | undefined;
let sessionWorkDir: string | undefined;
const userTurns: NonNullable<CachedTranscriptFile['turnMetadata']>['userTurns'] = [];
for (const line of file.jsonl.split(/\r?\n/)) {
if (!line.trim()) continue;
let record: Record<string, unknown>;
try {
const parsed = JSON.parse(line);
if (!isRecord(parsed)) continue;
record = parsed;
} catch {
continue;
}
if (record.type === 'session_meta' && isRecord(record.payload)) {
sessionTimestamp = parseTimestamp(record.payload.timestamp) ?? parseTimestamp(record.timestamp);
sessionWorkDir = typeof record.payload.cwd === 'string' ? record.payload.cwd : undefined;
continue;
}
if (record.type !== 'response_item' || !isRecord(record.payload)) continue;
const payload = record.payload;
if (payload.type !== 'message' || payload.role !== 'user' || !Array.isArray(payload.content)) continue;
const timestamp = parseTimestamp(record.timestamp) ?? sessionTimestamp;
for (const item of payload.content) {
if (!isRecord(item) || item.type !== 'input_text' || typeof item.text !== 'string') continue;
userTurns.push({
content: item.text.trim(),
...(timestamp !== undefined ? { timestamp } : {}),
});
}
}
file.turnMetadata = { sessionTimestamp, sessionWorkDir, userTurns };
return file.turnMetadata;
}
function transcriptMatchesWorkDir(file: CachedTranscriptFile, expectedWorkDir?: string): boolean {
if (!expectedWorkDir) return true;
const { sessionWorkDir } = transcriptTurnMetadata(file);
return sessionWorkDir !== undefined && resolve(sessionWorkDir) === resolve(expectedWorkDir);
}
function transcriptMatchesTurn(
file: CachedTranscriptFile,
hints: CcConnectTranscriptTurnHint[],
expectedWorkDir?: string,
): boolean {
const { userTurns } = transcriptTurnMetadata(file);
if (userTurns.length === 0 || !transcriptMatchesWorkDir(file, expectedWorkDir)) return false;
return hints.some((hint) => userTurns.some((turn) => (
turn.timestamp !== undefined
&& Math.abs(turn.timestamp - hint.timestamp) <= TRANSCRIPT_TURN_MATCH_WINDOW_MS
&& turn.content === hint.content.trim()
)));
}
async function findTurnTranscriptFiles(
codexHomeDir: string,
hints: CcConnectTranscriptTurnHint[],
expectedWorkDir?: string,
): Promise<string[]> {
const sessionRoot = join(codexHomeDir, 'sessions');
const directories = new Map<string, string>();
const recentHints = [...hints]
.sort((left, right) => right.timestamp - left.timestamp)
.slice(0, MAX_FALLBACK_TURN_HINTS);
for (const hint of recentHints) {
for (const parts of transcriptCandidateDateParts(hint.timestamp)) {
const directory = join(sessionRoot, ...parts);
directories.set(directory, directory);
if (directories.size >= MAX_FALLBACK_DIRECTORIES) break;
}
if (directories.size >= MAX_FALLBACK_DIRECTORIES) break;
}
const matches: string[] = [];
let candidateFiles = 0;
let candidateBytes = 0;
for (const directory of directories.values()) {
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
const transcriptEntries = entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'))
.sort((left, right) => right.name.localeCompare(left.name));
for (const entry of transcriptEntries) {
if (candidateFiles >= MAX_FALLBACK_CANDIDATE_FILES) return matches;
candidateFiles += 1;
const path = join(directory, entry.name);
const metadata = await stat(path).catch(() => null);
if (!metadata || metadata.size > MAX_FALLBACK_FILE_BYTES) continue;
if (candidateBytes + metadata.size > MAX_FALLBACK_TOTAL_BYTES) return matches;
candidateBytes += metadata.size;
const file = await readTranscriptFile(path);
if (file?.jsonl && transcriptMatchesTurn(file, recentHints, expectedWorkDir)) matches.push(path);
}
}
return matches;
}
export function parseCcConnectCodexTranscriptTools(jsonl: string): RawMessage[] {
const messages: RawMessage[] = [];
const toolNamesByCallId = new Map<string, string>();
for (const line of jsonl.split(/\r?\n/)) {
if (!line.trim()) continue;
let record: Record<string, unknown>;
try {
const parsed = JSON.parse(line);
if (!isRecord(parsed)) continue;
record = parsed;
} catch {
continue;
}
if (record.type !== 'response_item' || !isRecord(record.payload)) continue;
const payload = record.payload;
const payloadType = typeof payload.type === 'string' ? payload.type : '';
const callId = typeof payload.call_id === 'string'
? payload.call_id.trim()
: typeof payload.id === 'string'
? payload.id.trim()
: '';
if (!callId) continue;
const timestamp = parseTimestamp(record.timestamp);
if (payloadType === 'function_call' || payloadType === 'custom_tool_call') {
const rawName = typeof payload.name === 'string' ? payload.name.trim() : '';
const name = displayToolName(rawName);
toolNamesByCallId.set(callId, name);
messages.push({
id: `cc-connect-codex-tool-${callId}`,
role: 'assistant',
content: [{
type: 'toolCall',
id: callId,
name,
arguments: parseToolArguments(payload.arguments ?? payload.input),
}],
...(timestamp !== undefined ? { timestamp } : {}),
stopReason: 'tool_use',
});
continue;
}
if (payloadType === 'function_call_output' || payloadType === 'custom_tool_call_output') {
const rawOutput = toolOutputText(payload.output ?? payload.content);
const output = truncateToolOutput(rawOutput);
const name = toolNamesByCallId.get(callId) || 'tool';
const isError = toolOutputIsError(rawOutput);
messages.push({
id: `cc-connect-codex-tool-result-${callId}`,
role: 'toolresult',
toolCallId: callId,
toolName: name,
content: output,
details: {
status: isError ? 'error' : 'completed',
aggregated: output,
},
...(isError ? { isError: true } : {}),
...(timestamp !== undefined ? { timestamp } : {}),
});
continue;
}
if (payloadType === 'web_search_call') {
const name = 'Web Search';
messages.push({
id: `cc-connect-codex-tool-${callId}`,
role: 'assistant',
content: [{
type: 'toolCall',
id: callId,
name,
arguments: payload.action ?? {},
}],
...(timestamp !== undefined ? { timestamp } : {}),
stopReason: 'tool_use',
});
const status = typeof payload.status === 'string' ? payload.status.toLowerCase() : '';
const isError = ['cancelled', 'error', 'failed'].includes(status);
if (status === 'completed' || isError) {
const output = isError ? `Web search ${status}` : 'Web search completed';
messages.push({
id: `cc-connect-codex-tool-result-${callId}`,
role: 'toolresult',
toolCallId: callId,
toolName: name,
content: output,
details: {
status: isError ? 'error' : 'completed',
aggregated: output,
},
...(isError ? { isError: true } : {}),
...(timestamp !== undefined ? { timestamp } : {}),
});
}
continue;
}
if (payloadType === 'mcp_tool_call') {
const server = typeof payload.server === 'string' ? payload.server : '';
const tool = typeof payload.tool === 'string'
? payload.tool
: typeof payload.name === 'string'
? payload.name
: 'tool';
const name = server ? `${server}: ${tool}` : tool;
messages.push({
id: `cc-connect-codex-tool-${callId}`,
role: 'assistant',
content: [{
type: 'toolCall',
id: callId,
name,
arguments: parseToolArguments(payload.arguments ?? payload.input),
}],
...(timestamp !== undefined ? { timestamp } : {}),
stopReason: 'tool_use',
});
if (payload.result !== undefined || payload.error !== undefined) {
const isError = payload.error !== undefined;
const output = truncateToolOutput(toolOutputText(payload.error ?? payload.result));
messages.push({
id: `cc-connect-codex-tool-result-${callId}`,
role: 'toolresult',
toolCallId: callId,
toolName: name,
content: output,
details: {
status: isError ? 'error' : 'completed',
aggregated: output,
},
...(isError ? { isError: true } : {}),
...(timestamp !== undefined ? { timestamp } : {}),
});
}
}
}
return messages;
}
export async function loadCcConnectCodexTranscriptTools(
codexHomeDirs: string | Iterable<string>,
agentSessionId: string,
turnHints: CcConnectTranscriptTurnHint[] = [],
expectedWorkDir?: string,
): Promise<RawMessage[]> {
const hasValidAgentSessionId = /^[A-Za-z0-9_-]+$/.test(agentSessionId);
if (!hasValidAgentSessionId && turnHints.length === 0) return [];
const homes = typeof codexHomeDirs === 'string'
? [codexHomeDirs]
: Array.from(codexHomeDirs);
const uniqueHomes = Array.from(new Set(homes.filter(Boolean)));
const idMatchedPaths = new Set<string>();
for (const codexHomeDir of uniqueHomes) {
if (hasValidAgentSessionId) {
const sessionPathCacheKey = `${resolve(codexHomeDir)}\0${agentSessionId}`;
let transcriptPath = transcriptPathBySessionId.get(sessionPathCacheKey);
if (!transcriptPath) {
transcriptPath = await findTranscriptFile(join(codexHomeDir, 'sessions'), agentSessionId);
}
if (transcriptPath) {
setBoundedCache(
transcriptPathBySessionId,
sessionPathCacheKey,
transcriptPath,
MAX_TRANSCRIPT_PATH_CACHE_ENTRIES,
);
const file = await readTranscriptFile(transcriptPath);
const matchesPublicTurn = turnHints.length === 0
|| (file !== null && transcriptMatchesTurn(file, turnHints, expectedWorkDir));
if (file?.jsonl && transcriptMatchesWorkDir(file, expectedWorkDir) && matchesPublicTurn) {
idMatchedPaths.add(transcriptPath);
}
}
}
}
let transcriptPaths = new Set<string>();
if (idMatchedPaths.size === 1) {
transcriptPaths = idMatchedPaths;
} else if (idMatchedPaths.size === 0) {
const fallbackPaths = new Set<string>();
for (const codexHomeDir of uniqueHomes) {
for (const path of await findTurnTranscriptFiles(codexHomeDir, turnHints, expectedWorkDir)) {
fallbackPaths.add(path);
}
}
if (fallbackPaths.size === 1) transcriptPaths = fallbackPaths;
}
const messages: RawMessage[] = [];
for (const transcriptPath of transcriptPaths) {
const file = await readTranscriptFile(transcriptPath);
if (!file?.jsonl) continue;
file.toolMessages ??= parseCcConnectCodexTranscriptTools(file.jsonl);
messages.push(...file.toolMessages);
}
return messages.sort((left, right) => (left.timestamp ?? 0) - (right.timestamp ?? 0));
}
@@ -0,0 +1,8 @@
export const CC_CONNECT_MANAGEMENT_PORT = 9820;
export function buildCcConnectWebAdminUrl(port = CC_CONNECT_MANAGEMENT_PORT): string {
const normalizedPort = Number.isFinite(port) && port > 0
? Math.trunc(port)
: CC_CONNECT_MANAGEMENT_PORT;
return `http://127.0.0.1:${normalizedPort}/`;
}
+63
View File
@@ -0,0 +1,63 @@
import { app } from 'electron';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { getClawXDataLayout, resolveClawXDataRoot } from '../utils/clawx-data-layout';
function binaryName(): string {
return process.platform === 'win32' ? 'cc-connect.exe' : 'cc-connect';
}
export function getCcConnectManagedDir(): string {
return getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData'))).ccConnectRuntimeDir;
}
export function getCcConnectConfigPath(): string {
return join(getCcConnectManagedDir(), 'config.toml');
}
export function getCcConnectCodexHomeDir(): string {
return join(getCcConnectManagedDir(), 'codex-home');
}
export function getCcConnectAccountCodexHomeDir(accountId: string): string {
const normalized = accountId.trim() || 'default';
const safeAccountId = encodeURIComponent(normalized).replace(/%/g, '_');
const layout = getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData')));
return join(layout.credentialsDir, 'oauth', safeAccountId, 'codex-home');
}
export function getCcConnectWorkspacesDir(): string {
return getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData'))).agentWorkspacesDir;
}
export function getCcConnectAgentWorkspaceDir(agentId = 'main'): string {
const safeAgentId = agentId.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-') || 'main';
return join(getCcConnectWorkspacesDir(), safeAgentId);
}
export function getCcConnectProviderProfilePath(): string {
return join(getCcConnectManagedDir(), 'provider-profile.json');
}
export function getCcConnectBinaryPath(): string {
if (!app.isPackaged && process.env.CLAWX_CC_CONNECT_PATH) {
return process.env.CLAWX_CC_CONNECT_PATH;
}
if (app.isPackaged) {
return join(process.resourcesPath, 'cc-connect', binaryName());
}
const bundledDevBinary = join(process.cwd(), 'build', 'cc-connect', `${process.platform}-${process.arch}`, binaryName());
if (existsSync(bundledDevBinary)) {
return bundledDevBinary;
}
return bundledDevBinary;
}
export function assertCcConnectBinaryPath(candidate = getCcConnectBinaryPath()): string {
if (!existsSync(candidate)) {
throw new Error(
`cc-connect binary not found at ${candidate}. Run pnpm run bundle:cc-connect:current before selecting cc-connect runtime.`,
);
}
return candidate;
}
@@ -0,0 +1,786 @@
import { access, chmod, cp, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { app } from 'electron';
import { getProviderAccount, getDefaultProviderAccountId } from '@electron/services/providers/provider-store';
import { getProviderSecret, getSecretStore } from '@electron/services/secrets/secret-store';
import { getProviderDefaultModel } from '@electron/utils/provider-registry';
import type { ProviderAccount, ProviderSecret } from '@electron/shared/providers/types';
import {
getCcConnectAccountCodexHomeDir,
getCcConnectCodexHomeDir,
getCcConnectProviderProfilePath,
} from './cc-connect-paths';
export type CodexProviderProfile = {
providerId: string | null;
vendorId: string | null;
label?: string;
authMode?: string;
model?: string;
modelRef?: string;
supported: boolean;
unsupportedReason?: string;
codexArgs: string[];
env?: Record<string, string>;
envKeys?: string[];
launcherEnv?: Record<string, string>;
ccConnectProvider?: {
name: string;
apiKeyEnvKey?: string;
baseUrl?: string;
model?: string;
wireApi?: 'responses';
};
secretAvailable: boolean;
codexHomeDir?: string;
updatedAt: string;
};
type OpenAIOAuthTokenSet = {
idToken: string;
accessToken: string;
refreshToken: string;
accountId: string;
};
type OpenAIOAuthTokenResolution = {
tokens: OpenAIOAuthTokenSet;
source: 'managed' | 'secret';
};
type OpenAIOAuthTokenResolutionOptions = {
preferSecret?: boolean;
};
export type CodexOAuthAuthFileSummary = {
path: string;
exists: boolean;
complete: boolean;
accountId?: string;
authMode?: string;
lastRefresh?: string;
updatedAt?: string;
error?: string;
};
export type CodexOAuthProviderSummary = {
accountId: string;
vendorId: string;
authMode?: string;
hasOAuthSecret: boolean;
subject?: string;
email?: string;
managedMatchesAccount?: boolean;
userMatchesAccount?: boolean;
};
export type CodexOAuthStatus = {
success: true;
managedCodexHome: string;
authPath: string;
managed: CodexOAuthAuthFileSummary;
user: CodexOAuthAuthFileSummary;
provider?: CodexOAuthProviderSummary;
};
function resolveModel(account: ProviderAccount): string | undefined {
const model = account.model?.trim();
if (model) return model;
return getProviderDefaultModel(account.vendorId)?.trim() || undefined;
}
function publicProfile(profile: CodexProviderProfile): CodexProviderProfile {
const { env, ...rest } = profile;
return {
...rest,
envKeys: Object.keys(env ?? {}),
};
}
function tomlString(value: string): string {
return JSON.stringify(value);
}
function tomlInlineStringMap(values: Record<string, string>): string {
return `{ ${Object.entries(values).map(([key, value]) => `${tomlString(key)} = ${tomlString(value)}`).join(', ')} }`;
}
function normalizeOpenAIResponsesBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, '').replace(/\/responses$/i, '');
}
function normalizeModelHubCodexResponsesBaseUrl(baseUrl: string): string | null {
const trimmed = baseUrl.trim();
if (!trimmed) return null;
try {
const url = new URL(trimmed);
if (url.hostname !== 'aidp.bytedance.net') return null;
if (!url.pathname.startsWith('/api/modelhub/online')) return null;
url.pathname = '/api/modelhub/online';
url.search = '';
url.hash = '';
return url.toString().replace(/\/$/, '');
} catch {
return null;
}
}
async function writeManagedCodexResponsesConfig(options: {
accountId: string;
providerKey: string;
providerName: string;
baseUrl: string;
envKey: string;
model?: string;
envHttpHeaders?: Record<string, string>;
modelReasoningEffort?: string;
}): Promise<string> {
const codexHomeDir = getCcConnectAccountCodexHomeDir(options.accountId);
await mkdir(codexHomeDir, { recursive: true });
const configPath = join(codexHomeDir, 'config.toml');
const tableKey = /^[A-Za-z_][A-Za-z0-9_-]*$/.test(options.providerKey)
? options.providerKey
: tomlString(options.providerKey);
const envHeaderEntries = Object.entries(options.envHttpHeaders ?? {});
const lines = [
...(options.model ? [`model = ${tomlString(options.model)}`] : []),
`model_provider = ${tomlString(options.providerKey)}`,
...(options.modelReasoningEffort ? [`model_reasoning_effort = ${tomlString(options.modelReasoningEffort)}`] : []),
'',
`[model_providers.${tableKey}]`,
`name = ${tomlString(options.providerName)}`,
`base_url = ${tomlString(options.baseUrl)}`,
`env_key = ${tomlString(options.envKey)}`,
'wire_api = "responses"',
...(envHeaderEntries.length > 0
? [`env_http_headers = ${tomlInlineStringMap(options.envHttpHeaders ?? {})}`]
: []),
'',
];
await writeFile(configPath, lines.join('\n'), { encoding: 'utf8', mode: 0o600 });
await chmod(configPath, 0o600).catch(() => {});
return codexHomeDir;
}
function stableModelHubSessionId(account: ProviderAccount): string {
return `clawx-cc-connect-${account.id}`;
}
function sanitizedEnvKeyPart(value: string): string {
const sanitized = value
.trim()
.replace(/[^A-Za-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.toUpperCase();
return sanitized || 'HEADER';
}
function accountScopedEnvKey(accountId: string, purpose: string): string {
return `CLAWX_CODEX_${sanitizedEnvKeyPart(accountId)}_${sanitizedEnvKeyPart(purpose)}`;
}
function buildCustomHeaderEnv(account: ProviderAccount, options?: { exclude?: Set<string> }): {
env: Record<string, string>;
envHttpHeaders: Record<string, string>;
} {
const entries = Object.entries(account.headers ?? {})
.map(([name, value]) => [name.trim(), String(value ?? '').trim()] as const)
.filter(([name, value]) => name && value)
.filter(([name]) => !options?.exclude?.has(name.toLowerCase()));
const env: Record<string, string> = {};
const envHttpHeaders: Record<string, string> = {};
const used = new Set<string>();
for (const [name, value] of entries) {
const baseKey = accountScopedEnvKey(account.id, `HEADER_${name}`);
let envKey = baseKey;
let index = 2;
while (used.has(envKey)) {
envKey = `${baseKey}_${index}`;
index += 1;
}
used.add(envKey);
env[envKey] = value;
envHttpHeaders[name] = envKey;
}
return { env, envHttpHeaders };
}
function extractSessionIdFromExtraHeader(value: string): string | undefined {
try {
const parsed = JSON.parse(value) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
const sessionId = (parsed as Record<string, unknown>).session_id;
return typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : undefined;
} catch {
return undefined;
}
}
function buildModelHubEnv(account: ProviderAccount, apiKey: string): {
env: Record<string, string>;
envHttpHeaders: Record<string, string>;
} {
const apiKeyEnvKey = accountScopedEnvKey(account.id, 'API_KEY');
const extraHeaderEnvKey = accountScopedEnvKey(account.id, 'EXTRA_HEADER');
const stickySessionEnvKey = accountScopedEnvKey(account.id, 'STICKY_SESSION_ID');
const customHeaders = buildCustomHeaderEnv(account, { exclude: new Set(['api-key', 'extra']) });
const existingExtraHeader = account.headers?.extra?.trim();
const sessionId = existingExtraHeader
? extractSessionIdFromExtraHeader(existingExtraHeader) ?? stableModelHubSessionId(account)
: stableModelHubSessionId(account);
const extraHeader = existingExtraHeader || JSON.stringify({ session_id: sessionId });
return {
env: {
[apiKeyEnvKey]: apiKey,
...customHeaders.env,
[extraHeaderEnvKey]: extraHeader,
[stickySessionEnvKey]: sessionId,
},
envHttpHeaders: {
...customHeaders.envHttpHeaders,
'Api-Key': apiKeyEnvKey,
extra: extraHeaderEnvKey,
},
};
}
function getUserCodexAuthPath(): string {
const e2eOverride = process.env.CLAWX_E2E_USER_CODEX_AUTH_JSON?.trim();
if (process.env.CLAWX_E2E === '1' && e2eOverride) {
return e2eOverride;
}
return join(app.getPath('home'), '.codex', 'auth.json');
}
async function ensureAccountCodexHome(accountId: string): Promise<string> {
const accountHome = getCcConnectAccountCodexHomeDir(accountId);
await mkdir(accountHome, { recursive: true });
return accountHome;
}
async function migrateLegacyCodexHomeToAccount(accountId: string): Promise<void> {
const accountHome = getCcConnectAccountCodexHomeDir(accountId);
const legacyHome = getCcConnectCodexHomeDir();
const accountExists = await access(accountHome).then(() => true).catch(() => false);
if (accountExists) return;
const legacyExists = await access(legacyHome).then(() => true).catch(() => false);
if (!legacyExists) return;
await mkdir(dirname(accountHome), { recursive: true });
try {
await rename(legacyHome, accountHome);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EXDEV') {
await cp(legacyHome, accountHome, { recursive: true, force: false, errorOnExist: false });
await rm(legacyHome, { recursive: true, force: true });
return;
}
throw error;
}
}
async function writeManagedOpenAIOAuthAuthFile(
tokens: OpenAIOAuthTokenSet,
accountId: string,
): Promise<string> {
const codexHomeDir = getCcConnectAccountCodexHomeDir(accountId);
await mkdir(codexHomeDir, { recursive: true });
const authPath = join(codexHomeDir, 'auth.json');
await writeFile(authPath, JSON.stringify({
auth_mode: 'chatgpt',
OPENAI_API_KEY: null,
tokens: {
id_token: tokens.idToken,
access_token: tokens.accessToken,
refresh_token: tokens.refreshToken,
account_id: tokens.accountId,
},
last_refresh: new Date().toISOString(),
}, null, 2), { encoding: 'utf8', mode: 0o600 });
await chmod(authPath, 0o600).catch(() => {});
return codexHomeDir;
}
async function readCompleteCodexAuthTokens(authPath: string): Promise<OpenAIOAuthTokenSet | undefined> {
try {
const auth = JSON.parse(await readFile(authPath, 'utf8')) as {
tokens?: {
id_token?: unknown;
access_token?: unknown;
refresh_token?: unknown;
account_id?: unknown;
};
};
const tokens = auth.tokens;
if (
!tokens ||
typeof tokens.id_token !== 'string' ||
typeof tokens.access_token !== 'string' ||
typeof tokens.refresh_token !== 'string' ||
typeof tokens.account_id !== 'string' ||
!tokens.id_token.trim() ||
!tokens.access_token.trim() ||
!tokens.refresh_token.trim() ||
!tokens.account_id.trim()
) {
return undefined;
}
return {
idToken: tokens.id_token.trim(),
accessToken: tokens.access_token.trim(),
refreshToken: tokens.refresh_token.trim(),
accountId: tokens.account_id.trim(),
};
} catch {
return undefined;
}
}
async function readCodexAuthSummary(authPath: string): Promise<CodexOAuthAuthFileSummary> {
let raw: string;
try {
raw = await readFile(authPath, 'utf8');
} catch {
return { path: authPath, exists: false, complete: false };
}
const updatedAt = await stat(authPath)
.then((fileStat) => fileStat.mtime.toISOString())
.catch(() => undefined);
try {
const parsed = JSON.parse(raw) as {
auth_mode?: unknown;
tokens?: { account_id?: unknown };
last_refresh?: unknown;
};
const tokens = await readCompleteCodexAuthTokens(authPath);
return {
path: authPath,
exists: true,
complete: Boolean(tokens),
accountId: tokens?.accountId ?? (
typeof parsed.tokens?.account_id === 'string' && parsed.tokens.account_id.trim()
? parsed.tokens.account_id.trim()
: undefined
),
authMode: typeof parsed.auth_mode === 'string' ? parsed.auth_mode : undefined,
lastRefresh: typeof parsed.last_refresh === 'string' ? parsed.last_refresh : undefined,
updatedAt,
};
} catch {
return {
path: authPath,
exists: true,
complete: false,
updatedAt,
error: 'Invalid Codex auth.json',
};
}
}
function codexTokensMatchAccount(
tokens: OpenAIOAuthTokenSet,
account: ProviderAccount,
secret?: Extract<ProviderSecret, { type: 'oauth' }>,
): boolean {
if (!secret) return true;
const expectedAccountId = secret.subject?.trim();
const userAccountId = tokens.accountId.trim();
const accessMatches = tokens.accessToken === secret.accessToken;
const refreshMatches = tokens.refreshToken === secret.refreshToken;
const accountMatches = Boolean(expectedAccountId && userAccountId && expectedAccountId === userAccountId);
const providerIdMatches = Boolean(userAccountId && account.id === userAccountId);
return accessMatches || refreshMatches || accountMatches || providerIdMatches;
}
async function resolveProviderAccount(accountId?: string): Promise<{
account: ProviderAccount | null;
secret?: Extract<ProviderSecret, { type: 'oauth' }>;
}> {
const resolvedAccountId = accountId?.trim() || await getDefaultProviderAccountId();
const account = resolvedAccountId ? await getProviderAccount(resolvedAccountId) : null;
const secret = account ? await getProviderSecret(account.id) : null;
return {
account,
secret: secret?.type === 'oauth' && secret.accessToken && secret.refreshToken ? secret : undefined,
};
}
async function resolveOpenAIOAuthTokens(
account: ProviderAccount,
secret?: Extract<ProviderSecret, { type: 'oauth' }>,
options?: OpenAIOAuthTokenResolutionOptions,
): Promise<OpenAIOAuthTokenResolution | undefined> {
const secretIdToken = secret?.idToken?.trim();
const secretResolution: OpenAIOAuthTokenResolution | undefined = secret && secretIdToken
? {
tokens: {
idToken: secretIdToken,
accessToken: secret.accessToken,
refreshToken: secret.refreshToken,
accountId: secret.subject?.trim() || account.id,
},
source: 'secret',
}
: undefined;
// Browser re-login is authoritative once; normal starts keep Codex-rotated managed tokens.
if (options?.preferSecret && secretResolution) {
return secretResolution;
}
const managedAuthPath = join(await ensureAccountCodexHome(account.id), 'auth.json');
const managedTokens = await readCompleteCodexAuthTokens(managedAuthPath);
if (managedTokens && codexTokensMatchAccount(managedTokens, account, secret)) {
return { tokens: managedTokens, source: 'managed' };
}
return secretResolution;
}
export async function getCcConnectCodexOAuthStatus(payload?: {
accountId?: string;
}): Promise<CodexOAuthStatus> {
const { account, secret } = await resolveProviderAccount(payload?.accountId);
const resolvedAccountId = account?.id ?? payload?.accountId?.trim() ?? 'default';
const managedCodexHome = await ensureAccountCodexHome(resolvedAccountId);
const authPath = join(managedCodexHome, 'auth.json');
const userAuthPath = getUserCodexAuthPath();
const [managed, user] = await Promise.all([
readCodexAuthSummary(authPath),
readCodexAuthSummary(userAuthPath),
]);
const managedTokens = account ? await readCompleteCodexAuthTokens(authPath) : undefined;
const userTokens = account ? await readCompleteCodexAuthTokens(userAuthPath) : undefined;
return {
success: true,
managedCodexHome,
authPath,
managed,
user,
...(account ? {
provider: {
accountId: account.id,
vendorId: account.vendorId,
authMode: account.authMode,
hasOAuthSecret: Boolean(secret),
subject: secret?.subject,
email: secret?.email,
managedMatchesAccount: managedTokens ? codexTokensMatchAccount(managedTokens, account, secret) : undefined,
userMatchesAccount: userTokens ? codexTokensMatchAccount(userTokens, account, secret) : undefined,
},
} : {}),
};
}
export async function importUserCodexOAuthToManagedHome(payload?: {
accountId?: string;
}): Promise<CodexOAuthStatus> {
const { account, secret } = await resolveProviderAccount(payload?.accountId);
const userAuthPath = getUserCodexAuthPath();
const tokens = await readCompleteCodexAuthTokens(userAuthPath);
if (!tokens) {
throw new Error(`No complete Codex OAuth auth.json found at ${userAuthPath}`);
}
if (account && !codexTokensMatchAccount(tokens, account, secret)) {
throw new Error('Local Codex OAuth credentials do not match the selected provider account');
}
await writeManagedOpenAIOAuthAuthFile(tokens, account?.id ?? payload?.accountId?.trim() ?? 'default');
return getCcConnectCodexOAuthStatus({ accountId: account?.id ?? payload?.accountId });
}
export async function logoutCcConnectCodexOAuth(payload?: {
accountId?: string;
managedOnly?: boolean;
}): Promise<CodexOAuthStatus> {
const { account } = await resolveProviderAccount(payload?.accountId);
const accountId = account?.id ?? payload?.accountId?.trim() ?? 'default';
const managedHome = await ensureAccountCodexHome(accountId);
await rm(join(managedHome, 'auth.json'), { force: true });
if (!payload?.managedOnly && account?.authMode === 'oauth_browser') {
await getSecretStore().delete(account.id);
}
return getCcConnectCodexOAuthStatus({ accountId });
}
async function buildProfileForAccount(
account: ProviderAccount,
options?: OpenAIOAuthTokenResolutionOptions,
): Promise<CodexProviderProfile> {
const secret = await getProviderSecret(account.id);
const model = resolveModel(account);
const base = {
providerId: account.id,
vendorId: account.vendorId,
label: account.label,
authMode: account.authMode,
model,
modelRef: model ? `${account.vendorId}/${model}` : undefined,
secretAvailable: Boolean(secret),
updatedAt: new Date().toISOString(),
};
if (account.vendorId === 'openai') {
if (account.authMode === 'oauth_browser') {
const oauthSecret = secret?.type === 'oauth' && secret.accessToken && secret.refreshToken
? secret
: undefined;
const tokenResolution = await resolveOpenAIOAuthTokens(account, oauthSecret, options);
if (!tokenResolution) {
return {
...base,
supported: false,
unsupportedReason: 'Codex OAuth credentials are missing. Sign in to Codex using the ClawX-managed CODEX_HOME or sign in to OpenAI again before using cc-connect Codex runtime.',
codexArgs: [],
};
}
const codexHomeDir = tokenResolution.source === 'managed'
? await ensureAccountCodexHome(account.id)
: await writeManagedOpenAIOAuthAuthFile(tokenResolution.tokens, account.id);
return {
...base,
supported: true,
codexArgs: model ? ['--model', model] : [],
env: { CODEX_HOME: codexHomeDir },
codexHomeDir,
secretAvailable: true,
};
}
const env: Record<string, string> = {};
const apiKeyEnvKey = accountScopedEnvKey(account.id, 'API_KEY');
if ((secret?.type === 'api_key' || secret?.type === 'local') && secret.apiKey) {
env[apiKeyEnvKey] = secret.apiKey;
}
if (!env[apiKeyEnvKey]) {
return {
...base,
supported: false,
unsupportedReason: 'OpenAI API key credentials are missing. Add an OpenAI API key before using the cc-connect Codex runtime with this provider.',
codexArgs: [],
};
}
const baseUrl = account.baseUrl?.trim();
if (baseUrl) {
const providerKey = 'clawx-openai';
const normalizedBaseUrl = normalizeOpenAIResponsesBaseUrl(baseUrl);
const codexHomeDir = await writeManagedCodexResponsesConfig({
accountId: account.id,
providerKey,
providerName: 'OpenAI',
baseUrl: normalizedBaseUrl,
envKey: apiKeyEnvKey,
model,
});
return {
...base,
supported: true,
codexArgs: [
'-c',
`model_provider=${tomlString(providerKey)}`,
'-c',
`model_providers.${providerKey}.name="OpenAI"`,
'-c',
`model_providers.${providerKey}.base_url=${tomlString(normalizedBaseUrl)}`,
'-c',
`model_providers.${providerKey}.env_key=${tomlString(apiKeyEnvKey)}`,
'-c',
`model_providers.${providerKey}.wire_api="responses"`,
...(model ? ['--model', model] : []),
],
env: {
...env,
CODEX_HOME: codexHomeDir,
},
codexHomeDir,
launcherEnv: { OPENAI_API_KEY: apiKeyEnvKey },
ccConnectProvider: {
name: providerKey,
apiKeyEnvKey,
baseUrl: normalizedBaseUrl,
wireApi: 'responses',
...(model ? { model } : {}),
},
};
}
const codexHomeDir = await ensureAccountCodexHome(account.id);
return {
...base,
supported: true,
codexArgs: model ? ['--model', model] : [],
env: { ...env, CODEX_HOME: codexHomeDir },
codexHomeDir,
launcherEnv: { OPENAI_API_KEY: apiKeyEnvKey },
ccConnectProvider: {
name: 'openai',
apiKeyEnvKey,
...(model ? { model } : {}),
},
};
}
if (account.vendorId === 'custom') {
const protocol = account.apiProtocol || 'openai-completions';
if (protocol !== 'openai-responses') {
return {
...base,
supported: false,
unsupportedReason: `cc-connect Codex runtime cannot use custom provider "${account.label}" because Codex 0.137 only supports the Responses wire API. This provider is configured for Chat Completions.`,
codexArgs: [],
};
}
const baseUrl = account.baseUrl?.trim();
if (!baseUrl) {
return {
...base,
supported: false,
unsupportedReason: `cc-connect Codex runtime cannot use custom provider "${account.label}" because a Responses-compatible base URL is required.`,
codexArgs: [],
};
}
if ((secret?.type !== 'api_key' && secret?.type !== 'local') || !secret.apiKey) {
return {
...base,
supported: false,
unsupportedReason: `cc-connect Codex runtime cannot use custom provider "${account.label}" because its API key is missing.`,
codexArgs: [],
};
}
const modelHubBaseUrl = normalizeModelHubCodexResponsesBaseUrl(baseUrl);
const providerKey = modelHubBaseUrl ? 'modelhub_openapi' : 'clawx-custom';
const envKey = accountScopedEnvKey(account.id, 'API_KEY');
const normalizedBaseUrl = modelHubBaseUrl ?? normalizeOpenAIResponsesBaseUrl(baseUrl);
const customHeaders = modelHubBaseUrl
? buildModelHubEnv(account, secret.apiKey)
: buildCustomHeaderEnv(account);
const env: Record<string, string> = modelHubBaseUrl
? customHeaders.env
: { [envKey]: secret.apiKey, ...customHeaders.env };
const envHttpHeaders = Object.keys(customHeaders.envHttpHeaders).length > 0
? customHeaders.envHttpHeaders
: undefined;
const codexHomeDir = await writeManagedCodexResponsesConfig({
accountId: account.id,
providerKey,
providerName: modelHubBaseUrl ? 'ByteDance ModelHub OpenAPI' : (account.label || 'Custom'),
baseUrl: normalizedBaseUrl,
envKey,
model,
envHttpHeaders,
...(modelHubBaseUrl ? { modelReasoningEffort: 'none' } : {}),
});
return {
...base,
supported: true,
codexArgs: [
'-c',
`model_provider=${tomlString(providerKey)}`,
'-c',
`model_providers.${providerKey}.name=${tomlString(modelHubBaseUrl ? 'ByteDance ModelHub OpenAPI' : (account.label || 'Custom'))}`,
'-c',
`model_providers.${providerKey}.base_url=${tomlString(normalizedBaseUrl)}`,
'-c',
`model_providers.${providerKey}.env_key=${tomlString(envKey)}`,
'-c',
`model_providers.${providerKey}.wire_api="responses"`,
...(modelHubBaseUrl ? [
'-c',
'model_reasoning_effort="none"',
] : []),
...(envHttpHeaders ? [
'-c',
`model_providers.${providerKey}.env_http_headers=${tomlInlineStringMap(envHttpHeaders)}`,
] : []),
...(model ? ['--model', model] : []),
],
env: {
...env,
CODEX_HOME: codexHomeDir,
},
codexHomeDir,
ccConnectProvider: {
name: providerKey,
apiKeyEnvKey: envKey,
baseUrl: normalizedBaseUrl,
wireApi: 'responses',
...(model ? { model } : {}),
},
};
}
if (account.vendorId === 'ollama') {
return {
...base,
supported: true,
codexArgs: [
'--oss',
'--local-provider',
'ollama',
...(model ? ['--model', model] : []),
],
};
}
return {
...base,
supported: false,
unsupportedReason: `cc-connect Codex runtime currently supports OpenAI/Codex and Ollama provider accounts; "${account.vendorId}" is not supported yet.`,
codexArgs: [],
};
}
export async function buildCcConnectProviderProfileForAccount(
accountId: string,
): Promise<CodexProviderProfile> {
const account = await getProviderAccount(accountId);
if (account) return buildProfileForAccount(account);
return {
providerId: accountId,
vendorId: null,
supported: false,
unsupportedReason: `Provider account "${accountId}" was not found`,
codexArgs: [],
secretAvailable: false,
updatedAt: new Date().toISOString(),
};
}
export async function syncCcConnectProviderProfile(
payload?: { providerId?: string; reason?: string },
): Promise<CodexProviderProfile> {
const providerId = payload?.providerId?.trim() || await getDefaultProviderAccountId();
const account = providerId ? await getProviderAccount(providerId) : null;
if (account?.vendorId === 'openai' && account.authMode === 'oauth_browser') {
await migrateLegacyCodexHomeToAccount(account.id);
}
const profile: CodexProviderProfile = account
? await buildProfileForAccount(account, { preferSecret: payload?.reason === 'oauth' })
: {
providerId: null,
vendorId: null,
supported: true,
codexArgs: [],
secretAvailable: false,
updatedAt: new Date().toISOString(),
};
const profilePath = getCcConnectProviderProfilePath();
await mkdir(dirname(profilePath), { recursive: true });
await writeFile(profilePath, JSON.stringify({
...publicProfile(profile),
reason: payload?.reason ?? 'sync',
}, null, 2), 'utf8');
return profile;
}
export function toPublicCodexProviderProfile(profile: CodexProviderProfile): CodexProviderProfile {
return publicProfile(profile);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,125 @@
import { randomUUID } from 'node:crypto';
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { app } from 'electron';
import { getClawXDataLayout, resolveClawXDataRoot } from '../utils/clawx-data-layout';
import { getCcConnectManagedDir } from './cc-connect-paths';
type SessionMetadataDocument = {
schema: 'clawx-cc-connect-session-metadata';
version: 1;
labels: Record<string, string>;
updatedAt: string;
migratedFromLegacyAt?: string;
};
export interface CcConnectSessionMetadataStore {
getLabel(sessionKey: string): Promise<string | undefined>;
setLabel(sessionKey: string, label: string): Promise<void>;
deleteLabel(sessionKey: string): Promise<void>;
}
function defaultMetadataPath(): string {
const layout = getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData')));
return join(layout.appDir, 'cc-connect-session-metadata.json');
}
function defaultLegacyPath(): string {
return join(getCcConnectManagedDir(), 'data', 'sessions', '.clawx-supplemental-history.json');
}
async function writeAtomic(path: string, document: SessionMetadataDocument): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
await writeFile(temporaryPath, `${JSON.stringify(document, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
await chmod(temporaryPath, 0o600).catch(() => {});
await rename(temporaryPath, path);
await chmod(path, 0o600).catch(() => {});
}
function emptyDocument(): SessionMetadataDocument {
return {
schema: 'clawx-cc-connect-session-metadata',
version: 1,
labels: {},
updatedAt: new Date(0).toISOString(),
};
}
function normalizedLabels(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return Object.fromEntries(Object.entries(value).flatMap(([key, label]) => (
typeof label === 'string' && label.trim() ? [[key, label.trim().slice(0, 80)]] : []
)));
}
export class FileCcConnectSessionMetadataStore implements CcConnectSessionMetadataStore {
private queue = Promise.resolve();
constructor(
private readonly metadataPath = defaultMetadataPath(),
private readonly legacyPath = defaultLegacyPath(),
) {}
async getLabel(sessionKey: string): Promise<string | undefined> {
const document = await this.readDocument();
return document.labels[sessionKey];
}
async setLabel(sessionKey: string, label: string): Promise<void> {
const normalized = label.trim().slice(0, 80);
if (!normalized) throw new Error('Label cannot be empty');
await this.exclusive(async () => {
const document = await this.readDocument();
document.labels[sessionKey] = normalized;
document.updatedAt = new Date().toISOString();
await writeAtomic(this.metadataPath, document);
});
}
async deleteLabel(sessionKey: string): Promise<void> {
await this.exclusive(async () => {
const document = await this.readDocument();
if (!(sessionKey in document.labels)) return;
delete document.labels[sessionKey];
document.updatedAt = new Date().toISOString();
await writeAtomic(this.metadataPath, document);
});
}
private async readDocument(): Promise<SessionMetadataDocument> {
try {
const parsed = JSON.parse(await readFile(this.metadataPath, 'utf8')) as Partial<SessionMetadataDocument>;
if (parsed.schema !== 'clawx-cc-connect-session-metadata' || parsed.version !== 1) {
throw new Error(`Unsupported cc-connect session metadata: ${this.metadataPath}`);
}
return { ...parsed, labels: normalizedLabels(parsed.labels) } as SessionMetadataDocument;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
const document = emptyDocument();
try {
const legacy = JSON.parse(await readFile(this.legacyPath, 'utf8')) as { labels?: unknown };
document.labels = normalizedLabels(legacy.labels);
document.migratedFromLegacyAt = new Date().toISOString();
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
document.updatedAt = new Date().toISOString();
await writeAtomic(this.metadataPath, document);
return document;
}
private async exclusive<T>(operation: () => Promise<T>): Promise<T> {
const previous = this.queue;
let release!: () => void;
this.queue = new Promise<void>((resolve) => { release = resolve; });
await previous;
try {
return await operation();
} finally {
release();
}
}
}
+73
View File
@@ -0,0 +1,73 @@
import { cp, mkdir, rm, writeFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import type { SkillsStatusResult } from '@shared/host-api/contract';
import { getCcConnectCodexHomeDir } from './cc-connect-paths';
import { listLocalSkills, type LocalSkillRecord } from '../services/skills/local-skill-service';
function safeSkillDirName(skill: Pick<LocalSkillRecord, 'id' | 'slug' | 'baseDir'>): string {
const candidate = skill.slug || skill.id || (skill.baseDir ? basename(skill.baseDir) : 'skill');
return candidate.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'skill';
}
function isCodexNativeSkill(skill: LocalSkillRecord): boolean {
return skill.source === 'agents-skills-personal' || skill.source === 'agents-skills-project';
}
export async function syncCcConnectSkillRecords(
records: LocalSkillRecord[],
codexHomeDir = getCcConnectCodexHomeDir(),
): Promise<SkillsStatusResult> {
const skillsRoot = join(codexHomeDir, 'skills');
await mkdir(skillsRoot, { recursive: true });
const enabled = records.filter((skill) => skill.enabled !== false && skill.baseDir);
const manifest: Array<Record<string, unknown>> = [];
for (const skill of enabled) {
const targetDirName = safeSkillDirName(skill);
const targetDir = join(skillsRoot, targetDirName);
await rm(targetDir, { recursive: true, force: true });
const native = isCodexNativeSkill(skill);
if (!native) {
await cp(skill.baseDir!, targetDir, { recursive: true, force: true });
}
const runtimeDir = native ? skill.baseDir! : targetDir;
manifest.push({
skillKey: skill.id,
slug: skill.slug,
name: skill.name,
description: skill.description,
source: skill.source,
baseDir: runtimeDir,
filePath: join(runtimeDir, 'SKILL.md'),
projection: native ? 'codex-native' : 'mirrored',
version: skill.version,
bundled: skill.isBundled,
always: skill.isCore,
});
}
await writeFile(join(skillsRoot, 'manifest.json'), JSON.stringify({
updatedAt: new Date().toISOString(),
skills: manifest,
}, null, 2), 'utf8');
return {
skills: manifest.map((skill) => ({
skillKey: String(skill.skillKey || ''),
slug: typeof skill.slug === 'string' ? skill.slug : undefined,
name: typeof skill.name === 'string' ? skill.name : undefined,
description: typeof skill.description === 'string' ? skill.description : undefined,
disabled: false,
version: typeof skill.version === 'string' ? skill.version : undefined,
bundled: skill.bundled === true,
always: skill.always === true,
source: typeof skill.source === 'string' ? skill.source : undefined,
baseDir: typeof skill.baseDir === 'string' ? skill.baseDir : undefined,
filePath: typeof skill.filePath === 'string' ? skill.filePath : undefined,
})),
};
}
export async function syncCcConnectSkills(codexHomeDir?: string): Promise<SkillsStatusResult> {
return syncCcConnectSkillRecords(await listLocalSkills(), codexHomeDir);
}
+62
View File
@@ -0,0 +1,62 @@
import { app } from 'electron';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
export type CodexBundle = {
baseDir: string;
binaryPath: string;
pathDir: string;
targetTriple: string;
};
function codexBinaryName(): string {
return process.platform === 'win32' ? 'codex.exe' : 'codex';
}
function codexTargetTriple(platform = process.platform, arch = process.arch): string {
if (platform === 'darwin' && arch === 'x64') return 'x86_64-apple-darwin';
if (platform === 'darwin' && arch === 'arm64') return 'aarch64-apple-darwin';
if (platform === 'linux' && arch === 'x64') return 'x86_64-unknown-linux-musl';
if (platform === 'linux' && arch === 'arm64') return 'aarch64-unknown-linux-musl';
if (platform === 'win32' && arch === 'x64') return 'x86_64-pc-windows-msvc';
if (platform === 'win32' && arch === 'arm64') return 'aarch64-pc-windows-msvc';
throw new Error(`Unsupported Codex target: ${platform}-${arch}`);
}
function baseDir(): string {
if (app.isPackaged) {
return join(process.resourcesPath, 'codex');
}
if (process.env.CLAWX_CODEX_PATH) {
return dirname(dirname(process.env.CLAWX_CODEX_PATH));
}
return join(process.cwd(), 'build', 'codex', `${process.platform}-${process.arch}`);
}
export function getCodexBundle(): CodexBundle {
const base = baseDir();
return {
baseDir: base,
binaryPath: join(base, 'bin', codexBinaryName()),
pathDir: join(base, 'codex-path'),
targetTriple: codexTargetTriple(),
};
}
export function assertCodexBundle(candidate = getCodexBundle()): CodexBundle {
if (!existsSync(candidate.binaryPath)) {
throw new Error(
`Codex binary not found at ${candidate.binaryPath}. Run pnpm run bundle:codex:current before selecting cc-connect runtime.`,
);
}
return candidate;
}
export function prependCodexPathDir(env: NodeJS.ProcessEnv, bundle = getCodexBundle()): NodeJS.ProcessEnv {
if (!existsSync(bundle.pathDir)) return env;
const delimiter = process.platform === 'win32' ? ';' : ':';
return {
...env,
PATH: [bundle.pathDir, env.PATH || ''].filter(Boolean).join(delimiter),
};
}
+142
View File
@@ -0,0 +1,142 @@
import { EventEmitter } from 'node:events';
import { getSetting, setSetting } from '@electron/utils/store';
import type {
RuntimeCapabilities,
RuntimeEventName,
RuntimeKind,
RuntimeOperationCapabilities,
RuntimeProvider,
RuntimeStatus,
} from './types';
export type RuntimeManagerOptions = {
openclaw: RuntimeProvider;
ccConnect: RuntimeProvider;
};
function normalizeRuntimeKind(value: unknown): RuntimeKind {
return value === 'cc-connect' ? 'cc-connect' : 'openclaw';
}
export class RuntimeManager extends EventEmitter {
private activeKind: RuntimeKind | null = null;
private readonly providers: Record<RuntimeKind, RuntimeProvider>;
private selectionQueue: Promise<void> = Promise.resolve();
constructor(options: RuntimeManagerOptions) {
super();
this.providers = {
openclaw: options.openclaw,
'cc-connect': options.ccConnect,
};
this.forwardProviderEvents(options.openclaw);
this.forwardProviderEvents(options.ccConnect);
}
async getActiveKind(): Promise<RuntimeKind> {
await this.selectionQueue;
return await this.ensureActiveKind();
}
private async ensureActiveKind(): Promise<RuntimeKind> {
if (!this.activeKind) {
const persistedKind = normalizeRuntimeKind(await getSetting('runtimeKind'));
const devModeUnlocked = await getSetting('devModeUnlocked');
this.activeKind = devModeUnlocked === true ? persistedKind : 'openclaw';
if (persistedKind !== this.activeKind) {
await setSetting('runtimeKind', this.activeKind);
}
}
return this.activeKind;
}
getActiveProvider(): RuntimeProvider {
return this.providers[this.activeKind ?? 'openclaw'];
}
getProvider(kind: RuntimeKind): RuntimeProvider {
return this.providers[kind];
}
async setActiveKind(kind: RuntimeKind): Promise<void> {
const change = async () => {
await this.ensureActiveKind();
const requestedKind = normalizeRuntimeKind(kind);
const devModeUnlocked = await getSetting('devModeUnlocked');
const nextKind = requestedKind === 'cc-connect' && devModeUnlocked !== true
? 'openclaw'
: requestedKind;
const previous = this.getActiveProvider();
if (this.activeKind !== nextKind) {
await previous.stop();
}
this.activeKind = nextKind;
await setSetting('runtimeKind', nextKind);
this.emit('status', this.getStatus());
};
const result = this.selectionQueue.then(change, change);
this.selectionQueue = result.then(() => undefined, () => undefined);
await result;
}
listCapabilities(): RuntimeCapabilities {
return this.getActiveProvider().listCapabilities();
}
listOperationCapabilities(): RuntimeOperationCapabilities {
return this.getActiveProvider().listOperationCapabilities();
}
getStatus(): RuntimeStatus {
return this.getActiveProvider().getStatus();
}
start(): Promise<void> {
return this.getActiveProvider().start();
}
stop(): Promise<void> {
return this.getActiveProvider().stop();
}
restart(): Promise<void> {
return this.getActiveProvider().restart();
}
checkHealth(options?: { probe?: boolean }) {
return this.getActiveProvider().checkHealth(options);
}
rpc<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T> {
return this.getActiveProvider().rpc(method, params, timeoutMs);
}
private forwardProviderEvents(provider: RuntimeProvider): void {
const events: RuntimeEventName[] = [
'status',
'error',
'notification',
'gateway:health',
'gateway:presence',
'chat:message',
'chat:runtime-event',
'channel:status',
'exit',
];
for (const eventName of events) {
provider.on(eventName, (payload: unknown) => {
if (provider !== this.getActiveProvider()) return;
if (eventName === 'status' && payload && typeof payload === 'object') {
this.emit(eventName, {
...(payload as Record<string, unknown>),
runtimeKind: provider.kind,
capabilities: provider.listCapabilities(),
operationCapabilities: provider.listOperationCapabilities(),
});
return;
}
this.emit(eventName, payload);
});
}
}
}
+186
View File
@@ -0,0 +1,186 @@
import { EventEmitter } from 'node:events';
import type { GatewayManager } from '../gateway/manager';
import type {
RuntimeControlUiPayload,
RuntimeProvider,
RuntimeConfigRefreshPayload,
RuntimeSendWithMediaPayload,
} from './types';
import {
OPENCLAW_RUNTIME_CAPABILITIES,
withRuntimeStatus,
} from './types';
import { getRuntimeOperationCapabilities } from './rpc-contract';
import { createChatSendWithMediaHandler } from '../services/chat-api';
import {
createOpenClawCronJob,
deleteOpenClawCronJob,
listCronJobs,
toggleOpenClawCronJob,
triggerOpenClawCronJob,
updateOpenClawCronJob,
} from '../services/cron-api';
import { createSessionsApi } from '../services/sessions-api';
import { logger } from '../utils/logger';
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../utils/openclaw-doctor';
import { PORTS } from '../utils/config';
import { scheduleControlUiDeviceAutoApproval } from '../utils/control-ui-device-pairing';
import { buildOpenClawControlUiUrl } from '../utils/openclaw-control-ui';
import { getSetting } from '../utils/store';
import { getRecentTokenUsageHistory } from '../utils/token-usage';
import { writeOpenClawCompatibilityProjection } from '../utils/channel-config';
import type { OpenClawDoctorMode } from '@shared/host-api/contract';
import { runtimeUsageLimit, toRuntimeUsageRecords } from './usage';
export class OpenClawRuntimeProvider extends EventEmitter implements RuntimeProvider {
readonly kind = 'openclaw' as const;
private readonly sessionsApi = createSessionsApi();
constructor(private readonly gatewayManager: GatewayManager) {
super();
const forward = (eventName: string) => (payload: unknown) => {
this.emit(eventName, payload);
};
for (const eventName of [
'status',
'error',
'notification',
'gateway:health',
'gateway:presence',
'chat:message',
'chat:runtime-event',
'channel:status',
'exit',
]) {
this.gatewayManager.on(eventName, forward(eventName));
}
}
listCapabilities() {
return OPENCLAW_RUNTIME_CAPABILITIES;
}
listOperationCapabilities() {
return getRuntimeOperationCapabilities(this.kind);
}
getStatus() {
return withRuntimeStatus(
this.gatewayManager.getStatus(),
this.kind,
this.listCapabilities(),
undefined,
this.listOperationCapabilities(),
);
}
async start() {
await writeOpenClawCompatibilityProjection();
return await this.gatewayManager.start();
}
stop() {
return this.gatewayManager.stop();
}
async restart() {
await writeOpenClawCompatibilityProjection();
return await this.gatewayManager.restart();
}
checkHealth(options?: { probe?: boolean }) {
return this.gatewayManager.checkHealth(options);
}
rpc<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T> {
switch (method) {
case 'cron.list':
return listCronJobs(this.gatewayManager) as Promise<T>;
case 'cron.create':
case 'cron.add':
return createOpenClawCronJob(this.gatewayManager, params as never) as Promise<T>;
case 'cron.update': {
const body = params && typeof params === 'object' ? params as Record<string, unknown> : {};
if ('input' in body) {
return updateOpenClawCronJob(this.gatewayManager, body as never) as Promise<T>;
}
return this.gatewayManager.rpc(method, params, timeoutMs);
}
case 'cron.delete':
case 'cron.remove':
return deleteOpenClawCronJob(this.gatewayManager, params) as Promise<T>;
case 'cron.toggle':
return toggleOpenClawCronJob(this.gatewayManager, params as never) as Promise<T>;
case 'cron.run':
return triggerOpenClawCronJob(this.gatewayManager, params) as Promise<T>;
case 'runtime.controlUi':
return this.getControlUi(params as never) as Promise<T>;
case 'sessions.rename':
case 'session.rename':
return this.sessionsApi.rename(params as never) as Promise<T>;
default:
return this.gatewayManager.rpc(method, params, timeoutMs);
}
}
async sendMessageWithMedia(payload: RuntimeSendWithMediaPayload) {
const handler = createChatSendWithMediaHandler(this.gatewayManager, logger);
const response = await handler(payload);
if (!response.success) {
throw new Error(response.error || 'OpenClaw chat send failed');
}
return response.result ?? {};
}
async listSessions(payload?: unknown) {
return await this.sessionsApi.summaries(payload as never);
}
async loadHistory(payload?: unknown) {
return await this.sessionsApi.history(payload as never);
}
async deleteSession(payload?: unknown) {
return await this.sessionsApi.delete(payload as never);
}
async listUsage(payload?: unknown) {
const limit = runtimeUsageLimit(payload);
const entries = await getRecentTokenUsageHistory({
...(limit !== undefined ? { limit } : {}),
runtimeKind: 'openclaw',
});
return {
success: true,
records: toRuntimeUsageRecords(entries, { runtimeKind: this.kind }),
};
}
async listLogs() {
return { content: logger.getRecentLogs().join('\n') };
}
runDoctor(mode: OpenClawDoctorMode) {
return mode === 'fix' ? runOpenClawDoctorFix() : runOpenClawDoctor();
}
async refreshConfig(payload: RuntimeConfigRefreshPayload): Promise<void> {
if (this.gatewayManager.getStatus().state === 'stopped') return;
if (payload.forceRestart) {
this.gatewayManager.debouncedRestart(150);
return;
}
this.gatewayManager.debouncedReload(150);
}
async getControlUi(payload?: RuntimeControlUiPayload) {
if (!this.listCapabilities().controlUi) {
return { success: false, error: 'openclaw runtime does not support Control UI' };
}
const token = await getSetting('gatewayToken');
const port = this.getStatus().port || PORTS.OPENCLAW_GATEWAY;
const url = buildOpenClawControlUiUrl(port, token, { view: payload?.view });
scheduleControlUiDeviceAutoApproval(this.gatewayManager);
return { success: true, url, token, port };
}
}
+128
View File
@@ -0,0 +1,128 @@
import type {
RuntimeCapabilities,
RuntimeKind,
RuntimeOperationCapabilities,
RuntimeOperationSupport,
} from './types';
export type RuntimeRpcContractEntry = {
runtime: RuntimeKind;
method: string;
capability: keyof RuntimeCapabilities;
support: RuntimeOperationSupport;
notes: string;
};
const OPENCLAW_PROXY_METHODS: Array<[string, keyof RuntimeCapabilities, string]> = [
['chat.send', 'chat', 'Sent through OpenClaw Gateway chat.send.'],
['chat.abort', 'chat', 'Forwarded to OpenClaw Gateway.'],
['chat.approval.respond', 'chat', 'Forwarded to OpenClaw Gateway.'],
['sessions.list', 'sessions', 'Served by the OpenClaw session API facade.'],
['chat.history', 'history', 'Served by the OpenClaw session API facade.'],
['sessions.delete', 'sessions', 'Served by the OpenClaw session API facade.'],
['session.delete', 'sessions', 'Compatibility alias for sessions.delete.'],
['chat.session.delete', 'sessions', 'Compatibility alias for sessions.delete.'],
['sessions.rename', 'sessions', 'Served by the OpenClaw session API facade.'],
['session.rename', 'sessions', 'Compatibility alias for sessions.rename.'],
['providers.sync', 'providers', 'Forwarded to OpenClaw Gateway/provider services.'],
['providers.profile', 'providers', 'Forwarded to OpenClaw Gateway/provider services.'],
['models.sync', 'models', 'Forwarded to OpenClaw Gateway/model services.'],
['models.profile', 'models', 'Forwarded to OpenClaw Gateway/model services.'],
['skills.status', 'skills', 'Forwarded to OpenClaw skills service.'],
['skills.update', 'skills', 'Forwarded to OpenClaw skills service.'],
['channels.status', 'channels', 'Forwarded to OpenClaw Gateway.'],
['channels.add', 'channels', 'Forwarded to OpenClaw Gateway.'],
['channels.requestQr', 'channels', 'Forwarded to OpenClaw Gateway.'],
['channels.connect', 'channels', 'Forwarded to OpenClaw Gateway.'],
['channels.disconnect', 'channels', 'Forwarded to OpenClaw Gateway.'],
['channels.delete', 'channels', 'Forwarded to OpenClaw Gateway.'],
['runtime.controlUi', 'controlUi', 'Opens the OpenClaw Control UI.'],
['cron.list', 'cron', 'Adapted by the OpenClaw runtime provider.'],
['cron.create', 'cron', 'Adapted by the OpenClaw runtime provider.'],
['cron.add', 'cron', 'Compatibility alias for cron.create.'],
['cron.update', 'cron', 'Adapted by the OpenClaw runtime provider.'],
['cron.delete', 'cron', 'Adapted by the OpenClaw runtime provider.'],
['cron.remove', 'cron', 'Compatibility alias for cron.delete.'],
['cron.toggle', 'cron', 'Adapted by the OpenClaw runtime provider.'],
['cron.run', 'cron', 'Adapted by the OpenClaw runtime provider.'],
['logs.list', 'logs', 'Served from the OpenClaw log buffer.'],
['doctor.run', 'doctor', 'Runs openclaw doctor.'],
['doctor.fix', 'doctor', 'Runs openclaw doctor --fix.'],
['doctor.memory.status', 'doctor', 'Forwarded to OpenClaw memory doctor RPCs.'],
];
const CC_CONNECT_NATIVE_METHODS: Array<[string, keyof RuntimeCapabilities, string]> = [
['chat.send', 'chat', 'Delivered through cc-connect BridgePlatform into Codex.'],
['chat.abort', 'chat', 'Sends cc-connect /stop to the active Bridge session; runtime restart is only a disconnected-Bridge fallback.'],
['chat.approval.respond', 'chat', 'Returns a validated card_action through cc-connect BridgePlatform for a pending approval, question, or runtime choice.'],
['sessions.list', 'sessions', 'Loaded from the cc-connect public Management session API.'],
['chat.history', 'history', 'Loaded from the cc-connect public Management session history API.'],
['sessions.delete', 'sessions', 'Deletes the runtime session through the cc-connect public Management API.'],
['session.delete', 'sessions', 'Compatibility alias for sessions.delete.'],
['chat.session.delete', 'sessions', 'Compatibility alias for sessions.delete.'],
['sessions.rename', 'sessions', 'Stores a ClawX display label without mutating cc-connect private session files.'],
['session.rename', 'sessions', 'Compatibility alias for sessions.rename.'],
['providers.sync', 'providers', 'Writes the managed Codex provider profile and restarts when needed.'],
['providers.profile', 'providers', 'Returns the managed Codex profile plus public cc-connect project provider/model state without restart.'],
['models.sync', 'models', 'Aliases provider sync for the active Codex model profile.'],
['models.profile', 'models', 'Returns the managed Codex model plus public cc-connect project provider/model state without restart.'],
['skills.status', 'skills', 'Synchronizes skills into the managed cc-connect Codex home.'],
['skills.update', 'skills', 'Synchronizes skills into the managed cc-connect Codex home.'],
['channels.status', 'channels', 'Reads configured channel accounts plus live cc-connect project platform status.'],
['channels.connect', 'channels', 'Reloads cc-connect channel platform config through the Management API.'],
['channels.disconnect', 'channels', 'Reloads cc-connect channel platform config through the Management API.'],
['channels.delete', 'channels', 'Reloads cc-connect channel platform config after channel config deletion.'],
['runtime.controlUi', 'controlUi', 'Opens the cc-connect Web Admin.'],
['cron.list', 'cron', 'Uses cc-connect management API.'],
['cron.create', 'cron', 'Uses cc-connect management API.'],
['cron.add', 'cron', 'Compatibility alias for cron.create.'],
['cron.update', 'cron', 'Uses cc-connect management API.'],
['cron.delete', 'cron', 'Uses cc-connect management API.'],
['cron.remove', 'cron', 'Compatibility alias for cron.delete.'],
['cron.toggle', 'cron', 'Uses cc-connect management API update with enabled=true/false.'],
['cron.run', 'cron', 'Uses cc-connect management API.'],
['logs.list', 'logs', 'Served from managed cc-connect config and runtime paths.'],
['doctor.run', 'doctor', 'Runs cc-connect doctor user-isolation.'],
];
const CC_CONNECT_UNSUPPORTED_METHODS: Array<[string, keyof RuntimeCapabilities, string]> = [
['channels.add', 'channels', 'Channel accounts are configured through the ClawX Host API before cc-connect reload.'],
['channels.requestQr', 'channels', 'cc-connect does not expose the OpenClaw QR pairing RPC.'],
['doctor.fix', 'doctor', 'cc-connect Doctor does not support fix mode.'],
['doctor.memory.status', 'doctor', 'OpenClaw Dreams memory doctor RPCs do not have a cc-connect equivalent.'],
];
function entries(
runtime: RuntimeKind,
support: RuntimeOperationSupport,
items: Array<[string, keyof RuntimeCapabilities, string]>,
): RuntimeRpcContractEntry[] {
return items.map(([method, capability, notes]) => ({
runtime,
method,
capability,
support,
notes,
}));
}
export const RUNTIME_RPC_CONTRACT: RuntimeRpcContractEntry[] = [
...entries('openclaw', 'proxy', OPENCLAW_PROXY_METHODS),
...entries('cc-connect', 'native', CC_CONNECT_NATIVE_METHODS),
...entries('cc-connect', 'unsupported', CC_CONNECT_UNSUPPORTED_METHODS),
];
export function getRuntimeRpcCoverage(runtime: RuntimeKind): RuntimeRpcContractEntry[] {
return RUNTIME_RPC_CONTRACT.filter((entry) => entry.runtime === runtime);
}
export function getRuntimeOperationCapabilities(runtime: RuntimeKind): RuntimeOperationCapabilities {
return Object.fromEntries(getRuntimeRpcCoverage(runtime).map((entry) => [
entry.method,
{
capability: entry.capability,
support: entry.support,
notes: entry.notes,
},
]));
}
+190
View File
@@ -0,0 +1,190 @@
import type { EventEmitter } from 'node:events';
import type { RawMessage } from '@shared/chat/types';
import type { OpenClawDoctorMode, OpenClawDoctorResult } from '@shared/host-api/contract';
import type {
GatewayHealth,
GatewayStatus,
RuntimeCapabilities,
RuntimeKind,
RuntimeOperationCapabilities,
} from '@shared/types/gateway';
export type {
RuntimeCapabilities,
RuntimeKind,
RuntimeOperationCapabilities,
RuntimeOperationSupport,
} from '@shared/types/gateway';
export type RuntimeStatus = GatewayStatus & {
runtimeKind: RuntimeKind;
capabilities: RuntimeCapabilities;
configDir?: string;
};
export type RuntimeHealth = GatewayHealth;
export type RuntimeSessionListResult = {
success?: boolean;
sessions?: Array<{ key: string; displayName?: string; agentId?: string }>;
summaries?: Array<{ sessionKey: string; firstUserText: string | null; lastTimestamp: number | null }>;
error?: string;
};
export type RuntimeHistoryResult = {
success?: boolean;
messages?: RawMessage[];
error?: string;
};
export type RuntimeDeleteSessionResult = {
success: boolean;
error?: string;
};
export type RuntimeLogResult = {
content: string;
};
export type RuntimeUsageRecord = {
id: string;
runtimeKind: RuntimeKind;
logicalSessionId: string;
runtimeSessionId: string;
turnId: string;
agentId: string;
providerAccountId?: string;
provider: string;
model: string;
timestamp: string;
status: 'available' | 'missing' | 'error';
inputTokens: number;
cachedInputTokens: number;
cacheWriteTokens: number;
outputTokens: number;
reasoningTokens: number;
totalTokens: number;
costUsd?: number;
content?: string;
};
export type RuntimeUsageListResult = {
success: boolean;
records: RuntimeUsageRecord[];
error?: string;
};
export type RuntimeSendWithMediaPayload = {
sessionKey: string;
message: string;
deliver?: boolean;
idempotencyKey: string;
media?: Array<{ filePath: string; mimeType: string; fileName: string }>;
};
export type RuntimeSendWithMediaResult = {
runId?: string;
};
export type RuntimeConfigRefreshPayload = {
scope: 'channels' | 'providers' | 'skills' | 'runtime';
reason: string;
channelType?: string;
forceRestart?: boolean;
};
export type RuntimeProviderSyncPayload = {
providerId?: string;
reason: string;
};
export type RuntimeControlUiPayload = {
view?: 'dreams';
};
export type RuntimeControlUiResult = {
success: boolean;
url?: string;
token?: string;
port?: number;
error?: string;
};
export type RuntimeEventName =
| 'status'
| 'error'
| 'notification'
| 'gateway:health'
| 'gateway:presence'
| 'chat:message'
| 'chat:runtime-event'
| 'channel:status'
| 'exit';
export type RuntimeProvider = {
kind: RuntimeKind;
on: EventEmitter['on'];
off: EventEmitter['off'];
start: () => Promise<void>;
stop: () => Promise<void>;
restart: () => Promise<void>;
getStatus: () => RuntimeStatus;
checkHealth: (options?: { probe?: boolean }) => Promise<RuntimeHealth>;
rpc: <T = unknown>(method: string, params?: unknown, timeoutMs?: number) => Promise<T>;
sendMessageWithMedia: (payload: RuntimeSendWithMediaPayload) => Promise<RuntimeSendWithMediaResult>;
listSessions: (payload?: unknown) => Promise<RuntimeSessionListResult>;
loadHistory: (payload?: unknown) => Promise<RuntimeHistoryResult>;
deleteSession: (payload?: unknown) => Promise<RuntimeDeleteSessionResult>;
listUsage: (payload?: unknown) => Promise<RuntimeUsageListResult>;
listLogs: (payload?: { tailLines?: number }) => Promise<RuntimeLogResult>;
runDoctor: (mode: OpenClawDoctorMode) => Promise<OpenClawDoctorResult>;
listCapabilities: () => RuntimeCapabilities;
listOperationCapabilities: () => RuntimeOperationCapabilities;
refreshConfig?: (payload: RuntimeConfigRefreshPayload) => Promise<void>;
syncProviderProfile?: (payload: RuntimeProviderSyncPayload) => Promise<unknown>;
getControlUi?: (payload?: RuntimeControlUiPayload) => Promise<RuntimeControlUiResult>;
};
export const OPENCLAW_RUNTIME_CAPABILITIES: RuntimeCapabilities = {
chat: true,
sessions: true,
history: true,
providers: true,
models: true,
channels: true,
cron: true,
logs: true,
skills: true,
doctor: true,
controlUi: true,
};
export const CC_CONNECT_RUNTIME_CAPABILITIES: RuntimeCapabilities = {
chat: true,
sessions: true,
history: true,
providers: true,
models: true,
channels: true,
cron: true,
logs: true,
skills: true,
doctor: true,
controlUi: true,
};
export function withRuntimeStatus(
status: GatewayStatus,
runtimeKind: RuntimeKind,
capabilities: RuntimeCapabilities,
configDir?: string,
operationCapabilities?: RuntimeOperationCapabilities,
): RuntimeStatus {
return {
...status,
runtimeKind,
capabilities,
...(operationCapabilities ? { operationCapabilities } : {}),
...(configDir ? { configDir } : {}),
};
}
+94
View File
@@ -0,0 +1,94 @@
import { createHash } from 'node:crypto';
import type { TokenUsageHistoryEntry } from '../utils/token-usage-core';
import type { RuntimeKind, RuntimeUsageRecord } from './types';
type RuntimeUsageIdentity = {
runtimeKind: RuntimeKind;
logicalSessionId?: string;
runtimeSessionId?: string;
providerAccountId?: string;
provider?: string;
model?: string;
};
export function runtimeUsageLimit(payload: unknown): number | undefined {
const value = payload && typeof payload === 'object' && !Array.isArray(payload)
? (payload as { limit?: unknown }).limit
: payload;
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.max(Math.floor(value), 1);
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return Math.max(Math.floor(parsed), 1);
}
return undefined;
}
export function toRuntimeUsageRecords(
entries: TokenUsageHistoryEntry[],
identity: RuntimeUsageIdentity,
): RuntimeUsageRecord[] {
return entries.map((entry) => {
const logicalSessionId = identity.logicalSessionId ?? entry.sessionId;
const runtimeSessionId = identity.runtimeSessionId ?? entry.sessionId;
const fallbackTurnId = createHash('sha256')
.update(JSON.stringify([
runtimeSessionId,
entry.timestamp,
entry.provider,
entry.model,
entry.content,
entry.inputTokens,
entry.outputTokens,
entry.totalTokens,
]))
.digest('hex')
.slice(0, 20);
const turnId = entry.turnId ?? `${runtimeSessionId}:${fallbackTurnId}`;
return {
id: `${identity.runtimeKind}:${runtimeSessionId}:${turnId}`,
runtimeKind: identity.runtimeKind,
logicalSessionId,
runtimeSessionId,
turnId,
agentId: entry.agentId,
...(identity.providerAccountId ? { providerAccountId: identity.providerAccountId } : {}),
provider: entry.provider ?? identity.provider ?? 'unknown',
model: entry.model ?? identity.model ?? 'unknown',
timestamp: entry.timestamp,
status: entry.usageStatus,
inputTokens: entry.inputTokens,
cachedInputTokens: entry.cacheReadTokens,
cacheWriteTokens: entry.cacheWriteTokens,
outputTokens: entry.outputTokens,
reasoningTokens: entry.reasoningTokens ?? 0,
totalTokens: entry.totalTokens,
...(entry.costUsd !== undefined ? { costUsd: entry.costUsd } : {}),
...(entry.content ? { content: entry.content } : {}),
};
});
}
export function toTokenUsageHistoryEntry(record: RuntimeUsageRecord): TokenUsageHistoryEntry {
return {
runtimeKind: record.runtimeKind,
timestamp: record.timestamp,
sessionId: record.logicalSessionId,
runtimeSessionId: record.runtimeSessionId,
turnId: record.turnId,
agentId: record.agentId,
...(record.providerAccountId ? { providerAccountId: record.providerAccountId } : {}),
model: record.model,
provider: record.provider,
...(record.content ? { content: record.content } : {}),
usageStatus: record.status,
inputTokens: record.inputTokens,
outputTokens: record.outputTokens,
cacheReadTokens: record.cachedInputTokens,
cacheWriteTokens: record.cacheWriteTokens,
...(record.reasoningTokens > 0 ? { reasoningTokens: record.reasoningTokens } : {}),
totalTokens: record.totalTokens,
...(record.costUsd !== undefined ? { costUsd: record.costUsd } : {}),
};
}
+4 -14
View File
@@ -31,7 +31,6 @@ import { logger } from '../utils/logger';
import { recordAcpTrace } from './acp-trace';
import { AcpSessionAccessRegistry, type AcpSessionAccessContext } from './acp-session-access-registry';
import { expandPath } from '../utils/paths';
import { getSetting } from '../utils/store';
type AcpConnection = Pick<ClientSideConnection, 'initialize' | 'newSession' | 'loadSession' | 'prompt' | 'cancel'>;
type MainWindowLike = {
@@ -395,15 +394,12 @@ export class AcpChatService {
}
this.permissionsEnabled = true;
const messageId = payload.messageId ?? randomUUID();
const isSlashCommand = payload.message?.trimStart().startsWith('/') === true;
await connection.prompt({
sessionId: acpSessionId,
prompt,
// 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 },
_meta: { sessionKey: payload.sessionKey, prefixCwd: true, messageId },
});
this.trace('session/prompt:success', {
sessionKey: payload.sessionKey,
@@ -495,7 +491,7 @@ export class AcpChatService {
}
private async initializeConnectionOnce(attempt: number): Promise<AcpConnection> {
if (!this.connection) this.connection = await this.spawnConnection();
if (!this.connection) this.connection = this.spawnConnection();
const connection = this.connection;
const child = this.child;
@@ -550,15 +546,9 @@ export class AcpChatService {
});
}
private async spawnConnection(): Promise<ClientSideConnection> {
const gatewayToken = await getSetting('gatewayToken');
private spawnConnection(): ClientSideConnection {
const spec = getOpenClawEmbeddedForkSpec(['acp']);
const forked = fork(spec.modulePath, spec.args, {
...spec.options,
// ACP is a local Gateway client, so it must use the token that started
// this ClawX-owned Gateway instead of relying on config-file fallback.
env: { ...spec.options.env, OPENCLAW_GATEWAY_TOKEN: gatewayToken },
});
const forked = fork(spec.modulePath, spec.args, spec.options);
if (!forked.stdin || !forked.stdout || !forked.stderr) {
forked.kill();
throw new Error('ACP process did not expose stdio pipes');
+79 -6
View File
@@ -1,4 +1,5 @@
import type { GatewayManager } from '../gateway/manager';
import type { RuntimeManager } from '../runtime/manager';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import {
assignChannelToAgent,
@@ -11,6 +12,11 @@ import {
updateAgentModel,
updateAgentName,
} from '../utils/agent-config';
import {
deleteCcConnectAgentBinding,
setCcConnectAgentPermissionMode,
setCcConnectAgentProviderBinding,
} from '../runtime/cc-connect-agent-bindings';
import { deleteChannelAccountConfig } from '../utils/channel-config';
import { ensureClawXContext } from '../utils/openclaw-workspace';
import { isRecord } from './payload-utils';
@@ -18,6 +24,7 @@ import { syncAgentModelOverrideToRuntime, syncAllProviderAuthToRuntime } from '.
type AgentsApiContext = {
gatewayManager: GatewayManager;
runtimeManager?: RuntimeManager;
};
function requireString(payload: unknown, key: string): string {
@@ -27,16 +34,47 @@ function requireString(payload: unknown, key: string): string {
return payload[key].trim();
}
export function createAgentsApi(_ctx: AgentsApiContext): CompleteHostServiceRegistry['agents'] {
async function refreshActiveRuntime(ctx: AgentsApiContext, reason: string): Promise<void> {
const provider = ctx.runtimeManager?.getActiveProvider();
if (provider?.refreshConfig) {
await provider.refreshConfig({ scope: 'runtime', reason });
return;
}
if (ctx.gatewayManager.getStatus().state !== 'stopped') {
ctx.gatewayManager.debouncedReload();
}
}
async function restartRuntimeForAgentDeletion(ctx: AgentsApiContext): Promise<void> {
try {
if (ctx.runtimeManager) {
await ctx.runtimeManager.restart();
} else {
await ctx.gatewayManager.restart();
}
console.log('[agents] Runtime restart completed after agent deletion');
} catch (err) {
console.warn('[agents] Runtime restart after agent deletion failed:', err);
}
}
function usesCcConnect(ctx: AgentsApiContext): boolean {
return ctx.runtimeManager?.getActiveProvider().kind === 'cc-connect';
}
export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegistry['agents'] {
return {
list: async () => ({ success: true, ...(await listAgentsSnapshot()) }),
create: async (payload) => {
const name = requireString(payload, 'name');
const inheritWorkspace = isRecord(payload) ? payload.inheritWorkspace === true : undefined;
const snapshot = await createAgent(name, { inheritWorkspace });
syncAllProviderAuthToRuntime().catch((err) => {
console.warn('[agents] Failed to sync provider auth after agent creation:', err);
});
if (!usesCcConnect(ctx)) {
syncAllProviderAuthToRuntime().catch((err) => {
console.warn('[agents] Failed to sync provider auth after agent creation:', err);
});
}
await refreshActiveRuntime(ctx, 'create-agent');
void ensureClawXContext({ waitForAllConfiguredWorkspaces: true }).catch((err) => {
console.warn('[agents] Failed to ensure ClawX context after agent creation:', err);
});
@@ -46,19 +84,52 @@ export function createAgentsApi(_ctx: AgentsApiContext): CompleteHostServiceRegi
const agentId = requireString(payload, 'id');
const name = requireString(payload, 'name');
const snapshot = await updateAgentName(agentId, name);
await refreshActiveRuntime(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 providerAccountIdProvided = isRecord(payload)
&& Object.prototype.hasOwnProperty.call(payload, 'providerAccountId');
const providerAccountId = isRecord(payload) && typeof payload.providerAccountId === 'string'
? payload.providerAccountId
: null;
const permissionMode = isRecord(payload) && (payload.permissionMode === 'suggest' || payload.permissionMode === 'full-auto')
? payload.permissionMode
: undefined;
const snapshot = await updateAgentModel(agentId, modelRef);
await syncAllProviderAuthToRuntime();
await syncAgentModelOverrideToRuntime(agentId);
if (providerAccountIdProvided) {
await setCcConnectAgentProviderBinding(agentId, providerAccountId);
snapshot.agents = snapshot.agents.map((agent) => (
agent.id === agentId ? { ...agent, providerAccountId } : agent
));
}
if (permissionMode) {
await setCcConnectAgentPermissionMode(agentId, permissionMode);
snapshot.agents = snapshot.agents.map((agent) => (
agent.id === agentId ? { ...agent, permissionMode } : agent
));
}
if (!usesCcConnect(ctx)) {
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.
await refreshActiveRuntime(ctx, 'update-agent-model');
return { success: true, ...snapshot };
},
delete: async (payload) => {
const agentId = requireString(payload, 'id');
const { snapshot, removedEntry } = await deleteAgentConfig(agentId);
await deleteCcConnectAgentBinding(agentId);
await restartRuntimeForAgentDeletion(ctx);
await removeAgentWorkspaceDirectory(removedEntry).catch((err) => {
console.warn('[agents] Failed to remove workspace after agent deletion:', err);
});
@@ -68,6 +139,7 @@ export function createAgentsApi(_ctx: AgentsApiContext): CompleteHostServiceRegi
const agentId = requireString(payload, 'id');
const channelType = requireString(payload, 'channelType');
const snapshot = await assignChannelToAgent(agentId, channelType);
await refreshActiveRuntime(ctx, 'assign-channel');
return { success: true, ...snapshot };
},
removeChannel: async (payload) => {
@@ -93,6 +165,7 @@ export function createAgentsApi(_ctx: AgentsApiContext): CompleteHostServiceRegi
await clearChannelBinding(channelType, accountId);
}
const snapshot = await listAgentsSnapshot();
await refreshActiveRuntime(ctx, 'remove-agent-channel');
return { success: true, ...snapshot };
},
};
+7 -2
View File
@@ -1,4 +1,5 @@
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { RuntimeManager } from '../runtime/manager';
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../utils/openclaw-doctor';
import { isRecord } from './payload-utils';
@@ -6,11 +7,15 @@ type OpenClawDoctorPayload = {
mode?: unknown;
};
export function createAppApi(): CompleteHostServiceRegistry['app'] {
export function createAppApi(runtimeManager?: RuntimeManager): CompleteHostServiceRegistry['app'] {
return {
openClawDoctor: async (payload) => {
const body = isRecord(payload) ? payload as OpenClawDoctorPayload : {};
return body.mode === 'fix' ? runOpenClawDoctorFix() : runOpenClawDoctor();
const mode = body.mode === 'fix' ? 'fix' : 'diagnose';
if (runtimeManager) {
return runtimeManager.getActiveProvider().runDoctor(mode);
}
return mode === 'fix' ? runOpenClawDoctorFix() : runOpenClawDoctor();
},
};
}
+13 -35
View File
@@ -48,7 +48,6 @@ 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',
@@ -107,7 +106,6 @@ type LocalScope = 'workspace' | 'openclaw-media' | 'staging';
type ResolvedLocal = {
kind: 'local';
entryKind: 'file' | 'directory';
canonicalPath: string;
scope: LocalScope;
mimeType: string;
@@ -573,21 +571,13 @@ export function createAttachmentAccess(dependencies: AttachmentAccessDependencie
}
if (!isSamePath(canonicalCandidate, stagedPath)) throw new AttachmentFailure('invalidReference');
const stagedStat = await fs.stat(canonicalCandidate);
const entryKind = stagedStat.isFile()
? 'file'
: stagedStat.isDirectory()
? 'directory'
: null;
if (!entryKind) throw new AttachmentFailure('notFile');
if (!stagedStat.isFile()) throw new AttachmentFailure('notFile');
return {
kind: 'local',
entryKind,
canonicalPath: canonicalCandidate,
scope: 'staging',
mimeType: entryKind === 'directory'
? DIRECTORY_MIME_TYPE
: mimeTypeHint || mimeTypeForPath(canonicalCandidate),
size: entryKind === 'directory' ? 0 : stagedStat.size,
mimeType: mimeTypeHint || mimeTypeForPath(canonicalCandidate),
size: stagedStat.size,
};
}
}
@@ -599,12 +589,7 @@ export function createAttachmentAccess(dependencies: AttachmentAccessDependencie
throw new AttachmentFailure(attachmentFailure(error));
}
const targetStat = await fs.stat(canonicalCandidate);
const entryKind = targetStat.isFile()
? 'file'
: targetStat.isDirectory()
? 'directory'
: null;
if (!entryKind) throw new AttachmentFailure('notFile');
if (!targetStat.isFile()) throw new AttachmentFailure('notFile');
const workspaceRoot = mediaOnly ? null : await frozenCanonicalDirectory(context.workspaceRoot, fs);
const scope: LocalScope = workspaceRoot && isInside(canonicalCandidate, workspaceRoot)
@@ -613,13 +598,10 @@ export function createAttachmentAccess(dependencies: AttachmentAccessDependencie
return {
kind: 'local',
entryKind,
canonicalPath: canonicalCandidate,
scope,
mimeType: entryKind === 'directory'
? DIRECTORY_MIME_TYPE
: mimeTypeHint || mimeTypeForPath(canonicalCandidate),
size: entryKind === 'directory' ? 0 : targetStat.size,
mimeType: mimeTypeHint || mimeTypeForPath(canonicalCandidate),
size: targetStat.size,
};
};
@@ -642,7 +624,6 @@ 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,
@@ -706,7 +687,7 @@ export function createAttachmentAccess(dependencies: AttachmentAccessDependencie
...(displayPath ? { displayPath } : {}),
mimeType: target.mimeType,
size: target.size,
target: { kind: 'local', scope: target.scope, entryKind: target.entryKind, ref },
target: { kind: 'local', scope: target.scope, ref },
};
} catch (error) {
return { ok: false, displayName, error: attachmentFailure(error) };
@@ -718,7 +699,6 @@ 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');
@@ -750,7 +730,6 @@ 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');
@@ -822,10 +801,9 @@ export function createAttachmentAccess(dependencies: AttachmentAccessDependencie
}
};
const requireCurrentLocalFileTarget = async (ref: AttachmentFileRef): Promise<ResolvedLocal> => {
const requireCurrentLocalTarget = 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');
}
@@ -836,7 +814,7 @@ export function createAttachmentAccess(dependencies: AttachmentAccessDependencie
ref: AttachmentFileRef,
): Promise<AttachmentOpenHandlersResult> => {
try {
const target = await requireCurrentLocalFileTarget(ref);
const target = await requireCurrentLocalTarget(ref);
if (dependencies.openWith.platform === 'linux') {
return { ok: true, platform: 'linux', handlers: [] };
}
@@ -863,7 +841,7 @@ export function createAttachmentAccess(dependencies: AttachmentAccessDependencie
payload: OpenAttachmentWithPayload,
): Promise<OpenAttachmentResult> => {
try {
const target = await requireCurrentLocalFileTarget(payload?.ref);
const target = await requireCurrentLocalTarget(payload?.ref);
if (typeof payload?.handlerId !== 'string'
|| !payload.handlerId.trim()
|| payload.handlerId.length > HANDLER_ID_MAX_LENGTH) {
@@ -872,7 +850,7 @@ export function createAttachmentAccess(dependencies: AttachmentAccessDependencie
await dependencies.openWith.open(
target.canonicalPath,
payload.handlerId,
async () => (await requireCurrentLocalFileTarget(payload.ref)).canonicalPath,
async () => (await requireCurrentLocalTarget(payload.ref)).canonicalPath,
);
return { ok: true };
} catch (error) {
@@ -882,8 +860,8 @@ export function createAttachmentAccess(dependencies: AttachmentAccessDependencie
const revealAttachment = async (ref: AttachmentFileRef): Promise<OpenAttachmentResult> => {
try {
await requireCurrentLocalFileTarget(ref);
const revalidated = await requireCurrentLocalFileTarget(ref);
await requireCurrentLocalTarget(ref);
const revalidated = await requireCurrentLocalTarget(ref);
shell.showItemInFolder(revalidated.canonicalPath);
return { ok: true };
} catch (error) {
+120 -16
View File
@@ -22,7 +22,6 @@ import {
assignChannelAccountToAgent,
clearAllBindingsForChannel,
clearChannelBinding,
ensureScopedChannelBinding as ensureAgentScopedChannelBinding,
listAgentsSnapshot,
listAgentsSnapshotFromConfig,
} from '../utils/agent-config';
@@ -74,6 +73,7 @@ import {
import { buildGatewayHealthSummary } from '../utils/gateway-health';
import { logger } from '../utils/logger';
import type { GatewayManager, GatewayHealthSummary } from '../gateway/manager';
import type { RuntimeManager } from '../runtime/manager';
import { isRecord } from './payload-utils';
const WECHAT_QR_TIMEOUT_MS = 8 * 60 * 1000;
@@ -84,6 +84,7 @@ async function listWhatsAppDirectoryPeersFromConfig(_params: unknown): Promise<u
type ChannelsApiContext = {
gatewayManager: GatewayManager;
runtimeManager?: RuntimeManager;
mainWindow?: BrowserWindow;
};
@@ -154,6 +155,11 @@ 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`);
@@ -261,11 +267,12 @@ export async function buildChannelAccountsView(
]);
let gatewayStatus: GatewayChannelStatusPayload | null = null;
const runtimeStatusSource = ctx.runtimeManager ?? ctx.gatewayManager;
if (!skipRuntime) {
try {
const probe = options?.probe === true;
const rpcStartedAt = Date.now();
gatewayStatus = await ctx.gatewayManager.rpc<GatewayChannelStatusPayload>(
gatewayStatus = await runtimeStatusSource.rpc<GatewayChannelStatusPayload>(
'channels.status',
{ probe },
probe ? 5000 : 8000,
@@ -288,8 +295,9 @@ export async function buildChannelAccountsView(
consecutiveHeartbeatMisses: 0,
consecutiveRpcFailures: 0,
};
const status = ctx.runtimeManager?.getStatus() ?? ctx.gatewayManager.getStatus();
const gatewayHealth = buildGatewayHealthSummary({
status: ctx.gatewayManager.getStatus(),
status,
diagnostics: gatewayDiagnostics,
lastChannelsStatusOkAt,
lastChannelsStatusFailureAt,
@@ -375,7 +383,7 @@ export async function buildChannelAccountsView(
const baseGroupStatus = pickChannelRuntimeStatus(visibleAccountSnapshots, channelSummary, {
gatewayHealthState: effectiveGatewayHealthState,
});
const groupStatus = !gatewayStatus && !skipRuntime && ctx.gatewayManager.getStatus().state === 'running'
const groupStatus = !gatewayStatus && !skipRuntime && status.state === 'running'
? 'degraded'
: effectiveGatewayHealthState && !hasRuntimeError && baseGroupStatus === 'connected'
? 'degraded'
@@ -387,7 +395,7 @@ export async function buildChannelAccountsView(
channelType: uiChannelType,
defaultAccountId,
status: groupStatus,
statusReason: !gatewayStatus && !skipRuntime && ctx.gatewayManager.getStatus().state === 'running'
statusReason: !gatewayStatus && !skipRuntime && status.state === 'running'
? 'channels_status_timeout'
: groupStatus === 'degraded' && effectiveGatewayHealthState
? overlayStatusReason(gatewayHealth, 'gateway_degraded')
@@ -920,8 +928,99 @@ 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> {
await ensureAgentScopedChannelBinding(resolveStoredChannelType(channelType), accountId);
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);
}
async function scheduleGatewayChannelRestart(ctx: ChannelsApiContext, reason: string): Promise<void> {
const provider = ctx.runtimeManager?.getActiveProvider();
if (provider?.refreshConfig) {
await provider.refreshConfig({ scope: 'channels', reason, forceRestart: true });
return;
}
if (ctx.gatewayManager.getStatus().state === 'stopped') return;
ctx.gatewayManager.debouncedRestart();
void reason;
}
async function scheduleGatewayChannelSaveRefresh(ctx: ChannelsApiContext, channelType: string, reason: string): Promise<void> {
const provider = ctx.runtimeManager?.getActiveProvider();
if (provider?.refreshConfig) {
const storedChannelType = resolveStoredChannelType(channelType);
await provider.refreshConfig({
scope: 'channels',
reason,
channelType: storedChannelType,
forceRestart: FORCE_RESTART_CHANNELS.has(storedChannelType),
});
return;
}
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;
}
function toComparableConfig(input: Record<string, unknown>): Record<string, string> {
@@ -993,6 +1092,7 @@ async function awaitWeChatQrLogin(
});
await saveChannelConfig(UI_WECHAT_CHANNEL_TYPE, { enabled: true }, normalizedAccountId);
await ensureScopedChannelBinding(UI_WECHAT_CHANNEL_TYPE, normalizedAccountId);
await scheduleGatewayChannelSaveRefresh(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE, `wechat:loginSuccess:${normalizedAccountId}`);
if (activeQrLogins.get(loginKey) !== sessionKey) return;
emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'success', {
@@ -1055,6 +1155,7 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
const accountId = requireString(payload, 'accountId');
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
await setChannelDefaultAccount(channelType, accountId);
await scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setDefaultAccount:${channelType}`);
return { success: true };
},
bindingSave: async (payload) => {
@@ -1067,16 +1168,11 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
throw new Error(`Agent "${agentId}" not found`);
}
const storedChannelType = resolveStoredChannelType(channelType);
if (accountId === 'default') {
await assignChannelAccountToAgent(agentId, storedChannelType, accountId);
} else {
await assignChannelAccountToAgent(
agentId,
storedChannelType,
accountId,
{ migrateLegacy: true },
);
if (accountId !== 'default') {
await migrateLegacyChannelWideBinding(storedChannelType);
}
await assignChannelAccountToAgent(agentId, storedChannelType, accountId);
await scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setBinding:${channelType}`);
return { success: true };
},
bindingDelete: async (payload) => {
@@ -1084,6 +1180,7 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
const accountId = optionalString(payload, 'accountId');
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
await clearChannelBinding(resolveStoredChannelType(channelType), accountId);
await scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:clearBinding:${channelType}`);
return { success: true };
},
validateConfig: async (payload) => {
@@ -1101,20 +1198,25 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
const accountId = optionalString(payload, 'accountId');
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
const storedChannelType = resolveStoredChannelType(channelType);
await ensureChannelPluginInstalled(storedChannelType);
if (ctx.runtimeManager?.getActiveProvider().kind !== 'cc-connect') {
await ensureChannelPluginInstalled(storedChannelType);
}
const existingValues = await getChannelFormValues(channelType, accountId);
if (isSameConfigValues(existingValues, config)) {
await ensureScopedChannelBinding(channelType, accountId);
await scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfigNoChange:${storedChannelType}`);
return { success: true, noChange: true };
}
await saveChannelConfig(channelType, config, accountId);
await ensureScopedChannelBinding(channelType, accountId);
await 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);
await scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${resolveStoredChannelType(channelType)}`);
return { success: true };
},
formValues: async (payload) => {
@@ -1129,9 +1231,11 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
if (accountId) {
await deleteChannelAccountConfig(channelType, accountId);
await clearChannelBinding(storedChannelType, accountId);
await scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:deleteAccount:${storedChannelType}`);
} else {
await deleteChannelConfig(channelType);
await clearAllBindingsForChannel(storedChannelType);
await scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${storedChannelType}`);
}
return { success: true };
},
+141 -4
View File
@@ -1,24 +1,161 @@
import type { BrowserWindow } from 'electron';
import type { GatewayManager } from '../gateway/manager';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { RuntimeManager } from '../runtime/manager';
import type { RuntimeSendWithMediaPayload } from '../runtime/types';
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 createChatSendWithMediaHandler(
gatewayManager: GatewayManager,
log = logger,
): (payload?: unknown) => ReturnType<CompleteHostServiceRegistry['chat']['sendWithMedia']> {
return 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);
log.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');
log.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;
}
log.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';
log.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) {
log.error(`[chat:sendWithMedia] Error: ${String(error)}`);
return { success: false, error: String(error) };
}
};
}
export function createChatApi({
gatewayManager,
runtimeManager,
mainWindow,
acpSessionAccessRegistry,
}: {
gatewayManager: GatewayManager;
runtimeManager?: RuntimeManager;
mainWindow: BrowserWindow;
acpSessionAccessRegistry: AcpSessionAccessRegistry;
}): CompleteHostServiceRegistry['chat'] {
const acpChat = createAcpChatService(mainWindow, acpSessionAccessRegistry, gatewayManager);
const openClawHandler = createChatSendWithMediaHandler(gatewayManager, logger);
const withOpenClawAcp = async <T>(operation: () => Promise<T>) => {
if (runtimeManager && await runtimeManager.getActiveKind() !== 'openclaw') {
return {
success: false,
error: 'ACP chat is only available for the OpenClaw runtime',
} as T;
}
return operation();
};
return {
loadAcpSession: (payload) => acpChat.loadSession(payload),
sendAcpPrompt: (payload) => acpChat.sendPrompt(payload),
cancelAcpSession: (payload) => acpChat.cancelSession(payload),
respondAcpPermission: (payload) => acpChat.respondPermission(payload),
sendWithMedia: async (payload) => {
if (!runtimeManager) return openClawHandler(payload);
try {
const result = await runtimeManager.getActiveProvider().sendMessageWithMedia(
(isRecord(payload) ? payload : {}) as RuntimeSendWithMediaPayload,
);
return { success: true, result };
} catch (error) {
return { success: false, error: String(error) };
}
},
loadAcpSession: (payload) => withOpenClawAcp(() => acpChat.loadSession(payload)),
sendAcpPrompt: (payload) => withOpenClawAcp(() => acpChat.sendPrompt(payload)),
cancelAcpSession: (payload) => withOpenClawAcp(() => acpChat.cancelSession(payload)),
respondAcpPermission: (payload) => withOpenClawAcp(() => acpChat.respondPermission(payload)),
};
}
+126 -131
View File
@@ -1,14 +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 { HostSuccess } from '@shared/host-api/contract';
import type { CronJob, CronJobCreateInput, CronJobDelivery, CronJobUpdateInput, CronSchedule } from '@shared/types/cron';
import type { GatewayManager } from '../gateway/manager';
import type { RuntimeManager } from '../runtime/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 {
@@ -62,7 +62,6 @@ interface CronSessionFallbackMessage {
}
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;
@@ -96,83 +95,14 @@ function formatDuration(durationMs: number | undefined): string | null {
return `${Math.round(durationMs / 1000)}s`;
}
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 {
function buildCronRunMessage(entry: CronRunLogEntry, index: number): 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 = fullReply?.trim() || summary || error;
let content = summary || error;
if (!content) {
content = status === 'error' ? 'Scheduled task failed.' : 'Scheduled task completed.';
}
@@ -267,7 +197,6 @@ 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[] {
@@ -304,7 +233,7 @@ function buildCronSessionFallbackMessages(params: {
}
matchingRuns.forEach((entry, index) => {
const message = buildCronRunMessage(entry, index, params.fullReplies?.get(entry));
const message = buildCronRunMessage(entry, index);
if (message) messages.push(message);
});
@@ -475,7 +404,7 @@ function transformCronJob(job: GatewayCronJob): CronJob {
};
}
async function listCronJobs(gatewayManager: GatewayManager): Promise<CronJob[]> {
export async function listCronJobs(gatewayManager: GatewayManager): Promise<CronJob[]> {
let jobs: GatewayCronJob[] = [];
let usedFallback = false;
@@ -579,67 +508,128 @@ function getId(payload: unknown): string {
return id.trim();
}
export function createCronApi({ gatewayManager }: { gatewayManager: GatewayManager }): CompleteHostServiceRegistry['cron'] {
export async function createOpenClawCronJob(
gatewayManager: GatewayManager,
input: CronJobCreateInput,
): Promise<CronJob> {
const agentId = typeof input.agentId === 'string' && input.agentId.trim() ? input.agentId.trim() : 'main';
const delivery = normalizeCronDelivery(input.delivery);
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(delivery.channel);
if (delivery.mode === 'announce' && unsupportedDeliveryError) {
throw new Error(unsupportedDeliveryError);
}
const result = await gatewayManager.rpc('cron.add', {
name: input.name,
schedule: normalizeScheduleInput(input.schedule),
payload: { kind: 'agentTurn', message: input.message },
enabled: typeof input.enabled === 'boolean' ? input.enabled : true,
wakeMode: 'next-heartbeat',
sessionTarget: 'isolated',
agentId,
delivery,
});
if (!result || typeof result !== 'object') {
throw new Error('Cron create returned an invalid job');
}
return transformCronJob(result as GatewayCronJob);
}
export async function updateOpenClawCronJob(
gatewayManager: GatewayManager,
payload: { id: string; input: CronJobUpdateInput },
): Promise<CronJob> {
const id = getId(payload);
const input = isRecord(payload.input) ? payload.input : {};
const patch = buildCronUpdatePatch(input);
delete patch.id;
delete patch.input;
const deliveryPatch = patch.delivery && typeof patch.delivery === 'object'
? patch.delivery as Record<string, unknown>
: undefined;
const deliveryChannel = typeof deliveryPatch?.channel === 'string' && deliveryPatch.channel.trim()
? deliveryPatch.channel.trim()
: undefined;
const deliveryMode = typeof deliveryPatch?.mode === 'string' && deliveryPatch.mode.trim()
? deliveryPatch.mode.trim()
: undefined;
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(deliveryChannel);
if (unsupportedDeliveryError && deliveryMode !== 'none') {
throw new Error(unsupportedDeliveryError);
}
const result = await gatewayManager.rpc('cron.update', { id, patch });
if (!result || typeof result !== 'object') {
throw new Error('Cron update returned an invalid job');
}
return transformCronJob(result as GatewayCronJob);
}
function normalizeHostSuccess(result: unknown): HostSuccess {
if (isRecord(result) && typeof result.success === 'boolean') {
return { success: result.success, ...(typeof result.error === 'string' ? { error: result.error } : {}) };
}
return { success: true };
}
export async function deleteOpenClawCronJob(gatewayManager: GatewayManager, payload: unknown): Promise<HostSuccess> {
return normalizeHostSuccess(await gatewayManager.rpc('cron.remove', { id: getId(payload) }));
}
export async function toggleOpenClawCronJob(gatewayManager: GatewayManager, payload: { id: string; enabled: boolean }): Promise<HostSuccess> {
return normalizeHostSuccess(await gatewayManager.rpc('cron.update', {
id: getId(payload),
patch: { enabled: payload.enabled === true },
}));
}
export async function triggerOpenClawCronJob(gatewayManager: GatewayManager, payload: unknown): Promise<HostSuccess> {
return normalizeHostSuccess(await gatewayManager.rpc('cron.run', { id: getId(payload), mode: 'force' }));
}
export function createCronApi({
gatewayManager,
runtimeManager,
}: {
gatewayManager: GatewayManager;
runtimeManager?: RuntimeManager;
}): CompleteHostServiceRegistry['cron'] {
const runtimeSupportsCron = () => runtimeManager?.listCapabilities().cron === true;
return {
list: async () => listCronJobs(gatewayManager),
list: async () => {
if (runtimeSupportsCron()) {
return await runtimeManager!.rpc<CronJob[]>('cron.list');
}
return listCronJobs(gatewayManager);
},
create: async (payload) => {
const input = payload;
const agentId = typeof input.agentId === 'string' && input.agentId.trim() ? input.agentId.trim() : 'main';
const delivery = normalizeCronDelivery(input.delivery);
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(delivery.channel);
if (delivery.mode === 'announce' && unsupportedDeliveryError) {
throw new Error(unsupportedDeliveryError);
if (runtimeSupportsCron()) {
return await runtimeManager!.rpc<CronJob>('cron.create', payload);
}
const result = await gatewayManager.rpc('cron.add', {
name: input.name,
schedule: normalizeScheduleInput(input.schedule),
payload: { kind: 'agentTurn', message: input.message },
enabled: typeof input.enabled === 'boolean' ? input.enabled : true,
wakeMode: 'next-heartbeat',
sessionTarget: 'isolated',
agentId,
delivery,
});
if (!result || typeof result !== 'object') {
throw new Error('Cron create returned an invalid job');
}
return transformCronJob(result as GatewayCronJob);
return createOpenClawCronJob(gatewayManager, payload);
},
update: async (payload) => {
const body = payload;
const id = getId(body);
const input = isRecord(body.input) ? body.input : {};
const patch = buildCronUpdatePatch(input);
delete patch.id;
delete patch.input;
const deliveryPatch = patch.delivery && typeof patch.delivery === 'object'
? patch.delivery as Record<string, unknown>
: undefined;
const deliveryChannel = typeof deliveryPatch?.channel === 'string' && deliveryPatch.channel.trim()
? deliveryPatch.channel.trim()
: undefined;
const deliveryMode = typeof deliveryPatch?.mode === 'string' && deliveryPatch.mode.trim()
? deliveryPatch.mode.trim()
: undefined;
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(deliveryChannel);
if (unsupportedDeliveryError && deliveryMode !== 'none') {
throw new Error(unsupportedDeliveryError);
if (runtimeSupportsCron()) {
return await runtimeManager!.rpc<CronJob>('cron.update', payload);
}
const result = await gatewayManager.rpc('cron.update', { id, patch });
if (!result || typeof result !== 'object') {
throw new Error('Cron update returned an invalid job');
return updateOpenClawCronJob(gatewayManager, payload);
},
delete: async (payload) => {
if (runtimeSupportsCron()) {
return await runtimeManager!.rpc<HostSuccess>('cron.delete', { id: getId(payload) });
}
return transformCronJob(result as GatewayCronJob);
return deleteOpenClawCronJob(gatewayManager, payload);
},
delete: async (payload) => gatewayManager.rpc('cron.remove', { id: getId(payload) }),
toggle: async (payload) => {
const body = payload;
return gatewayManager.rpc('cron.update', {
id: getId(body),
patch: { enabled: body.enabled === true },
});
if (runtimeSupportsCron()) {
return await runtimeManager!.rpc<HostSuccess>('cron.toggle', { id: getId(payload), enabled: payload.enabled === true });
}
return toggleOpenClawCronJob(gatewayManager, payload);
},
trigger: async (payload) => {
if (runtimeSupportsCron()) {
return await runtimeManager!.rpc<HostSuccess>('cron.run', { id: getId(payload), mode: 'force' });
}
return triggerOpenClawCronJob(gatewayManager, payload);
},
trigger: async (payload) => gatewayManager.rpc('cron.run', { id: getId(payload), mode: 'force' }),
sessionHistory: async (payload) => {
const body = payload;
const sessionKey = typeof body.sessionKey === 'string' ? body.sessionKey.trim() : '';
@@ -648,6 +638,13 @@ export function createCronApi({ gatewayManager }: { gatewayManager: GatewayManag
const rawLimit = typeof body.limit === 'number' ? body.limit : Number(body.limit || 200);
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 200) : 200;
const activeProvider = runtimeManager?.getActiveProvider();
if (activeProvider?.listCapabilities().history) {
const history = await activeProvider.loadHistory({ sessionKey, limit });
if (history.messages && history.messages.length > 0) {
return history;
}
}
const [jobsResult, runs, sessionEntry] = await Promise.all([
gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000)
.catch(() => ({ jobs: [] as GatewayCronJob[] })),
@@ -656,13 +653,11 @@ export function createCronApi({ gatewayManager }: { gatewayManager: GatewayManag
]);
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),
+214 -1
View File
@@ -1,17 +1,31 @@
import { execFile } from 'node:child_process';
import { open } from 'node:fs/promises';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { GatewayManager } from '../gateway/manager';
import type { RuntimeManager } from '../runtime/manager';
import type { RuntimeProvider } from '../runtime/types';
import type { CronJob } from '@shared/types/cron';
import { logger } from '../utils/logger';
import { getOpenClawConfigDir } from '../utils/paths';
import { buildGatewayHealthSummary } from '../utils/gateway-health';
import { buildChannelAccountsView, getChannelStatusDiagnostics } from './channels-api';
import { getAcpTraceSnapshot, recordRendererAcpTrace } from './acp-trace';
import {
getCcConnectBinaryPath,
getCcConnectCodexHomeDir,
getCcConnectConfigPath,
getCcConnectManagedDir,
getCcConnectProviderProfilePath,
} from '../runtime/cc-connect-paths';
import { getCodexBundle } from '../runtime/codex-paths';
import { getCcConnectCodexOAuthStatus } from '../runtime/cc-connect-provider-profile';
const DEFAULT_TAIL_LINES = 200;
type DiagnosticsApiContext = {
gatewayManager: GatewayManager;
runtimeManager?: RuntimeManager;
};
async function readTail(filePath: string, tailLines = DEFAULT_TAIL_LINES): Promise<string> {
@@ -46,6 +60,204 @@ async function readTail(filePath: string, tailLines = DEFAULT_TAIL_LINES): Promi
}
}
async function readJsonFile(filePath: string): Promise<Record<string, unknown> | null> {
try {
const file = await open(filePath, 'r');
try {
const stat = await file.stat();
if (stat.size <= 0 || stat.size > 1024 * 1024) return null;
const buffer = Buffer.allocUnsafe(stat.size);
const { bytesRead } = await file.read(buffer, 0, stat.size, 0);
const parsed = JSON.parse(buffer.subarray(0, bytesRead).toString('utf8')) as unknown;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: null;
} finally {
await file.close();
}
} catch {
return null;
}
}
async function runVersionCommand(binaryPath: string): Promise<Record<string, unknown>> {
return await new Promise((resolve) => {
execFile(binaryPath, ['--version'], { timeout: 5_000 }, (error, stdout, stderr) => {
const output = `${stdout || ''}${stderr ? `\n${stderr}` : ''}`.trim();
if (error) {
resolve({
success: false,
command: `${binaryPath} --version`,
error: error.message,
output,
});
return;
}
resolve({
success: true,
command: `${binaryPath} --version`,
output,
version: output.split('\n')[0]?.trim() || undefined,
});
});
});
}
async function buildBinaryDiagnostics(binaryPath: string, manifestPath: string): Promise<Record<string, unknown>> {
const [manifest, versionCommand] = await Promise.all([
readJsonFile(manifestPath),
runVersionCommand(binaryPath),
]);
return {
binaryPath,
manifestPath,
manifest,
versionCommand,
};
}
async function probeCcConnectManagement(activeProvider: ReturnType<RuntimeManager['getActiveProvider']> | undefined) {
if (!activeProvider?.getControlUi) {
return { success: false, error: 'cc-connect control UI route is unavailable' };
}
try {
const control = await activeProvider.getControlUi();
if (!control.success || !control.url) {
return {
success: false,
port: control.port,
error: control.error || 'cc-connect control UI route is unavailable',
};
}
const url = new URL('/api/v1/status', control.url);
const response = await fetch(url, {
headers: control.token ? { Authorization: `Bearer ${control.token}` } : undefined,
});
const text = await response.text();
return {
success: response.ok,
port: control.port,
status: response.status,
body: text.trim().slice(0, 2_000),
...(response.ok ? {} : { error: text.trim() || `HTTP ${response.status}` }),
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
async function buildCcConnectCronDiagnostics(activeProvider: RuntimeProvider | undefined): Promise<Record<string, unknown>> {
const knownGaps = [
'scheduled-prompt-delivery-unproven',
'heartbeat-unproven',
'external-channel-delivery-targets-unproven',
'muted-scheduled-delivery-behavior-unproven',
];
if (!activeProvider?.rpc) {
return {
success: false,
knownGaps,
error: 'active runtime provider RPC is unavailable',
};
}
try {
const jobs = await activeProvider.rpc<CronJob[]>('cron.list');
const list = Array.isArray(jobs) ? jobs : [];
return {
success: true,
jobCount: list.length,
jobs: list.slice(0, 50).map((job) => ({
id: job.id,
name: job.name,
agentId: job.agentId,
enabled: job.enabled,
deliveryMode: job.delivery?.mode,
hasPrompt: Boolean(job.message && !job.exec),
hasExec: Boolean(job.exec),
sessionMode: job.sessionMode,
timeoutMins: job.timeoutMins,
mute: job.mute,
nextRun: job.nextRun,
lastRun: job.lastRun ? {
time: job.lastRun.time,
success: job.lastRun.success,
hasError: Boolean(job.lastRun.error),
duration: job.lastRun.duration,
} : undefined,
})),
truncated: list.length > 50,
knownGaps,
};
} catch (error) {
return {
success: false,
knownGaps,
error: error instanceof Error ? error.message : String(error),
};
}
}
async function buildRuntimeDiagnostics(ctx: DiagnosticsApiContext) {
const runtimeStatus = ctx.runtimeManager?.getStatus();
const activeProvider = ctx.runtimeManager?.getActiveProvider();
const base = {
activeKind: activeProvider?.kind ?? runtimeStatus?.runtimeKind ?? 'openclaw',
status: runtimeStatus,
operationCapabilities: activeProvider?.listOperationCapabilities?.(),
};
if ((activeProvider?.kind ?? runtimeStatus?.runtimeKind) !== 'cc-connect') {
return base;
}
const managedDir = getCcConnectManagedDir();
const configPath = getCcConnectConfigPath();
const providerProfilePath = getCcConnectProviderProfilePath();
const ccConnectBinaryPath = getCcConnectBinaryPath();
const codexBundle = getCodexBundle();
const [oauth, providerProfile, runtimeLogs, ccConnectBinary, codexBinary, managementApi, cron] = await Promise.all([
getCcConnectCodexOAuthStatus().catch((error) => ({
success: false,
error: error instanceof Error ? error.message : String(error),
})),
readJsonFile(providerProfilePath),
activeProvider?.listLogs?.().catch((error) => ({
content: `Failed to read cc-connect logs: ${String(error)}`,
})),
buildBinaryDiagnostics(ccConnectBinaryPath, join(dirname(ccConnectBinaryPath), 'manifest.json')),
buildBinaryDiagnostics(codexBundle.binaryPath, join(codexBundle.baseDir, 'manifest.json')),
probeCcConnectManagement(activeProvider),
buildCcConnectCronDiagnostics(activeProvider),
]);
const codexHomeDir = providerProfile
&& typeof providerProfile === 'object'
&& typeof (providerProfile as Record<string, unknown>).codexHomeDir === 'string'
? (providerProfile as Record<string, string>).codexHomeDir
: getCcConnectCodexHomeDir();
return {
...base,
ccConnect: {
managedDir,
configPath,
codexHomeDir,
providerProfilePath,
oauth,
providerProfile,
binaries: {
ccConnect: ccConnectBinary,
codex: codexBinary,
},
managementApi,
cron,
logTail: runtimeLogs?.content ?? '',
},
};
}
export function createDiagnosticsApi(ctx: DiagnosticsApiContext): CompleteHostServiceRegistry['diagnostics'] {
return {
gatewaySnapshot: async () => {
@@ -74,6 +286,7 @@ export function createDiagnosticsApi(ctx: DiagnosticsApiContext): CompleteHostSe
capturedAt: Date.now(),
platform: process.platform,
gateway,
runtime: await buildRuntimeDiagnostics(ctx),
channels,
clawxLogTail: await logger.readLogFile(DEFAULT_TAIL_LINES),
gatewayLogTail: await readTail(join(openClawDir, 'logs', 'gateway.log')),
+50 -15
View File
@@ -29,7 +29,9 @@ import {
FILE_PREVIEW_MAX_TEXT_BYTES,
} from '@shared/file-preview/limits';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { expandPath, resolveOpenClawStateDir } from '../utils/paths';
import type { RuntimeManager } from '../runtime/manager';
import { expandPath } from '../utils/paths';
import { getRuntimeOutboundMediaDir } from '../utils/runtime-media-paths';
import {
resolveClawXStagingDir,
type AttachmentAccess,
@@ -138,6 +140,7 @@ type WorkspaceFs = {
type FilesApiDependencies = {
workspaceFs?: WorkspaceFs;
runtimeManager?: Pick<RuntimeManager, 'getStatus'>;
attachmentAccess?: AttachmentAccess;
openWith?: AttachmentOpenWithService;
stagedAttachments?: StagedAttachmentRegistry;
@@ -376,7 +379,7 @@ function getWorkspaceBinaryCap(value: unknown): number {
return Math.max(1, Math.min(maxBytes ?? FILE_PREVIEW_MAX_BINARY_BYTES, FILE_PREVIEW_MAX_BINARY_BYTES));
}
function getFilePreviewWriteRoots(): string[] {
function getFilePreviewWriteRoots(runtimeManager?: Pick<RuntimeManager, 'getStatus'>): string[] {
const roots: string[] = [];
roots.push(resolve(join(homedir(), '.openclaw')));
try {
@@ -385,12 +388,14 @@ function getFilePreviewWriteRoots(): string[] {
// ignore
}
roots.push(resolve(resolveClawXStagingDir()));
roots.push(resolve(getRuntimeOutboundMediaDir(runtimeManager)));
return roots;
}
async function resolveSandboxedPath(
input: string,
mode: 'read' | 'write' = 'read',
runtimeManager?: Pick<RuntimeManager, 'getStatus'>,
): Promise<ResolvedSandboxedPath> {
if (!input.trim()) {
throw new Error('outsideSandbox');
@@ -403,7 +408,7 @@ async function resolveSandboxedPath(
} catch {
real = resolve(expanded);
}
const writeRoots = getFilePreviewWriteRoots();
const writeRoots = getFilePreviewWriteRoots(runtimeManager);
if (writeRoots.some((root) => isPathInside(real, root))) {
return { realPath: real, readOnly: false };
}
@@ -478,7 +483,17 @@ export function createFilesApi(dependencies: FilesApiDependencies = {}): Complet
return pinned;
};
const stateDir = await ensureDirectory(resolveOpenClawStateDir());
const runtimeOutboundDir = resolve(getRuntimeOutboundMediaDir(dependencies.runtimeManager));
const runtimeStateDir = dirname(dirname(runtimeOutboundDir));
const isCcConnect = dependencies.runtimeManager?.getStatus().runtimeKind === 'cc-connect';
const stateDir = isCcConnect
? await (async () => {
const dataRootPath = dirname(dirname(runtimeStateDir));
const dataRoot = await ensureDirectory(dataRootPath);
const runtimesDir = await ensureDirectory(join(dataRoot.canonicalPath, 'runtimes'), dataRoot);
return ensureDirectory(runtimeStateDir, runtimesDir);
})()
: await ensureDirectory(runtimeStateDir);
const mediaDir = await ensureDirectory(join(stateDir.canonicalPath, 'media'), stateDir);
const outboundDir = await ensureDirectory(join(mediaDir.canonicalPath, 'outbound'), mediaDir);
const stagingRoot = await ensureDirectory(join(outboundDir.canonicalPath, 'clawx-staging'), outboundDir);
@@ -612,16 +627,12 @@ 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: canonicalPath,
stagedPath: filePath,
preview: null,
});
continue;
@@ -848,7 +859,11 @@ export function createFilesApi(dependencies: FilesApiDependencies = {}): Complet
},
readText: async (payload) => {
try {
const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read');
const { realPath: real, readOnly } = await resolveSandboxedPath(
requirePath(payload),
'read',
dependencies.runtimeManager,
);
const fsP = await import('node:fs/promises');
const stat = await fsP.stat(real);
if (!stat.isFile()) return { ok: false, error: 'notFound' };
@@ -873,7 +888,11 @@ export function createFilesApi(dependencies: FilesApiDependencies = {}): Complet
try {
const body = isRecord(payload) ? payload as PathPayload : {};
const opts = getBinaryOptions(body.opts);
const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read');
const { realPath: real, readOnly } = await resolveSandboxedPath(
requirePath(payload),
'read',
dependencies.runtimeManager,
);
const fsP = await import('node:fs/promises');
const stat = await fsP.stat(real);
if (!stat.isFile()) return { ok: false, error: 'notFound' };
@@ -903,7 +922,11 @@ export function createFilesApi(dependencies: FilesApiDependencies = {}): Complet
if (Buffer.byteLength(body.content, 'utf8') > FILE_PREVIEW_MAX_TEXT_BYTES) {
return { ok: false, error: 'tooLarge' };
}
const { realPath: real } = await resolveSandboxedPath(requirePath(payload), 'write');
const { realPath: real } = await resolveSandboxedPath(
requirePath(payload),
'write',
dependencies.runtimeManager,
);
const fsP = await import('node:fs/promises');
let stat;
try {
@@ -923,7 +946,11 @@ export function createFilesApi(dependencies: FilesApiDependencies = {}): Complet
},
stat: async (payload) => {
try {
const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read');
const { realPath: real, readOnly } = await resolveSandboxedPath(
requirePath(payload),
'read',
dependencies.runtimeManager,
);
const fsP = await import('node:fs/promises');
const stat = await fsP.stat(real);
return {
@@ -943,7 +970,11 @@ export function createFilesApi(dependencies: FilesApiDependencies = {}): Complet
},
listDir: async (payload) => {
try {
const { realPath: real } = await resolveSandboxedPath(requirePath(payload), 'read');
const { realPath: real } = await resolveSandboxedPath(
requirePath(payload),
'read',
dependencies.runtimeManager,
);
const fsP = await import('node:fs/promises');
const dirents = await fsP.readdir(real, { withFileTypes: true });
const entries = await Promise.all(dirents.map(async (entry) => {
@@ -973,7 +1004,11 @@ export function createFilesApi(dependencies: FilesApiDependencies = {}): Complet
try {
const body = isRecord(payload) ? payload as PathPayload : {};
const opts = getTreeOptions(body.opts);
const { realPath: real } = await resolveSandboxedPath(requirePath(payload), 'read');
const { realPath: real } = await resolveSandboxedPath(
requirePath(payload),
'read',
dependencies.runtimeManager,
);
const fsP = await import('node:fs/promises');
const stat = await fsP.stat(real);
if (!stat.isDirectory()) return { ok: false, error: 'notDirectory' };
+35 -21
View File
@@ -1,16 +1,17 @@
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';
import { logger } from '../utils/logger';
import { buildOpenClawControlUiUrl } from '../utils/openclaw-control-ui';
import { getSetting } from '../utils/store';
import type { RuntimeManager } from '../runtime/manager';
import { isRecord } from './payload-utils';
type HealthPayload = {
probe?: unknown;
};
type ControlUiPayload = {
view?: unknown;
};
type RpcPayload = {
method?: unknown;
params?: unknown;
@@ -25,34 +26,42 @@ function parseTimeoutMs(timeoutMs: unknown): number | undefined {
return timeoutMs;
}
export function createGatewayApi(gatewayManager: GatewayManager): CompleteHostServiceRegistry['gateway'] {
export function createGatewayApi(
runtimeManager: RuntimeManager,
gatewayRpcBackpressure: GatewayRpcBackpressure,
gatewayManager?: GatewayManager,
): CompleteHostServiceRegistry['gateway'] {
return {
status: () => gatewayManager.getStatus(),
status: () => runtimeManager.getStatus(),
start: async () => {
await gatewayManager.start();
await runtimeManager.start();
return { success: true };
},
stop: async () => {
await gatewayManager.stop();
await runtimeManager.stop();
return { success: true };
},
restart: async () => {
await gatewayManager.restart();
await runtimeManager.restart();
return { success: true };
},
health: async (payload) => {
const body = isRecord(payload) ? payload as HealthPayload : {};
return gatewayManager.checkHealth({ probe: body.probe === true });
return runtimeManager.checkHealth({ probe: body.probe === true });
},
controlUi: async () => {
const status = gatewayManager.getStatus();
const token = await getSetting('gatewayToken');
const port = status.port || PORTS.OPENCLAW_GATEWAY;
const url = buildOpenClawControlUiUrl(port, token);
void approvePendingLocalDeviceRequests(gatewayManager).catch((error) => {
logger.debug(`[gateway] Control UI device auto-approve skipped: ${String(error)}`);
});
return { success: true, url, token, port };
controlUi: async (payload) => {
const status = runtimeManager.getStatus();
const body = isRecord(payload) ? payload as ControlUiPayload : {};
const view = body.view === 'dreams' ? 'dreams' : undefined;
const provider = runtimeManager.getActiveProvider();
if (!status.capabilities?.controlUi || !provider.getControlUi) {
return {
success: false,
error: `${status.runtimeKind ?? 'runtime'} runtime does not support Control UI`,
};
}
void gatewayManager;
return provider.getControlUi(view ? { view } : {});
},
rpc: async (payload) => {
const body = isRecord(payload) ? payload as RpcPayload : {};
@@ -61,7 +70,12 @@ export function createGatewayApi(gatewayManager: GatewayManager): CompleteHostSe
throw new Error('Invalid gateway RPC method');
}
const timeoutMs = parseTimeoutMs(body.timeoutMs);
return gatewayManager.rpc(method, body.params, timeoutMs);
return gatewayRpcBackpressure.run(
method,
body.params,
timeoutMs,
(rpcMethod, rpcParams, rpcTimeoutMs) => runtimeManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
);
},
};
}
+35 -1
View File
@@ -2,9 +2,11 @@ import { dialog, nativeImage } from 'electron';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { RuntimeManager } from '../runtime/manager';
import type { AttachmentFileRef } from '@shared/host-api/contract';
import { resolveOutgoingMediaAttachment, type AttachmentAccess } from './attachment-access';
import { resolveOpenClawStateDir } from '../utils/paths';
import { getRuntimeOutgoingMediaRecordDirs } from '../utils/runtime-media-paths';
import {
CLAWX_OPENAI_IMAGE_DEFAULT_MODEL,
CLAWX_OPENAI_IMAGE_PROVIDER_KEY,
@@ -28,6 +30,7 @@ type ThumbnailEntry = {
};
type MediaApiDependencies = {
runtimeManager?: Pick<RuntimeManager, 'getStatus'>;
attachmentAccess?: Pick<AttachmentAccess, 'resolveAttachment' | 'readAttachmentBinary'>;
};
@@ -99,6 +102,37 @@ function normalizeThumbnailEntries(payload: unknown): ThumbnailEntry[] {
return Array.isArray(value) ? value as ThumbnailEntry[] : [];
}
async function resolveRuntimeOutgoingMediaUrl(
gatewayUrl: string,
runtimeManager?: Pick<RuntimeManager, 'getStatus'>,
): Promise<{ path: string; mimeType: string } | null> {
try {
const match = gatewayUrl.match(/\/api\/chat\/media\/outgoing\/[^/]+\/([^/]+)\//);
if (!match) return null;
const attachmentId = decodeURIComponent(match[1]);
if (!/^[A-Za-z0-9._-]+$/.test(attachmentId)) return null;
const fsP = await import('node:fs/promises');
for (const recordDir of getRuntimeOutgoingMediaRecordDirs(runtimeManager)) {
try {
const raw = await fsP.readFile(join(recordDir, `${attachmentId}.json`), 'utf8');
const record = JSON.parse(raw) as {
original?: { path?: string; contentType?: string };
};
if (!record.original?.path) continue;
return {
path: record.original.path,
mimeType: record.original.contentType || 'application/octet-stream',
};
} catch {
// Continue across current and historical runtime media roots.
}
}
} catch {
// Treat malformed or unavailable runtime media records as missing.
}
return null;
}
export function createMediaApi(dependencies: MediaApiDependencies = {}): CompleteHostServiceRegistry['media'] {
return {
thumbnails: async (payload) => {
@@ -157,7 +191,7 @@ export function createMediaApi(dependencies: MediaApiDependencies = {}): Complet
const resolved = await resolveOutgoingMediaAttachment({
uri: entry.gatewayUrl,
stateDir: resolveOpenClawStateDir(),
});
}) ?? await resolveRuntimeOutgoingMediaUrl(entry.gatewayUrl, dependencies.runtimeManager);
if (!resolved) {
results[entry.gatewayUrl] = { preview: null, fileSize: 0 };
continue;
+203 -44
View File
@@ -2,6 +2,7 @@ import type { BrowserWindow } from 'electron';
import type { HostApiContract } from '@shared/host-api/contract';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { GatewayManager } from '../gateway/manager';
import type { RuntimeManager } from '../runtime/manager';
import type { ProviderConfig } from '../utils/secure-storage';
import { browserOAuthManager, type BrowserOAuthProviderType } from '../utils/browser-oauth';
import { deviceOAuthManager, type OAuthProviderType } from '../utils/device-oauth';
@@ -22,9 +23,15 @@ import {
import { validateApiKeyWithProvider } from './providers/provider-validation';
import type { ProviderAccount } from '../shared/providers/types';
import { isRecord } from './payload-utils';
import {
getCcConnectCodexOAuthStatus,
importUserCodexOAuthToManagedHome,
logoutCcConnectCodexOAuth,
} from '../runtime/cc-connect-provider-profile';
type ProvidersApiContext = {
gatewayManager: GatewayManager;
runtimeManager?: RuntimeManager;
mainWindow: BrowserWindow;
};
@@ -34,7 +41,6 @@ type ProviderPayload<Action extends keyof HostApiContract['providers']> =
type ValidationOptions = {
baseUrl?: string;
apiProtocol?: string;
modelId?: string;
};
function hasObjectChanges<T extends Record<string, unknown>>(
@@ -151,6 +157,96 @@ function getSavePayload(payload: unknown): { config: ProviderConfig; apiKey?: st
};
}
async function syncActiveRuntimeProviderProfile(
ctx: Pick<ProvidersApiContext, 'runtimeManager'>,
payload: { providerId?: string; reason: string },
): Promise<boolean> {
const provider = ctx.runtimeManager?.getActiveProvider();
if (!provider?.syncProviderProfile) return false;
await provider.syncProviderProfile(payload);
return true;
}
async function syncProviderApiKeyToActiveRuntime(
providerType: string,
providerId: string,
apiKey: string,
ctx: Pick<ProvidersApiContext, 'runtimeManager'>,
): Promise<void> {
if (await syncActiveRuntimeProviderProfile(ctx, { providerId, reason: 'api-key' })) {
return;
}
await syncProviderApiKeyToRuntime(providerType, providerId, apiKey);
}
async function syncSavedProviderToActiveRuntime(
config: ProviderConfig,
apiKey: string | undefined,
ctx: Pick<ProvidersApiContext, 'gatewayManager' | 'runtimeManager'>,
): Promise<void> {
if (await syncActiveRuntimeProviderProfile(ctx, { providerId: config.id, reason: 'save' })) {
return;
}
await syncSavedProviderToRuntime(config, apiKey, ctx.gatewayManager);
}
async function syncUpdatedProviderToActiveRuntime(
config: ProviderConfig,
apiKey: string | undefined,
ctx: Pick<ProvidersApiContext, 'gatewayManager' | 'runtimeManager'>,
reason = 'update',
): Promise<void> {
if (await syncActiveRuntimeProviderProfile(ctx, { providerId: config.id, reason })) {
return;
}
await syncUpdatedProviderToRuntime(config, apiKey, ctx.gatewayManager);
}
async function syncDeletedProviderToActiveRuntime(
provider: ProviderConfig | null,
providerId: string,
ctx: Pick<ProvidersApiContext, 'gatewayManager' | 'runtimeManager'>,
runtimeProviderKey?: string,
): Promise<void> {
if (await syncActiveRuntimeProviderProfile(ctx, { providerId, reason: 'delete' })) {
return;
}
await syncDeletedProviderToRuntime(provider, providerId, ctx.gatewayManager, runtimeProviderKey);
}
async function syncDeletedProviderApiKeyToActiveRuntime(
provider: ProviderConfig | null,
providerId: string,
ctx: Pick<ProvidersApiContext, 'runtimeManager'>,
runtimeProviderKey?: string,
): Promise<void> {
if (await syncActiveRuntimeProviderProfile(ctx, { providerId, reason: 'delete-api-key' })) {
return;
}
await syncDeletedProviderApiKeyToRuntime(provider, providerId, runtimeProviderKey);
}
async function syncDefaultProviderToActiveRuntime(
providerId: string,
ctx: Pick<ProvidersApiContext, 'gatewayManager' | 'runtimeManager'>,
): Promise<void> {
if (await syncActiveRuntimeProviderProfile(ctx, { providerId, reason: 'set-default' })) {
return;
}
await syncDefaultProviderToRuntime(providerId, ctx.gatewayManager);
}
async function removeProviderFromActiveRuntime(
providerKey: string,
ctx: Pick<ProvidersApiContext, 'runtimeManager'>,
providerId: string,
): Promise<void> {
if (await syncActiveRuntimeProviderProfile(ctx, { providerId, reason: 'remove-provider' })) {
return;
}
await removeProviderFromOpenClaw(providerKey);
}
async function validateKey(payload: ProviderPayload<'validateKey'>): Promise<{ valid: boolean; error?: string }> {
try {
const body = getPayloadRecord(payload, 'validateKey');
@@ -181,18 +277,16 @@ 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) };
}
}
async function saveProvider(payload: ProviderPayload<'save'>, gatewayManager?: GatewayManager) {
async function saveProvider(payload: ProviderPayload<'save'>, ctx: ProvidersApiContext) {
const providerService = getProviderService();
const { config, apiKey } = getSavePayload(payload);
try {
@@ -201,44 +295,44 @@ async function saveProvider(payload: ProviderPayload<'save'>, gatewayManager?: G
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await providerService._setProviderApiKeyInternal(config.id, trimmedKey);
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
await syncProviderApiKeyToActiveRuntime(config.type, config.id, trimmedKey, ctx);
}
}
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
await syncSavedProviderToActiveRuntime(config, apiKey, ctx);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function deleteProvider(payload: ProviderPayload<'delete'>, gatewayManager?: GatewayManager) {
async function deleteProvider(payload: ProviderPayload<'delete'>, ctx: ProvidersApiContext) {
const providerService = getProviderService();
const providerId = getProviderId(payload, 'delete');
try {
const existing = await providerService._getProviderInternal(providerId);
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
await providerService._deleteProviderInternal(providerId);
await syncDeletedProviderToActiveRuntime(existing, providerId, ctx);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function setProviderApiKey(payload: ProviderPayload<'setApiKey'>) {
async function setProviderApiKey(payload: ProviderPayload<'setApiKey'>, ctx: ProvidersApiContext) {
const providerService = getProviderService();
const { providerId, apiKey } = getApiKeyPayload(payload, 'setApiKey');
try {
await providerService._setProviderApiKeyInternal(providerId, apiKey);
const provider = await providerService._getProviderInternal(providerId);
const providerType = provider?.type || providerId;
await syncProviderApiKeyToRuntime(providerType, providerId, apiKey);
await syncProviderApiKeyToActiveRuntime(providerType, providerId, apiKey, ctx);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function updateProviderWithKey(payload: ProviderPayload<'updateWithKey'>, gatewayManager?: GatewayManager) {
async function updateProviderWithKey(payload: ProviderPayload<'updateWithKey'>, ctx: ProvidersApiContext) {
const providerService = getProviderService();
const { providerId, updates, apiKey } = getProviderUpdatePayload(payload);
const existing = await providerService._getProviderInternal(providerId);
@@ -262,24 +356,26 @@ async function updateProviderWithKey(payload: ProviderPayload<'updateWithKey'>,
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await providerService._setProviderApiKeyInternal(providerId, trimmedKey);
await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey);
await syncProviderApiKeyToActiveRuntime(nextConfig.type, providerId, trimmedKey, ctx);
} else {
await providerService._deleteProviderApiKeyInternal(providerId);
await removeProviderFromOpenClaw(ock);
await removeProviderFromActiveRuntime(ock, ctx, providerId);
}
}
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
await syncUpdatedProviderToActiveRuntime(nextConfig, apiKey, ctx);
return { success: true };
} catch (error) {
try {
await providerService._saveProviderInternal(existing);
if (previousKey) {
await providerService._setProviderApiKeyInternal(providerId, previousKey);
await saveProviderKeyToOpenClaw(previousOck, previousKey);
if (!await syncActiveRuntimeProviderProfile(ctx, { providerId, reason: 'rollback' })) {
await saveProviderKeyToOpenClaw(previousOck, previousKey);
}
} else {
await providerService._deleteProviderApiKeyInternal(providerId);
await removeProviderFromOpenClaw(previousOck);
await removeProviderFromActiveRuntime(previousOck, ctx, providerId);
}
} catch (rollbackError) {
logger.warn('Failed to rollback provider updateWithKey:', rollbackError);
@@ -288,32 +384,32 @@ async function updateProviderWithKey(payload: ProviderPayload<'updateWithKey'>,
}
}
async function deleteProviderApiKey(payload: ProviderPayload<'deleteApiKey'>) {
async function deleteProviderApiKey(payload: ProviderPayload<'deleteApiKey'>, ctx: ProvidersApiContext) {
const providerService = getProviderService();
const providerId = getProviderId(payload, 'deleteApiKey');
try {
await providerService._deleteProviderApiKeyInternal(providerId);
const provider = await providerService._getProviderInternal(providerId);
await syncDeletedProviderApiKeyToRuntime(provider, providerId);
await syncDeletedProviderApiKeyToActiveRuntime(provider, providerId, ctx);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function setDefaultProvider(payload: ProviderPayload<'setDefault'>, gatewayManager?: GatewayManager) {
async function setDefaultProvider(payload: ProviderPayload<'setDefault'>, ctx: ProvidersApiContext) {
const providerService = getProviderService();
const providerId = getProviderId(payload, 'setDefault');
try {
await providerService._setDefaultProviderInternal(providerId);
await syncDefaultProviderToRuntime(providerId, gatewayManager);
await syncDefaultProviderToActiveRuntime(providerId, ctx);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function createAccount(payload: ProviderPayload<'createAccount'>, gatewayManager?: GatewayManager) {
async function createAccount(payload: ProviderPayload<'createAccount'>, ctx: ProvidersApiContext) {
const providerService = getProviderService();
const body = getPayloadRecord(payload, 'createAccount');
if (!isRecord(body.account)) {
@@ -322,14 +418,14 @@ async function createAccount(payload: ProviderPayload<'createAccount'>, gatewayM
const apiKey = typeof body.apiKey === 'string' ? body.apiKey : undefined;
try {
const account = await providerService.createAccount(body.account as unknown as ProviderAccount, apiKey);
await syncSavedProviderToRuntime(providerAccountToConfig(account), apiKey, gatewayManager);
await syncSavedProviderToActiveRuntime(providerAccountToConfig(account), apiKey, ctx);
return { success: true, account };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function updateAccount(payload: ProviderPayload<'updateAccount'>, gatewayManager?: GatewayManager) {
async function updateAccount(payload: ProviderPayload<'updateAccount'>, ctx: ProvidersApiContext) {
const providerService = getProviderService();
const body = getPayloadRecord(payload, 'updateAccount');
const accountId = typeof body.accountId === 'string' ? body.accountId.trim() : '';
@@ -348,7 +444,7 @@ async function updateAccount(payload: ProviderPayload<'updateAccount'>, gatewayM
return { success: true, noChange: true, account: existing };
}
const account = await providerService.updateAccount(accountId, updates, apiKey);
await syncUpdatedProviderToRuntime(providerAccountToConfig(account), apiKey, gatewayManager);
await syncUpdatedProviderToActiveRuntime(providerAccountToConfig(account), apiKey, ctx);
return { success: true, account };
} catch (error) {
return { success: false, error: String(error) };
@@ -357,7 +453,7 @@ async function updateAccount(payload: ProviderPayload<'updateAccount'>, gatewayM
async function deleteAccount(
payload: ProviderPayload<'deleteAccount'> & { apiKeyOnly?: boolean },
gatewayManager?: GatewayManager,
ctx: ProvidersApiContext,
) {
const providerService = getProviderService();
const body = getPayloadRecord(payload, 'deleteAccount');
@@ -372,12 +468,13 @@ async function deleteAccount(
? 'openai'
: undefined;
if (apiKeyOnly) {
await syncDeletedProviderApiKeyToRuntime(
await providerService._deleteProviderApiKeyInternal(accountId);
await syncDeletedProviderApiKeyToActiveRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
ctx,
runtimeProviderKey,
);
await providerService._deleteProviderApiKeyInternal(accountId);
return { success: true };
}
const currentDefaultAccountId = await providerService.getDefaultAccountId();
@@ -385,24 +482,24 @@ async function deleteAccount(
? selectReplacementDefaultAccount(await providerService.listAccounts(), accountId)
: undefined;
await providerService.deleteAccount(accountId);
if (replacementDefault) {
await syncDefaultProviderToRuntime(replacementDefault.id);
await providerService.setDefaultAccount(replacementDefault.id);
await syncDefaultProviderToActiveRuntime(replacementDefault.id, ctx);
}
await syncDeletedProviderToRuntime(
await syncDeletedProviderToActiveRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
gatewayManager,
ctx,
runtimeProviderKey,
);
await providerService.deleteAccount(accountId);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function setDefaultAccount(payload: ProviderPayload<'setDefaultAccount'>, gatewayManager?: GatewayManager) {
async function setDefaultAccount(payload: ProviderPayload<'setDefaultAccount'>, ctx: ProvidersApiContext) {
const providerService = getProviderService();
const accountId = getAccountId(payload, 'setDefaultAccount');
try {
@@ -411,7 +508,7 @@ async function setDefaultAccount(payload: ProviderPayload<'setDefaultAccount'>,
return { success: true, noChange: true };
}
await providerService.setDefaultAccount(accountId);
await syncDefaultProviderToRuntime(accountId, gatewayManager);
await syncDefaultProviderToActiveRuntime(accountId, ctx);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
@@ -467,10 +564,69 @@ async function submitOAuth(payload: ProviderPayload<'submitOAuth'>) {
}
}
async function codexOAuthStatus(payload?: ProviderPayload<'codexOAuthStatus'>) {
try {
const accountId = payloadString(payload, 'accountId');
return await getCcConnectCodexOAuthStatus({ accountId });
} catch (error) {
logger.error('providers.codexOAuthStatus failed', error);
return { success: false, error: String(error) };
}
}
async function importCodexOAuth(
payload: ProviderPayload<'importCodexOAuth'> | undefined,
ctx: Pick<ProvidersApiContext, 'runtimeManager'>,
) {
try {
const accountId = payloadString(payload, 'accountId');
const result = await importUserCodexOAuthToManagedHome({ accountId });
await syncActiveRuntimeProviderProfile(ctx, {
providerId: result.provider?.accountId ?? accountId,
reason: 'codex-oauth-import',
});
return result;
} catch (error) {
logger.error('providers.importCodexOAuth failed', error);
return { success: false, error: String(error) };
}
}
async function logoutCodexOAuth(
payload: ProviderPayload<'logoutCodexOAuth'> | undefined,
ctx: Pick<ProvidersApiContext, 'runtimeManager'>,
) {
try {
const accountId = payloadString(payload, 'accountId');
const managedOnly = isRecord(payload) && payload.managedOnly === true;
const result = await logoutCcConnectCodexOAuth({ accountId, managedOnly });
await syncActiveRuntimeProviderProfile(ctx, {
providerId: result.provider?.accountId ?? accountId,
reason: 'codex-oauth-logout',
});
return result;
} catch (error) {
logger.error('providers.logoutCodexOAuth failed', error);
return { success: false, error: String(error) };
}
}
export function createProvidersApi(ctx: ProvidersApiContext): CompleteHostServiceRegistry['providers'] {
const providerService = getProviderService();
deviceOAuthManager.setWindow(ctx.mainWindow);
browserOAuthManager.setWindow(ctx.mainWindow);
browserOAuthManager.setSuccessHandler(async ({ accountId }) => {
const account = await providerService.getAccount(accountId);
if (!account) {
throw new Error(`Provider account not found after OAuth success: ${accountId}`);
}
await syncUpdatedProviderToActiveRuntime(
providerAccountToConfig(account),
undefined,
ctx,
'oauth',
);
});
return {
list: async () => providerService._listProvidersWithKeyInfoInternal(),
@@ -479,12 +635,12 @@ export function createProvidersApi(ctx: ProvidersApiContext): CompleteHostServic
hasApiKey: async (payload) => providerService._hasProviderApiKeyInternal(getProviderId(payload, 'hasApiKey')),
getApiKey: async (payload) => providerService._getProviderApiKeyInternal(getProviderId(payload, 'getApiKey')),
validateKey,
save: async (payload) => saveProvider(payload, ctx.gatewayManager),
delete: async (payload) => deleteProvider(payload, ctx.gatewayManager),
setApiKey: setProviderApiKey,
updateWithKey: async (payload) => updateProviderWithKey(payload, ctx.gatewayManager),
deleteApiKey: deleteProviderApiKey,
setDefault: async (payload) => setDefaultProvider(payload, ctx.gatewayManager),
save: async (payload) => saveProvider(payload, ctx),
delete: async (payload) => deleteProvider(payload, ctx),
setApiKey: async (payload) => setProviderApiKey(payload, ctx),
updateWithKey: async (payload) => updateProviderWithKey(payload, ctx),
deleteApiKey: async (payload) => deleteProviderApiKey(payload, ctx),
setDefault: async (payload) => setDefaultProvider(payload, ctx),
accounts: async () => providerService.listAccounts(),
vendors: async () => providerService.listVendors(),
accountKeyInfo: async () => providerService.listAccountsKeyInfo(),
@@ -492,13 +648,16 @@ export function createProvidersApi(ctx: ProvidersApiContext): CompleteHostServic
getAccount: async (payload) => providerService.getAccount(getAccountId(payload, 'getAccount')),
getAccountApiKey: async (payload) => providerService.getAccountApiKey(getAccountId(payload, 'getAccountApiKey')),
hasAccountApiKey: async (payload) => providerService.hasAccountApiKey(getAccountId(payload, 'hasAccountApiKey')),
createAccount: async (payload) => createAccount(payload, ctx.gatewayManager),
updateAccount: async (payload) => updateAccount(payload, ctx.gatewayManager),
deleteAccount: async (payload) => deleteAccount(payload, ctx.gatewayManager),
deleteAccountApiKey: async (payload) => deleteAccount({ accountId: getAccountId(payload, 'deleteAccountApiKey'), apiKeyOnly: true }, ctx.gatewayManager),
setDefaultAccount: async (payload) => setDefaultAccount(payload, ctx.gatewayManager),
createAccount: async (payload) => createAccount(payload, ctx),
updateAccount: async (payload) => updateAccount(payload, ctx),
deleteAccount: async (payload) => deleteAccount(payload, ctx),
deleteAccountApiKey: async (payload) => deleteAccount({ accountId: getAccountId(payload, 'deleteAccountApiKey'), apiKeyOnly: true }, ctx),
setDefaultAccount: async (payload) => setDefaultAccount(payload, ctx),
requestOAuth,
cancelOAuth,
submitOAuth,
codexOAuthStatus,
importCodexOAuth: async (payload) => importCodexOAuth(payload, ctx),
logoutCodexOAuth: async (payload) => logoutCodexOAuth(payload, ctx),
};
}
@@ -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.6-sol`;
const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.5`;
/**
* Provider types that are not in the built-in provider registry (no `providerConfig.api`).
@@ -185,6 +185,29 @@ 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,
@@ -499,21 +522,29 @@ 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;
}
await syncAgentModelsToRuntime();
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider save:', err);
}
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) {
@@ -548,14 +579,22 @@ export async function syncUpdatedProviderToRuntime(
}
}
await syncAgentModelsToRuntime();
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider update:', err);
}
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) {
@@ -565,6 +604,11 @@ 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(
@@ -582,7 +626,7 @@ export async function syncDeletedProviderApiKeyToRuntime(
export async function syncDefaultProviderToRuntime(
providerId: string,
_gatewayManager?: GatewayManager,
gatewayManager?: GatewayManager,
): Promise<void> {
const provider = await getProvider(providerId);
if (!provider) {
@@ -698,7 +742,15 @@ export async function syncDefaultProviderToRuntime(
fallbackModels.map((fallback) => fallback.replace(/^openai-codex\//, `${browserOAuthRuntimeProvider}/`)),
);
logger.info(`Configured openclaw.json for browser OAuth provider "${provider.id}"`);
await syncAgentModelsToRuntime();
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}"`,
);
return;
}
@@ -723,14 +775,18 @@ export async function syncDefaultProviderToRuntime(
logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`);
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)] : [],
});
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);
}
}
if (
@@ -747,6 +803,15 @@ export async function syncDefaultProviderToRuntime(
});
}
await syncAgentModelsToRuntime();
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after default provider switch:', err);
}
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway reload after provider switch to "${ock}"`,
{ onlyIfRunning: true },
);
}
@@ -196,7 +196,6 @@ async function validateOpenAiCompatibleKey(
apiKey: string,
apiProtocol: 'openai-completions' | 'openai-responses',
baseUrl?: string,
modelId?: string,
): Promise<ValidationResult> {
const trimmedBaseUrl = baseUrl?.trim();
if (!trimmedBaseUrl) {
@@ -204,7 +203,6 @@ 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);
@@ -213,9 +211,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, probeModel);
return await performResponsesProbe(providerType, probeUrl, headers);
}
return await performChatCompletionsProbe(providerType, probeUrl, headers, probeModel);
return await performChatCompletionsProbe(providerType, probeUrl, headers);
}
return modelsResult;
@@ -225,7 +223,6 @@ async function performResponsesProbe(
providerLabel: string,
url: string,
headers: Record<string, string>,
modelId: string,
): Promise<ValidationResult> {
try {
logValidationRequest(providerLabel, 'POST', url, headers);
@@ -233,7 +230,7 @@ async function performResponsesProbe(
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: modelId,
model: 'validation-probe',
input: 'hi',
}),
});
@@ -252,7 +249,6 @@ async function performChatCompletionsProbe(
providerLabel: string,
url: string,
headers: Record<string, string>,
modelId: string,
): Promise<ValidationResult> {
try {
logValidationRequest(providerLabel, 'POST', url, headers);
@@ -260,7 +256,7 @@ async function performChatCompletionsProbe(
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: modelId,
model: 'validation-probe',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 1,
}),
@@ -357,7 +353,7 @@ async function validateOpenRouterKey(
export async function validateApiKeyWithProvider(
providerType: string,
apiKey: string,
options?: { baseUrl?: string; apiProtocol?: string; modelId?: string },
options?: { baseUrl?: string; apiProtocol?: string },
): Promise<ValidationResult> {
const profile = getValidationProfile(providerType, options);
const resolvedBaseUrl = options?.baseUrl || getProviderConfig(providerType)?.baseUrl;
@@ -379,7 +375,6 @@ export async function validateApiKeyWithProvider(
trimmedKey,
'openai-completions',
resolvedBaseUrl,
options?.modelId,
);
case 'openai-responses':
return await validateOpenAiCompatibleKey(
@@ -387,7 +382,6 @@ export async function validateApiKeyWithProvider(
trimmedKey,
'openai-responses',
resolvedBaseUrl,
options?.modelId,
);
case 'google-query-key':
return await validateGoogleQueryKey(providerType, trimmedKey, resolvedBaseUrl);
@@ -1,3 +1,6 @@
import { app } from 'electron';
import { getClawXDataLayout, resolveClawXDataRoot } from '../../utils/clawx-data-layout';
// Lazy-load electron-store (ESM module) from the main process only.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let providerStore: any = null;
@@ -7,6 +10,7 @@ export async function getClawXProviderStore() {
const Store = (await import('electron-store')).default;
providerStore = new Store({
name: 'clawx-providers',
cwd: getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData'))).appDir,
defaults: {
schemaVersion: 0,
providers: {} as Record<string, unknown>,
@@ -0,0 +1,177 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from 'node:crypto';
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { app, safeStorage } from 'electron';
import type { ProviderSecret } from '../../shared/providers/types';
import { getClawXDataLayout, resolveClawXDataRoot } from '../../utils/clawx-data-layout';
const VAULT_SCHEMA = 'clawx-credential-vault';
const VAULT_VERSION = 1;
let credentialMutationQueue: Promise<void> = Promise.resolve();
type CredentialVaultDocument = {
schema: typeof VAULT_SCHEMA;
version: typeof VAULT_VERSION;
secrets: Record<string, ProviderSecret>;
channelSecrets: Record<string, Record<string, string>>;
};
export interface CredentialCipher {
isEncryptionAvailable(): boolean;
encryptString(value: string): Buffer;
decryptString(value: Buffer): string;
}
function credentialPaths() {
const layout = getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData')));
return {
vaultPath: join(layout.credentialsDir, 'secrets.enc'),
indexPath: join(layout.credentialsDir, 'index.json'),
};
}
function e2eCredentialCipher(secret: string): CredentialCipher {
const key = createHash('sha256').update(secret).digest();
return {
isEncryptionAvailable: () => true,
encryptString: (value) => {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
return Buffer.concat([iv, cipher.getAuthTag(), encrypted]);
},
decryptString: (value) => {
const iv = value.subarray(0, 12);
const authTag = value.subarray(12, 28);
const encrypted = value.subarray(28);
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
},
};
}
function defaultCredentialCipher(): CredentialCipher {
const e2eKey = process.env.CLAWX_E2E_CREDENTIAL_KEY?.trim();
if (process.env.CLAWX_E2E === '1' && e2eKey) return e2eCredentialCipher(e2eKey);
return safeStorage;
}
function emptyVault(): CredentialVaultDocument {
return { schema: VAULT_SCHEMA, version: VAULT_VERSION, secrets: {}, channelSecrets: {} };
}
async function writeAtomic(path: string, content: string | Buffer, mode = 0o600): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
await writeFile(temporaryPath, content, { mode });
await chmod(temporaryPath, mode).catch(() => {});
await rename(temporaryPath, path);
await chmod(path, mode).catch(() => {});
}
function serializeCredentialMutation<T>(mutation: () => Promise<T>): Promise<T> {
const result = credentialMutationQueue.then(mutation, mutation);
credentialMutationQueue = result.then(() => undefined, () => undefined);
return result;
}
export async function readCredentialVault(
cipher: CredentialCipher = defaultCredentialCipher(),
): Promise<CredentialVaultDocument> {
const { vaultPath } = credentialPaths();
let encrypted: Buffer;
try {
encrypted = await readFile(vaultPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return emptyVault();
throw error;
}
if (!cipher.isEncryptionAvailable()) {
throw new Error('OS credential encryption is unavailable; refusing to read ClawX provider secrets');
}
const parsed = JSON.parse(cipher.decryptString(encrypted)) as Partial<CredentialVaultDocument>;
if (parsed.schema !== VAULT_SCHEMA || parsed.version !== VAULT_VERSION || !parsed.secrets) {
throw new Error('Unsupported or invalid ClawX credential vault');
}
return {
...(parsed as CredentialVaultDocument),
channelSecrets: parsed.channelSecrets ?? {},
};
}
export async function writeCredentialVault(
document: CredentialVaultDocument,
cipher: CredentialCipher = defaultCredentialCipher(),
): Promise<void> {
if (!cipher.isEncryptionAvailable()) {
throw new Error('OS credential encryption is unavailable; refusing to persist provider secrets');
}
const { vaultPath, indexPath } = credentialPaths();
const encrypted = cipher.encryptString(JSON.stringify(document));
await writeAtomic(vaultPath, encrypted);
await writeAtomic(indexPath, `${JSON.stringify({
schema: 'clawx-credential-index',
version: 1,
accountIds: Object.keys(document.secrets).sort(),
channelCredentialIds: Object.keys(document.channelSecrets).sort(),
updatedAt: new Date().toISOString(),
}, null, 2)}\n`);
}
export async function getVaultSecret(
accountId: string,
cipher: CredentialCipher = defaultCredentialCipher(),
): Promise<ProviderSecret | null> {
return (await readCredentialVault(cipher)).secrets[accountId] ?? null;
}
export async function setVaultSecret(
secret: ProviderSecret,
cipher: CredentialCipher = defaultCredentialCipher(),
): Promise<void> {
await serializeCredentialMutation(async () => {
const document = await readCredentialVault(cipher);
document.secrets[secret.accountId] = secret;
await writeCredentialVault(document, cipher);
});
}
export async function deleteVaultSecret(
accountId: string,
cipher: CredentialCipher = defaultCredentialCipher(),
): Promise<void> {
await serializeCredentialMutation(async () => {
const document = await readCredentialVault(cipher);
if (!(accountId in document.secrets)) return;
delete document.secrets[accountId];
if (Object.keys(document.secrets).length === 0 && Object.keys(document.channelSecrets).length === 0) {
const { vaultPath, indexPath } = credentialPaths();
await Promise.all([rm(vaultPath, { force: true }), rm(indexPath, { force: true })]);
return;
}
await writeCredentialVault(document, cipher);
});
}
export async function getChannelVaultSecrets(
cipher: CredentialCipher = defaultCredentialCipher(),
): Promise<Record<string, Record<string, string>>> {
return (await readCredentialVault(cipher)).channelSecrets;
}
export async function replaceChannelVaultSecrets(
channelSecrets: Record<string, Record<string, string>>,
cipher: CredentialCipher = defaultCredentialCipher(),
): Promise<void> {
await serializeCredentialMutation(async () => {
const document = await readCredentialVault(cipher);
document.channelSecrets = channelSecrets;
if (Object.keys(document.secrets).length === 0 && Object.keys(channelSecrets).length === 0) {
const { vaultPath, indexPath } = credentialPaths();
await Promise.all([rm(vaultPath, { force: true }), rm(indexPath, { force: true })]);
return;
}
await writeCredentialVault(document, cipher);
});
}
+45 -19
View File
@@ -1,5 +1,6 @@
import type { ProviderSecret } from '../../shared/providers/types';
import { getClawXProviderStore } from '../providers/store-instance';
import { deleteVaultSecret, getVaultSecret, setVaultSecret } from './credential-vault';
export interface SecretStore {
get(accountId: string): Promise<ProviderSecret | null>;
@@ -9,10 +10,19 @@ export interface SecretStore {
export class ElectronStoreSecretStore implements SecretStore {
async get(accountId: string): Promise<ProviderSecret | null> {
const encrypted = await getVaultSecret(accountId);
if (encrypted) {
const store = await getClawXProviderStore();
await this.clearLegacySecret(store, accountId);
return encrypted;
}
const store = await getClawXProviderStore();
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
const secret = secrets[accountId];
if (secret) {
await setVaultSecret(secret);
await this.clearLegacySecret(store, accountId);
return secret;
}
@@ -22,37 +32,32 @@ export class ElectronStoreSecretStore implements SecretStore {
return null;
}
return {
const migrated: ProviderSecret = {
type: 'api_key',
accountId,
apiKey,
};
await setVaultSecret(migrated);
await this.clearLegacySecret(store, accountId);
return migrated;
}
async set(secret: ProviderSecret): Promise<void> {
await setVaultSecret(secret);
const store = await getClawXProviderStore();
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
secrets[secret.accountId] = secret;
store.set('providerSecrets', secrets);
// Keep legacy apiKeys in sync until the rest of the app moves to account-based secrets.
const apiKeys = (store.get('apiKeys') ?? {}) as Record<string, string>;
if (secret.type === 'api_key') {
apiKeys[secret.accountId] = secret.apiKey;
} else if (secret.type === 'local') {
if (secret.apiKey) {
apiKeys[secret.accountId] = secret.apiKey;
} else {
delete apiKeys[secret.accountId];
}
} else {
delete apiKeys[secret.accountId];
}
store.set('apiKeys', apiKeys);
await this.clearLegacySecret(store, secret.accountId);
}
async delete(accountId: string): Promise<void> {
await deleteVaultSecret(accountId);
const store = await getClawXProviderStore();
await this.clearLegacySecret(store, accountId);
}
private async clearLegacySecret(store: {
get(key: string): unknown;
set(key: string, value: unknown): void;
}, accountId: string): Promise<void> {
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
delete secrets[accountId];
store.set('providerSecrets', secrets);
@@ -63,6 +68,27 @@ export class ElectronStoreSecretStore implements SecretStore {
}
}
export async function migrateLegacyProviderSecretsToVault(): Promise<number> {
const store = await getClawXProviderStore();
const legacySecrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
const legacyApiKeys = (store.get('apiKeys') ?? {}) as Record<string, string>;
const accountIds = new Set([...Object.keys(legacyApiKeys), ...Object.keys(legacySecrets)]);
if (accountIds.size === 0) return 0;
for (const accountId of accountIds) {
const existing = await getVaultSecret(accountId);
if (existing) continue;
const secret = legacySecrets[accountId] ?? (legacyApiKeys[accountId]
? { type: 'api_key' as const, accountId, apiKey: legacyApiKeys[accountId] }
: undefined);
if (secret) await setVaultSecret(secret);
}
store.set('providerSecrets', {});
store.set('apiKeys', {});
return accountIds.size;
}
const secretStore = new ElectronStoreSecretStore();
export function getSecretStore(): SecretStore {
+22 -3
View File
@@ -2,6 +2,7 @@ import { openSync, closeSync, fstatSync, readSync } from 'node:fs';
import { access } from 'node:fs/promises';
import { join } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { RuntimeManager } from '../runtime/manager';
import { stripAcpWorkingDirectoryPrefix } from '@shared/chat/session-title';
import { isOpenClawHeartbeatPollText } from '@shared/chat/openclaw-internal';
import type { RawMessage } from '@shared/chat/types';
@@ -473,7 +474,7 @@ async function loadSessionSummary(sessionKey: string, workspacePath: string | nu
}
}
export async function loadSessionTranscriptByKey(sessionKey: string, limit: number): Promise<RawMessage[] | null> {
async function loadSessionTranscriptByKey(sessionKey: string, limit: number): Promise<RawMessage[] | null> {
const parsed = parseSessionKey(sessionKey);
if (!parsed) return null;
@@ -621,9 +622,15 @@ async function renameSession(sessionKey: string, label: string): Promise<{ succe
return { success: true };
}
export function createSessionsApi(): CompleteHostServiceRegistry['sessions'] {
export function createSessionsApi(runtimeManager?: RuntimeManager): CompleteHostServiceRegistry['sessions'] {
return {
delete: async (payload) => deleteSession(getSessionKey(payload)),
delete: async (payload) => {
const provider = runtimeManager?.getActiveProvider();
if (provider?.listCapabilities().sessions) {
return provider.deleteSession(payload);
}
return deleteSession(getSessionKey(payload));
},
rename: async (payload) => {
const body = isRecord(payload) ? payload as SessionPayload : {};
const sessionKey = getSessionKey(payload);
@@ -631,9 +638,17 @@ export function createSessionsApi(): CompleteHostServiceRegistry['sessions'] {
if (typeof label !== 'string') {
throw new Error('Label cannot be empty');
}
const provider = runtimeManager?.getActiveProvider();
if (provider?.listCapabilities().sessions) {
return provider.rpc('sessions.rename', { sessionKey, label }) as Promise<{ success: boolean; error?: string }>;
}
return renameSession(sessionKey, label);
},
summaries: async (payload) => {
const provider = runtimeManager?.getActiveProvider();
if (provider?.listCapabilities().sessions) {
return provider.listSessions(payload) as ReturnType<CompleteHostServiceRegistry['sessions']['summaries']>;
}
const body = isRecord(payload) ? payload as SessionPayload : {};
const sessionKeys = Array.isArray(body.sessionKeys)
? body.sessionKeys.filter((value): value is string => typeof value === 'string' && value.startsWith('agent:'))
@@ -648,6 +663,10 @@ export function createSessionsApi(): CompleteHostServiceRegistry['sessions'] {
};
},
history: async (payload) => {
const provider = runtimeManager?.getActiveProvider();
if (provider?.listCapabilities().history) {
return provider.loadHistory(payload) as ReturnType<CompleteHostServiceRegistry['sessions']['history']>;
}
const body = isRecord(payload) ? payload as SessionPayload : {};
const limit = getLimit(payload);
+12 -3
View File
@@ -1,5 +1,7 @@
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { GatewayManager } from '../gateway/manager';
import type { RuntimeManager } from '../runtime/manager';
import type { RuntimeKind } from '@shared/types/gateway';
import { syncLaunchAtStartupSettingFromStore } from '../main/launch-at-startup';
import { createMenu } from '../main/menu';
import { applyProxySettings } from '../main/proxy';
@@ -86,7 +88,11 @@ async function handleProxySettingsChange(gatewayManager: GatewayManager): Promis
async function runSettingsSideEffects(
gatewayManager: GatewayManager,
patch: Partial<AppSettings>,
runtimeManager?: RuntimeManager,
): Promise<void> {
if (typeof patch.runtimeKind === 'string' && runtimeManager) {
await runtimeManager.setActiveKind(patch.runtimeKind as RuntimeKind);
}
if (patchTouchesProxy(patch)) {
await handleProxySettingsChange(gatewayManager);
}
@@ -98,7 +104,10 @@ async function runSettingsSideEffects(
}
}
export function createSettingsApi(gatewayManager: GatewayManager): CompleteHostServiceRegistry['settings'] {
export function createSettingsApi(
gatewayManager: GatewayManager,
runtimeManager?: RuntimeManager,
): CompleteHostServiceRegistry['settings'] {
return {
getAll: () => getAllSettings(),
get: async (payload) => {
@@ -109,7 +118,7 @@ export function createSettingsApi(gatewayManager: GatewayManager): CompleteHostS
const body = payload as SetPayload | undefined;
const key = await requireSettingKey(body);
await setSetting(key as never, body?.value as never);
await runSettingsSideEffects(gatewayManager, { [key]: body?.value } as Partial<AppSettings>);
await runSettingsSideEffects(gatewayManager, { [key]: body?.value } as Partial<AppSettings>, runtimeManager);
return { success: true };
},
setMany: async (payload) => {
@@ -118,7 +127,7 @@ export function createSettingsApi(gatewayManager: GatewayManager): CompleteHostS
for (const [key, value] of entries) {
await setSetting(key, value as never);
}
await runSettingsSideEffects(gatewayManager, patch);
await runSettingsSideEffects(gatewayManager, patch, runtimeManager);
return { success: true };
},
reset: async () => {
+69 -5
View File
@@ -1,7 +1,12 @@
import type { GatewayManager } from '../gateway/manager';
import type { RuntimeManager } from '../runtime/manager';
import type { ClawHubService, ClawHubInstallParams, ClawHubSearchParams, ClawHubUninstallParams } from '../gateway/clawhub';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { join } from 'node:path';
import { readFile } from 'node:fs/promises';
import { getCcConnectCodexHomeDir, getCcConnectProviderProfilePath } from '../runtime/cc-connect-paths';
import { getAllSkillConfigs, getSkillConfig, updateSkillConfig, updateSkillConfigs } from '../utils/skill-config';
import { getOpenClawSkillsDir } from '../utils/paths';
import {
collectQuickAccessSkills,
filterEnabledQuickAccessSkills,
@@ -86,12 +91,50 @@ function getConfigUpdates(payload: unknown): NormalizedSkillConfigUpdate[] {
export function createSkillsApi({
clawHubService,
gatewayManager,
runtimeManager,
}: {
clawHubService: ClawHubService;
gatewayManager: GatewayManager;
runtimeManager?: RuntimeManager;
}): CompleteHostServiceRegistry['skills'] {
const runtimeSupportsSkills = () => runtimeManager?.listCapabilities().skills === true;
const refreshCcConnectSkills = async () => {
if (runtimeManager?.getActiveProvider().kind === 'cc-connect') {
await runtimeManager.rpc('skills.update', {});
}
};
return {
local: async () => ({ success: true, skills: await listLocalSkills() }),
target: async () => {
const sourceDir = getOpenClawSkillsDir();
const activeKind = await runtimeManager?.getActiveKind();
if (activeKind === 'cc-connect') {
const profile: { codexHomeDir?: unknown } = await readFile(getCcConnectProviderProfilePath(), 'utf8')
.then((content) => JSON.parse(content) as { codexHomeDir?: unknown })
.catch(() => ({} as { codexHomeDir?: unknown }));
const codexHomeDir = typeof profile.codexHomeDir === 'string'
? profile.codexHomeDir
: getCcConnectCodexHomeDir();
const runtimeDir = join(codexHomeDir, 'skills');
return {
success: true,
runtimeKind: 'cc-connect',
sourceDir,
openDir: runtimeDir,
runtimeDir,
manifestPath: join(runtimeDir, 'manifest.json'),
mirrorMode: 'runtime-mirror',
};
}
return {
success: true,
runtimeKind: 'openclaw',
sourceDir,
openDir: sourceDir,
runtimeDir: sourceDir,
mirrorMode: 'source',
};
},
configs: async () => getAllSkillConfigs(),
allConfigs: async () => getAllSkillConfigs(),
getConfig: async (payload) => {
@@ -100,11 +143,23 @@ export function createSkillsApi({
},
updateConfig: async (payload) => {
const { skillKey, ...updates } = getConfigUpdate(payload);
return updateSkillConfig(skillKey, updates);
const result = await updateSkillConfig(skillKey, updates);
await refreshCcConnectSkills();
return result;
},
updateConfigs: async (payload) => {
const result = await updateSkillConfigs(getConfigUpdates(payload));
await refreshCcConnectSkills();
return result;
},
status: async () => {
if (runtimeSupportsSkills()) return await runtimeManager!.rpc('skills.status');
return gatewayManager.rpc('skills.status');
},
update: async (payload) => {
if (runtimeSupportsSkills()) return await runtimeManager!.rpc('skills.update', isRecord(payload) ? payload : {});
return gatewayManager.rpc('skills.update', isRecord(payload) ? payload : {});
},
updateConfigs: async (payload) => updateSkillConfigs(getConfigUpdates(payload)),
status: async () => gatewayManager.rpc('skills.status'),
update: async (payload) => gatewayManager.rpc('skills.update', isRecord(payload) ? payload : {}),
quickAccess: async (payload) => {
const body = isRecord(payload) ? payload as QuickAccessPayload : {};
const [scannedSkills, configs] = await Promise.all([
@@ -114,7 +169,14 @@ export function createSkillsApi({
getAllSkillConfigs(),
]);
let runtimeSkills: QuickAccessRuntimeSkillStatus[] | undefined;
if (gatewayManager.getStatus().state === 'running') {
if (runtimeSupportsSkills()) {
try {
const runtimeStatus = await runtimeManager!.rpc<{ skills?: QuickAccessRuntimeSkillStatus[] }>('skills.status');
runtimeSkills = runtimeStatus.skills || [];
} catch {
runtimeSkills = undefined;
}
} else if (gatewayManager.getStatus().state === 'running') {
try {
const runtimeStatus = await gatewayManager.rpc<{ skills?: QuickAccessRuntimeSkillStatus[] }>('skills.status');
runtimeSkills = runtimeStatus.skills || [];
@@ -151,6 +213,7 @@ export function createSkillsApi({
clawhubInstall: async (payload) => {
try {
await clawHubService.install((isRecord(payload) ? payload : {}) as ClawHubInstallParams);
await refreshCcConnectSkills();
return { success: true };
} catch (error) {
return { success: false, error: errorMessage(error) };
@@ -159,6 +222,7 @@ export function createSkillsApi({
clawhubUninstall: async (payload) => {
try {
await clawHubService.uninstall((isRecord(payload) ? payload : {}) as ClawHubUninstallParams);
await refreshCcConnectSkills();
return { success: true };
} catch (error) {
return { success: false, error: errorMessage(error) };
+44 -3
View File
@@ -1,9 +1,13 @@
import { getRecentTokenUsageHistory } from '../utils/token-usage';
import type { TokenUsageHistoryEntry } from '../utils/token-usage-core';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { RuntimeManager } from '../runtime/manager';
import type { RuntimeKind } from '@shared/types/gateway';
import { isRecord } from './payload-utils';
import { toTokenUsageHistoryEntry } from '../runtime/usage';
type RecentTokenHistoryPayload = {
limit?: unknown;
runtimeKind?: unknown;
};
function getSafeLimit(payload: unknown): number | undefined {
@@ -20,8 +24,45 @@ function getSafeLimit(payload: unknown): number | undefined {
return undefined;
}
export function createUsageApi(): CompleteHostServiceRegistry['usage'] {
function getExplicitRuntimeKind(payload: unknown): RuntimeKind | undefined {
return isRecord(payload) && (payload.runtimeKind === 'openclaw' || payload.runtimeKind === 'cc-connect')
? payload.runtimeKind
: undefined;
}
function getActiveRuntimeKind(runtimeManager?: RuntimeManager): RuntimeKind | undefined {
return runtimeManager?.getActiveProvider().kind;
}
async function getRuntimeTokenHistory(
limit: number | undefined,
runtimeKind: RuntimeKind,
runtimeManager: RuntimeManager | undefined,
): Promise<TokenUsageHistoryEntry[]> {
const provider = runtimeManager?.getProvider(runtimeKind);
if (!provider) return [];
if (runtimeKind === 'cc-connect' && provider !== runtimeManager?.getActiveProvider()) return [];
const result = await provider.listUsage({ ...(limit !== undefined ? { limit } : {}) });
if (!result.success) return [];
const entries = result.records.map(toTokenUsageHistoryEntry);
entries.sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp));
return entries.slice(0, limit ?? entries.length);
}
export async function getRecentTokenHistoryForRuntime(
payload?: unknown,
runtimeManager?: RuntimeManager,
) {
const limit = getSafeLimit(payload);
const runtimeKind = getExplicitRuntimeKind(payload) ?? getActiveRuntimeKind(runtimeManager);
if (!runtimeKind) return [];
return getRuntimeTokenHistory(limit, runtimeKind, runtimeManager);
}
export function createUsageApi(runtimeManager?: RuntimeManager): CompleteHostServiceRegistry['usage'] {
return {
recentTokenHistory: async (payload) => getRecentTokenUsageHistory(getSafeLimit(payload)),
recentTokenHistory: async (payload) => {
return getRecentTokenHistoryForRuntime(payload, runtimeManager);
},
};
}
+20 -6
View File
@@ -1,7 +1,7 @@
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';
import { normalizeWebBrowserTopLevelUrl } from '../../shared/web-browser';
export interface WebBrowserApiDependencies {
browserSession: Session;
@@ -18,9 +18,9 @@ function requireLiveGuest(registry: WebBrowserGuestRegistry): WebContents {
}
function requireAllowedUrl(url: string): string {
const normalizedUrl = normalizeWebBrowserHtmlFileUrl(url);
const normalizedUrl = normalizeWebBrowserTopLevelUrl(url);
if (!normalizedUrl) {
throw new Error('Only local HTML file URLs are allowed');
throw new Error('Web browser URL is not allowed');
}
return normalizedUrl;
}
@@ -34,7 +34,7 @@ function isAbortedLoad(error: unknown): boolean {
export function createWebBrowserApi(
dependencies: WebBrowserApiDependencies,
): CompleteHostServiceRegistry['webBrowser'] {
const { registry } = dependencies;
const { browserSession, registry } = dependencies;
const openExternal = dependencies.openExternal ?? ((url: string) => shell.openExternal(url));
return {
@@ -48,8 +48,22 @@ export function createWebBrowserApi(
}
},
async openExternal({ url }) {
await openExternal(requireAllowedUrl(url));
async clearCookies() {
await browserSession.clearStorageData({ storages: ['cookies'] });
},
async clearSiteData() {
await Promise.all([
browserSession.clearCache(),
browserSession.clearStorageData({
storages: ['cachestorage', 'localstorage', 'indexdb', 'serviceworkers'],
}),
]);
},
async openExternal() {
const guest = requireLiveGuest(registry);
await openExternal(requireAllowedUrl(guest.getURL()));
},
};
}
+28 -168
View File
@@ -1,190 +1,50 @@
export type ModelInputModality = 'text' | 'image';
type ContextWindowRule = {
/** Human-readable family label; kept so the table reads as documentation. */
label: string;
pattern: RegExp;
contextWindow: number;
};
/**
* Context-window defaults for well-known model families, applied to model rows
* that would otherwise carry no `contextWindow`.
* Conservative context-window defaults for well-known model families, applied
* to custom-provider 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 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 },
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 },
];
/**
* 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;
/** Safe floor for unknown custom models: high enough to avoid compaction spam. */
export const DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW = 131_072;
/**
* 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);
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 Math.min(DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW, ceiling);
return DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW;
}
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 supportsImageInput = VISION_MODEL_PATTERNS.some((pattern) => matchesModelId(pattern, modelId));
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)
);
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.6-sol',
defaultModelId: 'gpt-5.5',
isOAuth: true,
supportsApiKey: true,
showModelId: true,
modelIdPlaceholder: 'gpt-5.6-sol',
modelIdPlaceholder: 'gpt-5.5',
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: 262144,
contextWindow: 256000,
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: 262144,
contextWindow: 256000,
maxTokens: 8192,
},
],
+1
View File
@@ -207,6 +207,7 @@ export type ProviderSecret =
accountId: string;
accessToken: string;
refreshToken: string;
idToken?: string;
expiresAt: number;
scopes?: string[];
email?: string;
+109 -232
View File
@@ -1,18 +1,23 @@
import { copyFile, lstat, mkdir, readdir, rm } from 'fs/promises';
import { access, copyFile, mkdir, readdir, rm } from 'fs/promises';
import { constants } from 'fs';
import { join, normalize } from 'path';
import { isDeepStrictEqual } from 'node:util';
import { mutateOpenClawConfig } from '../gateway/config-delivery';
import { deleteAgentChannelAccounts, listConfiguredChannelsFromConfig, readOpenClawConfig } from './channel-config';
import { app } from 'electron';
import { deleteAgentChannelAccounts, listConfiguredChannels, readOpenClawConfig, writeOpenClawConfig } 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';
import { ensureClawXIdentityFile } from './openclaw-workspace';
import {
listCcConnectAgentPermissionModes,
listCcConnectAgentProviderBindings,
} from '../runtime/cc-connect-agent-bindings';
import { getClawXDataLayout, resolveClawXDataRoot } from './clawx-data-layout';
const MAIN_AGENT_ID = 'main';
const MAIN_AGENT_NAME = 'Main Agent';
const DEFAULT_ACCOUNT_ID = 'default';
const DEFAULT_WORKSPACE_PATH = '~/.openclaw/workspace';
const AGENT_BOOTSTRAP_FILES = [
'AGENTS.md',
'SOUL.md',
@@ -62,11 +67,6 @@ 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;
@@ -152,7 +152,7 @@ function slugifyAgentId(name: string): string {
async function fileExists(path: string): Promise<boolean> {
try {
await lstat(path);
await access(path, constants.F_OK);
return true;
} catch {
return false;
@@ -171,7 +171,13 @@ function getDefaultWorkspacePath(config: AgentConfigDocument): string {
: undefined);
return typeof defaults?.workspace === 'string' && defaults.workspace.trim()
? defaults.workspace
: DEFAULT_WORKSPACE_PATH;
: getClawXManagedWorkspacePath(MAIN_AGENT_ID);
}
function getClawXManagedWorkspacePath(agentId: string): string {
const safeAgentId = agentId.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-') || MAIN_AGENT_ID;
const layout = getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData')));
return join(layout.agentWorkspacesDir, safeAgentId);
}
function getDefaultAgentDirPath(agentId: string): string {
@@ -222,7 +228,7 @@ function normalizeAgentsConfig(config: AgentConfigDocument): {
};
}
function isChannelBinding(binding: unknown): binding is ChannelBindingConfig {
function isChannelBinding(binding: unknown): binding is BindingConfig {
if (!binding || typeof binding !== 'object') return false;
const candidate = binding as BindingConfig;
if (typeof candidate.agentId !== 'string' || !candidate.agentId) return false;
@@ -355,10 +361,8 @@ function trimTrailingSeparators(path: string): string {
}
function getManagedWorkspaceDirectory(agent: AgentListEntry): string | null {
if (agent.id === MAIN_AGENT_ID) return null;
const configuredWorkspace = expandPath(agent.workspace || `~/.openclaw/workspace-${agent.id}`);
const managedWorkspace = join(getOpenClawConfigDir(), `workspace-${agent.id}`);
const configuredWorkspace = expandPath(agent.workspace || getClawXManagedWorkspacePath(agent.id));
const managedWorkspace = getClawXManagedWorkspacePath(agent.id);
const normalizedConfigured = trimTrailingSeparators(normalize(configuredWorkspace));
const normalizedManaged = trimTrailingSeparators(normalize(managedWorkspace));
@@ -416,7 +420,7 @@ async function provisionAgentFilesystem(
const { entries } = normalizeAgentsConfig(config);
const mainEntry = entries.find((entry) => entry.id === MAIN_AGENT_ID) ?? createImplicitMainEntry(config);
const sourceWorkspace = expandPath(mainEntry.workspace || getDefaultWorkspacePath(config));
const targetWorkspace = expandPath(agent.workspace || `~/.openclaw/workspace-${agent.id}`);
const targetWorkspace = expandPath(agent.workspace || getClawXManagedWorkspacePath(agent.id));
const sourceAgentDir = expandPath(mainEntry.agentDir || getDefaultAgentDirPath(MAIN_AGENT_ID));
const targetAgentDir = expandPath(agent.agentDir || getDefaultAgentDirPath(agent.id));
const targetSessionsDir = join(getOpenClawConfigDir(), 'agents', agent.id, 'sessions');
@@ -465,12 +469,15 @@ function listConfiguredAccountIdsForChannel(config: AgentConfigDocument, channel
async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedChannels?: string[]): Promise<AgentsSnapshot> {
const { entries, defaultAgentId } = normalizeAgentsConfig(config);
const configuredChannels = preloadedChannels
?? await listConfiguredChannelsFromConfig(config as OpenClawConfig);
const configuredChannels = preloadedChannels ?? await listConfiguredChannels();
const { channelToAgent, accountToAgent } = getChannelBindingMap(config.bindings);
const defaultAgentIdNorm = normalizeAgentIdForBinding(defaultAgentId);
const channelOwners: Record<string, string> = {};
const channelAccountOwners: Record<string, string> = {};
const [providerBindings, permissionModes] = await Promise.all([
listCcConnectAgentProviderBindings(),
listCcConnectAgentPermissionModes(),
]);
// Build per-agent channel lists from account-scoped bindings
const agentChannelSets = new Map<string, Set<string>>();
@@ -478,11 +485,15 @@ 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
accountId === DEFAULT_ACCOUNT_ID && !hasExplicitAccountBindingForChannel
? channelToAgent.get(channelType)
: undefined
);
@@ -524,8 +535,10 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedCha
modelDisplay: modelLabel,
modelRef: explicitModelRef || defaultModelRef || null,
overrideModelRef: explicitModelRef,
providerAccountId: providerBindings[entry.id] ?? null,
permissionMode: permissionModes[entry.id] ?? 'full-auto',
inheritedModel,
workspace: entry.workspace || (entry.id === MAIN_AGENT_ID ? getDefaultWorkspacePath(config) : `~/.openclaw/workspace-${entry.id}`),
workspace: entry.workspace || getClawXManagedWorkspacePath(entry.id),
agentDir: entry.agentDir || getDefaultAgentDirPath(entry.id),
mainSessionKey: buildAgentMainSessionKey(config, entry.id),
channelTypes: configuredChannels
@@ -545,25 +558,16 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedCha
}
export async function listAgentsSnapshot(): Promise<AgentsSnapshot> {
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);
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);
});
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> {
@@ -600,12 +604,8 @@ export async function createAgent(
name: string,
options?: { inheritWorkspace?: boolean },
): Promise<AgentsSnapshot> {
let snapshot: AgentsSnapshot | undefined;
let createdAgentId = '';
let agentToProvision: AgentListEntry | undefined;
let provisioningConfig: AgentConfigDocument | undefined;
await mutateOpenClawConfig(async (configSnapshot) => {
const config = configSnapshot as AgentConfigDocument;
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
const { agentsConfig, entries, syntheticMain } = normalizeAgentsConfig(config);
const normalizedName = normalizeAgentName(name);
const existingIds = new Set(entries.map((entry) => entry.id));
@@ -622,7 +622,7 @@ export async function createAgent(
const newAgent: AgentListEntry = {
id: nextId,
name: normalizedName,
workspace: `~/.openclaw/workspace-${nextId}`,
workspace: getClawXManagedWorkspacePath(nextId),
agentDir: getDefaultAgentDirPath(nextId),
};
@@ -636,61 +636,18 @@ export async function createAgent(
list: nextEntries,
};
createdAgentId = nextId;
agentToProvision = newAgent;
provisioningConfig = structuredClone(config);
snapshot = await buildSnapshotFromConfig(config);
await provisionAgentFilesystem(config, newAgent, { inheritWorkspace: options?.inheritWorkspace });
await writeOpenClawConfig(config);
logger.info('Created agent config entry', { agentId: nextId, inheritWorkspace: !!options?.inheritWorkspace });
return 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> {
let snapshot: AgentsSnapshot | undefined;
const normalizedName = normalizeAgentName(name);
await mutateOpenClawConfig(async (configSnapshot) => {
const config = configSnapshot as AgentConfigDocument;
return withConfigLock(async () => {
const config = await readOpenClawConfig() 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`);
@@ -706,10 +663,10 @@ export async function updateAgentName(agentId: string, name: string): Promise<Ag
list: entries,
};
snapshot = await buildSnapshotFromConfig(config);
await writeOpenClawConfig(config);
logger.info('Updated agent name', { agentId, name: normalizedName });
return buildSnapshotFromConfig(config);
});
logger.info('Updated agent name', { agentId, name: normalizedName });
return snapshot!;
}
function isValidModelRef(modelRef: string): boolean {
@@ -718,16 +675,15 @@ function isValidModelRef(modelRef: string): boolean {
}
export async function updateAgentModel(agentId: string, modelRef: string | null): Promise<AgentsSnapshot> {
const normalizedModelRef = typeof modelRef === 'string' ? modelRef.trim() : '';
let snapshot: AgentsSnapshot | undefined;
await mutateOpenClawConfig(async (configSnapshot) => {
const config = configSnapshot as AgentConfigDocument;
return withConfigLock(async () => {
const config = await readOpenClawConfig() 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) {
@@ -767,24 +723,21 @@ export async function updateAgentModel(agentId: string, modelRef: string | null)
list: entries,
};
snapshot = await buildSnapshotFromConfig(config);
await writeOpenClawConfig(config);
logger.info('Updated agent model', { agentId, modelRef: normalizedModelRef || null });
return buildSnapshotFromConfig(config);
});
logger.info('Updated agent model', { agentId, modelRef: normalizedModelRef || null });
return snapshot!;
}
export async function deleteAgentConfig(agentId: string): Promise<{ snapshot: AgentsSnapshot; removedEntry: AgentListEntry }> {
if (agentId === MAIN_AGENT_ID) {
throw new Error('The main agent cannot be deleted');
}
return withConfigLock(async () => {
if (agentId === MAIN_AGENT_ID) {
throw new Error('The main agent cannot be deleted');
}
let result: { snapshot: AgentsSnapshot; removedEntry: AgentListEntry } | undefined;
await mutateOpenClawConfig(async (configSnapshot) => {
const config = configSnapshot as AgentConfigDocument;
const config = await readOpenClawConfig() as AgentConfigDocument;
const { agentsConfig, entries, defaultAgentId } = normalizeAgentsConfig(config);
const bindingsBeforeDeletion = Array.isArray(config.bindings)
? config.bindings.filter(isChannelBinding)
: [];
const snapshotBeforeDeletion = await buildSnapshotFromConfig(config);
const removedEntry = entries.find((entry) => entry.id === agentId);
const nextEntries = entries.filter((entry) => entry.id !== agentId);
if (!removedEntry || nextEntries.length === entries.length) {
@@ -808,87 +761,81 @@ 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(
[...boundChannelTypes]
.filter((channelType) => {
const accountOwner = accountToAgent.get(`${channelType}:${legacyAccountId}`);
const effectiveOwner = accountOwner
?? (legacyAccountId === DEFAULT_ACCOUNT_ID ? channelToAgent.get(channelType) : undefined);
return effectiveOwner === normalizedAgentId;
Object.entries(snapshotBeforeDeletion.channelAccountOwners)
.filter(([channelAccountKey, owner]) => {
if (owner !== normalizedAgentId) return false;
const accountId = channelAccountKey.slice(channelAccountKey.indexOf(':') + 1);
return accountId === legacyAccountId;
})
.map((channelType) => `${channelType}:${legacyAccountId}`),
.map(([channelAccountKey]) => channelAccountKey),
);
await writeOpenClawConfig(config);
await deleteAgentChannelAccounts(agentId, ownedLegacyAccounts);
result = { snapshot: await buildSnapshotFromConfig(config), removedEntry };
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 };
});
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> {
let snapshot: AgentsSnapshot | undefined;
const accountId = resolveAccountIdForAgent(agentId);
await mutateOpenClawConfig(async (configSnapshot) => {
const config = configSnapshot as AgentConfigDocument;
return withConfigLock(async () => {
const config = await readOpenClawConfig() 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);
snapshot = await buildSnapshotFromConfig(config);
await writeOpenClawConfig(config);
logger.info('Assigned channel to agent', { agentId, channelType, accountId });
return 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> {
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;
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
const { entries } = normalizeAgentsConfig(config);
if (!entries.some((entry) => entry.id === agentId)) {
throw new Error(`Agent "${agentId}" not found`);
}
if (options?.migrateLegacy) {
const validAgentIds = new Set(entries.map((entry) => normalizeAgentIdForBinding(entry.id)));
migrateLegacyChannelBindingInConfig(config, channelType, validAgentIds);
if (!accountId.trim()) {
throw new Error('accountId is required');
}
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, trimmedAccountId);
snapshot = await buildSnapshotFromConfig(config);
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);
});
logger.info('Assigned channel account to agent', { agentId, channelType, accountId: trimmedAccountId });
return snapshot!;
}
export async function clearChannelBinding(channelType: string, accountId?: string): Promise<AgentsSnapshot> {
let snapshot: AgentsSnapshot | undefined;
await mutateOpenClawConfig(async (configSnapshot) => {
const config = configSnapshot as AgentConfigDocument;
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
config.bindings = upsertBindingsForChannel(config.bindings, channelType, null, accountId);
snapshot = await buildSnapshotFromConfig(config);
await writeOpenClawConfig(config);
logger.info('Cleared channel binding', { channelType, accountId });
return buildSnapshotFromConfig(config);
});
logger.info('Cleared channel binding', { channelType, accountId });
return snapshot!;
}
export async function clearAllBindingsForChannel(channelType: string): Promise<void> {
await mutateOpenClawConfig((configSnapshot) => {
const config = configSnapshot as AgentConfigDocument;
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
if (!Array.isArray(config.bindings)) return;
const nextBindings = config.bindings.filter((binding) => {
@@ -897,77 +844,7 @@ 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 });
}
+23 -52
View File
@@ -4,12 +4,6 @@ import { logger } from './logger';
import { loginOpenAICodexOAuth, type OpenAICodexOAuthCredentials } from './openai-codex-oauth';
import { getProviderService } from '../services/providers/provider-service';
import { getSecretStore } from '../services/secrets/secret-store';
import {
ensureOpenClawProviderAgentRuntimePins,
OPENAI_CODEX_OAUTH_PROVIDER_CONFIG,
saveOAuthTokenToOpenClaw,
setOpenClawDefaultModelWithOverride,
} from './openclaw-auth';
// Google was removed: OpenClaw's `google-gemini-cli` OAuth integration is an
// unofficial third-party flow that requires the `gemini` CLI binary to be on
@@ -17,22 +11,33 @@ import {
// account suspensions. ClawX does not bundle that binary, so the only
// browser-OAuth provider we currently expose end-to-end is OpenAI Codex.
export type BrowserOAuthProviderType = 'openai';
export type BrowserOAuthSuccessPayload = {
provider: BrowserOAuthProviderType;
accountId: string;
};
const OPENAI_RUNTIME_PROVIDER_ID = 'openai';
const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.6-sol';
const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.5';
class BrowserOAuthManager extends EventEmitter {
export class BrowserOAuthManager extends EventEmitter {
private activeAccountId: string | null = null;
private activeLabel: string | null = null;
private active = false;
private mainWindow: BrowserWindow | null = null;
private pendingManualCodeResolve: ((value: string) => void) | null = null;
private pendingManualCodeReject: ((reason?: unknown) => void) | null = null;
private successHandler: ((payload: BrowserOAuthSuccessPayload) => Promise<void>) | null = null;
setWindow(window: BrowserWindow) {
this.mainWindow = window;
}
setSuccessHandler(
handler: ((payload: BrowserOAuthSuccessPayload) => Promise<void>) | null,
): void {
this.successHandler = handler;
}
async startFlow(
provider: BrowserOAuthProviderType,
options?: { accountId?: string; label?: string },
@@ -125,12 +130,6 @@ class BrowserOAuthManager extends EventEmitter {
) {
const accountId = this.activeAccountId || providerType;
const accountLabel = this.activeLabel;
this.active = false;
this.activeAccountId = null;
this.activeLabel = null;
this.pendingManualCodeResolve = null;
this.pendingManualCodeReject = null;
logger.info(`[BrowserOAuth] Successfully completed OAuth for ${providerType}`);
const providerService = getProviderService();
const existing = await providerService.getAccount(accountId);
@@ -175,49 +174,21 @@ class BrowserOAuthManager extends EventEmitter {
accountId,
accessToken: token.access,
refreshToken: token.refresh,
idToken: token.idToken,
expiresAt: token.expires,
email: oauthTokenEmail,
subject: oauthTokenSubject,
});
await saveOAuthTokenToOpenClaw(runtimeProviderId, {
access: token.access,
refresh: token.refresh,
expires: token.expires,
email: oauthTokenEmail,
projectId: oauthTokenSubject,
accountId: oauthTokenSubject,
});
const modelId = normalizedExistingModel || defaultModel;
const modelRef = `${runtimeProviderId}/${modelId}`;
const fallbackModelRefs = (nextAccount.fallbackModels ?? [])
.map((fallback) => fallback.trim())
.filter(Boolean)
.map((fallback) => (
fallback.replace(/^openai-codex\//, `${runtimeProviderId}/`).startsWith(`${runtimeProviderId}/`)
? fallback.replace(/^openai-codex\//, `${runtimeProviderId}/`)
: `${runtimeProviderId}/${fallback}`
));
try {
await setOpenClawDefaultModelWithOverride(
runtimeProviderId,
modelRef,
{
baseUrl: OPENAI_CODEX_OAUTH_PROVIDER_CONFIG.baseUrl,
api: OPENAI_CODEX_OAUTH_PROVIDER_CONFIG.api,
},
fallbackModelRefs,
);
await ensureOpenClawProviderAgentRuntimePins();
logger.info(`[BrowserOAuth] Registered ${runtimeProviderId} in openclaw.json (default model: ${modelRef})`);
} catch (err) {
logger.warn('[BrowserOAuth] Failed to register OpenAI OAuth provider in openclaw.json:', err);
throw err;
}
this.emit('oauth:success', { provider: providerType, accountId: nextAccount.id });
const successPayload = { provider: providerType, accountId: nextAccount.id };
await this.successHandler?.(successPayload);
this.active = false;
this.activeAccountId = null;
this.activeLabel = null;
this.pendingManualCodeResolve = null;
this.pendingManualCodeReject = null;
logger.info(`[BrowserOAuth] Successfully completed OAuth for ${providerType}`);
this.emit('oauth:success', successPayload);
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:success', {
provider: providerType,
+284 -214
View File
@@ -8,10 +8,13 @@ import { access, mkdir, readFile, writeFile, readdir, stat, rm } from 'fs/promis
import { constants } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { mutateOpenClawConfig, readOpenClawConfigSnapshot } from '../gateway/config-delivery';
import { getOpenClawResolvedDir, resolveOpenClawConfigPath } from './paths';
import { getOpenClawResolvedDir } from './paths';
import * as logger from './logger';
import { proxyAwareFetch } from './proxy-fetch';
import { withConfigLock } from './config-mutex';
import { readClawXRuntimeConfig, writeClawXRuntimeConfig } from './clawx-runtime-config';
import { getChannelVaultSecrets, replaceChannelVaultSecrets } from '../services/secrets/credential-vault';
import { getSetting } from './store';
import {
OPENCLAW_WECHAT_CHANNEL_TYPE,
isWechatChannelType,
@@ -20,6 +23,7 @@ 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;
@@ -153,7 +157,8 @@ function sanitizeDiscordGuilds(config: unknown): void {
/**
* Strip `defaultAccount` from channel sections whose plugin schema
* declares additionalProperties:false without listing `defaultAccount`.
* Call before committing channel-config mutations.
* Call right before every `writeOpenClawConfig` in channel-config
* mutation functions.
*/
function sanitizeChannelSectionsBeforeWrite(config: OpenClawConfig): void {
if (!config.channels) return;
@@ -364,51 +369,6 @@ 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,
@@ -496,12 +456,113 @@ export interface OpenClawConfig {
[key: string]: unknown;
}
const CHANNEL_SECRET_FIELDS = new Set([
'accessToken',
'appPassword',
'appSecret',
'appToken',
'botSecret',
'botToken',
'callbackAesKey',
'callbackToken',
'channelAccessToken',
'channelSecret',
'channelToken',
'clientSecret',
'corpSecret',
'encryptKey',
'password',
'secret',
'serviceAccountKey',
'token',
]);
function cloneConfig(config: OpenClawConfig): OpenClawConfig {
return JSON.parse(JSON.stringify(config)) as OpenClawConfig;
}
function channelCredentialId(channelType: string, accountId: string): string {
return `${channelType}:${accountId}`;
}
function stripChannelSecrets(config: OpenClawConfig): {
config: OpenClawConfig;
secrets: Record<string, Record<string, string>>;
found: boolean;
} {
const sanitized = cloneConfig(config);
const secrets: Record<string, Record<string, string>> = {};
let found = false;
for (const [channelType, section] of Object.entries(sanitized.channels ?? {})) {
const accounts = section.accounts && typeof section.accounts === 'object'
? section.accounts as Record<string, ChannelConfigData>
: null;
const defaultAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim()
? section.defaultAccount.trim()
: 'default';
const entries: Array<[string, ChannelConfigData]> = [
[defaultAccountId, section],
...Object.entries(accounts ?? {}),
];
for (const [accountId, account] of entries) {
const accountSecrets: Record<string, string> = {};
for (const field of CHANNEL_SECRET_FIELDS) {
const value = account[field];
if (typeof value !== 'string' || !value) continue;
accountSecrets[field] = value;
delete account[field];
found = true;
}
if (Object.keys(accountSecrets).length > 0) {
const credentialId = channelCredentialId(channelType, accountId);
secrets[credentialId] = { ...(secrets[credentialId] ?? {}), ...accountSecrets };
}
}
}
return { config: sanitized, secrets, found };
}
function hydrateChannelSecrets(
config: OpenClawConfig,
secrets: Record<string, Record<string, string>>,
): OpenClawConfig {
const hydrated = cloneConfig(config);
for (const [channelType, section] of Object.entries(hydrated.channels ?? {})) {
const accounts = section.accounts && typeof section.accounts === 'object'
? section.accounts as Record<string, ChannelConfigData>
: null;
const defaultAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim()
? section.defaultAccount.trim()
: 'default';
const entries: Array<[string, ChannelConfigData]> = [
[defaultAccountId, section],
...Object.entries(accounts ?? {}),
];
for (const [accountId, account] of entries) {
Object.assign(account, secrets[channelCredentialId(channelType, accountId)] ?? {});
}
}
return hydrated;
}
// ── Config I/O ───────────────────────────────────────────────────
export async function readOpenClawConfig(): Promise<OpenClawConfig> {
async function ensureConfigDir(): Promise<void> {
if (!(await fileExists(OPENCLAW_DIR))) {
await mkdir(OPENCLAW_DIR, { recursive: true });
}
}
async function readOpenClawCompatibilityConfig(): Promise<OpenClawConfig> {
await ensureConfigDir();
if (!(await fileExists(CONFIG_FILE))) {
return {};
}
try {
const snapshot = await readOpenClawConfigSnapshot();
return snapshot.config as OpenClawConfig;
const content = await readFile(CONFIG_FILE, 'utf-8');
return JSON.parse(content) as OpenClawConfig;
} catch (error) {
logger.error('Failed to read OpenClaw config', error);
console.error('Failed to read OpenClaw config:', error);
@@ -509,6 +570,49 @@ export async function readOpenClawConfig(): Promise<OpenClawConfig> {
}
}
export async function readOpenClawConfig(): Promise<OpenClawConfig> {
const config = await readClawXRuntimeConfig({
readOpenClawCompatibility: readOpenClawCompatibilityConfig,
openClawConfigPath: CONFIG_FILE,
});
const stripped = stripChannelSecrets(config);
if (stripped.found) {
await replaceChannelVaultSecrets(stripped.secrets);
await writeClawXRuntimeConfig(stripped.config);
return config;
}
return hydrateChannelSecrets(config, await getChannelVaultSecrets());
}
export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void> {
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;
const stripped = stripChannelSecrets(config);
await replaceChannelVaultSecrets(stripped.secrets);
await writeClawXRuntimeConfig(stripped.config);
if (await getSetting('runtimeKind').catch(() => 'openclaw') === 'openclaw') {
await writeOpenClawCompatibilityProjection(config);
}
} catch (error) {
logger.error('Failed to write OpenClaw config', error);
console.error('Failed to write OpenClaw config:', error);
throw error;
}
}
export async function writeOpenClawCompatibilityProjection(config?: OpenClawConfig): Promise<void> {
await ensureConfigDir();
const projected = config ?? await readOpenClawConfig();
await writeFile(CONFIG_FILE, JSON.stringify(projected, null, 2), { encoding: 'utf8', mode: 0o600 });
}
// ── Channel operations ───────────────────────────────────────────
async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: string): Promise<void> {
@@ -699,14 +803,26 @@ function transformChannelConfig(
}
}
if (channelType === 'feishu') {
const adminUsers = transformedConfig.adminUsers;
delete transformedConfig.adminUsers;
if (typeof adminUsers === 'string') {
const admins = adminUsers.split(',').map((value) => value.trim()).filter(Boolean);
transformedConfig.adminFrom = admins.length > 0
? admins
: existingAccountConfig.adminFrom;
}
}
if (channelType === 'feishu' || channelType === 'wecom') {
const existingDmPolicy = existingAccountConfig.dmPolicy === 'pairing' ? 'open' : existingAccountConfig.dmPolicy;
transformedConfig.dmPolicy = transformedConfig.dmPolicy ?? existingDmPolicy ?? 'open';
const hasExplicitAllowFrom = transformedConfig.allowFrom !== undefined;
let allowFrom = (transformedConfig.allowFrom ?? existingAccountConfig.allowFrom ?? ['*']) as string[];
if (!Array.isArray(allowFrom)) {
allowFrom = [allowFrom] as string[];
}
transformedConfig.dmPolicy = transformedConfig.dmPolicy
?? (hasExplicitAllowFrom && !allowFrom.includes('*') ? 'allowlist' : existingDmPolicy ?? 'open');
if (transformedConfig.dmPolicy === 'open' && !allowFrom.includes('*')) {
allowFrom = [...allowFrom, '*'];
@@ -762,6 +878,9 @@ function migrateLegacyChannelConfigToAccounts(
channelSection: ChannelConfigData,
defaultAccountId: string = DEFAULT_ACCOUNT_ID,
): void {
const targetAccountId = typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim()
? channelSection.defaultAccount.trim()
: defaultAccountId;
const legacyPayload = getLegacyChannelPayload(channelSection);
const legacyKeys = Object.keys(legacyPayload);
const existingAccounts = getChannelAccountsMap(channelSection);
@@ -769,15 +888,15 @@ function migrateLegacyChannelConfigToAccounts(
if (legacyKeys.length === 0) {
if (hasAccounts && typeof channelSection.defaultAccount !== 'string') {
channelSection.defaultAccount = defaultAccountId;
channelSection.defaultAccount = targetAccountId;
}
return;
}
const accounts = ensureChannelAccountsMap(channelSection);
const existingDefaultAccount = accounts[defaultAccountId] ?? {};
const existingDefaultAccount = accounts[targetAccountId] ?? {};
accounts[defaultAccountId] = {
accounts[targetAccountId] = {
...(channelSection.enabled !== undefined ? { enabled: channelSection.enabled } : {}),
...legacyPayload,
...existingDefaultAccount,
@@ -786,7 +905,7 @@ function migrateLegacyChannelConfigToAccounts(
channelSection.defaultAccount =
typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim()
? channelSection.defaultAccount
: defaultAccountId;
: targetAccountId;
for (const key of legacyKeys) {
delete channelSection[key];
@@ -843,12 +962,10 @@ export async function saveChannelConfig(
config: ChannelConfigData,
accountId?: string,
): Promise<void> {
const resolvedChannelType = resolveStoredChannelType(channelType);
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
let transformedKeys: string[] = [];
await mutateOpenClawConfig(async (snapshot) => {
const currentConfig = snapshot as OpenClawConfig;
return withConfigLock(async () => {
const resolvedChannelType = resolveStoredChannelType(channelType);
const currentConfig = await readOpenClawConfig();
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType);
await ensurePluginAllowlist(currentConfig, resolvedChannelType);
@@ -865,14 +982,16 @@ export async function saveChannelConfig(
}
const channelSection = currentConfig.channels[resolvedChannelType];
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
const currentDefaultAccountId = typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim()
? channelSection.defaultAccount.trim()
: DEFAULT_ACCOUNT_ID;
migrateLegacyChannelConfigToAccounts(channelSection, currentDefaultAccountId);
// Guard: reject if this bot/app credential is already used by another account.
assertNoDuplicateCredential(resolvedChannelType, config, channelSection, resolvedAccountId);
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;
@@ -934,15 +1053,16 @@ 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> {
@@ -997,6 +1117,9 @@ function extractFormValues(channelType: string, saved: ChannelConfigData): Recor
}
}
} else {
if (channelType === 'feishu' && Array.isArray(saved.adminFrom)) {
values.adminUsers = saved.adminFrom.join(', ');
}
for (const [key, value] of Object.entries(saved)) {
if (typeof value === 'string' && key !== 'enabled') {
values[key] = value;
@@ -1016,54 +1139,35 @@ export async function getChannelFormValues(channelType: string, accountId?: stri
}
export async function deleteChannelAccountConfig(channelType: string, accountId: string): Promise<void> {
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,
);
return withConfigLock(async () => {
const resolvedChannelType = resolveStoredChannelType(channelType);
const currentConfig = await readOpenClawConfig();
const channelSection = currentConfig.channels?.[resolvedChannelType];
if (!channelSection) {
if (isWechatChannelType(resolvedChannelType)) {
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
deleteWeChatAccount = true;
}
if (deletedPluginAccount) {
deletedAccount = true;
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
sanitizeChannelSectionsBeforeWrite(currentConfig);
await writeOpenClawConfig(currentConfig);
await deleteWeChatAccountState(accountId);
}
return;
}
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);
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
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;
}
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]) return;
delete accounts[accountId];
deletedAccount = true;
if (Object.keys(accounts).length === 0) {
delete currentConfig.channels![resolvedChannelType];
@@ -1096,31 +1200,21 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
}
}
syncPluginChannelAccountMirror(currentConfig, resolvedChannelType);
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
sanitizeChannelSectionsBeforeWrite(currentConfig);
await writeOpenClawConfig(currentConfig);
if (isWechatChannelType(resolvedChannelType)) {
deleteWeChatAccount = true;
await deleteWeChatAccountState(accountId);
}
});
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> {
const resolvedChannelType = resolveStoredChannelType(channelType);
let deleteWeChat = false;
let deletedConfig: 'channel' | 'plugin' | undefined;
await mutateOpenClawConfig((snapshot) => {
deleteWeChat = false;
deletedConfig = undefined;
const currentConfig = snapshot as OpenClawConfig;
return withConfigLock(async () => {
const resolvedChannelType = resolveStoredChannelType(channelType);
const currentConfig = await readOpenClawConfig();
cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType);
if (currentConfig.channels?.[resolvedChannelType]) {
@@ -1145,43 +1239,37 @@ export async function deleteChannelConfig(channelType: string): Promise<void> {
removePluginRegistration(currentConfig, WECOM_PLUGIN_ID);
}
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
await writeOpenClawConfig(currentConfig);
if (isWechatChannelType(resolvedChannelType)) {
deleteWeChat = true;
await deleteWeChatState();
}
deletedConfig = 'channel';
console.log(`Deleted channel config for ${resolvedChannelType}`);
} else if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
if (currentConfig.plugins?.entries?.[resolvedChannelType] || currentConfig.plugins?.allow?.includes(resolvedChannelType)) {
removePluginRegistration(currentConfig, resolvedChannelType);
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
deletedConfig = 'plugin';
await writeOpenClawConfig(currentConfig);
console.log(`Deleted plugin channel config for ${resolvedChannelType}`);
}
} else if (isWechatChannelType(resolvedChannelType)) {
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
deleteWeChat = true;
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);
}
}
});
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 {
@@ -1295,14 +1383,14 @@ export async function listConfiguredChannelAccounts(): Promise<Record<string, Co
}
export async function setChannelDefaultAccount(channelType: string, accountId: string): Promise<void> {
const resolvedChannelType = resolveStoredChannelType(channelType);
const trimmedAccountId = accountId.trim();
if (!trimmedAccountId) {
throw new Error('accountId is required');
}
return withConfigLock(async () => {
const resolvedChannelType = resolveStoredChannelType(channelType);
const trimmedAccountId = accountId.trim();
if (!trimmedAccountId) {
throw new Error('accountId is required');
}
await mutateOpenClawConfig((snapshot) => {
const currentConfig = snapshot as OpenClawConfig;
const currentConfig = await readOpenClawConfig();
const channelSection = currentConfig.channels?.[resolvedChannelType];
if (!channelSection) {
throw new Error(`Channel "${resolvedChannelType}" is not configured`);
@@ -1323,37 +1411,38 @@ 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> {
let modified = false;
const accountId = agentId === 'main' ? DEFAULT_ACCOUNT_ID : agentId;
return withConfigLock(async () => {
const currentConfig = await readOpenClawConfig();
if (!currentConfig.channels) return;
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;
const accountId = agentId === 'main' ? DEFAULT_ACCOUNT_ID : agentId;
let modified = false;
const currentDefaultAccountId = typeof section.defaultAccount === 'string'
&& section.defaultAccount.trim()
? section.defaultAccount.trim()
: DEFAULT_ACCOUNT_ID;
migrateLegacyChannelConfigToAccounts(section, currentDefaultAccountId);
for (const channelType of Object.keys(currentConfig.channels)) {
const section = currentConfig.channels[channelType];
migrateLegacyChannelConfigToAccounts(section, DEFAULT_ACCOUNT_ID);
const accounts = getChannelAccountsMap(section);
if (!accounts?.[accountId]) continue;
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;
}
delete accounts[accountId];
if (Object.keys(accounts).length === 0) {
delete channels[channelType];
delete currentConfig.channels[channelType];
} else {
if (section.defaultAccount === accountId) {
const nextDefaultAccountId = Object.keys(accounts).sort((a, b) => {
@@ -1378,37 +1467,21 @@ 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> {
const resolvedChannelType = resolveStoredChannelType(channelType);
let pluginChannel = false;
await mutateOpenClawConfig(async (snapshot) => {
pluginChannel = false;
const currentConfig = snapshot as OpenClawConfig;
return withConfigLock(async () => {
const resolvedChannelType = resolveStoredChannelType(channelType);
const currentConfig = await readOpenClawConfig();
cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType);
if (isWechatChannelType(resolvedChannelType)) {
@@ -1420,7 +1493,6 @@ export async function setChannelEnabled(channelType: string, enabled: boolean):
}
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
pluginChannel = true;
if (enabled) {
ensurePluginRegistration(currentConfig, resolvedChannelType);
} else {
@@ -1433,6 +1505,8 @@ 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;
}
@@ -1440,36 +1514,32 @@ 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 }> {
let cleanedDanglingState = false;
let hasConfiguredWeChatAccounts = false;
await mutateOpenClawConfig((snapshot) => {
cleanedDanglingState = false;
hasConfiguredWeChatAccounts = false;
const currentConfig = snapshot as OpenClawConfig;
return withConfigLock(async () => {
const currentConfig = await readOpenClawConfig();
const channelSection = currentConfig.channels?.[WECHAT_PLUGIN_ID];
hasConfiguredWeChatAccounts = channelHasConfiguredAccounts(channelSection);
const hasConfiguredWeChatAccounts = channelHasConfiguredAccounts(channelSection);
const hadPluginRegistration = Boolean(
currentConfig.plugins?.entries?.[WECHAT_PLUGIN_ID]
|| currentConfig.plugins?.allow?.includes(WECHAT_PLUGIN_ID),
);
if (hasConfiguredWeChatAccounts) {
return;
return { cleanedDanglingState: false };
}
const modified = removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
cleanedDanglingState = hadPluginRegistration || modified;
});
if (!hasConfiguredWeChatAccounts) {
if (modified) {
await writeOpenClawConfig(currentConfig);
}
await deleteWeChatState();
}
return { cleanedDanglingState };
return { cleanedDanglingState: hadPluginRegistration || modified };
});
}
// ── Validation ───────────────────────────────────────────────────
+137
View File
@@ -0,0 +1,137 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
export const CLAWX_DATA_VERSION = 1;
export interface ClawXDataLayout {
root: string;
stateDir: string;
dataVersionPath: string;
migrationJournalPath: string;
locksDir: string;
writerLockPath: string;
appDir: string;
credentialsDir: string;
skillsDir: string;
workspacesDir: string;
agentWorkspacesDir: string;
runtimesDir: string;
ccConnectRuntimeDir: string;
openClawRuntimeDir: string;
electronUserDataDir: string;
logsDir: string;
backupsDir: string;
cacheDir: string;
}
export interface ClawXDataVersionFile {
schema: 'clawx-data';
version: number;
createdAt: string;
updatedAt: string;
}
function cleanOverride(value: string | undefined): string | undefined {
const cleaned = value?.trim();
return cleaned ? resolve(cleaned) : undefined;
}
export function resolveClawXDataRoot(
env: NodeJS.ProcessEnv = process.env,
electronUserDataFallback?: string,
): string {
const fallback = cleanOverride(electronUserDataFallback);
const fallbackRoot = fallback
&& basename(fallback) === 'electron'
&& basename(dirname(fallback)) === 'system'
? resolve(fallback, '..', '..')
: fallback;
return cleanOverride(env.CLAWX_DATA_HOME)
?? cleanOverride(env.CLAWX_USER_DATA_DIR)
?? fallbackRoot
?? join(homedir(), '.clawx');
}
export function getClawXDataLayout(
root = resolveClawXDataRoot(),
env: NodeJS.ProcessEnv = process.env,
): ClawXDataLayout {
const resolvedRoot = resolve(root);
const stateDir = join(resolvedRoot, 'state');
const locksDir = join(resolvedRoot, 'locks');
const runtimesDir = join(resolvedRoot, 'runtimes');
const workspacesDir = join(resolvedRoot, 'workspaces');
const explicitElectronUserData = cleanOverride(env.CLAWX_USER_DATA_DIR);
const flatCompatibility = Boolean(explicitElectronUserData && !cleanOverride(env.CLAWX_DATA_HOME));
return {
root: resolvedRoot,
stateDir,
dataVersionPath: join(stateDir, 'data-version.json'),
migrationJournalPath: join(stateDir, 'migration-journal.jsonl'),
locksDir,
writerLockPath: join(locksDir, 'writer.lock'),
appDir: flatCompatibility ? resolvedRoot : join(resolvedRoot, 'app'),
credentialsDir: join(resolvedRoot, 'credentials'),
skillsDir: join(resolvedRoot, 'skills'),
workspacesDir,
agentWorkspacesDir: join(workspacesDir, 'agents'),
runtimesDir,
ccConnectRuntimeDir: join(runtimesDir, 'cc-connect'),
openClawRuntimeDir: join(runtimesDir, 'openclaw'),
electronUserDataDir: explicitElectronUserData ?? join(resolvedRoot, 'system', 'electron'),
logsDir: join(resolvedRoot, 'logs'),
backupsDir: join(resolvedRoot, 'backups'),
cacheDir: join(resolvedRoot, 'cache'),
};
}
function writeJsonAtomic(path: string, value: unknown): void {
mkdirSync(dirname(path), { recursive: true });
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
renameSync(temporaryPath, path);
}
export function initializeClawXDataLayout(layout = getClawXDataLayout()): ClawXDataVersionFile {
for (const dir of [
layout.stateDir,
layout.locksDir,
layout.appDir,
layout.credentialsDir,
layout.skillsDir,
layout.agentWorkspacesDir,
layout.ccConnectRuntimeDir,
layout.openClawRuntimeDir,
layout.electronUserDataDir,
layout.logsDir,
layout.backupsDir,
layout.cacheDir,
]) {
mkdirSync(dir, { recursive: true });
}
if (existsSync(layout.dataVersionPath)) {
const current = JSON.parse(readFileSync(layout.dataVersionPath, 'utf8')) as Partial<ClawXDataVersionFile>;
if (current.schema !== 'clawx-data' || !Number.isInteger(current.version)) {
throw new Error(`Invalid ClawX data version file: ${layout.dataVersionPath}`);
}
if ((current.version ?? 0) > CLAWX_DATA_VERSION) {
throw new Error(
`ClawX data version ${current.version} is newer than supported version ${CLAWX_DATA_VERSION}; refusing to write`,
);
}
return current as ClawXDataVersionFile;
}
const now = new Date().toISOString();
const versionFile: ClawXDataVersionFile = {
schema: 'clawx-data',
version: CLAWX_DATA_VERSION,
createdAt: now,
updatedAt: now,
};
writeJsonAtomic(layout.dataVersionPath, versionFile);
return versionFile;
}
+100
View File
@@ -0,0 +1,100 @@
import { cp, lstat, mkdir, readFile, readdir, realpath, stat, writeFile } from 'node:fs/promises';
import { basename, dirname, join, resolve } from 'node:path';
import type { ClawXDataLayout } from './clawx-data-layout';
export interface ClawXLegacyMigrationResult {
skipped: boolean;
copied: string[];
source: string;
target: string;
}
const LEGACY_ELECTRON_PROFILE_PATHS = [
'Local Storage',
'IndexedDB',
join('Partitions', 'clawx-web-browser'),
] as const;
async function exists(path: string): Promise<boolean> {
return stat(path).then(() => true).catch(() => false);
}
async function copyIfMissing(source: string, target: string, copied: string[]): Promise<void> {
if (!(await exists(source))) return;
if (await exists(target)) {
const targetStat = await stat(target);
if (!targetStat.isDirectory() || (await readdir(target)).length > 0) return;
}
await mkdir(dirname(target), { recursive: true });
await cp(source, target, {
recursive: true,
errorOnExist: false,
force: false,
filter: async (sourcePath) => {
const entry = await lstat(sourcePath);
return entry.isDirectory() || entry.isFile() || entry.isSymbolicLink();
},
});
copied.push(target);
}
async function canonicalPath(path: string): Promise<string> {
return realpath(path).catch(() => resolve(path));
}
async function appendJournal(layout: ClawXDataLayout, record: Record<string, unknown>): Promise<void> {
await mkdir(layout.stateDir, { recursive: true });
const previous = await readFile(layout.migrationJournalPath, 'utf8').catch(() => '');
await writeFile(layout.migrationJournalPath, `${previous}${JSON.stringify(record)}\n`, {
encoding: 'utf8',
mode: 0o600,
});
}
export async function migrateLegacyClawXData(options: {
legacyElectronUserDataDir: string;
layout: ClawXDataLayout;
}): Promise<ClawXLegacyMigrationResult> {
const source = await canonicalPath(options.legacyElectronUserDataDir);
const target = await canonicalPath(options.layout.root);
const electronUserDataDir = await canonicalPath(options.layout.electronUserDataDir);
if (
source === electronUserDataDir
|| source === target
|| source.startsWith(`${target}/`)
) {
return { skipped: true, copied: [], source, target };
}
const copied: string[] = [];
for (const fileName of ['settings.json', 'clawx-providers.json']) {
await copyIfMissing(join(source, fileName), join(options.layout.appDir, fileName), copied);
}
for (const fileName of ['window-state.json', 'clawx-device-identity.json']) {
await copyIfMissing(join(source, fileName), join(options.layout.electronUserDataDir, fileName), copied);
}
for (const relativePath of LEGACY_ELECTRON_PROFILE_PATHS) {
await copyIfMissing(
join(source, relativePath),
join(options.layout.electronUserDataDir, relativePath),
copied,
);
}
await copyIfMissing(
join(source, 'runtimes', 'cc-connect'),
options.layout.ccConnectRuntimeDir,
copied,
);
await copyIfMissing(join(source, 'logs'), options.layout.logsDir, copied);
await appendJournal(options.layout, {
schema: 'clawx-data-migration',
version: 1,
migration: 'legacy-electron-user-data-import',
source,
target,
copied: copied.map((path) => basename(path)),
completedAt: new Date().toISOString(),
});
return { skipped: false, copied, source, target };
}
+71
View File
@@ -0,0 +1,71 @@
import { randomUUID } from 'node:crypto';
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { app } from 'electron';
import { getClawXDataLayout, resolveClawXDataRoot } from './clawx-data-layout';
type RuntimeConfigDocument<T> = {
schema: 'clawx-runtime-config';
version: 1;
importedFromOpenClawAt?: string;
updatedAt: string;
config: T;
};
function runtimeConfigPath(): string {
const layout = getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData')));
return join(layout.appDir, 'runtime-config.json');
}
async function writeAtomic(path: string, value: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
await chmod(temporaryPath, 0o600).catch(() => {});
await rename(temporaryPath, path);
await chmod(path, 0o600).catch(() => {});
}
async function readDocument<T>(): Promise<RuntimeConfigDocument<T> | null> {
try {
const parsed = JSON.parse(await readFile(runtimeConfigPath(), 'utf8')) as Partial<RuntimeConfigDocument<T>>;
if (parsed.schema === 'clawx-runtime-config' && parsed.version === 1 && parsed.config) {
return parsed as RuntimeConfigDocument<T>;
}
} catch {
// Missing canonical config is imported from the compatibility source.
}
return null;
}
export async function readClawXRuntimeConfig<T extends Record<string, unknown>>(options: {
readOpenClawCompatibility: () => Promise<T>;
openClawConfigPath: string;
}): Promise<T> {
const canonicalPath = runtimeConfigPath();
const document = await readDocument<T>();
if (document) return document.config;
const config = await options.readOpenClawCompatibility();
await writeAtomic(canonicalPath, {
schema: 'clawx-runtime-config',
version: 1,
importedFromOpenClawAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
config,
} satisfies RuntimeConfigDocument<T>);
return config;
}
export async function writeClawXRuntimeConfig<T extends Record<string, unknown>>(config: T): Promise<void> {
await writeAtomic(runtimeConfigPath(), {
schema: 'clawx-runtime-config',
version: 1,
updatedAt: new Date().toISOString(),
config,
} satisfies RuntimeConfigDocument<T>);
}
export function getClawXRuntimeConfigPath(): string {
return runtimeConfigPath();
}
+1 -1
View File
@@ -13,7 +13,7 @@ type BuildGatewayHealthSummaryOptions = {
};
const CHANNEL_STATUS_FAILURE_WINDOW_MS = 2 * 60_000;
const HEARTBEAT_MISS_THRESHOLD = 10;
const HEARTBEAT_MISS_THRESHOLD = 4;
export function buildGatewayHealthSummary(
options: BuildGatewayHealthSummaryOptions,
+23 -3
View File
@@ -11,6 +11,7 @@ import { app } from 'electron';
import { join } from 'path';
import { existsSync, mkdirSync, appendFileSync } from 'fs';
import { appendFile, open, readdir, stat } from 'fs/promises';
import { getClawXDataLayout } from './clawx-data-layout';
/**
* Log levels
@@ -80,8 +81,27 @@ function flushBufferSync(): void {
writeBuffer = [];
}
// Ensure all buffered data reaches disk before the process exits.
process.on('exit', flushBufferSync);
type LoggerGlobalState = typeof globalThis & {
__clawxLoggerExitFlushers?: Set<() => void>;
__clawxLoggerExitHandlerRegistered?: boolean;
};
const loggerGlobalState = globalThis as LoggerGlobalState;
const loggerExitFlushers = loggerGlobalState.__clawxLoggerExitFlushers ?? new Set<() => void>();
loggerGlobalState.__clawxLoggerExitFlushers = loggerExitFlushers;
loggerExitFlushers.add(flushBufferSync);
// Ensure all buffered data reaches disk before the process exits. Vitest can
// reload this module many times, so keep one process listener and fan out to
// each module instance's buffer flusher.
if (!loggerGlobalState.__clawxLoggerExitHandlerRegistered) {
process.on('exit', () => {
for (const flush of loggerExitFlushers) {
flush();
}
});
loggerGlobalState.__clawxLoggerExitHandlerRegistered = true;
}
// ── Initialisation ───────────────────────────────────────────────
@@ -95,7 +115,7 @@ export function initLogger(): void {
currentLevel = LogLevel.INFO;
}
logDir = join(app.getPath('userData'), 'logs');
logDir = getClawXDataLayout().logsDir;
if (!existsSync(logDir)) {
mkdirSync(logDir, { recursive: true });
+5 -1
View File
@@ -26,6 +26,7 @@ const SUCCESS_HTML = `<!doctype html>
export interface OpenAICodexOAuthCredentials {
access: string;
refresh: string;
idToken?: string;
expires: number;
accountId: string;
email?: string;
@@ -219,7 +220,7 @@ function startLocalOAuthServer(state: string): Promise<OpenAICodexLocalServer |
async function exchangeAuthorizationCode(
code: string,
verifier: string,
): Promise<{ access: string; refresh: string; expires: number }> {
): Promise<{ access: string; refresh: string; idToken?: string; expires: number }> {
const response = await proxyAwareFetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -240,6 +241,7 @@ async function exchangeAuthorizationCode(
const json = await response.json() as {
access_token?: string;
refresh_token?: string;
id_token?: string;
expires_in?: number;
};
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== 'number') {
@@ -249,6 +251,7 @@ async function exchangeAuthorizationCode(
return {
access: json.access_token,
refresh: json.refresh_token,
idToken: typeof json.id_token === 'string' && json.id_token.trim() ? json.id_token.trim() : undefined,
expires: Date.now() + json.expires_in * 1000,
};
}
@@ -306,6 +309,7 @@ export async function loginOpenAICodexOAuth(options: {
return {
access: token.access,
refresh: token.refresh,
idToken: token.idToken,
expires: token.expires,
accountId,
email: getEmailFromAccessToken(token.access),
+254 -300
View File
@@ -28,18 +28,10 @@ import {
isOpenClawOAuthPluginProviderKey,
} from './provider-keys';
import { normalizePiAiModelCost, type PiAiModelCostRates } from '../shared/pi-ai-model-cost';
import {
mutateOpenClawConfig,
readOpenClawConfigSnapshot,
reloadOpenClawSecretsIfRunning,
} from '../gateway/config-delivery';
import {
ensureMemorySearchFtsDefault,
hasUserMemorySearchConfig,
MEMORY_SEARCH_FTS_MIGRATION_VERSION,
} from './openclaw-memory-search';
import { withConfigLock } from './config-mutex';
import { ensureMemorySearchDisabledDefault, hasUserMemorySearchConfig } from './openclaw-memory-search';
import { PORTS } from './config';
import { getSetting, setSetting } from './store';
import { getSetting } from './store';
import {
assertValidApiProtocol,
normalizeOpenClawApiProtocol,
@@ -434,6 +426,12 @@ 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;
}
@@ -442,27 +440,19 @@ async function readAuthProfiles(agentId = 'main'): Promise<AuthProfilesStore> {
async function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): Promise<void> {
writeAuthProfilesToSqlite(store, agentId);
try {
await writeJsonFile(getAuthProfilesPath(agentId), store);
} catch (error) {
console.warn(`Failed to update compatibility auth-profiles.json for agent "${agentId}":`, error);
}
await writeJsonFile(getAuthProfilesPath(agentId), store);
}
/** 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 {
migrated = await migrateAuthProfilesJsonToSqliteIfNeeded(agentId) || migrated;
await migrateAuthProfilesJsonToSqliteIfNeeded(agentId);
} catch (error) {
console.warn(`Failed to migrate auth profiles to SQLite for agent "${agentId}":`, error);
}
}
if (migrated) {
await reloadOpenClawSecretsIfRunning();
}
}
function getApiKeyFromAuthProfilesStore(
@@ -530,6 +520,7 @@ 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). */
@@ -696,11 +687,8 @@ async function getProvidersFromAuthProfileStores(
return providers;
}
function collectActiveProviderIdsFromConfig(
config: Record<string, unknown>,
authProfileProviders: Iterable<string> = [],
): Set<string> {
const activeProviders = new Set(authProfileProviders);
async function collectActiveProviderIdsFromConfig(config: Record<string, unknown>): Promise<Set<string>> {
const activeProviders = new Set<string>();
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>)) {
@@ -732,6 +720,11 @@ function collectActiveProviderIdsFromConfig(
{ 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);
}
@@ -740,7 +733,7 @@ function collectActiveProviderIdsFromConfig(
}
async function readOpenClawJson(): Promise<Record<string, unknown>> {
return (await readOpenClawConfigSnapshot()).config;
return (await readJsonFile<Record<string, unknown>>(OPENCLAW_CONFIG_PATH)) ?? {};
}
async function resolveInstalledFeishuPluginId(): Promise<string | null> {
@@ -929,10 +922,7 @@ 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, {
providerKey,
apiProtocol: typeof entry.api === 'string' ? entry.api : undefined,
});
row.contextWindow = inferCustomModelContextWindow(row.id);
backfilled.push(`${providerKey}/${row.id}`);
}
}
@@ -940,6 +930,21 @@ 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) ───────────────────────────────
/**
@@ -986,7 +991,6 @@ 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(', ')})`);
}
@@ -1048,7 +1052,6 @@ 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(', ')})`);
}
@@ -1061,18 +1064,13 @@ 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(', ')})`);
}
@@ -1130,6 +1128,7 @@ 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() : '';
@@ -1142,6 +1141,7 @@ function pruneStaleRuntimeModelConfig(
) {
delete modelCfg.primary;
modified = true;
console.log(`Removed stale runtime model ref "${primary}" from ${context}`);
}
}
@@ -1165,13 +1165,8 @@ 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>,
authProfileProviders?: Iterable<string>,
): Promise<boolean> {
const activeProviders = authProfileProviders
? collectActiveProviderIdsFromConfig(config, authProfileProviders)
: await getActiveOpenClawProviders();
export async function pruneStaleRuntimeAgentModelRefs(config: Record<string, unknown>): Promise<boolean> {
const activeProviders = await getActiveOpenClawProviders();
const agents = config.agents;
if (!isPlainRecord(agents)) return false;
@@ -1179,7 +1174,7 @@ export async function pruneStaleRuntimeAgentModelRefs(
const agentDefaults = agents.defaults;
if (isPlainRecord(agentDefaults) && isPlainRecord(agentDefaults.model)) {
if (pruneStaleRuntimeModelConfig(agentDefaults.model, activeProviders)) {
if (pruneStaleRuntimeModelConfig(agentDefaults.model, activeProviders, 'agents.defaults.model')) {
deleteModelConfigIfEmpty(agentDefaults);
modified = true;
}
@@ -1188,7 +1183,8 @@ export async function pruneStaleRuntimeAgentModelRefs(
if (Array.isArray(agents.list)) {
for (const entry of agents.list) {
if (!isPlainRecord(entry) || !isPlainRecord(entry.model)) continue;
if (pruneStaleRuntimeModelConfig(entry.model, activeProviders)) {
const agentId = typeof entry.id === 'string' ? entry.id : 'unknown';
if (pruneStaleRuntimeModelConfig(entry.model, activeProviders, `agent "${agentId}" model override`)) {
deleteModelConfigIfEmpty(entry);
modified = true;
}
@@ -1199,13 +1195,50 @@ export async function pruneStaleRuntimeAgentModelRefs(
}
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');
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) => {
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 modified = false;
// Remove plugin registrations for OAuth providers (e.g. MiniMax).
@@ -1213,6 +1246,7 @@ 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}"`);
}
}
@@ -1222,6 +1256,7 @@ 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'
@@ -1242,6 +1277,7 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
}
delete authProfiles[profileId];
modified = true;
console.log(`Removed OpenClaw auth profile: ${profileId}`);
}
}
@@ -1258,6 +1294,7 @@ 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`);
}
}
@@ -1265,69 +1302,21 @@ 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) {
normalizeAgentsDefaultsCompactionMode(config);
await writeOpenClawJson(config);
}
});
// 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;
});
} catch (err) {
console.warn(`Failed to remove provider ${provider} from openclaw.json:`, err);
}
}
@@ -1456,8 +1445,8 @@ function migrateOpenAiCodexOAuthRuntimeToOpenAiInConfig(config: Record<string, u
export async function pruneInvalidApiProviderEntries(): Promise<string[]> {
const removed: string[] = [];
await mutateOpenClawConfig((config) => {
removed.length = 0;
await withConfigLock(async () => {
const config = await readOpenClawJson();
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
let modified = false;
@@ -1489,7 +1478,7 @@ export async function pruneInvalidApiProviderEntries(): Promise<string[]> {
if (modified) {
models.providers = providers;
config.models = models;
normalizeAgentsDefaultsCompactionMode(config);
await writeOpenClawJson(config);
}
});
return removed;
@@ -1519,7 +1508,8 @@ export async function setOpenClawDefaultModel(
modelOverride?: string,
fallbackModels: string[] = []
): Promise<void> {
await mutateOpenClawConfig((config) => {
return withConfigLock(async () => {
const config = await readOpenClawJson();
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
const model = normalizeModelRef(provider, modelOverride);
@@ -1599,7 +1589,7 @@ export async function setOpenClawDefaultModel(
if (!gateway.mode) gateway.mode = 'local';
config.gateway = gateway;
normalizeAgentsDefaultsCompactionMode(config);
await writeOpenClawJson(config);
console.log(`Set OpenClaw default model to "${model}" for provider "${provider}"`);
});
}
@@ -1808,8 +1798,8 @@ function healAnthropicMessagesMaxTokensInConfig(config: Record<string, unknown>)
*/
export async function ensureAnthropicMessagesModelMaxTokens(): Promise<string[]> {
const healed: string[] = [];
await mutateOpenClawConfig((config) => {
healed.length = 0;
await withConfigLock(async () => {
const config = await readOpenClawJson();
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
let modified = false;
@@ -1828,7 +1818,7 @@ export async function ensureAnthropicMessagesModelMaxTokens(): Promise<string[]>
if (modified) {
models.providers = providers;
config.models = models;
normalizeAgentsDefaultsCompactionMode(config);
await writeOpenClawJson(config);
}
});
return healed;
@@ -1925,10 +1915,7 @@ 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, {
providerKey: provider,
apiProtocol: options.api,
}),
contextWindow: inferCustomModelContextWindow(id),
}
: {}),
}));
@@ -1994,11 +1981,12 @@ function upsertOpenClawProviderEntry(
*/
export async function ensureOpenClawProviderAgentRuntimePins(): Promise<string[]> {
let pinned: string[] = [];
await mutateOpenClawConfig((config) => {
await withConfigLock(async () => {
const config = await readOpenClawJson();
pinned = applyOpenClawProviderAgentRuntimePinsToConfig(config);
if (pinned.length > 0) {
normalizeAgentsDefaultsCompactionMode(config);
await writeOpenClawJson(config);
}
});
return pinned;
@@ -2089,7 +2077,8 @@ export async function syncProviderConfigToOpenClaw(
modelId: string | undefined,
override: RuntimeProviderConfigOverride
): Promise<void> {
await mutateOpenClawConfig((config) => {
return withConfigLock(async () => {
const config = await readOpenClawJson();
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
if (override.baseUrl && override.api) {
@@ -2110,7 +2099,7 @@ export async function syncProviderConfigToOpenClaw(
ensureOAuthPluginEnabled(config, provider);
}
normalizeAgentsDefaultsCompactionMode(config);
await writeOpenClawJson(config);
});
}
@@ -2178,7 +2167,9 @@ export async function syncOpenAiCompatibleImageRelay(params: {
apiKey?: string;
imageModelIds?: string[];
}): Promise<void> {
await mutateOpenClawConfig((config) => {
return withConfigLock(async () => {
const config = await readOpenClawJson();
if (!params.enabled) {
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
@@ -2195,24 +2186,15 @@ export async function syncOpenAiCompatibleImageRelay(params: {
const primary = typeof imageGenerationModel?.primary === 'string'
? imageGenerationModel.primary.trim().toLowerCase()
: '';
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;
}
if (defaults && primary.startsWith(`${CLAWX_OPENAI_IMAGE_PROVIDER_KEY}/`)) {
delete defaults.imageGenerationModel;
}
removePluginRegistrations(config, [CLAWX_OPENAI_IMAGE_PROVIDER_KEY]);
normalizeAgentsDefaultsCompactionMode(config);
await writeOpenClawJson(config);
await removeProviderKeyFromOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY);
if (params.apiKey?.trim()) {
await saveProviderKeyToOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY, params.apiKey.trim());
}
return;
}
@@ -2223,12 +2205,6 @@ 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',
@@ -2236,24 +2212,13 @@ export async function syncOpenAiCompatibleImageRelay(params: {
mergeExistingModels: false,
request: { allowPrivateNetwork: true },
});
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);
});
await writeOpenClawJson(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());
}
if (params.apiKey?.trim()) {
await saveProviderKeyToOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY, params.apiKey.trim());
}
});
}
export function readOpenAiCompatibleImageRelayState(
@@ -2284,7 +2249,8 @@ export async function setOpenClawDefaultModelWithOverride(
override: RuntimeProviderConfigOverride,
fallbackModels: string[] = []
): Promise<void> {
await mutateOpenClawConfig((config) => {
return withConfigLock(async () => {
const config = await readOpenClawJson();
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
const model = normalizeModelRef(provider, modelOverride);
@@ -2328,7 +2294,7 @@ export async function setOpenClawDefaultModelWithOverride(
ensureOAuthPluginEnabled(config, provider);
}
normalizeAgentsDefaultsCompactionMode(config);
await writeOpenClawJson(config);
console.log(
`Set OpenClaw default model to "${model}" for provider "${provider}" (runtime override)`
);
@@ -2343,24 +2309,67 @@ 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, authProfileProviders] = await Promise.all([
readOpenClawJson(),
getActiveAuthProfileProviders(),
]);
return collectActiveProviderIdsFromConfig(
config,
authProfileProviders,
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 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;
}
/**
@@ -2429,8 +2438,9 @@ function applyControlUiAllowedOrigins(controlUi: Record<string, unknown>, port:
* Write the ClawX gateway token into ~/.openclaw/openclaw.json.
*/
export async function syncGatewayTokenToConfig(token: string): Promise<void> {
const gatewayPort = (await getSetting('gatewayPort')) || PORTS.OPENCLAW_GATEWAY;
await mutateOpenClawConfig((config) => {
return withConfigLock(async () => {
const config = await readOpenClawJson();
const gateway = (
config.gateway && typeof config.gateway === 'object'
? { ...(config.gateway as Record<string, unknown>) }
@@ -2452,15 +2462,16 @@ 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;
normalizeAgentsDefaultsCompactionMode(config);
await writeOpenClawJson(config);
console.log('Synced gateway token to openclaw.json');
});
console.log('Synced gateway token to openclaw.json');
}
/**
@@ -2514,7 +2525,9 @@ function ensureWebFetchSsrfPolicyInConfig(config: Record<string, unknown>): bool
* Ensure browser automation is enabled in ~/.openclaw/openclaw.json.
*/
export async function syncBrowserConfigToOpenClaw(): Promise<void> {
await mutateOpenClawConfig((config) => {
return withConfigLock(async () => {
const config = await readOpenClawJson();
const browser = (
config.browser && typeof config.browser === 'object'
? { ...(config.browser as Record<string, unknown>) }
@@ -2550,7 +2563,7 @@ export async function syncBrowserConfigToOpenClaw(): Promise<void> {
if (!changed) return;
config.browser = browser;
normalizeAgentsDefaultsCompactionMode(config);
await writeOpenClawJson(config);
console.log('Synced browser and web_fetch config to openclaw.json');
});
}
@@ -2568,7 +2581,9 @@ export async function syncBrowserConfigToOpenClaw(): Promise<void> {
export async function syncSessionIdleMinutesToOpenClaw(): Promise<void> {
const DEFAULT_IDLE_MINUTES = 10_080; // 7 days
await mutateOpenClawConfig((config) => {
return withConfigLock(async () => {
const config = await readOpenClawJson();
const session = (
config.session && typeof config.session === 'object'
? { ...(config.session as Record<string, unknown>) }
@@ -2587,36 +2602,22 @@ export async function syncSessionIdleMinutesToOpenClaw(): Promise<void> {
session.idleMinutes = DEFAULT_IDLE_MINUTES;
config.session = session;
normalizeAgentsDefaultsCompactionMode(config);
await writeOpenClawJson(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 coordinator transaction. Replaces three separate config mutations
* during pre-launch sync.
* single config lock + read + write cycle. Replaces three separate
* withConfigLock calls 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[] = [];
const changed = await mutateOpenClawConfig((config) => {
return withConfigLock(async () => {
const config = await readOpenClawJson();
let modified = true;
pinnedProviderRuntimes = [];
compactionLog = undefined;
memorySearchDefaultResult = 'unchanged';
backfilledContextWindows = [];
// ── Gateway token + controlUi ──
const gateway = (
@@ -2639,6 +2640,7 @@ 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';
@@ -2679,9 +2681,10 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
modified = true;
}
pinnedProviderRuntimes = applyOpenClawProviderAgentRuntimePinsToConfig(config);
const 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 ──
@@ -2703,65 +2706,37 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
// ── Compaction safeguard default ──
if (ensureCompactionSafeguardDefault(config)) {
modified = true;
compactionLog = `[batch-sync] Seeded agents.defaults.compaction.mode=safeguard reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`;
console.log(`[batch-sync] Seeded agents.defaults.compaction.mode=safeguard reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`);
} else if (backfillCompactionReserveTokensFloor(config)) {
modified = true;
compactionLog = `[batch-sync] Backfilled agents.defaults.compaction.reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`;
console.log(`[batch-sync] Backfilled agents.defaults.compaction.reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`);
}
// ── Memory search default ──
// 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') {
// 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)) {
modified = true;
console.log('[batch-sync] Seeded agents.defaults.memorySearch.enabled=false (no embedding provider configured)');
}
// ── Custom provider contextWindow backfill ──
backfilledContextWindows = backfillCustomProviderModelContextWindows(config);
const backfilledContextWindows = backfillCustomProviderModelContextWindows(config);
if (backfilledContextWindows.length > 0) {
modified = true;
console.log(`[batch-sync] Backfilled contextWindow for custom provider models: ${backfilledContextWindows.join(', ')}`);
}
if (modified) {
normalizeAgentsDefaultsCompactionMode(config);
await writeOpenClawJson(config);
console.log('Synced gateway token, browser config, web_fetch SSRF policy, and session idle to openclaw.json');
}
});
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,
);
}
}
/**
@@ -2819,10 +2794,7 @@ async function updateModelsJsonProviderEntriesForAgents(
&& typeof base.contextWindow !== 'number'
&& typeof base.contextTokens !== 'number'
) {
base.contextWindow = inferCustomModelContextWindow(m.id, {
providerKey: providerType,
apiProtocol: entry.api,
});
base.contextWindow = inferCustomModelContextWindow(m.id);
}
return {
...base,
@@ -2888,7 +2860,6 @@ 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[] {
@@ -2908,22 +2879,25 @@ function ensureToolDenyIncludes(
}
export async function sanitizeOpenClawConfig(): Promise<void> {
// The prelaunch file fallback must not turn a missing or corrupt config into
// a valid-looking skeleton. The coordinator performs the successful mutation.
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();
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;
}
await mutateOpenClawConfig(async (config) => {
// 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;
let modified = false;
// ── skills section ──────────────────────────────────────────────
@@ -3014,6 +2988,20 @@ 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
@@ -3089,24 +3077,6 @@ 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
@@ -3168,22 +3138,6 @@ 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;
@@ -3524,7 +3478,7 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
const bundled = discoverBundledPlugins();
const installedExtensionIds = await discoverInstalledExtensionPluginIds();
const loadedPluginIds = await discoverLoadedPluginIdsFromConfig(config);
const activeProviderIds = collectActiveProviderIdsFromConfig(config, authProfileProviders);
const activeProviderIds = await collectActiveProviderIdsFromConfig(config);
const explicitlyEnabledBundledPluginIds = Object.keys(pEntries)
.filter((pluginId) => {
@@ -3733,7 +3687,7 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
}
if (modified) {
normalizeAgentsDefaultsCompactionMode(config);
await writeOpenClawJson(config);
console.log('[sanitize] openclaw.json sanitized successfully');
}
});
+13 -1
View File
@@ -5,11 +5,23 @@
* (`#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 url = new URL('/', `http://127.0.0.1:${port}`);
const path = options.view ? CONTROL_UI_VIEW_PATHS[options.view] : '/';
const url = new URL(path, `http://127.0.0.1:${port}`);
const trimmedToken = token.trim();
if (trimmedToken) {
+21 -28
View File
@@ -1,8 +1,8 @@
/**
* Read/write agents.defaults.imageGenerationModel and per-agent auth readiness.
*/
import { mutateOpenClawConfig } from '../gateway/config-delivery';
import { readOpenClawConfig } from './channel-config';
import { readOpenClawConfig, writeOpenClawConfig } from './channel-config';
import { withConfigLock } from './config-mutex';
import {
getOAuthTokenFromOpenClaw,
getProviderApiKeyFromOpenClaw,
@@ -10,11 +10,7 @@ import {
syncOpenAiCompatibleImageRelay,
} from './openclaw-auth';
import { ensureClawXOpenAiImagePluginInstalled } from './plugin-install';
import {
listAgentsSnapshot,
listAgentsSnapshotFromConfig,
type AgentsSnapshot,
} from './agent-config';
import { listAgentsSnapshot, type AgentsSnapshot } from './agent-config';
import { expandPath } from './paths';
import {
generateImageInProcess,
@@ -88,7 +84,6 @@ type AgentModelConfigShape = {
primary?: string;
fallbacks?: string[];
timeoutMs?: number;
[key: string]: unknown;
};
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -135,12 +130,12 @@ function parseImageGenerationModelConfig(raw: unknown): ImageGenerationModelConf
function buildImageGenerationModelConfigWrite(
config: ImageGenerationModelConfig,
existing: unknown,
): AgentModelConfigShape | undefined {
const next: AgentModelConfigShape = isRecord(existing) ? { ...existing } : {};
delete next.primary;
delete next.fallbacks;
delete next.timeoutMs;
if (!config.primary && config.fallbacks.length === 0 && config.timeoutMs === null) {
return undefined;
}
const next: AgentModelConfigShape = {};
if (config.primary) {
next.primary = config.primary;
}
@@ -150,7 +145,7 @@ function buildImageGenerationModelConfigWrite(
if (config.timeoutMs !== null) {
next.timeoutMs = config.timeoutMs;
}
return Object.keys(next).length > 0 ? next : undefined;
return next;
}
export function parseProviderFromModelRef(modelRef: string): string | null {
@@ -212,8 +207,8 @@ export async function setImageGenerationConfig(
}
}
let savedConfig: ImageGenerationModelConfig | undefined;
await mutateOpenClawConfig((config) => {
return withConfigLock(async () => {
const config = await readOpenClawConfig();
const agents = (config.agents && typeof config.agents === 'object'
? { ...(config.agents as Record<string, unknown>) }
: {}) as Record<string, unknown>;
@@ -225,7 +220,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;
@@ -239,9 +234,10 @@ export async function setImageGenerationConfig(
agents.defaults = defaults;
config.agents = agents;
savedConfig = parseImageGenerationModelConfig(defaults.imageGenerationModel);
await writeOpenClawConfig(config);
return readImageGenerationConfig();
});
return savedConfig!;
}
async function buildAgentAuthRows(
@@ -320,10 +316,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;
@@ -352,12 +348,6 @@ 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) {
@@ -374,11 +364,14 @@ 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 listAgentsSnapshotFromConfig(cfg);
const snapshot = await listAgentsSnapshot();
const rows = await listImageGenerationProvidersInProcess({
config: cfg,
isProviderConfigured: (providerId) => isImageProviderAuthenticated(providerId, snapshot.defaultAgentId),
+14 -32
View File
@@ -1,15 +1,14 @@
/**
* Memory search default seeding for openclaw.json.
*
* 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.
* 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.
*/
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);
}
@@ -30,35 +29,18 @@ export function hasUserMemorySearchConfig(config: Record<string, unknown>): bool
}
/**
* 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.
* 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.
*/
export function ensureMemorySearchFtsDefault(
config: Record<string, unknown>,
migrateLegacyDisabledDefault = false,
): MemorySearchDefaultResult {
export function ensureMemorySearchDisabledDefault(config: Record<string, unknown>): boolean {
if (hasUserMemorySearchConfig(config)) return false;
const agents = (isRecord(config.agents) ? config.agents : {}) as Record<string, unknown>;
const list = Array.isArray(agents.list) ? agents.list : [];
if (list.some((entry) => isRecord(entry) && entry.memorySearch !== undefined)) {
return 'unchanged';
}
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' };
defaults.memorySearch = { enabled: false };
agents.defaults = defaults;
config.agents = agents;
return memorySearch === undefined ? 'seeded' : 'migrated';
return true;
}
+12 -20
View File
@@ -1,7 +1,7 @@
import { mutateOpenClawConfig } from '../gateway/config-delivery';
import type { OpenClawConfig } from './channel-config';
import { readOpenClawConfig, writeOpenClawConfig } from './channel-config';
import { resolveProxySettings, type ProxySettings } from './proxy';
import { logger } from './logger';
import { withConfigLock } from './config-mutex';
interface SyncProxyOptions {
/**
@@ -19,26 +19,23 @@ export async function syncProxyConfigToOpenClaw(
settings: ProxySettings,
options: SyncProxyOptions = {},
): Promise<void> {
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;
return withConfigLock(async () => {
const config = await readOpenClawConfig();
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) {
syncState.result = 'preserved';
logger.info('Skipped Telegram proxy sync because ClawX proxy is disabled and preserve mode is enabled');
return;
}
@@ -60,12 +57,7 @@ export async function syncProxyConfigToOpenClaw(
delete config.channels.telegram.proxy;
}
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') {
await writeOpenClawConfig(config);
logger.info(`Synced Telegram proxy to OpenClaw config (${nextProxy || 'disabled'})`);
}
});
}
+4 -3
View File
@@ -6,6 +6,7 @@ import { createRequire } from 'node:module';
import { dirname, join, resolve } from 'path';
import { homedir } from 'os';
import { existsSync, mkdirSync, readFileSync, realpathSync } from 'fs';
import { getClawXDataLayout, resolveClawXDataRoot } from './clawx-data-layout';
const require = createRequire(import.meta.url);
@@ -79,21 +80,21 @@ export function getOpenClawSkillsDir(): string {
* Get ClawX config directory
*/
export function getClawXConfigDir(): string {
return join(homedir(), '.clawx');
return resolveClawXDataRoot();
}
/**
* Get ClawX logs directory
*/
export function getLogsDir(): string {
return join(getElectronApp().getPath('userData'), 'logs');
return getClawXDataLayout().logsDir;
}
/**
* Get ClawX data directory
*/
export function getDataDir(): string {
return getElectronApp().getPath('userData');
return resolveClawXDataRoot();
}
/**

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