Compare commits

..
252 changed files with 42604 additions and 1264 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
+35
View File
@@ -33,6 +33,41 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
# The runtime compatibility test executes Electron to assert its embedded
# Node and SQLite versions, so this job cannot rely on the package alone.
# Use the same extraction path as Electron E2E because install.js can
# leave a partially extracted dist directory on GitHub-hosted runners.
- name: Install Electron binary for runtime compatibility test
shell: bash
env:
force_no_cache: 'true'
run: |
set -euo pipefail
unset ELECTRON_SKIP_BINARY_DOWNLOAD
ELECTRON_DIR="$(node -p "require('path').dirname(require.resolve('electron/package.json'))")"
echo "Electron package dir: $ELECTRON_DIR"
rm -rf "$ELECTRON_DIR/dist" "$ELECTRON_DIR/path.txt"
mkdir -p "$ELECTRON_DIR/dist"
ZIP="$(cd "$ELECTRON_DIR" && node -e "
const { downloadArtifact } = require('@electron/get');
const { version } = require('./package.json');
downloadArtifact({ version, artifactName: 'electron', force: true })
.then((z) => { process.stdout.write(z); process.exit(0); })
.catch((e) => { console.error(e); process.exit(1); });
")"
ZIP_SIZE="$(stat -c%s "$ZIP")"
echo "Downloaded zip: $ZIP ($ZIP_SIZE bytes)"
unzip -oq "$ZIP" -d "$ELECTRON_DIR/dist"
echo "Extracted top-level entries: $(ls -1 "$ELECTRON_DIR/dist" | wc -l | tr -d ' ')"
if [ -f "$ELECTRON_DIR/dist/electron.d.ts" ]; then
mv "$ELECTRON_DIR/dist/electron.d.ts" "$ELECTRON_DIR/electron.d.ts"
fi
test -f "$ELECTRON_DIR/dist/electron"
chmod +x "$ELECTRON_DIR/dist/electron"
printf '%s' 'electron' > "$ELECTRON_DIR/path.txt"
- name: Generate extension bridge
run: pnpm run ext:bridge
+1 -1
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:
+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
+57 -10
View File
@@ -93,7 +93,17 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
私たちはアップストリームのOpenClawプロジェクトとの厳密な整合性を維持することにコミットしており、公式リリースが提供する最新の機能、安定性の改善、エコシステムの互換性に常にアクセスできることを保証します。
開発者モードを有効にすると、サイドバーにはネイティブの Dreams ページも表示され、ClawX 内で OpenClaw の記憶レビュー、夢日記、基本メンテナンス操作を扱えます。詳細な診断が必要な場合は、そのページから完全版の OpenClaw Dreams UI も開けます。
開発者モードを有効にし、OpenClaw が active runtime の場合、サイドバーにはネイティブの Dreams ページも表示され、ClawX 内で OpenClaw の記憶レビュー、夢日記、基本メンテナンス操作を扱えます。詳細な診断が必要な場合は、そのページから完全版の OpenClaw Dreams UI も開けます。
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 専用のままです。
---
@@ -129,12 +139,14 @@ ClawX には Tencent 公式の個人 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 サブスクリプション)の両方に対応しています。
@@ -196,7 +208,7 @@ ClawXを初めて起動すると、**セットアップウィザード**が以
### プロキシ設定
ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネルがローカルプロキシクライアントを介してインターネットにアクセスする必要がある環境向けに、組み込みのプロキシ設定が含まれています。
ClawXには、Electron、OpenClaw Gateway、任意の cc-connect/Codex runtime、またはTelegramなどのチャネルがローカルプロキシクライアントを介してインターネットにアクセスする必要がある環境向けに、組み込みのプロキシ設定が含まれています。
**設定 → ゲートウェイ → プロキシ**を開いて以下を設定します:
@@ -217,10 +229,11 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ
- `host:port`のみの値はHTTPとして扱われます。
- 高度なプロキシフィールドが空の場合、ClawXは`プロキシサーバー`にフォールバックします。
- プロキシ設定を保存すると、Electronのネットワーク設定が即座に再適用され、ゲートウェイが自動的に再起動されます。
- cc-connect runtime モードでは、Codex 子プロセスが同じ `HTTP_PROXY``HTTPS_PROXY``ALL_PROXY`、バイパス環境値を継承します。
- ClawXはTelegramが有効な場合、プロキシをOpenClawのTelegramチャネル設定にも同期します。
- ClawXのプロキシが無効な状態では、Gatewayの通常再起動時に既存のTelegramチャネルプロキシ設定を保持します。
- OpenClaw設定のTelegramプロキシを明示的に消したい場合は、プロキシ無効の状態で一度「保存」を実行してください。
- **設定 → 詳細 → 開発者** では **OpenClaw Doctor** を実行でき、`openclaw doctor --json` の診断出力をアプリ内で確認できます。
- **設定 → 詳細 → 開発者** の 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` エントリーポイント経由で実行されます。
---
@@ -229,7 +242,7 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ
ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を採用しています。Renderer は単一クライアント抽象を呼び出し、プロトコル選択とライフサイクルは Main が管理します:
Chat は Electron Main が所有する ACP stdio bridge を使用します。Renderer は型付き host event を受け取り、メモリ上の ACP timeline を描画します。Gateway は providers、models、skills、workspace、settings、diagnostics、media configuration などの非 Chat 機能を引き続き担当します。
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 応答はストリーミングを継続します。完了前に戻ると最新のメモリ内 timeline が復元され、ライブ応答の表示が続きます。完了後は通常の ACP 履歴リプレイが引き続き唯一の正となります。
@@ -275,17 +288,17 @@ ACP Chat は、runtime が画像生成メディアを信頼できる構造化メ
│ 型付き IPC リクエスト
┌─────────────────────────────────────────────────────────────────┐
│ Main Host Services と Gateway Manager │
│ Main Host Services と Runtime Manager │
│ │
│ • host:invoke 型付きサービスディスパッチ │
│ • 設定、ファイル、セッション、スキル、プロバイダー、診断サービス │
│ • Main が Gateway WebSocket とプロセス監視を所有 │
│ • Runtime 選択、transport、プロセス監視を所有
└──────────────────────────────┬──────────────────────────────────┘
│ Main 所有 WebSocket
┌─────────────────────────────────────────────────────────────────┐
│ OpenClaw ゲートウェイ
│ OpenClaw Gateway 経路(図示)
│ │
│ • AIエージェントランタイムとオーケストレーション │
│ • メッセージチャネル管理 │
@@ -297,7 +310,7 @@ ACP Chat は、runtime が画像生成メディアを信頼できる構造化メ
- **プロセス分離**: AIランタイムは別プロセスで動作し、重い計算処理中でもUIの応答性を確保します
- **フロントエンド呼び出しの単一入口**: Renderer は host-api/api-client を通じて呼び出し、下位プロトコルに依存しません
- **Mainによるトランスポート制御**: ACP Chat stdio bridge と Gateway トランスポートは Electron Main が所有し、Renderer は型付き IPC で Main と通信します
- **Mainによるトランスポート制御**: OpenClaw ACP/Gateway transport と cc-connect BridgePlatform dispatch は Electron Main が所有し、Renderer は型付き IPC で Main と通信します
- **拡張 IPC コントリビューション**: Main プロセス拡張は HTTP route ではなく、型付き IPC レジストリを通じて host-api action を提供します
- **グレースフルリカバリ**: 再接続・タイムアウト・バックオフで一時的障害を自動処理します
- **セキュアストレージ**: APIキーや機密データは、OSのネイティブセキュアストレージ機構を活用します
@@ -306,7 +319,7 @@ ACP Chat は、runtime が画像生成メディアを信頼できる構造化メ
### プロセスモデルと Gateway トラブルシューティング
- ClawX は Electron アプリのため、**1つのアプリインスタンスでも複数プロセス(main/renderer/zygote/utility)が表示される**のが正常です。
- 単一起動保護は Electron のロックに加え、ローカルのプロセスロックファイルも併用し、デスクトップ IPC / セッションバスが不安定な環境でも重複起動を防ぎます。
- 単一起動保護は 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 として表示します。
@@ -337,7 +350,7 @@ AI を開発ワークフローに統合できます。エージェントを使
### 前提条件
- **Node.js**: 22.19以上(LTS推奨)
- **Node.js**: 対応するメジャー系列の 22.22.3以上、24.15.0以上、または25.9.0以上(Node 24 LTS推奨)
- **パッケージマネージャー**: pnpm 9以上(推奨)またはnpm
- **LinuxUbuntu/Debian**: Electron を実行する前に、必要なシステムライブラリをインストールしてください:
```bash
@@ -373,6 +386,8 @@ AI を開発ワークフローに統合できます。エージェントを使
```
### 利用可能なコマンド
cc-connect の実環境検証はローカル env ファイルを読み込めますが、リポジトリ内の認証情報ファイルは gitignore されている必要があります。リポジトリ外の `--env-file` パスは利用でき、レポートには書き込まれません。`.env.cc-connect.local.example` は `.env.cc-connect.local` のフィールドテンプレートです。
```bash
# 開発
pnpm run init # 依存関係のインストール + バンドルバイナリ(uv、agent-browser)のダウンロード
@@ -385,10 +400,39 @@ 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 # フロントエンドのみビルド
@@ -397,6 +441,9 @@ 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/ロールバック/クリーンアップを検証
```
ヘッドレス Linux では Electron テストに表示サーバーが必要です。`xvfb-run -a pnpm run test:e2e` を利用してください。
+57 -10
View File
@@ -93,7 +93,17 @@ ClawX is built directly upon the official **OpenClaw** core. Instead of requirin
We are committed to maintaining strict alignment with the upstream OpenClaw project, ensuring that you always have access to the latest capabilities, stability improvements, and ecosystem compatibility provided by the official releases.
When Developer Mode is enabled, the sidebar also provides a native Dreams page for OpenClaw memory review, dream diary inspection, and basic maintenance actions. The full upstream OpenClaw Dreams UI remains available from that page when deeper diagnostics are needed.
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.
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.
---
@@ -129,12 +139,14 @@ ClawX now also bundles Tencent's official personal WeChat channel plugin, so you
### ⏰ 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.
@@ -199,7 +211,7 @@ The wizard preselects your system language when it is supported, and falls back
### Proxy Settings
ClawX includes built-in proxy settings for environments where Electron, the OpenClaw Gateway, or channels such as Telegram 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** and configure:
@@ -220,10 +232,11 @@ 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**, you can run **OpenClaw Doctor** to execute `openclaw doctor --json` and inspect the diagnostic output without leaving the app.
- 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.
---
@@ -232,7 +245,7 @@ Notes:
ClawX employs a **dual-process architecture** with a unified host API layer. The renderer talks to a single client abstraction, while Electron Main owns protocol selection and process lifecycle:
Chat uses an ACP stdio bridge owned by Electron Main. Renderer receives typed host events and renders an in-memory ACP timeline. Gateway remains responsible for non-Chat capabilities such as providers, models, skills, workspace, settings, diagnostics, and media configuration.
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.
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.
@@ -278,17 +291,17 @@ ACP Chat can also display generated image previews when image-generation media i
│ Typed IPC requests
┌──────────────────────────────────────────────────────────────────┐
Main Host Services & Gateway Manager │
│ Main Host Services & Runtime Manager
│ │
│ • host:invoke typed service dispatcher │
│ • Settings, files, sessions, skills, providers, diagnostics │
│ • Main-owned Gateway WebSocket and process supervision
│ • Runtime selection, transport, and process supervision │
└──────────────────────────────┬───────────────────────────────────┘
│ Main-owned WebSocket
┌──────────────────────────────────────────────────────────────────┐
OpenClaw Gateway
│ OpenClaw Gateway path (shown)
│ │
│ • AI agent runtime and orchestration │
│ • Message channel management │
@@ -300,7 +313,7 @@ ACP Chat can also display generated image previews when image-generation media i
- **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 the ACP Chat stdio bridge and Gateway transports; the renderer talks to Main over typed IPC
- **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
@@ -309,7 +322,7 @@ ACP Chat can also display generated image previews when image-generation media i
### 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 local process-file lock fallback, preventing duplicate app launch in environments where desktop IPC/session bus is unstable.
- 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.
@@ -340,7 +353,7 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro
### Prerequisites
- **Node.js**: 22.19+ (LTS recommended)
- **Node.js**: 22.22.3+, 24.15.0+, or 25.9.0+ within the corresponding supported major line (Node 24 LTS recommended)
- **Package Manager**: pnpm 9+ (recommended) or npm
- **Linux (Ubuntu/Debian)**: Install required system libraries before running Electron:
```bash
@@ -376,6 +389,8 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro
```
### 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
# Development
pnpm run init # Install dependencies + download bundled binaries (uv, agent-browser)
@@ -388,10 +403,39 @@ 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
@@ -400,6 +444,9 @@ pnpm package # Package for current platform (includes bundled prein
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
```
On headless Linux, run Electron tests under a display server such as `xvfb-run -a pnpm run test:e2e`.
+1 -1
View File
@@ -306,7 +306,7 @@ ClawX использует **двухпроцессную архитектуру
### Требования
- **Node.js**: 22.19+ (рекомендуется LTS)
- **Node.js**: 22.22.3+, 24.15.0+ или 25.9.0+ в пределах соответствующей основной версии (рекомендуется Node 24 LTS)
- **Менеджер пакетов**: pnpm 9+ (рекомендуется) или npm
### Структура проекта
+57 -10
View File
@@ -94,7 +94,17 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
我们致力于与上游 OpenClaw 项目保持严格同步,确保你始终可以使用官方发布的最新功能、稳定性改进和生态兼容性。
打开开发者模式,侧边栏还会提供原生 Dreams 页面,可在 ClawX 内查看 OpenClaw 记忆回顾、梦境日记,并执行基础维护操作;需要更深诊断时仍可从该页面打开完整 OpenClaw Dreams UI。
打开开发者模式且当前 runtime 为 OpenClaw 时,侧边栏还会提供原生 Dreams 页面,可在 ClawX 内查看 OpenClaw 记忆回顾、梦境日记,并执行基础维护操作;需要更深诊断时仍可从该页面打开完整 OpenClaw Dreams UI。
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 下显示。
---
@@ -130,12 +140,14 @@ ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Chan
### ⏰ 定时任务自动化
调度 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 订阅)登录。
@@ -200,7 +212,7 @@ pnpm dev
### 代理设置
ClawX 内置了代理设置,适用于需要通过本地代理客户端访问外网的场景,包括 Electron 本身、OpenClaw Gateway,以及 Telegram 这类频道的联网请求。
ClawX 内置了代理设置,适用于需要通过本地代理客户端访问外网的场景,包括 Electron 本身、OpenClaw Gateway、可选的 cc-connect/Codex runtime,以及 Telegram 这类频道的联网请求。
打开 **设置 → 网关 → 代理**,配置以下内容:
@@ -221,10 +233,11 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问
- 只填写 `host:port` 时,会按 HTTP 代理处理。
- 高级代理项留空时,会自动回退到“代理服务器”。
- 保存代理设置后,Electron 网络层会立即重新应用代理,并自动重启 Gateway。
- 在 cc-connect runtime 模式下,Codex 子进程会继承同一组 `HTTP_PROXY``HTTPS_PROXY``ALL_PROXY` 和绕过规则环境变量。
- 如果启用了 TelegramClawX 还会把代理同步到 OpenClaw 的 Telegram 频道配置中。
- 当 ClawX 代理处于关闭状态时,Gateway 的常规重启会保留已有的 Telegram 频道代理配置。
- 如果你要明确清空 OpenClaw 中的 Telegram 代理,请在关闭代理后点一次“保存代理设置”。
-**设置 → 高级 → 开发者** 中,可以直接运行 **OpenClaw Doctor**,执行 `openclaw doctor --json` 并在应用内查看诊断输出
-**设置 → 高级 → 开发者** 中,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` 入口运行,以保证终端输入行为稳定。
---
@@ -233,7 +246,7 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问
ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用统一客户端抽象,协议选择与进程生命周期由 Electron 主进程统一管理:
Chat 使用由 Electron Main 持有的 ACP stdio bridgeRenderer 接收类型化 host events并渲染内存中的 ACP timeline。Gateway 仍负责 providers、models、skills、workspace、settings、diagnostics 和 media configuration 等非 Chat 能力
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 回复仍会继续流式接收。若在回复完成前返回,ClawX 会恢复最新的内存 timeline 并继续显示实时输出;回复完成后,普通 ACP 历史回放仍是唯一事实来源。
@@ -279,17 +292,17 @@ ACP Chat 也可在 runtime 以可信结构化媒体投递图像生成结果时
│ 类型化 IPC 请求
┌─────────────────────────────────────────────────────────────────┐
│ 主进程 Host Services 与 Gateway Manager │
│ 主进程 Host Services 与 Runtime Manager │
│ │
│ • host:invoke 类型化服务分发 │
│ • 设置、文件、会话、技能、供应商、诊断服务 │
│ • 主进程持有 Gateway WebSocket 并负责进程监控
│ • Runtime 选择、传输与进程监控
└──────────────────────────────┬──────────────────────────────────┘
│ 主进程持有 WebSocket
┌─────────────────────────────────────────────────────────────────┐
│ OpenClaw 网关
│ OpenClaw 网关路径(图示)
│ │
│ • AI 智能体运行时与编排 │
│ • 消息频道管理 │
@@ -301,7 +314,7 @@ ACP Chat 也可在 runtime 以可信结构化媒体投递图像生成结果时
- **进程隔离**:AI 运行时在独立进程中运行,确保即使在高负载计算期间 UI 也能保持响应
- **前端调用单一入口**:渲染层统一走 host-api/api-client,不感知底层协议细节
- **主进程掌控传输策略**ACP Chat stdio bridge 与 Gateway 传输都由 Electron Main 持有,渲染进程通过类型化 IPC 调用 Main
- **主进程掌控传输策略**OpenClaw ACP/Gateway 传输与 cc-connect BridgePlatform 分派都由 Electron Main 持有,渲染进程通过类型化 IPC 调用 Main
- **扩展 IPC 贡献点**:主进程扩展通过类型化 IPC 注册表贡献 host-api action,而不是挂载 HTTP route
- **优雅恢复**:内置重连、超时、退避逻辑,自动处理瞬时故障
- **安全存储**:API 密钥和敏感数据利用操作系统原生的安全存储机制
@@ -310,7 +323,7 @@ ACP Chat 也可在 runtime 以可信结构化媒体投递图像生成结果时
### 进程模型与 Gateway 排障
- ClawX 基于 Electron,**单个应用实例出现多个系统进程是正常现象**main/renderer/zygote/utility)。
- 单实例保护同时使用 Electron 自带锁与本地进程文件锁回退机制,可在桌面会话总线异常时避免重复启动。
- 单实例保护同时使用 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 故障。
@@ -341,7 +354,7 @@ ACP Chat 也可在 runtime 以可信结构化媒体投递图像生成结果时
### 前置要求
- **Node.js**22.19+(推荐 LTS 版本
- **Node.js**对应主版本范围内的 22.22.3+、24.15.0+ 或 25.9.0+(推荐 Node 24 LTS
- **包管理器**pnpm 9+(推荐)或 npm
- **LinuxUbuntu/Debian**:运行 Electron 前,请先安装所需系统库:
```bash
@@ -377,6 +390,8 @@ ACP Chat 也可在 runtime 以可信结构化媒体投递图像生成结果时
```
### 常用命令
cc-connect 真实验证可以加载本地 env 文件,但仓库内的凭据文件必须被 gitignore;仓库外 `--env-file` 路径可以使用且不会写入报告。`.env.cc-connect.local.example` 是 `.env.cc-connect.local` 的字段模板。
```bash
# 开发
pnpm run init # 安装依赖并下载捆绑二进制(uv、agent-browser
@@ -389,10 +404,39 @@ 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 # 仅构建前端
@@ -401,6 +445,9 @@ 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/回滚/清理
```
在无头 Linux 环境下,Electron 测试需要显示服务;可使用 `xvfb-run -a pnpm run test:e2e`。
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+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.
+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;
}
+31 -2
View File
@@ -33,8 +33,9 @@ import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
import { logger } from '../utils/logger';
import { prependPathEntry } from '../utils/env-path';
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe, buildCandidateSources, repairTrustedOfficialPluginInstallRecords, syncTrustedOfficialPluginInstallRecord, resolvePluginNpmPackagePath } from '../utils/plugin-install';
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe, buildCandidateSources, repairTrustedOfficialPluginInstallRecords, removeTrustedOfficialPluginInstallRecord, 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';
import { cleanupAgentsSymlinkedSkills, cleanupStalePluginRuntimeDeps } from './skills-symlink-cleanup';
import {
@@ -266,6 +267,18 @@ function cleanupUnconfiguredChannelPlugins(configuredChannels: string[]): boolea
return succeeded;
}
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;
// Metadata can outlive the directory (for example after an interrupted
// 2026.6.10 → 2026.7.1 migration). OpenClaw validates tracked records even
// when the channel is no longer configured, so reconcile this on every
// launch rather than hiding it behind the directory-maintenance cache.
removeTrustedOfficialPluginInstallRecord(dirName);
}
}
function resolveImageGenerationPrimary(config: unknown): string | null {
if (!config || typeof config !== 'object') return null;
const agents = (config as { agents?: unknown }).agents;
@@ -526,7 +539,10 @@ export async function syncGatewayConfigBeforeLaunch(
// Always refresh trusted install metadata through ClawX — this must not
// be skipped when plugin-maintenance is cache-hit, otherwise official
// external plugins like WhatsApp fail openKeyedStore at runtime.
measureSync(timingsMs, 'trustedPluginInstallSyncMs', repairTrustedOfficialPluginInstallRecords);
measureSync(timingsMs, 'trustedPluginInstallSyncMs', () => {
cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels);
repairTrustedOfficialPluginInstallRecords();
});
} catch (err) {
logger.warn('Failed to auto-upgrade plugins:', err);
}
@@ -625,6 +641,19 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
throw new Error(`OpenClaw package not found at: ${openclawDir}`);
}
await measureAsync(timingsMs, 'upgradeSnapshotMs', async () => {
try {
const snapshot = await ensureOpenClaw2026_7_1UpgradeSnapshot();
if (snapshot.status === 'created') {
logger.info(`[upgrade] Created OpenClaw 2026.7.1 pre-migration snapshot (${snapshot.files.length} files): ${snapshot.snapshotDir}`);
}
} catch (error) {
// OpenClaw also maintains migration-specific backups. Keep startup
// available if the additional ClawX safety snapshot cannot be written.
logger.warn('[upgrade] Failed to create OpenClaw 2026.7.1 pre-migration snapshot:', error);
}
});
const appSettings = await measureAsync(timingsMs, 'settingsMs', getAllSettings);
const prelaunchSummary = await measureAsync(timingsMs, 'prelaunchSyncMs', async () => (
await syncGatewayConfigBeforeLaunch(appSettings, openclawDir)
+51 -11
View File
@@ -24,7 +24,9 @@ import {
type GatewayLifecycleState,
getReconnectScheduleDecision,
getReconnectSkipReason,
isOpenClawFatalConfigExitCode,
} from './process-policy';
import { removeOpenClaw2026_7_1UpgradeSnapshot } from '../utils/openclaw-upgrade-snapshot';
import {
clearPendingGatewayRequests,
rejectPendingGatewayRequest,
@@ -61,6 +63,11 @@ import {
recordGatewayStartupStderrLine,
} from './startup-stderr';
import { runGatewayStartupSequence } from './startup-orchestrator';
import {
hasFatalRuntimeFailureSignal,
hasInvalidConfigFailureSignal,
hasStartupMigrationLockSignal,
} from './startup-recovery';
import {
GatewayCapabilityMonitor,
type GatewayCapabilityName,
@@ -190,6 +197,7 @@ export class GatewayManager extends EventEmitter {
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;
@@ -251,6 +259,7 @@ export class GatewayManager extends EventEmitter {
logger.info('Gateway subsystems ready (event received)');
this.setStatus({ gatewayReady: true });
}
void this.cleanupOpenClawUpgradeSnapshot();
});
this.on('gateway:health', (payload) => {
this.capabilityMonitor.recordOpenClawHealth(payload);
@@ -462,7 +471,17 @@ export class GatewayManager extends EventEmitter {
error
);
this.setStatus({ state: 'error', error: String(error) });
if (this.shouldReconnect) {
const fatalStartupFailure = isOpenClawFatalConfigExitCode(this.processExitCode)
|| hasFatalRuntimeFailureSignal(error, this.recentStartupStderrLines)
|| hasStartupMigrationLockSignal(error, this.recentStartupStderrLines)
|| hasInvalidConfigFailureSignal(error, this.recentStartupStderrLines);
if (fatalStartupFailure) {
// OpenClaw 2026.7.1 uses EX_CONFIG for fatal configuration failures.
// Runtime and SQLite compatibility failures are likewise not repaired
// by restarting the same binary, so leave recovery to a manual start.
this.shouldReconnect = false;
logger.error('Gateway startup failed fatally; automatic reconnect disabled');
} else if (this.shouldReconnect) {
logger.warn('Gateway start failed; scheduling auto-reconnect recovery');
this.scheduleReconnect();
}
@@ -1121,16 +1140,23 @@ export class GatewayManager extends EventEmitter {
this.setStatus({ state: 'stopped' });
}
// Always attempt reconnect from process exit. scheduleReconnect()
// internally checks shouldReconnect and reconnect-timer guards, so
// calling it unconditionally is safe — intentional stop() calls set
// shouldReconnect=false which makes scheduleReconnect() no-op.
//
// On Windows, the WS close handler intentionally skips reconnect
// (to avoid racing with this exit handler). However, WS close
// fires *before* process exit and sets state='stopped', which
// previously caused this handler to also skip reconnect — leaving
// the gateway permanently dead with no recovery path.
const orchestratedStartupFailure = isOpenClawFatalConfigExitCode(code)
|| hasFatalRuntimeFailureSignal(undefined, this.recentStartupStderrLines)
|| hasStartupMigrationLockSignal(undefined, this.recentStartupStderrLines)
|| hasInvalidConfigFailureSignal(undefined, this.recentStartupStderrLines);
if (orchestratedStartupFailure) {
// During startup the orchestrator may still perform its one bounded
// doctor repair. Do not race it with an independent reconnect timer.
// If orchestration cannot recover, start() disables reconnect in its
// catch path so migration/config failures cannot create an outer loop.
if (this.status.state !== 'starting') this.shouldReconnect = false;
logger.error(`Gateway process reported a non-retriable startup condition (code=${String(code)}); reconnect not scheduled`);
return;
}
// Always attempt reconnect from non-fatal process exits.
// scheduleReconnect() internally checks shouldReconnect and timer
// guards, so intentional stop() remains a no-op.
this.scheduleReconnect();
},
onError: () => {
@@ -1333,6 +1359,20 @@ export class GatewayManager extends EventEmitter {
this.initialReadyHeartbeatRecoveryTimer = null;
}
private async cleanupOpenClawUpgradeSnapshot(): Promise<void> {
if (this.upgradeSnapshotCleanupAttempted) return;
this.upgradeSnapshotCleanupAttempted = true;
try {
const result = await removeOpenClaw2026_7_1UpgradeSnapshot();
if (result.status === 'removed') {
logger.info(`[upgrade] Removed OpenClaw 2026.7.1 pre-migration snapshot: ${result.snapshotDir}`);
}
} catch (error) {
logger.warn('[upgrade] Failed to remove OpenClaw 2026.7.1 pre-migration snapshot:', error);
}
}
/**
* Schedule reconnection attempt with exponential backoff
*/
+7
View File
@@ -10,6 +10,13 @@ export const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
maxDelay: 30000,
};
/** sysexits(3) EX_CONFIG, used by OpenClaw 2026.7.1 for fatal config startup errors. */
export const OPENCLAW_EX_CONFIG_EXIT_CODE = 78;
export function isOpenClawFatalConfigExitCode(code: number | null | undefined): boolean {
return code === OPENCLAW_EX_CONFIG_EXIT_CODE;
}
export function nextLifecycleEpoch(currentEpoch: number): number {
return currentEpoch + 1;
}
+54 -6
View File
@@ -8,10 +8,25 @@
const INVALID_CONFIG_PATTERNS: RegExp[] = [
/\binvalid config\b/i,
/\bconfig invalid\b/i,
/\bfatal configuration error\b/i,
/\bunrecognized key\b/i,
/\bstartup migration(?:s)?\b.*\b(?:blocked|failed|did not complete cleanly)\b/i,
/\bmigration\b.*\bopenclaw doctor --fix\b/i,
/\brun:\s*openclaw doctor --fix\b/i,
];
const FATAL_RUNTIME_PATTERNS: RegExp[] = [
/\bNode(?:\.js)?\b.*\boutside the supported range\b/i,
/\buses SQLite\b.*\bnot WAL-reset-safe\b/i,
/\bSQLite\b.*\bWAL-reset-safe runtime required\b/i,
/\bInstall Node 24\.15\+.*\bNode 22\.22\.3\+\b/i,
];
const STARTUP_MIGRATION_LOCK_PATTERNS: RegExp[] = [
/\bstartup migrations? (?:is|are) already running\b/i,
/\bretry after the other gateway finishes\b/i,
];
const TRANSIENT_START_ERROR_PATTERNS: RegExp[] = [
/WebSocket closed before handshake/i,
/ECONNREFUSED/i,
@@ -61,6 +76,33 @@ export function hasInvalidConfigFailureSignal(
return isInvalidConfigSignal(errorText);
}
function startupFailureCandidates(startupError: unknown, startupStderrLines: string[]): string[] {
return [
...startupStderrLines,
startupError instanceof Error
? `${startupError.name}: ${startupError.message}`
: String(startupError ?? ''),
];
}
/** Returns true for OpenClaw runtime/SQLite failures that doctor cannot repair. */
export function hasFatalRuntimeFailureSignal(
startupError: unknown,
startupStderrLines: string[],
): boolean {
return startupFailureCandidates(startupError, startupStderrLines)
.some((text) => FATAL_RUNTIME_PATTERNS.some((pattern) => pattern.test(text)));
}
/** Returns true while another/stale OpenClaw startup migration lease is active. */
export function hasStartupMigrationLockSignal(
startupError: unknown,
startupStderrLines: string[],
): boolean {
return startupFailureCandidates(startupError, startupStderrLines)
.some((text) => STARTUP_MIGRATION_LOCK_PATTERNS.some((pattern) => pattern.test(text)));
}
/**
* Retry guard for one-time config repair during a single startup flow.
*/
@@ -136,12 +178,18 @@ export function getGatewayStartupRecoveryAction(options: {
attempt: number;
maxAttempts: number;
}): GatewayStartupRecoveryAction {
if (shouldAttemptConfigAutoRepair(
options.startupError,
options.startupStderrLines,
options.configRepairAttempted,
)) {
return 'repair';
if (
hasFatalRuntimeFailureSignal(options.startupError, options.startupStderrLines)
|| hasStartupMigrationLockSignal(options.startupError, options.startupStderrLines)
) {
return 'fail';
}
if (hasInvalidConfigFailureSignal(options.startupError, options.startupStderrLines)) {
// One doctor pass is the only automated repair. If the same migration or
// config failure remains afterward, stop instead of treating the generic
// process-exited error as transient.
return options.configRepairAttempted ? 'fail' : 'repair';
}
if (options.attempt < options.maxAttempts && isTransientGatewayStartError(options.startupError)) {
+83 -35
View File
@@ -5,6 +5,9 @@
import { app, BrowserWindow, nativeImage, session, shell, type Session } from 'electron';
import { join } from 'path';
import { GatewayManager } from '../gateway/manager';
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';
@@ -53,19 +56,22 @@ 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).
@@ -104,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;
@@ -125,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();
@@ -320,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,
@@ -372,6 +408,7 @@ async function initialize(): Promise<void> {
// Register IPC handlers
registerIpcHandlers(
gatewayManager,
runtimeManager,
clawHubService,
window,
hostApiRegistry,
@@ -379,6 +416,7 @@ async function initialize(): Promise<void> {
webBrowserGuestRegistry,
);
await runtimeManager.getActiveKind();
loadMainWindow(window);
// Create system tray
@@ -389,6 +427,7 @@ async function initialize(): Promise<void> {
// Initialize extension system
await extensionRegistry.initialize({
gatewayManager,
runtimeManager,
getMainWindow: () => mainWindow,
hostApi: {
register: (extensionId, contributions) => (
@@ -465,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 });
});
@@ -546,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) {
@@ -604,6 +645,10 @@ if (gotTheLock) {
}
gatewayManager = new GatewayManager();
runtimeManager = new RuntimeManager({
openclaw: new OpenClawRuntimeProvider(gatewayManager),
ccConnect: new CcConnectRuntimeProvider(),
});
clawHubService = new ClawHubService();
// Register builtin extensions and load manifest
@@ -673,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);
@@ -682,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();
@@ -703,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
}
@@ -722,4 +770,4 @@ if (gotTheLock) {
}
// Export for testing
export { mainWindow, gatewayManager };
export { mainWindow, gatewayManager, runtimeManager };
+52 -42
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,
@@ -29,7 +30,7 @@ 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,
@@ -66,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 {
@@ -85,6 +86,7 @@ const gatewayRpcBackpressure = new GatewayRpcBackpressure();
*/
export function registerIpcHandlers(
gatewayManager: GatewayManager,
runtimeManager: RuntimeManager,
clawHubService: ClawHubService,
mainWindow: BrowserWindow,
hostApiRegistry: HostApiRegistry,
@@ -92,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,
@@ -105,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();
@@ -126,7 +129,7 @@ export function registerIpcHandlers(
registerSettingsHandlers(gatewayManager);
// Usage handlers
registerUsageHandlers();
registerUsageHandlers(runtimeManager);
// Cron task handlers (proxy to Gateway RPC)
registerCronHandlers(gatewayManager);
@@ -143,6 +146,7 @@ export function registerIpcHandlers(
function registerTypedHostHandlers(
gatewayManager: GatewayManager,
runtimeManager: RuntimeManager,
clawHubService: ClawHubService,
mainWindow: BrowserWindow,
hostApiRegistry: HostApiRegistry,
@@ -158,7 +162,7 @@ function registerTypedHostHandlers(
openWith: attachmentOpenWith,
});
hostApiRegistry.registerCoreServices({
app: createAppApi(),
app: createAppApi(runtimeManager),
openclaw: createOpenClawApi(),
shell: createShellApi(),
webBrowser: createWebBrowserApi({ browserSession, registry }),
@@ -166,28 +170,34 @@ function registerTypedHostHandlers(
window: createWindowApi(mainWindow),
updates: createUpdatesApi(appUpdater),
uv: createUvApi(),
settings: createSettingsApi(gatewayManager),
gateway: createGatewayApi(gatewayManager, gatewayRpcBackpressure),
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();
@@ -537,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 {
@@ -715,10 +720,10 @@ 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
@@ -728,7 +733,7 @@ function registerGatewayHandlers(gatewayManager: GatewayManager): void {
method,
params,
timeoutMs,
(rpcMethod, rpcParams, rpcTimeoutMs) => gatewayManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
(rpcMethod, rpcParams, rpcTimeoutMs) => runtimeManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
);
return { success: true, result };
} catch (error) {
@@ -807,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 => {
@@ -825,9 +833,14 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`);
gatewayManager.debouncedRestart(8000);
});
browserOAuthManager.on('oauth:success', ({ provider, accountId }) => {
logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`);
gatewayManager.debouncedRestart(8000);
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
@@ -1226,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);
});
}
/**
@@ -1315,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) ──────────────────────────────────────────
//
@@ -1384,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: () => {},
};
}
@@ -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 -2
View File
@@ -393,11 +393,13 @@ export class AcpChatService {
this.historicalGeneration = null;
}
this.permissionsEnabled = true;
const messageId = payload.messageId ?? randomUUID();
await connection.prompt({
sessionId: acpSessionId,
prompt,
messageId: payload.messageId ?? randomUUID(),
_meta: { sessionKey: payload.sessionKey, prefixCwd: true },
// ACP 1.1 removed messageId from the PromptRequest wire shape. Keep
// ClawX correlation metadata in the protocol extension envelope.
_meta: { sessionKey: payload.sessionKey, prefixCwd: true, messageId },
});
this.trace('session/prompt:success', {
sessionKey: payload.sessionKey,
+65 -22
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,23 +34,34 @@ function requireString(payload: unknown, key: string): string {
return payload[key].trim();
}
function scheduleGatewayReload(ctx: AgentsApiContext, reason: string): void {
if (ctx.gatewayManager.getStatus().state !== 'stopped') {
ctx.gatewayManager.debouncedReload();
async function refreshActiveRuntime(ctx: AgentsApiContext, reason: string): Promise<void> {
const provider = ctx.runtimeManager?.getActiveProvider();
if (provider?.refreshConfig) {
await provider.refreshConfig({ scope: 'runtime', reason });
return;
}
void reason;
if (ctx.gatewayManager.getStatus().state !== 'stopped') {
ctx.gatewayManager.debouncedReload();
}
}
async function restartGatewayForAgentDeletion(ctx: AgentsApiContext): Promise<void> {
async function restartRuntimeForAgentDeletion(ctx: AgentsApiContext): Promise<void> {
try {
await ctx.gatewayManager.restart();
console.log('[agents] Gateway restart completed after agent deletion');
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] Gateway restart after agent deletion failed:', 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()) }),
@@ -51,10 +69,12 @@ export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegis
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);
});
scheduleGatewayReload(ctx, 'create-agent');
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);
});
@@ -64,29 +84,52 @@ export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegis
const agentId = requireString(payload, 'id');
const name = requireString(payload, 'name');
const snapshot = await updateAgentName(agentId, name);
scheduleGatewayReload(ctx, 'update-agent');
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);
try {
await syncAllProviderAuthToRuntime();
await syncAgentModelOverrideToRuntime(agentId);
} catch (syncError) {
console.warn('[agents] Failed to sync runtime after updating agent model:', syncError);
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.
scheduleGatewayReload(ctx, 'update-agent-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 restartGatewayForAgentDeletion(ctx);
await deleteCcConnectAgentBinding(agentId);
await restartRuntimeForAgentDeletion(ctx);
await removeAgentWorkspaceDirectory(removedEntry).catch((err) => {
console.warn('[agents] Failed to remove workspace after agent deletion:', err);
});
@@ -96,7 +139,7 @@ export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegis
const agentId = requireString(payload, 'id');
const channelType = requireString(payload, 'channelType');
const snapshot = await assignChannelToAgent(agentId, channelType);
scheduleGatewayReload(ctx, 'assign-channel');
await refreshActiveRuntime(ctx, 'assign-channel');
return { success: true, ...snapshot };
},
removeChannel: async (payload) => {
@@ -122,7 +165,7 @@ export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegis
await clearChannelBinding(channelType, accountId);
}
const snapshot = await listAgentsSnapshot();
scheduleGatewayReload(ctx, 'remove-agent-channel');
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();
},
};
}
+38 -16
View File
@@ -73,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;
@@ -83,6 +84,7 @@ async function listWhatsAppDirectoryPeersFromConfig(_params: unknown): Promise<u
type ChannelsApiContext = {
gatewayManager: GatewayManager;
runtimeManager?: RuntimeManager;
mainWindow?: BrowserWindow;
};
@@ -265,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,
@@ -292,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,
@@ -379,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'
@@ -391,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')
@@ -985,13 +989,29 @@ async function ensureScopedChannelBinding(channelType: string, accountId?: strin
await migrateLegacyChannelWideBinding(storedChannelType);
}
function scheduleGatewayChannelRestart(ctx: ChannelsApiContext, reason: string): void {
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;
}
function scheduleGatewayChannelSaveRefresh(ctx: ChannelsApiContext, channelType: string, reason: string): void {
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)) {
@@ -1072,7 +1092,7 @@ async function awaitWeChatQrLogin(
});
await saveChannelConfig(UI_WECHAT_CHANNEL_TYPE, { enabled: true }, normalizedAccountId);
await ensureScopedChannelBinding(UI_WECHAT_CHANNEL_TYPE, normalizedAccountId);
scheduleGatewayChannelSaveRefresh(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE, `wechat:loginSuccess:${normalizedAccountId}`);
await scheduleGatewayChannelSaveRefresh(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE, `wechat:loginSuccess:${normalizedAccountId}`);
if (activeQrLogins.get(loginKey) !== sessionKey) return;
emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'success', {
@@ -1135,7 +1155,7 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
const accountId = requireString(payload, 'accountId');
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
await setChannelDefaultAccount(channelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setDefaultAccount:${channelType}`);
await scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setDefaultAccount:${channelType}`);
return { success: true };
},
bindingSave: async (payload) => {
@@ -1152,7 +1172,7 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
await migrateLegacyChannelWideBinding(storedChannelType);
}
await assignChannelAccountToAgent(agentId, storedChannelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setBinding:${channelType}`);
await scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setBinding:${channelType}`);
return { success: true };
},
bindingDelete: async (payload) => {
@@ -1160,7 +1180,7 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
const accountId = optionalString(payload, 'accountId');
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
await clearChannelBinding(resolveStoredChannelType(channelType), accountId);
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:clearBinding:${channelType}`);
await scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:clearBinding:${channelType}`);
return { success: true };
},
validateConfig: async (payload) => {
@@ -1178,23 +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);
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfigNoChange:${storedChannelType}`);
await scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfigNoChange:${storedChannelType}`);
return { success: true, noChange: true };
}
await saveChannelConfig(channelType, config, accountId);
await ensureScopedChannelBinding(channelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfig:${storedChannelType}`);
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);
scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${resolveStoredChannelType(channelType)}`);
await scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${resolveStoredChannelType(channelType)}`);
return { success: true };
},
formValues: async (payload) => {
@@ -1209,11 +1231,11 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
if (accountId) {
await deleteChannelAccountConfig(channelType, accountId);
await clearChannelBinding(storedChannelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:deleteAccount:${storedChannelType}`);
await scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:deleteAccount:${storedChannelType}`);
} else {
await deleteChannelConfig(channelType);
await clearAllBindingsForChannel(storedChannelType);
scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${storedChannelType}`);
await scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${storedChannelType}`);
}
return { success: true };
},
+60 -29
View File
@@ -1,6 +1,8 @@
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';
@@ -41,27 +43,19 @@ function normalizeMedia(media: unknown): Array<{ filePath: string; mimeType: str
});
}
export function createChatApi({
gatewayManager,
mainWindow,
acpSessionAccessRegistry,
}: {
gatewayManager: GatewayManager;
mainWindow: BrowserWindow;
acpSessionAccessRegistry: AcpSessionAccessRegistry;
}): CompleteHostServiceRegistry['chat'] {
const acpChat = createAcpChatService(mainWindow, acpSessionAccessRegistry, gatewayManager);
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' };
}
return {
sendWithMedia: async (payload) => {
const body = isRecord(payload) ? payload as ChatSendWithMediaPayload : {};
const sessionKey = typeof body.sessionKey === 'string' ? body.sessionKey : '';
const idempotencyKey = typeof body.idempotencyKey === 'string' ? body.idempotencyKey : '';
if (!sessionKey || !idempotencyKey) {
return { success: false, error: 'Invalid chat send payload' };
}
try {
try {
let message = typeof body.message === 'string' ? body.message : '';
const imageAttachments: Array<Record<string, unknown>> = [];
const fileReferences: string[] = [];
@@ -71,7 +65,7 @@ export function createChatApi({
const fsP = await import('node:fs/promises');
for (const item of media) {
const exists = await fsP.access(item.filePath).then(() => true, () => false);
logger.info(
log.info(
`[chat:sendWithMedia] Processing media: name=${item.fileName}, mimeType=${item.mimeType}, exists=${exists}, isVision=${VISION_MIME_TYPES.has(item.mimeType)}`,
);
@@ -82,7 +76,7 @@ export function createChatApi({
if (VISION_MIME_TYPES.has(item.mimeType)) {
const fileBuffer = await fsP.readFile(item.filePath);
const base64Data = fileBuffer.toString('base64');
logger.info(`[chat:sendWithMedia] Read ${fileBuffer.length} bytes, base64 length: ${base64Data.length}`);
log.info(`[chat:sendWithMedia] Read ${fileBuffer.length} bytes, base64 length: ${base64Data.length}`);
imageAttachments.push({
content: base64Data,
mimeType: item.mimeType,
@@ -107,24 +101,61 @@ export function createChatApi({
rpcParams.attachments = imageAttachments;
}
logger.info(
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';
logger.info(`[chat:sendWithMedia] RPC result: runId=${hasRunId ? 'present' : 'absent'}`);
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 {
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) {
logger.error(`[chat:sendWithMedia] Error: ${String(error)}`);
return { success: false, error: String(error) };
}
},
loadAcpSession: (payload) => acpChat.loadSession(payload),
sendAcpPrompt: (payload) => acpChat.sendPrompt(payload),
cancelAcpSession: (payload) => acpChat.cancelSession(payload),
respondAcpPermission: (payload) => acpChat.respondPermission(payload),
loadAcpSession: (payload) => withOpenClawAcp(() => acpChat.loadSession(payload)),
sendAcpPrompt: (payload) => withOpenClawAcp(() => acpChat.sendPrompt(payload)),
cancelAcpSession: (payload) => withOpenClawAcp(() => acpChat.cancelSession(payload)),
respondAcpPermission: (payload) => withOpenClawAcp(() => acpChat.respondPermission(payload)),
};
}
+148 -69
View File
@@ -1,8 +1,10 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
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';
@@ -53,7 +55,7 @@ interface CronSessionKeyParts {
interface CronSessionFallbackMessage {
id: string;
role: 'assistant' | 'system';
role: 'user' | 'assistant';
content: string;
timestamp: number;
isError?: boolean;
@@ -117,14 +119,14 @@ function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSession
return {
id: `cron-run-${entry.sessionId ?? entry.ts ?? index}`,
role: status === 'error' ? 'system' : 'assistant',
role: 'assistant',
content,
timestamp,
...(status === 'error' ? { isError: true } : {}),
};
}
async function readCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
async function readLegacyCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
const logPath = join(getOpenClawConfigDir(), 'cron', 'runs', `${jobId}.jsonl`);
const raw = await readFile(logPath, 'utf8').catch(() => '');
if (!raw.trim()) return [];
@@ -145,6 +147,24 @@ async function readCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
return entries;
}
async function readCronRunHistory(
gatewayManager: GatewayManager,
jobId: string,
limit: number,
): Promise<CronRunLogEntry[]> {
try {
const result = await gatewayManager.rpc<{ entries?: CronRunLogEntry[] }>('cron.runs', {
id: jobId,
limit,
sortDir: 'asc',
}, 8000);
if (Array.isArray(result?.entries)) return result.entries;
} catch {
// OpenClaw versions before SQLite cron history may not expose cron.runs.
}
return readLegacyCronRunLog(jobId);
}
async function readSessionStoreEntry(
agentId: string,
sessionKey: string,
@@ -204,12 +224,10 @@ function buildCronSessionFallbackMessages(params: {
: (normalizeTimestampMs(params.job?.state?.runningAtMs) ?? params.sessionEntry?.updatedAt);
if (taskName || prompt) {
const lines = [taskName ? `Scheduled task: ${taskName}` : 'Scheduled task'];
if (prompt) lines.push(`Prompt: ${prompt}`);
messages.push({
id: `cron-meta-${parsed.jobId}`,
role: 'system',
content: lines.join('\n'),
role: 'user',
content: prompt || taskName,
timestamp: Math.max(0, (firstRelevantTimestamp ?? Date.now()) - 1),
});
}
@@ -224,17 +242,10 @@ function buildCronSessionFallbackMessages(params: {
if (runningAt) {
messages.push({
id: `cron-running-${parsed.jobId}`,
role: 'system',
role: 'assistant',
content: 'This scheduled task is still running in OpenClaw, but no chat transcript is available yet.',
timestamp: runningAt,
});
} else if (messages.length === 0) {
messages.push({
id: `cron-empty-${parsed.jobId}`,
role: 'system',
content: 'No chat transcript is available for this scheduled task yet.',
timestamp: params.sessionEntry?.updatedAt ?? Date.now(),
});
}
}
@@ -393,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;
@@ -497,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() : '';
@@ -566,10 +638,17 @@ 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[] })),
readCronRunLog(parsedSession.jobId),
readCronRunHistory(gatewayManager, parsedSession.jobId, limit),
readSessionStoreEntry(parsedSession.agentId, sessionKey),
]);
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
+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')),
+49 -10
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);
@@ -844,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' };
@@ -869,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' };
@@ -899,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 {
@@ -919,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 {
@@ -939,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) => {
@@ -969,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' };
+19 -20
View File
@@ -1,11 +1,7 @@
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 = {
@@ -31,38 +27,41 @@ function parseTimeoutMs(timeoutMs: unknown): number | undefined {
}
export function createGatewayApi(
gatewayManager: GatewayManager,
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 (payload) => {
const status = runtimeManager.getStatus();
const body = isRecord(payload) ? payload as ControlUiPayload : {};
const status = gatewayManager.getStatus();
const token = await getSetting('gatewayToken');
const port = status.port || PORTS.OPENCLAW_GATEWAY;
const view = body.view === 'dreams' ? 'dreams' : undefined;
const url = buildOpenClawControlUiUrl(port, token, { view });
void approvePendingLocalDeviceRequests(gatewayManager).catch((error) => {
logger.debug(`[gateway] Control UI device auto-approve skipped: ${String(error)}`);
});
return { success: true, url, token, port };
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 : {};
@@ -75,7 +74,7 @@ export function createGatewayApi(
method,
body.params,
timeoutMs,
(rpcMethod, rpcParams, rpcTimeoutMs) => gatewayManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
(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;
+201 -39
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;
};
@@ -150,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');
@@ -189,7 +286,7 @@ async function validateKey(payload: ProviderPayload<'validateKey'>): Promise<{ v
}
}
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 {
@@ -198,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 providerService._deleteProviderInternal(providerId);
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
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);
@@ -259,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);
@@ -285,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)) {
@@ -319,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() : '';
@@ -345,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) };
@@ -354,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');
@@ -370,9 +469,10 @@ async function deleteAccount(
: undefined;
if (apiKeyOnly) {
await providerService._deleteProviderApiKeyInternal(accountId);
await syncDeletedProviderApiKeyToRuntime(
await syncDeletedProviderApiKeyToActiveRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
ctx,
runtimeProviderKey,
);
return { success: true };
@@ -385,12 +485,12 @@ async function deleteAccount(
await providerService.deleteAccount(accountId);
if (replacementDefault) {
await providerService.setDefaultAccount(replacementDefault.id);
await syncDefaultProviderToRuntime(replacementDefault.id);
await syncDefaultProviderToActiveRuntime(replacementDefault.id, ctx);
}
await syncDeletedProviderToRuntime(
await syncDeletedProviderToActiveRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
gatewayManager,
ctx,
runtimeProviderKey,
);
return { success: true };
@@ -399,7 +499,7 @@ async function deleteAccount(
}
}
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 {
@@ -408,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) };
@@ -464,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(),
@@ -476,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(),
@@ -489,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),
};
}
@@ -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 {
+21 -2
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';
@@ -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);
},
};
}
+1
View File
@@ -207,6 +207,7 @@ export type ProviderSecret =
accountId: string;
accessToken: string;
refreshToken: string;
idToken?: string;
expiresAt: number;
scopes?: string[];
email?: string;
+24 -9
View File
@@ -1,6 +1,7 @@
import { access, copyFile, mkdir, readdir, rm } from 'fs/promises';
import { constants } from 'fs';
import { join, normalize } from 'path';
import { app } from 'electron';
import { deleteAgentChannelAccounts, listConfiguredChannels, readOpenClawConfig, writeOpenClawConfig } from './channel-config';
import type { OpenClawConfig } from './channel-config';
import { withConfigLock } from './config-mutex';
@@ -8,11 +9,15 @@ 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',
@@ -166,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 {
@@ -350,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));
@@ -411,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,6 +474,10 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedCha
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>>();
@@ -522,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
@@ -607,7 +622,7 @@ export async function createAgent(
const newAgent: AgentListEntry = {
id: nextId,
name: normalizedName,
workspace: `~/.openclaw/workspace-${nextId}`,
workspace: getClawXManagedWorkspacePath(nextId),
agentDir: getDefaultAgentDirPath(nextId),
};
+22 -51
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.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,
+147 -11
View File
@@ -12,6 +12,9 @@ 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,
@@ -453,6 +456,95 @@ 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 ───────────────────────────────────────────────────
async function ensureConfigDir(): Promise<void> {
@@ -461,7 +553,7 @@ async function ensureConfigDir(): Promise<void> {
}
}
export async function readOpenClawConfig(): Promise<OpenClawConfig> {
async function readOpenClawCompatibilityConfig(): Promise<OpenClawConfig> {
await ensureConfigDir();
if (!(await fileExists(CONFIG_FILE))) {
@@ -478,9 +570,21 @@ export async function readOpenClawConfig(): Promise<OpenClawConfig> {
}
}
export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void> {
await ensureConfigDir();
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 =
@@ -490,7 +594,12 @@ export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void>
commands.restart = true;
config.commands = commands;
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
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);
@@ -498,6 +607,12 @@ export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void>
}
}
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> {
@@ -688,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, '*'];
@@ -751,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);
@@ -758,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,
@@ -775,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];
@@ -852,7 +982,10 @@ 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);
@@ -984,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;
+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();
}
+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),
+180
View File
@@ -0,0 +1,180 @@
import { chmod, copyFile, lstat, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
import { basename, dirname, join, relative, resolve } from 'node:path';
import { resolveOpenClawConfigPath, resolveOpenClawStateDir } from './paths';
const UPGRADE_ID = 'openclaw-2026.7.1';
const SNAPSHOT_DIR_MODE = 0o700;
const SNAPSHOT_FILE_MODE = 0o600;
const AGENT_AUTH_BASENAMES = new Set([
'auth-profiles.json',
'openclaw-agent.sqlite',
'openclaw-agent.sqlite-wal',
'openclaw-agent.sqlite-shm',
]);
export type OpenClawUpgradeSnapshotResult = {
status: 'created' | 'exists';
snapshotDir: string;
files: string[];
};
export type OpenClawUpgradeSnapshotCleanupResult = {
status: 'removed' | 'missing';
snapshotDir: string;
};
type SnapshotOptions = {
stateDir?: string;
configPath?: string;
};
function resolveSnapshotDir(stateDir: string): string {
return join(stateDir, 'backups', `clawx-${UPGRADE_ID}-pre-migration`);
}
async function isCopyableRegularFile(path: string): Promise<boolean> {
try {
const info = await lstat(path);
return info.isFile();
} catch {
return false;
}
}
async function snapshotMarkerExists(markerPath: string): Promise<boolean> {
try {
return (await stat(markerPath)).isFile();
} catch {
return false;
}
}
async function copyFileIfPresent(source: string, destination: string, copied: string[]): Promise<void> {
if (!await isCopyableRegularFile(source)) return;
await mkdir(dirname(destination), { recursive: true, mode: SNAPSHOT_DIR_MODE });
await copyFile(source, destination);
await chmod(destination, SNAPSHOT_FILE_MODE);
copied.push(destination);
}
async function copyTree(
sourceRoot: string,
destinationRoot: string,
copied: string[],
includeFile: (name: string) => boolean,
): Promise<void> {
let entries;
try {
entries = await readdir(sourceRoot, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isSymbolicLink()) continue;
const source = join(sourceRoot, entry.name);
const destination = join(destinationRoot, entry.name);
if (entry.isDirectory()) {
await mkdir(destination, { recursive: true, mode: SNAPSHOT_DIR_MODE });
await copyTree(source, destination, copied, includeFile);
} else if (entry.isFile() && includeFile(entry.name)) {
await copyFileIfPresent(source, destination, copied);
}
}
}
/**
* Creates a one-time pre-migration snapshot before ClawX first starts the
* OpenClaw 2026.7.1 Gateway. SQLite databases are copied together with their
* WAL/SHM sidecars; channel credentials under `credentials/` are intentionally
* excluded because this migration does not rewrite them.
*/
export async function ensureOpenClaw2026_7_1UpgradeSnapshot(
options: SnapshotOptions = {},
): Promise<OpenClawUpgradeSnapshotResult> {
const stateDir = resolve(options.stateDir ?? resolveOpenClawStateDir());
const configPath = resolve(options.configPath ?? resolveOpenClawConfigPath());
const snapshotDir = resolveSnapshotDir(stateDir);
const markerPath = join(snapshotDir, 'snapshot.json');
if (await snapshotMarkerExists(markerPath)) {
try {
const marker = JSON.parse(await readFile(markerPath, 'utf8')) as { files?: unknown };
return {
status: 'exists',
snapshotDir,
files: Array.isArray(marker.files)
? marker.files.filter((value): value is string => typeof value === 'string')
: [],
};
} catch {
// Replace malformed/incomplete snapshots below.
}
}
const tempDir = `${snapshotDir}.tmp-${process.pid}-${Date.now()}`;
const copiedDestinations: string[] = [];
await rm(tempDir, { recursive: true, force: true });
await mkdir(tempDir, { recursive: true, mode: SNAPSHOT_DIR_MODE });
try {
await copyFileIfPresent(configPath, join(tempDir, 'config', basename(configPath)), copiedDestinations);
for (const databasePath of [
join(stateDir, 'openclaw.sqlite'),
join(stateDir, 'state', 'openclaw.sqlite'),
]) {
const relativeDatabase = relative(stateDir, databasePath);
for (const suffix of ['', '-wal', '-shm']) {
await copyFileIfPresent(
`${databasePath}${suffix}`,
join(tempDir, 'state-files', `${relativeDatabase}${suffix}`),
copiedDestinations,
);
}
}
await copyTree(
join(stateDir, 'agents'),
join(tempDir, 'agents'),
copiedDestinations,
(name) => AGENT_AUTH_BASENAMES.has(name),
);
const files = copiedDestinations.map((path) => relative(tempDir, path)).sort();
await writeFile(join(tempDir, 'snapshot.json'), `${JSON.stringify({
upgrade: UPGRADE_ID,
createdAt: new Date().toISOString(),
configPath,
stateDir,
files,
}, null, 2)}\n`, { encoding: 'utf8', mode: SNAPSHOT_FILE_MODE });
await rm(snapshotDir, { recursive: true, force: true });
await mkdir(dirname(snapshotDir), { recursive: true, mode: SNAPSHOT_DIR_MODE });
await rename(tempDir, snapshotDir);
return { status: 'created', snapshotDir, files };
} catch (error) {
await rm(tempDir, { recursive: true, force: true });
throw error;
}
}
/**
* Removes the one-time OpenClaw 2026.7.1 pre-migration snapshot after Gateway
* startup succeeds so duplicated config/auth/SQLite secrets do not linger.
*/
export async function removeOpenClaw2026_7_1UpgradeSnapshot(
options: SnapshotOptions = {},
): Promise<OpenClawUpgradeSnapshotCleanupResult> {
const stateDir = resolve(options.stateDir ?? resolveOpenClawStateDir());
const snapshotDir = resolveSnapshotDir(stateDir);
const markerPath = join(snapshotDir, 'snapshot.json');
if (!await snapshotMarkerExists(markerPath)) {
return { status: 'missing', snapshotDir };
}
await rm(snapshotDir, { recursive: true, force: true });
return { status: 'removed', snapshotDir };
}
+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();
}
/**
+65 -1
View File
@@ -37,7 +37,10 @@ function resolveOpenClawStateDir(): string {
}
function resolveOpenClawStateSqlitePath(): string {
return join(resolveOpenClawStateDir(), 'openclaw.sqlite');
// OpenClaw 2026.7.1 moved the shared state database under state/.
// Writing the legacy root-level database leaves Gateway migrations reading
// stale plugin records from the canonical database.
return join(resolveOpenClawStateDir(), 'state', 'openclaw.sqlite');
}
function parseInstallRecordsJson(raw: unknown): Record<string, Record<string, unknown>> {
@@ -88,6 +91,7 @@ export function upsertPluginInstallRecordsIntoSqlite(
ensureOpenClawStateDirExists();
const sqlitePath = resolveOpenClawStateSqlitePath();
mkdirSync(join(resolveOpenClawStateDir(), 'state'), { recursive: true });
let db: DatabaseSync | null = null;
try {
@@ -157,6 +161,66 @@ export function upsertPluginInstallRecordsIntoSqlite(
}
}
/**
* Remove install records that must remain ClawX-managed rather than updated
* from their raw upstream npm package. Also clean the legacy root-level DB
* previously written by ClawX before OpenClaw 2026.7.1 moved state to state/.
*/
export function removePluginInstallRecordsFromSqlite(pluginIds: string[]): boolean {
if (pluginIds.length === 0) return false;
const stateDir = resolveOpenClawStateDir();
const sqlitePaths = [
resolveOpenClawStateSqlitePath(),
join(stateDir, 'openclaw.sqlite'),
];
let changed = false;
for (const sqlitePath of sqlitePaths) {
if (!existsSync(sqlitePath)) continue;
let db: DatabaseSync | null = null;
try {
db = openStateDatabase(sqlitePath);
const row = db.prepare(`
SELECT install_records_json
FROM installed_plugin_index
WHERE index_key = ?
`).get(INSTALLED_PLUGIN_INDEX_KEY) as { install_records_json?: string } | undefined;
if (!row) continue;
const records = parseInstallRecordsJson(row.install_records_json);
let databaseChanged = false;
for (const pluginId of pluginIds) {
if (Object.hasOwn(records, pluginId)) {
delete records[pluginId];
databaseChanged = true;
}
}
if (!databaseChanged) continue;
const now = Date.now();
db.prepare(`
UPDATE installed_plugin_index
SET install_records_json = ?,
updated_at_ms = ?,
generated_at_ms = ?
WHERE index_key = ?
`).run(JSON.stringify(records), now, now, INSTALLED_PLUGIN_INDEX_KEY);
changed = true;
} catch (error) {
logger.warn(`[plugin] Failed to remove install metadata from ${sqlitePath}:`, error);
} finally {
db?.close();
}
}
if (changed) {
logger.info(`[plugin] Removed managed install metadata from SQLite for: ${pluginIds.join(', ')}`);
}
return changed;
}
/** Ensure ~/.openclaw exists before first config write in fresh installs. */
export function ensureOpenClawStateDirExists(): void {
const stateDir = resolveOpenClawStateDir();
+300 -100
View File
@@ -7,12 +7,17 @@
*/
import { app } from 'electron';
import path from 'node:path';
import { existsSync, cpSync, copyFileSync, statSync, mkdirSync, rmSync, readFileSync, writeFileSync, readdirSync, realpathSync } from 'node:fs';
import { existsSync, cpSync, copyFileSync, statSync, lstatSync, mkdirSync, rmSync, readFileSync, writeFileSync, readdirSync, realpathSync, symlinkSync, unlinkSync } from 'node:fs';
import { readdir, stat, copyFile, mkdir } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { logger } from './logger';
import { upsertPluginInstallRecordsIntoSqlite, ensureOpenClawStateDirExists } from './plugin-install-index';
import { getOpenClawResolvedDir } from './paths';
import {
upsertPluginInstallRecordsIntoSqlite,
removePluginInstallRecordsFromSqlite,
ensureOpenClawStateDirExists,
} from './plugin-install-index';
function normalizeFsPathForWindows(filePath: string): string {
if (process.platform !== 'win32') return filePath;
@@ -122,7 +127,7 @@ const MANIFEST_ID_FIXES: Record<string, string> = {
/**
* After a plugin has been copied to ~/.openclaw/extensions/<dir>, fix any
* known manifest-ID mismatches so the Gateway can load the plugin.
* Also patches package.json fields that the Gateway uses as "entry hints".
* Also keeps package.json npm metadata usable by OpenClaw's repair planner.
*/
export function fixupPluginManifest(targetDir: string): void {
// 1. Fix openclaw.plugin.json id
@@ -131,45 +136,64 @@ export function fixupPluginManifest(targetDir: string): void {
const raw = readFileSync(fsPath(manifestPath), 'utf-8');
const manifest = JSON.parse(raw);
const oldId = manifest.id as string | undefined;
let modified = false;
if (oldId && MANIFEST_ID_FIXES[oldId]) {
const newId = MANIFEST_ID_FIXES[oldId];
manifest.id = newId;
writeFileSync(fsPath(manifestPath), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
modified = true;
logger.info(`[plugin] Fixed manifest ID: ${oldId}${newId}`);
}
// OpenClaw 2026.7.1 treats configured channel plugins without a static
// channelConfigs descriptor as stale/missing and invokes its npm repair
// flow. The WeCom package has no descriptor upstream, so provide a
// permissive schema that preserves ClawX's existing channel config fields.
if (manifest.id === 'wecom' && !manifest.channelConfigs?.wecom) {
manifest.channelConfigs = {
...(manifest.channelConfigs ?? {}),
wecom: {
schema: {
type: 'object',
additionalProperties: true,
},
},
};
modified = true;
logger.info('[plugin] Added WeCom channelConfigs compatibility descriptor');
}
if (modified) {
writeFileSync(fsPath(manifestPath), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
}
} catch {
// manifest may not exist yet — ignore
}
// 2. Fix package.json fields that Gateway uses as "entry hints"
// 2. Keep package.json package-manager metadata valid
const pkgPath = join(targetDir, 'package.json');
try {
const raw = readFileSync(fsPath(pkgPath), 'utf-8');
const pkg = JSON.parse(raw);
let modified = false;
// Check if the package name contains a legacy ID that needs fixing
for (const [oldId, newId] of Object.entries(MANIFEST_ID_FIXES)) {
if (typeof pkg.name === 'string' && pkg.name.includes(oldId)) {
pkg.name = pkg.name.replace(oldId, newId);
modified = true;
}
const install = pkg.openclaw?.install;
if (install) {
if (typeof install.npmSpec === 'string' && install.npmSpec.includes(oldId)) {
install.npmSpec = install.npmSpec.replace(oldId, newId);
modified = true;
}
if (typeof install.localPath === 'string' && install.localPath.includes(oldId)) {
install.localPath = install.localPath.replace(oldId, newId);
modified = true;
}
}
// Keep the real upstream npm package name/spec even though ClawX patches
// the effective plugin id. Rewriting these to the non-existent
// `@wecom/wecom` package makes OpenClaw's repair planner fail before the
// Gateway starts. Restore metadata previously rewritten by older ClawX
// compatibility code.
if (pkg.name === '@wecom/wecom') {
pkg.name = '@wecom/wecom-openclaw-plugin';
modified = true;
}
const install = pkg.openclaw?.install;
if (install?.npmSpec === '@wecom/wecom') {
install.npmSpec = '@wecom/wecom-openclaw-plugin';
modified = true;
}
if (modified) {
writeFileSync(fsPath(pkgPath), JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
logger.info(`[plugin] Fixed package.json entry hints in ${targetDir}`);
logger.info(`[plugin] Restored package.json npm metadata in ${targetDir}`);
}
} catch {
// ignore
@@ -242,25 +266,56 @@ const PLUGIN_NPM_NAMES: Record<string, string> = {
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
/**
* Official @openclaw/* channel plugins that ClawX mirrors into
* ~/.openclaw/extensions/. OpenClaw 2026.6+ requires matching
* plugins.installs metadata so trustedOfficialInstall is true and
* runtime APIs such as openKeyedStore are available.
* Channel plugins whose ClawX-managed mirrors need synchronized install
* metadata. OpenClaw 2026.6+ reads these records from SQLite for trust checks;
* OpenClaw 2026.7.1 also uses them to decide whether startup migrations should
* update an installed plugin.
*/
const TRUSTED_OFFICIAL_EXTENSION_PLUGINS: Record<string, string> = {
whatsapp: '@openclaw/whatsapp',
discord: '@openclaw/discord',
qqbot: '@openclaw/qqbot',
type TrustedOfficialExtensionPlugin = {
npmName: string;
/** Effective manifest/config id when it differs from the mirror directory. */
pluginId?: string;
/** Path records keep OpenClaw from replacing a ClawX-patched mirror. */
recordSource?: 'npm' | 'path';
legacyPluginIds?: string[];
};
type TrustedOfficialPluginInstallRecord = {
source: 'npm';
const TRUSTED_OFFICIAL_EXTENSION_PLUGINS: Record<string, TrustedOfficialExtensionPlugin> = {
dingtalk: { npmName: '@soimy/dingtalk' },
// WeCom intentionally runs under ClawX's legacy-compatible `wecom` id even
// though the upstream package manifest still declares
// `wecom-openclaw-plugin`. Keep it path-owned so startup migration does not
// replace the compatibility-patched mirror with the raw npm package.
wecom: {
npmName: '@wecom/wecom-openclaw-plugin',
recordSource: 'path',
legacyPluginIds: ['wecom-openclaw-plugin'],
},
// @larksuite/openclaw-lark 2026.7.9 declares ./dist/index.js as `main`, but
// publishes its runtime entry as ./index.js. OpenClaw 2026.7.1 rejects old
// managed npm records during its post-core smoke check. Make ClawX's complete
// mirror the canonical path-owned payload instead.
'feishu-openclaw-plugin': {
npmName: '@larksuite/openclaw-lark',
pluginId: 'openclaw-lark',
recordSource: 'path',
legacyPluginIds: ['feishu-openclaw-plugin', 'feishu'],
},
whatsapp: { npmName: '@openclaw/whatsapp' },
discord: { npmName: '@openclaw/discord' },
qqbot: { npmName: '@openclaw/qqbot' },
'openclaw-weixin': { npmName: '@tencent-weixin/openclaw-weixin' },
'clawx-openai-image': {
npmName: 'clawx-openai-image-plugin',
recordSource: 'path',
},
};
type TrustedOfficialPluginInstallRecord = Record<string, unknown> & {
source: 'npm' | 'path';
spec: string;
installPath: string;
version: string;
resolvedName: string;
resolvedVersion: string;
resolvedSpec: string;
installedAt: string;
};
@@ -277,52 +332,195 @@ function normalizePluginInstallPathForRecord(targetDir: string): string | null {
function buildTrustedOfficialPluginInstallRecord(
pluginDirName: string,
targetDir: string,
): TrustedOfficialPluginInstallRecord | null {
const npmName = TRUSTED_OFFICIAL_EXTENSION_PLUGINS[pluginDirName];
if (!npmName) return null;
): { pluginId: string; record: TrustedOfficialPluginInstallRecord } | null {
const definition = TRUSTED_OFFICIAL_EXTENSION_PLUGINS[pluginDirName];
if (!definition) return null;
const version = readPluginVersion(join(targetDir, 'package.json'));
const installPath = normalizePluginInstallPathForRecord(targetDir);
if (!version || !installPath) return null;
const pluginId = definition.pluginId ?? pluginDirName;
const installedAt = new Date().toISOString();
if (definition.recordSource === 'path') {
return {
pluginId,
record: {
source: 'path',
spec: targetDir,
sourcePath: targetDir,
installPath,
version,
installedAt,
},
};
}
return {
source: 'npm',
spec: npmName,
installPath,
version,
resolvedName: npmName,
resolvedVersion: version,
resolvedSpec: `${npmName}@${version}`,
installedAt: new Date().toISOString(),
pluginId,
record: {
source: 'npm',
spec: definition.npmName,
installPath,
version,
resolvedName: definition.npmName,
resolvedVersion: version,
resolvedSpec: `${definition.npmName}@${version}`,
installedAt,
},
};
}
function pluginInstallRecordIds(pluginDirName: string): string[] {
const definition = TRUSTED_OFFICIAL_EXTENSION_PLUGINS[pluginDirName];
return [...new Set([
pluginDirName,
definition?.pluginId,
...(definition?.legacyPluginIds ?? []),
].filter((value): value is string => Boolean(value)))];
}
function removeLegacyPluginInstallMetadataFromConfig(pluginIds: string[]): boolean {
if (!existsSync(fsPath(OPENCLAW_CONFIG_PATH))) return false;
const raw = readFileSync(fsPath(OPENCLAW_CONFIG_PATH), 'utf-8');
const config = JSON.parse(raw) as Record<string, unknown>;
const plugins = config.plugins;
if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return false;
const pluginsRecord = plugins as Record<string, unknown>;
const installs = pluginsRecord.installs;
if (!installs || typeof installs !== 'object' || Array.isArray(installs)) return false;
const installsRecord = installs as Record<string, unknown>;
const removedIds = pluginIds.filter((pluginId) => Object.hasOwn(installsRecord, pluginId));
if (removedIds.length === 0) return false;
for (const pluginId of removedIds) {
delete installsRecord[pluginId];
}
if (Object.keys(installsRecord).length === 0) {
delete pluginsRecord.installs;
}
writeFileSync(
fsPath(OPENCLAW_CONFIG_PATH),
`${JSON.stringify(config, null, 2)}\n`,
'utf-8',
);
logger.info(`[plugin] Removed legacy config install metadata for: ${removedIds.join(', ')}`);
return true;
}
function canonicalComparablePath(filePath: string): string {
let resolved: string;
try {
resolved = realpathSync(fsPath(filePath));
} catch {
resolved = path.resolve(filePath);
}
const withoutLongPathPrefix = resolved.replace(/^\\\\\?\\UNC\\/i, '\\\\').replace(/^\\\\\?\\/i, '');
return process.platform === 'win32' ? withoutLongPathPrefix.toLowerCase() : withoutLongPathPrefix;
}
/**
* Materialized mirrors live outside the bundled OpenClaw package tree, so
* Node's normal package lookup cannot resolve their declared `openclaw` peer.
* OpenClaw 2026.7.1 also audits this exact link before reporting Gateway ready.
*/
export function repairPluginOpenClawPeerLink(
targetDir: string,
openclawDir = getOpenClawResolvedDir(),
): boolean {
let packageJson: Record<string, unknown>;
try {
packageJson = JSON.parse(readFileSync(fsPath(join(targetDir, 'package.json')), 'utf-8')) as Record<string, unknown>;
} catch {
return false;
}
const peerDependencies = packageJson.peerDependencies;
if (
!peerDependencies
|| typeof peerDependencies !== 'object'
|| Array.isArray(peerDependencies)
|| typeof (peerDependencies as Record<string, unknown>).openclaw !== 'string'
) {
return true;
}
if (!existsSync(fsPath(join(openclawDir, 'package.json')))) {
logger.warn(`[plugin] Cannot link OpenClaw peer for ${targetDir}: runtime package missing at ${openclawDir}`);
return false;
}
const nodeModulesDir = join(targetDir, 'node_modules');
const linkPath = join(nodeModulesDir, 'openclaw');
try {
mkdirSync(fsPath(nodeModulesDir), { recursive: true });
const nodeModulesStat = lstatSync(fsPath(nodeModulesDir));
if (!nodeModulesStat.isDirectory() || nodeModulesStat.isSymbolicLink()) {
logger.warn(`[plugin] Cannot link OpenClaw peer because ${nodeModulesDir} is not a real directory`);
return false;
}
try {
if (canonicalComparablePath(linkPath) === canonicalComparablePath(openclawDir)) {
return true;
}
} catch {
// Fall through to lstat/creation for a missing or broken link.
}
let existing: ReturnType<typeof lstatSync> | null = null;
try {
existing = lstatSync(fsPath(linkPath));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
if (existing) {
if (existing.isSymbolicLink()) {
unlinkSync(fsPath(linkPath));
} else if (existing.isDirectory()) {
let existingPackageName: unknown;
try {
existingPackageName = JSON.parse(
readFileSync(fsPath(join(linkPath, 'package.json')), 'utf-8'),
).name;
} catch {
existingPackageName = null;
}
if (existingPackageName !== 'openclaw') {
logger.warn(`[plugin] Cannot replace non-OpenClaw peer directory at ${linkPath}`);
return false;
}
rmSync(fsPath(linkPath), { recursive: true, force: true });
} else {
logger.warn(`[plugin] Cannot replace non-directory OpenClaw peer at ${linkPath}`);
return false;
}
}
symlinkSync(openclawDir, fsPath(linkPath), 'junction');
if (canonicalComparablePath(linkPath) !== canonicalComparablePath(openclawDir)) {
logger.warn(`[plugin] OpenClaw peer link audit failed after creating ${linkPath}`);
return false;
}
logger.info(`[plugin] Linked OpenClaw peer: ${linkPath}${openclawDir}`);
return true;
} catch (error) {
logger.warn(`[plugin] Failed to link OpenClaw peer for ${targetDir}:`, error);
return false;
}
}
function persistTrustedOfficialPluginInstallRecordsToSqlite(
records: Record<string, Record<string, unknown>>,
): boolean {
return upsertPluginInstallRecordsIntoSqlite(records);
}
function trustedInstallRecordMatches(
existing: unknown,
expected: TrustedOfficialPluginInstallRecord,
): boolean {
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
return false;
}
const record = existing as Record<string, unknown>;
return record.source === expected.source
&& record.spec === expected.spec
&& record.installPath === expected.installPath
&& record.version === expected.version
&& record.resolvedName === expected.resolvedName
&& record.resolvedVersion === expected.resolvedVersion
&& record.resolvedSpec === expected.resolvedSpec;
}
/**
* Write or refresh plugins.installs.<id> for a ClawX-mirrored official plugin.
* Also persists the record into openclaw.sqlite for OpenClaw 2026.6+ trust checks.
* Persist a ClawX-mirrored plugin install record in OpenClaw's canonical SQLite
* index. OpenClaw 2026.7.1 treats config-level plugins.installs as legacy
* migration input, so remove that transient copy instead of recreating it.
* Safe to call repeatedly; no-ops when metadata is already current.
*/
export function syncTrustedOfficialPluginInstallRecord(
@@ -336,51 +534,53 @@ export function syncTrustedOfficialPluginInstallRecord(
return false;
}
// Repair this even when install metadata already matches. A copied plugin's
// node_modules intentionally excludes host peers, and OpenClaw's migration
// smoke check runs before the Gateway can supply any runtime fallback.
repairPluginOpenClawPeerLink(targetDir);
const recordIds = pluginInstallRecordIds(pluginDirName);
let jsonChanged = false;
try {
ensureOpenClawStateDirExists();
if (!existsSync(fsPath(OPENCLAW_CONFIG_PATH))) {
return false;
}
const raw = readFileSync(fsPath(OPENCLAW_CONFIG_PATH), 'utf-8');
const config = JSON.parse(raw) as Record<string, unknown>;
let plugins = config.plugins;
if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) {
plugins = { enabled: true, installs: {} };
config.plugins = plugins;
}
const pluginsRecord = plugins as Record<string, unknown>;
const installs = pluginsRecord.installs;
const installsRecord = installs && typeof installs === 'object' && !Array.isArray(installs)
? installs as Record<string, unknown>
: {};
const existing = installsRecord[pluginDirName];
if (!trustedInstallRecordMatches(existing, expected)) {
installsRecord[pluginDirName] = expected;
pluginsRecord.installs = installsRecord;
writeFileSync(
fsPath(OPENCLAW_CONFIG_PATH),
`${JSON.stringify(config, null, 2)}\n`,
'utf-8',
);
logger.info(`[plugin] Synced trusted install metadata for ${pluginDirName}`);
jsonChanged = true;
}
jsonChanged = removeLegacyPluginInstallMetadataFromConfig(recordIds);
} catch (error) {
logger.warn(`[plugin] Failed to sync trusted install metadata for ${pluginDirName}:`, error);
return false;
// Keep the canonical SQLite repair available even if legacy config cleanup
// cannot be completed in this pass.
logger.warn(`[plugin] Failed to remove legacy install metadata for ${pluginDirName}:`, error);
}
// Remove aliases left by older ClawX/OpenClaw ownership conventions, but do
// not delete the canonical id first: upsert can replace npm/path ownership
// atomically without creating a missing-record window.
const staleRecordIds = recordIds.filter((pluginId) => pluginId !== expected.pluginId);
const removedLegacyRecord = removePluginInstallRecordsFromSqlite(staleRecordIds);
const sqliteChanged = persistTrustedOfficialPluginInstallRecordsToSqlite({
[pluginDirName]: expected,
[expected.pluginId]: expected.record,
});
return jsonChanged || removedLegacyRecord || sqliteChanged;
}
/**
* Remove metadata for a ClawX mirror that is no longer configured. This must
* run even when its extension directory is already missing: stale records are
* themselves enough to fail OpenClaw's post-core payload smoke check.
*/
export function removeTrustedOfficialPluginInstallRecord(pluginDirName: string): boolean {
const recordIds = pluginInstallRecordIds(pluginDirName);
if (recordIds.length === 0) return false;
let jsonChanged = false;
try {
jsonChanged = removeLegacyPluginInstallMetadataFromConfig(recordIds);
} catch (error) {
logger.warn(`[plugin] Failed to remove stale config install metadata for ${pluginDirName}:`, error);
}
const sqliteChanged = removePluginInstallRecordsFromSqlite(recordIds);
return jsonChanged || sqliteChanged;
}
/** Repair trusted install metadata for all mirrored official plugins on disk. */
/** Repair managed install metadata and host peer links for all mirrors on disk. */
export function repairTrustedOfficialPluginInstallRecords(): void {
for (const pluginDirName of Object.keys(TRUSTED_OFFICIAL_EXTENSION_PLUGINS)) {
const targetDir = join(homedir(), '.openclaw', 'extensions', pluginDirName);
+44
View File
@@ -0,0 +1,44 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { RuntimeKind } from '@shared/types/gateway';
import { getCcConnectManagedDir } from '../runtime/cc-connect-paths';
type RuntimeStatusReader = {
getStatus?: () => { runtimeKind?: RuntimeKind };
};
function getActiveRuntimeKind(runtimeManager?: RuntimeStatusReader | null): RuntimeKind {
try {
return runtimeManager?.getStatus?.().runtimeKind === 'cc-connect' ? 'cc-connect' : 'openclaw';
} catch {
return 'openclaw';
}
}
export function getOpenClawMediaDir(): string {
return join(homedir(), '.openclaw', 'media');
}
export function getCcConnectMediaDir(): string {
return join(getCcConnectManagedDir(), 'media');
}
export function getRuntimeMediaDir(runtimeManager?: RuntimeStatusReader | null): string {
return getActiveRuntimeKind(runtimeManager) === 'cc-connect'
? getCcConnectMediaDir()
: getOpenClawMediaDir();
}
export function getRuntimeOutboundMediaDir(runtimeManager?: RuntimeStatusReader | null): string {
return join(getRuntimeMediaDir(runtimeManager), 'outbound');
}
export function getRuntimeOutgoingMediaRecordDirs(runtimeManager?: RuntimeStatusReader | null): string[] {
const active = getRuntimeMediaDir(runtimeManager);
const ccConnect = getCcConnectMediaDir();
const fallback = active === ccConnect ? getOpenClawMediaDir() : ccConnect;
return [
join(active, 'outgoing', 'records'),
join(fallback, 'outgoing', 'records'),
];
}
+15 -16
View File
@@ -35,10 +35,6 @@ import { getOpenClawProviderKeyForType } from './provider-keys';
export async function storeApiKey(providerId: string, apiKey: string): Promise<boolean> {
try {
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
keys[providerId] = apiKey;
s.set('apiKeys', keys);
await setProviderSecret({
type: 'api_key',
accountId: providerId,
@@ -65,9 +61,7 @@ export async function getApiKey(providerId: string): Promise<string | null> {
return secret.apiKey ?? null;
}
const s = await getClawXProviderStore();
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
return keys[providerId] || null;
return null;
} catch (error) {
console.error('Failed to retrieve API key:', error);
return null;
@@ -80,10 +74,6 @@ export async function getApiKey(providerId: string): Promise<string | null> {
export async function deleteApiKey(providerId: string): Promise<boolean> {
try {
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
delete keys[providerId];
s.set('apiKeys', keys);
await deleteProviderSecret(providerId);
return true;
} catch (error) {
@@ -102,9 +92,7 @@ export async function hasApiKey(providerId: string): Promise<boolean> {
return true;
}
const s = await getClawXProviderStore();
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
return providerId in keys;
return secret?.type === 'local' && Boolean(secret.apiKey);
}
/**
@@ -113,8 +101,19 @@ export async function hasApiKey(providerId: string): Promise<boolean> {
export async function listStoredKeyIds(): Promise<string[]> {
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
return Object.keys(keys);
const legacyKeys = (s.get('apiKeys') || {}) as Record<string, string>;
const accountIds = new Set([
...Object.keys(legacyKeys),
...(await listProviderAccounts()).map((account) => account.id),
]);
const stored: string[] = [];
for (const accountId of accountIds) {
const secret = await getProviderSecret(accountId);
if (secret?.type === 'api_key' || (secret?.type === 'local' && secret.apiKey)) {
stored.push(accountId);
}
}
return stored.sort();
}
// ==================== Provider Configuration ====================
+5
View File
@@ -7,6 +7,8 @@ import { randomBytes } from 'crypto';
import { app } from 'electron';
import { resolveSupportedLanguage } from '@shared/language';
import { DEFAULT_WORKSPACE_CWD } from '@shared/workspace';
import type { RuntimeKind } from '@shared/types/gateway';
import { getClawXDataLayout, resolveClawXDataRoot } from './clawx-data-layout';
// Lazy-load electron-store (ESM module)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -34,6 +36,7 @@ export interface AppSettings {
// Gateway
gatewayAutoStart: boolean;
runtimeKind: RuntimeKind;
gatewayPort: number;
gatewayToken: string;
proxyEnabled: boolean;
@@ -88,6 +91,7 @@ function createDefaultSettings(): AppSettings {
// Gateway
gatewayAutoStart: true,
runtimeKind: 'openclaw',
gatewayPort: 18789,
gatewayToken: generateToken(),
proxyEnabled: false,
@@ -125,6 +129,7 @@ async function getSettingsStore() {
const Store = (await import('electron-store')).default;
settingsStoreInstance = new Store<AppSettings>({
name: 'settings',
cwd: getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData'))).appDir,
defaults: createDefaultSettings(),
});
}
+234 -54
View File
@@ -1,19 +1,32 @@
export interface TokenUsageHistoryEntry {
runtimeKind?: 'openclaw' | 'cc-connect';
timestamp: string;
sessionId: string;
runtimeSessionId?: string;
agentId: string;
providerAccountId?: string;
model?: string;
provider?: string;
content?: string;
turnId?: string;
usageStatus: 'available' | 'missing' | 'error';
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
reasoningTokens?: number;
totalTokens: number;
costUsd?: number;
}
type UsageParseContext = {
sessionId: string;
agentId: string;
runtimeKind?: TokenUsageHistoryEntry['runtimeKind'];
model?: string;
provider?: string;
};
export function extractSessionIdFromTranscriptFileName(fileName: string): string | undefined {
if (!fileName.endsWith('.jsonl') && !fileName.includes('.jsonl.reset.')) return undefined;
return fileName
@@ -37,6 +50,8 @@ interface TranscriptUsageShape {
total_tokens?: number;
cache_read?: number;
cache_write?: number;
cachedInputTokens?: number;
cached_input_tokens?: number;
prompt_tokens?: number;
completion_tokens?: number;
cache_read_tokens?: number;
@@ -54,9 +69,24 @@ interface TranscriptUsageShape {
cacheReadTokenCount?: number;
cacheReadTokens?: number;
cache_write_token_count?: number;
cost?: {
reasoningTokens?: number;
reasoning_tokens?: number;
reasoningOutputTokens?: number;
reasoning_output_tokens?: number;
cost?: number | string | {
total?: number;
usd?: number;
total_usd?: number;
totalUsd?: number;
amount?: number;
};
costUsd?: number;
cost_usd?: number;
costUSD?: number;
totalCost?: number;
total_cost?: number;
totalCostUsd?: number;
total_cost_usd?: number;
}
type UsageRecordStatus = 'available' | 'missing' | 'error';
@@ -66,6 +96,7 @@ interface ParsedUsageTokens {
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
reasoningTokens?: number;
totalTokens: number;
costUsd?: number;
usageStatus: UsageRecordStatus;
@@ -96,6 +127,32 @@ function firstUsageNumber(usage: TranscriptUsageShape | undefined, candidates: s
return undefined;
}
function parseUsageCostUsd(usage: TranscriptUsageShape): number | undefined {
const direct = firstUsageNumber(usage, [
'costUsd',
'cost_usd',
'costUSD',
'totalCostUsd',
'total_cost_usd',
'totalCost',
'total_cost',
'cost',
]);
if (direct !== undefined) return direct;
if (usage.cost && typeof usage.cost === 'object' && !Array.isArray(usage.cost)) {
return firstUsageNumber(usage.cost as TranscriptUsageShape, [
'total',
'usd',
'total_usd',
'totalUsd',
'amount',
]);
}
return undefined;
}
function parseUsageFromShape(usage: unknown): ParsedUsageTokens | undefined {
if (usage === undefined) {
return undefined;
@@ -141,6 +198,8 @@ function parseUsageFromShape(usage: unknown): ParsedUsageTokens | undefined {
'cache_read_tokens',
'cacheReadTokenCount',
'cache_read_token_count',
'cachedInputTokens',
'cached_input_tokens',
]);
const cacheWriteTokens = firstUsageNumber(usageShape, [
'cacheWrite',
@@ -157,14 +216,21 @@ function parseUsageFromShape(usage: unknown): ParsedUsageTokens | undefined {
'totalTokenCount',
'total_token_count',
]);
const reasoningTokens = firstUsageNumber(usageShape, [
'reasoningTokens',
'reasoning_tokens',
'reasoningOutputTokens',
'reasoning_output_tokens',
]);
const hasUsageValue =
inputTokens !== undefined
|| outputTokens !== undefined
|| cacheReadTokens !== undefined
|| cacheWriteTokens !== undefined
|| reasoningTokens !== undefined
|| explicitTotalTokens !== undefined
|| normalizeUsageNumber(usageShape.cost?.total) !== undefined;
|| parseUsageCostUsd(usageShape) !== undefined;
if (!hasUsageValue) {
return {
@@ -180,8 +246,6 @@ function parseUsageFromShape(usage: unknown): ParsedUsageTokens | undefined {
const totalTokens = explicitTotalTokens ?? (
(inputTokens ?? 0)
+ (outputTokens ?? 0)
+ (cacheReadTokens ?? 0)
+ (cacheWriteTokens ?? 0)
);
return {
@@ -190,8 +254,9 @@ function parseUsageFromShape(usage: unknown): ParsedUsageTokens | undefined {
outputTokens: outputTokens ?? 0,
cacheReadTokens: cacheReadTokens ?? 0,
cacheWriteTokens: cacheWriteTokens ?? 0,
...(reasoningTokens !== undefined ? { reasoningTokens } : {}),
totalTokens,
costUsd: normalizeUsageNumber(usageShape.cost?.total),
costUsd: parseUsageCostUsd(usageShape),
};
}
@@ -214,8 +279,25 @@ interface TranscriptLineShape {
};
};
};
payload?: {
type?: string;
model?: string;
model_provider?: string;
info?: {
last_token_usage?: TranscriptUsageShape;
total_token_usage?: TranscriptUsageShape;
};
};
}
type UsageMessageShape = NonNullable<TranscriptLineShape['message']> & {
id?: string;
timestamp?: string | number;
created_at?: string | number;
createdAt?: string | number;
content?: unknown;
};
function normalizeUsageContent(value: unknown): string | undefined {
if (typeof value === 'string') {
const trimmed = value.trim();
@@ -257,13 +339,155 @@ function normalizeUsageContent(value: unknown): string | undefined {
return undefined;
}
function normalizeUsageTimestamp(value: unknown): string | undefined {
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : value;
}
if (typeof value === 'number' && Number.isFinite(value)) {
return new Date(value < 1e12 ? value * 1000 : value).toISOString();
}
return undefined;
}
function usageEntryFromMessage(
message: UsageMessageShape | undefined,
timestamp: string | undefined,
context: UsageParseContext,
): TokenUsageHistoryEntry | null {
if (!message || !timestamp) return null;
if (message.role === 'assistant' && 'usage' in message) {
const usage = parseUsageFromShape(message.usage);
if (!usage) return null;
const contentText = normalizeUsageContent((message as Record<string, unknown>).content);
return {
timestamp,
...(context.runtimeKind ? { runtimeKind: context.runtimeKind } : {}),
sessionId: context.sessionId,
agentId: context.agentId,
model: message.model ?? message.modelRef,
provider: message.provider,
...(contentText ? { content: contentText } : {}),
...(message.id ? { turnId: message.id } : {}),
...usage,
};
}
if (message.role !== 'toolResult' && message.role !== 'toolresult') {
return null;
}
const details = message.details;
if (!details || !('usage' in details)) {
return null;
}
const usage = parseUsageFromShape(details.usage);
if (!usage) return null;
const provider = details.provider ?? details.externalContent?.provider ?? message.provider;
const model = details.model ?? message.model ?? message.modelRef;
const contentText = normalizeUsageContent(details.content)
?? normalizeUsageContent((message as Record<string, unknown>).content);
return {
timestamp,
...(context.runtimeKind ? { runtimeKind: context.runtimeKind } : {}),
sessionId: context.sessionId,
agentId: context.agentId,
model,
provider,
...(contentText ? { content: contentText } : {}),
...(message.id ? { turnId: message.id } : {}),
...usage,
};
}
function usageEntryFromCodexTokenCount(
record: TranscriptLineShape,
timestamp: string | undefined,
context: UsageParseContext,
): TokenUsageHistoryEntry | null {
if (!timestamp || record.type !== 'event_msg' || record.payload?.type !== 'token_count') {
return null;
}
const usage = parseUsageFromShape(record.payload.info?.last_token_usage ?? record.payload.info?.total_token_usage);
if (!usage || usage.usageStatus !== 'available') {
return null;
}
return {
timestamp,
...(context.runtimeKind ? { runtimeKind: context.runtimeKind } : {}),
sessionId: context.sessionId,
agentId: context.agentId,
...(context.model ? { model: context.model } : {}),
provider: context.provider ?? 'codex',
...usage,
};
}
function usageContextWithJsonlMetadata(lines: string[], context: UsageParseContext): UsageParseContext {
let model = context.model;
let provider = context.provider;
for (const line of lines) {
let parsed: TranscriptLineShape;
try {
parsed = JSON.parse(line) as TranscriptLineShape;
} catch {
continue;
}
if (parsed.type !== 'session_meta' && parsed.type !== 'turn_context') continue;
const payload = parsed.payload as Record<string, unknown> | undefined;
if (!payload || typeof payload !== 'object') continue;
if (!model && typeof payload.model === 'string' && payload.model.trim()) {
model = payload.model.trim();
}
if (!provider && typeof payload.model_provider === 'string' && payload.model_provider.trim()) {
provider = payload.model_provider.trim();
}
if (model && provider) break;
}
return {
...context,
...(model ? { model } : {}),
...(provider ? { provider } : {}),
};
}
export function parseUsageEntriesFromMessages(
messages: unknown[],
context: UsageParseContext,
limit?: number,
): TokenUsageHistoryEntry[] {
const entries: TokenUsageHistoryEntry[] = [];
const maxEntries = typeof limit === 'number' && Number.isFinite(limit)
? Math.max(Math.floor(limit), 0)
: Number.POSITIVE_INFINITY;
for (let i = messages.length - 1; i >= 0 && entries.length < maxEntries; i -= 1) {
const item = messages[i];
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
const message = item as UsageMessageShape;
const timestamp = normalizeUsageTimestamp(message.timestamp ?? message.created_at ?? message.createdAt);
const entry = usageEntryFromMessage(message, timestamp, context);
if (entry) entries.push(entry);
}
return entries;
}
export function parseUsageEntriesFromJsonl(
content: string,
context: { sessionId: string; agentId: string },
context: UsageParseContext,
limit?: number,
): TokenUsageHistoryEntry[] {
const entries: TokenUsageHistoryEntry[] = [];
const lines = content.split(/\r?\n/).filter(Boolean);
const enrichedContext = usageContextWithJsonlMetadata(lines, context);
const maxEntries = typeof limit === 'number' && Number.isFinite(limit)
? Math.max(Math.floor(limit), 0)
: Number.POSITIVE_INFINITY;
@@ -276,54 +500,10 @@ export function parseUsageEntriesFromJsonl(
continue;
}
const message = parsed.message;
if (!message || !parsed.timestamp) {
continue;
}
if (message.role === 'assistant' && 'usage' in message) {
const usage = parseUsageFromShape(message.usage);
if (!usage) continue;
const contentText = normalizeUsageContent((message as Record<string, unknown>).content);
entries.push({
timestamp: parsed.timestamp,
sessionId: context.sessionId,
agentId: context.agentId,
model: message.model ?? message.modelRef,
provider: message.provider,
...(contentText ? { content: contentText } : {}),
...usage,
});
continue;
}
if (message.role !== 'toolResult') {
continue;
}
const details = message.details;
if (!details || !('usage' in details)) {
continue;
}
const usage = parseUsageFromShape(details.usage);
if (!usage) continue;
const provider = details.provider ?? details.externalContent?.provider ?? message.provider;
const model = details.model ?? message.model ?? message.modelRef;
const contentText = normalizeUsageContent(details.content)
?? normalizeUsageContent((message as Record<string, unknown>).content);
entries.push({
timestamp: parsed.timestamp,
sessionId: context.sessionId,
agentId: context.agentId,
model,
provider,
...(contentText ? { content: contentText } : {}),
...usage,
});
const timestamp = normalizeUsageTimestamp(parsed.timestamp);
const entry = usageEntryFromMessage(parsed.message, timestamp, enrichedContext)
?? usageEntryFromCodexTokenCount(parsed, timestamp, enrichedContext);
if (entry) entries.push(entry);
}
return entries;
+37 -8
View File
@@ -7,6 +7,7 @@ import {
parseUsageEntriesFromJsonl,
type TokenUsageHistoryEntry,
} from './token-usage-core';
import type { RuntimeKind } from '@shared/types/gateway';
import { listConfiguredAgentIds } from './agent-config';
export {
@@ -15,6 +16,14 @@ export {
type TokenUsageHistoryEntry,
} from './token-usage-core';
type RecentUsageSourceFile = {
filePath: string;
sessionId: string;
agentId: string;
mtimeMs: number;
source: 'openclaw-jsonl';
};
async function listAgentIdsWithSessionDirs(): Promise<string[]> {
const openclawDir = getOpenClawConfigDir();
const agentsDir = join(openclawDir, 'agents');
@@ -48,13 +57,13 @@ async function listAgentIdsWithSessionDirs(): Promise<string[]> {
return [...agentIds];
}
async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }>> {
async function listRecentSessionFiles(): Promise<RecentUsageSourceFile[]> {
const openclawDir = getOpenClawConfigDir();
const agentsDir = join(openclawDir, 'agents');
try {
const agentEntries = await listAgentIdsWithSessionDirs();
const files: Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }> = [];
const files: RecentUsageSourceFile[] = [];
for (const agentId of agentEntries) {
const sessionsDir = join(agentsDir, agentId, 'sessions');
@@ -72,6 +81,7 @@ async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessi
sessionId,
agentId,
mtimeMs: fileStat.mtimeMs,
source: 'openclaw-jsonl',
});
} catch {
continue;
@@ -89,21 +99,40 @@ async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessi
}
}
export async function getRecentTokenUsageHistory(limit?: number): Promise<TokenUsageHistoryEntry[]> {
const files = await listRecentSessionFiles();
export type TokenUsageHistoryOptions = {
limit?: number;
runtimeKind?: RuntimeKind;
};
function normalizeTokenUsageOptions(input?: number | TokenUsageHistoryOptions): TokenUsageHistoryOptions {
if (typeof input === 'number') return { limit: input };
return input ?? {};
}
function matchesRuntimeKind(file: RecentUsageSourceFile, runtimeKind?: RuntimeKind): boolean {
if (!runtimeKind) return true;
return runtimeKind === 'openclaw' && file.source === 'openclaw-jsonl';
}
export async function getRecentTokenUsageHistory(input?: number | TokenUsageHistoryOptions): Promise<TokenUsageHistoryEntry[]> {
const options = normalizeTokenUsageOptions(input);
if (options.runtimeKind === 'cc-connect') return [];
const files = (await listRecentSessionFiles())
.filter((file) => matchesRuntimeKind(file, options.runtimeKind))
.sort((a, b) => b.mtimeMs - a.mtimeMs);
const results: TokenUsageHistoryEntry[] = [];
const maxEntries = typeof limit === 'number' && Number.isFinite(limit)
? Math.max(Math.floor(limit), 0)
const maxEntries = typeof options.limit === 'number' && Number.isFinite(options.limit)
? Math.max(Math.floor(options.limit), 0)
: Number.POSITIVE_INFINITY;
for (const file of files) {
if (results.length >= maxEntries) break;
try {
const content = await readFile(file.filePath, 'utf8');
const entries = parseUsageEntriesFromJsonl(content, {
sessionId: file.sessionId,
agentId: file.agentId,
}, Number.isFinite(maxEntries) ? maxEntries - results.length : undefined);
runtimeKind: 'openclaw',
});
results.push(...entries);
} catch (error) {
logger.debug(`Failed to read token usage transcript ${file.filePath}:`, error);
@@ -10,6 +10,6 @@ appliesTo:
Main owns ACP process, SDK, routing lifecycle, and serialization of operations on the shared ACP connection; Renderer owns semantic reduction into an in-memory timeline. Notifications emitted during `session/load` are returned as one generation-scoped raw batch and reduced in one Renderer state commit. Renderer may temporarily buffer matching host events during the IPC result handoff, while ordinary live prompt updates continue through host events. A pending prompt may retain a bounded Main routing context and Renderer timeline snapshot so navigation cannot drop its stream; those contexts must be keyed by session and generation, remain memory-only, and be released when the prompt settles. Permission requests are interactive only for an active prompt. Stale session generations are ignored, and ClawX does not persist a second ACP ledger or reduced Chat history.
ACP replay is the primary history authority. The only approved transcript-derived content supplements are best-effort recovery of asynchronous image-generation completions with proven `image_generate` context and recovery of explicit line-leading assistant OpenClaw `MEDIA:` attachment directives omitted by ACP. The general attachment exception does not require image-generation context, but it recovers only attachment references. A separate metadata-only supplement may annotate an ACP-replayed assistant turn with whole-turn duration because ACP `session/load` omits the original event timestamps; it cannot create turns or content. These exceptions remain marked and in memory; do not generalize them to bare paths, surrounding transcript prose, ordinary messages, tool cards, plans, permissions, thoughts, file activity, or any parallel history.
ACP replay is the primary history authority. The only approved transcript-derived content supplements are best-effort recovery of asynchronous image-generation completions with proven `image_generate` context and recovery of explicit line-leading assistant OpenClaw `MEDIA:` attachment directives omitted by ACP. The general attachment exception does not require image-generation context, but it recovers only attachment references. When ACP replay for a cron session is completely empty, scheduled-task prompt and completion summaries may instead come from Main's typed cron-history host API. This cron exception must come from Gateway `cron.runs` (with a Main-owned legacy file fallback), be generation-scoped and in memory, and never replace or duplicate non-empty ACP replay. A separate metadata-only supplement may annotate an ACP-replayed assistant turn with whole-turn duration because ACP `session/load` omits the original event timestamps; it cannot create turns or content. These exceptions remain marked and in memory; do not generalize them to bare paths, surrounding transcript prose, arbitrary ordinary messages, tool cards, plans, permissions, thoughts, file activity, or any parallel persisted history.
Historical transcript reads are limited to the newest `1000` message records. A successful live prompt reads content immediately and retries exactly once after `1500 ms`. General attachment and timing alignment treat history as a suffix and match the binary-free OpenClaw prompt-text projection of structured ACP user blocks by duplicate occurrence from the tail; they must not parse or globally remove user-authored resource marker text. Attachment-only empty projections remain eligible, and live content alignment also requires the current optimistic user identity. Every asynchronous result must retain the same active session, generation, supplement operation and attempt, and live turn where applicable. Unmatched, ambiguous, superseded, or stale work cannot mutate the timeline or timing annotations.
@@ -0,0 +1,102 @@
---
id: cc-connect-runtime-validation
title: cc-connect Replacement Runtime Validation
type: ai-coding-rule
appliesTo:
- gateway-backend-communication
requiredProfiles:
- fast
- comms
requiredTests:
- pnpm run verify:runtime-bundles
- pnpm run verify:packaged-runtime-resources -- --resources=<target-resources> --platform=<target-platform> --arch=<target-arch>
- pnpm run test:e2e:cc-connect
---
cc-connect runtime changes must preserve one execution boundary: Renderer calls
Host API, Host API calls `RuntimeManager`, and `CcConnectRuntimeProvider` talks
to cc-connect Bridge or Management API. Codex is only a cc-connect child.
Rules:
- ClawX must not spawn Codex or invoke Codex session/chat commands in
cc-connect mode.
- Production chat events, tools, approvals, cancellation, session history, and
usage must come from cc-connect public APIs/events. Codex transcripts may be
test oracles but not production transports.
- Approval, question, and runtime-choice responses must use cc-connect's public
Bridge `card_action` packet and validate against actions offered for the
pending run. A returned select-state card may close the Chat run; navigation
and button cards remain interactive until cc-connect emits a terminal reply,
the user chooses a select state, or the run aborts.
- Proactive runtime media must enter through cc-connect's public Bridge packets.
Host history must preserve every image/file/audio/video attachment, final-event
deduplication must distinguish packet message ids, and execution-graph folding
must not hide runtime-owned `gateway-media` cards.
- Chat cancellation must use cc-connect's public session-scoped `/stop` command
over Bridge. The normal path must close the selected Codex child without
restarting cc-connect; a whole-runtime restart is allowed only when Bridge is
disconnected and the stop command cannot be delivered.
- Agent permission mode must be stored in ClawX-owned runtime metadata, default
to `full-auto`, expose only `full-auto` and `suggest`, and project `suggest`
into the matching cc-connect project without mutating OpenClaw config.
- Managed Codex projects must select cc-connect's `app_server` backend over
`stdio://`; the default `exec` backend is not sufficient evidence for Codex
0.137 custom tool lifecycle parity.
- ClawX must not write cc-connect private session JSON. Unsupported official
mutations remain unsupported or use ClawX logical display metadata.
- cc-connect mode must not mutate OpenClaw config. Existing OpenClaw workspaces
may be referenced as external Agent workspaces.
- New ClawX state belongs under `~/.clawx` through the shared data-layout API.
Runtime code must not derive durable paths from `process.cwd()` or scattered
`app.getPath('userData')` calls.
- Provider bindings are account-specific. OAuth accounts use independent
complete `CODEX_HOME` directories; API keys and OAuth recovery material are
encrypted and never returned to Renderer.
- GUI and Channel Cron operate one cc-connect native scheduler. ClawX must not
emulate `at`, `every`, or scheduled prompts in a second scheduler.
- Tool events must include stable run, turn, event, sequence, session, Agent,
and project identity. Reconnect/replay must not duplicate cards.
- Bridge text previews must render the initial `preview_start`, replace content
on `update_message`, and clear transient text on `delete_message`. Structured
progress deletion must not erase semantic thinking/tool lifecycle from the
shared execution graph.
- Cached input is part of input and reasoning is part of output. Usage totals
must not add either category twice.
- When public cc-connect history exposes a turn without counters, Host API must
return an explicit `missing` usage record for that turn; it must not estimate
counts from cc-connect private state or Codex transcripts.
- Feishu/Lark parity requires a real inbound marker and real outbound reply
through cc-connect, not only config projection or connected status.
- Every user-visible change must include Electron E2E and all locale files.
- Mock, local-real, external-credential, and packaged evidence are separate
rows. One tier must not be used to claim another.
- Packaging must verify both the downloaded bundle and the copied Electron
resources. `afterPack` enforces exact manifest/SHA/permission checks before
signing. Final Windows/Linux resources keep exact SHA equality; signed macOS
resources must match the source bundle's Mach-O section payloads and pass
strict code-signature verification.
- Evidence reports and screenshots must be sanitized. API keys, OAuth tokens,
app secrets, management/bridge tokens, and Authorization headers must never
be written to artifacts or git.
- Replacement readiness stays PARTIAL while any required real-runtime row is
skipped, not run, failed, or only indirectly covered.
Minimum required scenarios:
1. OpenClaw remains the default and rollback path.
2. cc-connect starts from packaged resources with no runtime download.
3. Real API-key and OAuth GUI chat pass through Bridge; a real OAuth native tool
turn shows the execution graph from cc-connect progress-card events.
4. Two Agents with different accounts and workspaces do not cross-contaminate.
5. Named, cross-Agent, Channel, restart, rename, and hard-delete sessions use
public runtime APIs.
6. Usage is per-turn, deduplicated, and attributable to runtime, Agent, account,
model, and logical session.
7. Feishu/Lark inbound, response, session, and usage attribution pass.
8. Channel and GUI native Cron mutations are bidirectionally visible and a
scheduled response returns to the Channel.
9. Doctor, logs, health, crash recovery, port collision, and single-writer lock
produce real evidence.
10. macOS, Windows, and Linux packaged resources/startup/cleanup are checked
before release readiness.
@@ -55,6 +55,7 @@ requiredRules:
- active-config-guards
- provider-default-invariant
- provider-model-metadata-preservation
- cc-connect-runtime-validation
- provider-model-selection-authority
- sidebar-session-attention-authority
- web-browser-security-and-lifecycle
@@ -84,6 +85,8 @@ Renderer code must not create direct Gateway WebSocket connections. Gateway fram
Channel/plugin migration behavior is also part of this scenario when ClawX rewrites OpenClaw config before Gateway launch. Upgrades must preserve single-owner channel registration for migrated plugin-backed channels such as Feishu/Lark.
Scheduled-task history is Main-owned backend data. Current OpenClaw versions must be queried through the Gateway `cron.runs` RPC; direct file reads are allowed only as a compatibility fallback for older file-backed runtimes. When a cron base session has no ACP replay, Renderer may project that typed host result into a generation-scoped, in-memory historical ACP timeline, but must not replace or duplicate non-empty ACP replay.
The Web Browser privileged bridge is also Main-owned: Renderer address and recovery navigation, data clearing, and external opening flow through the typed Host API. The artifact tab value `web-browser` identifies this Electron guest and remains distinct from the Workspace file browser value `browser`; UI ownership stays in `chat-workspace-and-navigation`. The durable guest contract is `harness/reference/web-browser.md`.
Gateway session-catalog subscription, normalization, ordered list/event replay, attention transitions, and reconnect recovery are documented in `harness/reference/sidebar-session-attention.md`.
+8 -3
View File
@@ -3,7 +3,7 @@ id: acp-native-chat
title: Move Chat to ACP-native Main-owned stdio transport and Renderer reducer
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Replace the ClawX-specific Chat stream/history path with ACP session/load, session/prompt, session/cancel, session/update, and session/request_permission while keeping non-Chat Gateway capabilities intact.
intent: Replace the OpenClaw Chat stream/history path with ACP session/load, session/prompt, session/cancel, session/update, and session/request_permission while keeping cc-connect Chat owned by RuntimeManager and BridgePlatform.
touchedAreas:
- harness/specs/tasks/acp-native-chat.md
- package.json
@@ -31,6 +31,8 @@ touchedAreas:
- tests/unit/chat-input.test.tsx
- tests/unit/chat-acp-page.test.tsx
- tests/unit/chat-page-execution-graph.test.tsx
- tests/unit/chat-runtime-routing.test.ts
- tests/unit/runtime-chat-execution-graph.test.tsx
- tests/unit/host-api-facade.test.ts
- tests/unit/host-events.test.ts
- tests/unit/host-services.test.ts
@@ -43,8 +45,9 @@ touchedAreas:
- README.zh-CN.md
- README.ja-JP.md
expectedUserBehavior:
- Opening a Chat session loads history through ACP session/load replay.
- Sending a Chat prompt uses ACP session/prompt, shows an optimistic user segment, and coalesces it with the ACP user echo.
- With OpenClaw active, opening a Chat session loads history through ACP session/load replay.
- With OpenClaw active, sending a Chat prompt uses ACP session/prompt, shows an optimistic user segment, and coalesces it with the ACP user echo.
- With cc-connect active, Chat continues through RuntimeManager, the active RuntimeProvider, and cc-connect BridgePlatform; ClawX does not start or call OpenClaw ACP.
- Thinking, tool calls, permission requests, plans, generated files, and generated images appear as inline timeline blocks in ACP event order.
- The old Execution Graph aggregation is not used for the ACP Chat path.
- Renderer does not call Gateway HTTP or WebSocket endpoints directly.
@@ -74,6 +77,8 @@ requiredTests:
- pnpm run comms:compare
acceptance:
- Main starts and reuses openclaw acp through a spawn-safe CLI spec and @agentclientprotocol/sdk ClientSideConnection.
- Renderer selects ACP Chat only when the active runtime status is OpenClaw and selects Runtime Chat when it is cc-connect.
- Main rejects ACP load, prompt, cancel, and permission operations while cc-connect is active, and typed Chat send remains dispatched through the active RuntimeProvider.
- Main forwards ACP SessionNotification envelopes and permission request envelopes without translating text, thinking, tools, or media into legacy Chat events.
- Renderer reduces ACP notifications into an in-memory ordered timeline.
- No ClawX ACP replay ledger, Chat history cache, or reduced timeline persistence is introduced.
@@ -0,0 +1,210 @@
---
id: cc-connect-runtime-validation
title: Validate cc-connect runtime with real bundles and gated Codex/OpenAI credentials
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Make cc-connect runtime validation reproducible across mock bridge, real bundled binary startup, and opt-in real Codex OAuth, OpenAI API key, and Feishu/Lark channel checks.
touchedAreas:
- .env.cc-connect.local.example
- .github/workflows/**
- README.md
- README.zh-CN.md
- README.ja-JP.md
- docs/**
- harness/src/**
- harness/specs/**
- electron-builder.yml
- package.json
- pnpm-lock.yaml
- scripts/**
- electron/extensions/**
- electron/main/**
- electron/runtime/**
- electron/services/**
- electron/shared/**
- electron/utils/**
- shared/**
- src/**
- tests/e2e/**
- tests/fixtures/**
- tests/unit/**
expectedUserBehavior:
- cc-connect can be selected and started from ClawX-managed runtime paths in local dev.
- Mock bridge E2E continues to prove chat box delivery without external network or credentials.
- Real bundled cc-connect and Codex binaries can start the runtime without mock replacement.
- Real bundled cc-connect diagnostics expose runtime state, managed paths, operation capabilities, bundle version probes, provider profile summary, and Management API health without leaking the management token.
- Real bundled cc-connect validates Management API channel config reload and project platform status without external credentials by using a local webhook platform.
- Real bundled cc-connect validates Management API cron lifecycle and doctor execution through Host API without external model credentials.
- Codex OAuth status/import/logout through the real Electron Host API is covered with isolated synthetic auth state. Status may inspect only redacted user-global auth metadata and account match state; runtime profile construction must not consume it, import must require an explicit Host API action, and no token value may cross the Host API response.
- Real Codex OAuth chat, direct cross-agent session fidelity, and cc-connect-owned token usage can be verified only when a developer explicitly supplies a Codex auth file through `CLAWX_REAL_CODEX_AUTH_JSON` so the import into isolated managed CODEX_HOME is intentional.
- Managed cc-connect Codex projects use the cc-connect-owned app-server backend over stdio so public progress-card payloads drive the live Chat execution graph without using Codex transcripts as a real-time source. A separate bounded historical compatibility supplement may restore only workspace- and turn-matched Channel tool calls/results omitted by public history.
- Real OpenAI API-key chat and real Feishu/Lark channel lifecycle checks remain opt-in and are not default CI gates.
- Public provider profiles and committed test artifacts never contain OAuth token material.
- Chat preflight validates the provider profile bound to the target Agent project: an invalid binding blocks only that Agent, while a valid explicitly bound Agent remains usable when the default provider profile is invalid.
- Agent create, rename, model/account binding, Channel binding, and deletion refresh the active runtime. cc-connect Agent mutations must not invoke OpenClaw provider/auth projection, and deletion restarts the active runtime before workspace removal.
- cc-connect skill projection mirrors the shared skill registry into every distinct project `CODEX_HOME` at runtime start and after skill config or ClawHub install/uninstall changes; isolated Agent accounts must observe the same enabled skill set.
- Replacement readiness gaps are explicit, including Developer Mode release gating, live expired-token refresh failure plus browser re-login evidence, Codex app-server graceful `CancelTurn` support beyond cc-connect's session-scoped `/stop`, OpenClaw Doctor Fix non-parity, real Feishu inbound message delivery, non-approval standalone buttons, upstream-triggered delete-message delivery, and notarized release smoke. Native packaged smoke passed for darwin-arm64, darwin-x64, win32-x64, linux-x64, and linux-arm64 in workflow run `29176833065`.
requiredProfiles:
- fast
- comms
requiredRules:
- renderer-main-boundary
- backend-communication-boundary
- api-client-transport-policy
- host-api-fallback-policy
- host-events-fallback-policy
- gateway-readiness-policy
- capability-owner-resolution
- active-config-guards
- cc-connect-runtime-validation
- comms-regression
- docs-sync
requiredTests:
- tests/unit/cc-connect-provider-profile.test.ts
- tests/unit/codex-paths.test.ts
- tests/unit/cc-connect-runtime-provider.test.ts
- tests/unit/cc-connect-bridge-adapter.test.ts
- tests/unit/runtime-rpc-contract.test.ts
- tests/unit/runtime-packaging.test.ts
- tests/unit/packaged-cc-connect-smoke.test.ts
- tests/unit/process-instance-lock.test.ts
- tests/unit/cc-connect-local-real-verifier.test.ts
- tests/e2e/clawx-shared-root-single-writer.spec.ts
- tests/e2e/cc-connect-codex-oauth-lifecycle.spec.ts
- tests/e2e/cc-connect-codex-runtime.spec.ts
- tests/e2e/cc-connect-real-bundle-smoke.spec.ts
- tests/e2e/cc-connect-real-comprehensive.spec.ts
- tests/e2e/cc-connect-real-oauth-chat.spec.ts
- tests/e2e/cc-connect-real-openai-api-key.spec.ts
- tests/e2e/cc-connect-real-feishu-channel.spec.ts
validationCommands:
- pnpm run bundle:cc-connect:current
- pnpm run bundle:codex:current
- pnpm run verify:runtime-bundles
- pnpm run verify:packaged-runtime-resources -- --resources=<target-resources> --platform=<target-platform> --arch=<target-arch>
- pnpm run smoke:cc-connect:packaged
- pnpm run verify:cc-connect:local-real
- pnpm run verify:cc-connect:local-real:oauth-all
- pnpm run verify:cc-connect:local-real:api-key
- pnpm run verify:cc-connect:local-real:feishu
- pnpm run verify:cc-connect:local-real:feishu-inbound
- pnpm run verify:cc-connect:local-real:scheduled-cron
- pnpm run verify:cc-connect:local-real:all
- pnpm run verify:cc-connect:local-real:all-strict
- pnpm run verify:cc-connect:local-real:replacement-ready
- pnpm run verify:cc-connect:local-real:replacement-ready:check
- pnpm run verify:cc-connect:local-real:external-gates:check
- pnpm run verify:cc-connect:local-real:external-gates
- pnpm run verify:cc-connect:local-real:handoff
- pnpm run verify:cc-connect:local-real:packaged-oauth
- pnpm exec vitest run tests/unit/cc-connect-provider-profile.test.ts tests/unit/codex-paths.test.ts tests/unit/cc-connect-runtime-provider.test.ts tests/unit/cc-connect-bridge-adapter.test.ts tests/unit/runtime-rpc-contract.test.ts tests/unit/runtime-packaging.test.ts tests/unit/cc-connect-local-real-verifier.test.ts tests/unit/e2e-local-real-env.test.ts
- pnpm run test:e2e:cc-connect:codex-oauth-lifecycle
- CLAWX_REAL_OAUTH_E2E=1 CLAWX_REAL_CODEX_AUTH_JSON=<auth-json> pnpm run test:e2e:cc-connect:real-oauth
- pnpm run test:e2e:cc-connect
- CLAWX_REAL_OAUTH_E2E=1 CLAWX_E2E_HOME_DIR=<isolated-home> CLAWX_E2E_USER_DATA_DIR=<isolated-user-data> pnpm run test:e2e:cc-connect:real-comprehensive
- CLAWX_REAL_OPENAI_API_KEY_E2E=1 CLAWX_REAL_OPENAI_API_KEY=<key> pnpm run test:e2e:cc-connect:real-openai-api-key
- CLAWX_REAL_FEISHU_E2E=1 CLAWX_REAL_FEISHU_APP_ID=<app-id> CLAWX_REAL_FEISHU_APP_SECRET=<app-secret> pnpm run test:e2e:cc-connect:real-feishu
- CLAWX_REAL_FEISHU_INBOUND_E2E=1 CLAWX_REAL_FEISHU_APP_ID=<app-id> CLAWX_REAL_FEISHU_APP_SECRET=<app-secret> pnpm run test:e2e:cc-connect:real-feishu-inbound
- CLAWX_REAL_SCHEDULED_CRON_E2E=1 pnpm run test:e2e:cc-connect:real-scheduled-cron
- CLAWX_REAL_SCHEDULED_PROMPT_CRON_E2E=1 CLAWX_REAL_CODEX_AUTH_JSON=<auth-json> pnpm run test:e2e:cc-connect:real-scheduled-prompt-cron
acceptance:
- `pnpm run verify:runtime-bundles` passes for the current platform.
- electron-builder `afterPack` rejects missing, stale, corrupted, or non-executable cc-connect/Codex resources, and every release target invokes `verify:packaged-runtime-resources` against the final unpacked resources. Windows/Linux require exact binary SHA; signed macOS binaries require source SHA, Mach-O section equivalence, and strict code-signature verification.
- A production-like Electron startup E2E omits the `CLAWX_USER_DATA_DIR` compatibility override, supplies an isolated legacy Electron `--user-data-dir` before Main startup, points `CLAWX_DATA_HOME` at an isolated shared root, imports legacy state into `app/`, sets Electron userData to `system/electron`, writes version/journal evidence, preserves the legacy source, proves a second launch keeps canonical state when legacy data changes, never reads the developer's real userData, and passes on macOS, Windows, and Linux.
- Shared-root startup acquires `locks/writer.lock` before layout initialization, migration, runtime-manager construction, or scheduler startup and fails closed when lock acquisition throws. A real two-Electron E2E proves the duplicate cannot replace the live owner or open a window, the owner remains usable, shutdown releases the lock, and a successor process acquires it.
- `smoke:cc-connect:packaged` resolves the native unpacked layout on macOS, Windows, and Linux and verifies packaged Electron startup, runtime start/status, managed project workspaces, native Cron CRUD, cc-connect Doctor, rollback to OpenClaw, and PID/port/runtime-directory process cleanup without model credentials.
- Release publishing is blocked on native smoke jobs for macOS arm64, Windows x64, Linux x64, macOS x64 on `macos-15-intel`, and Linux arm64 on `ubuntu-24.04-arm`; Linux jobs run Electron through Xvfb. Manual unsigned macOS smoke must explicitly record skipped signature validation, while tag builds keep strict signature validation as a release gate.
- `pnpm run verify:cc-connect:local-real` writes a sanitized local real-validation report that records available bundles, local OAuth state, opt-in credential preconditions, packaged app availability, local env-file presence plus untracked/gitignore safety, residual process cleanup status, and a runtime parity coverage matrix without writing secret values or machine-local absolute repository/home/temp paths. Persisted paths use `<repo>`, `<home>`, and `<tmp>` placeholders; child validation commands still receive the real paths.
- The local OAuth state summary records only token key names, missing required token-key names, and sanitized expiry metadata; it must not write token values, and an explicit `CLAWX_REAL_CODEX_AUTH_JSON` file must be reported as a missing real OAuth precondition instead of being copied into managed `CODEX_HOME` when it is incomplete or clearly expired. A complete Codex OAuth auth file requires non-empty `access_token`, `account_id`, `id_token`, and `refresh_token` fields under `tokens`.
- `pnpm run verify:cc-connect:local-real:oauth-all` records and runs both dev comprehensive and packaged macOS cc-connect real OAuth smokes when `CLAWX_REAL_CODEX_AUTH_JSON` points at a token-bearing Codex auth file.
- `pnpm run verify:cc-connect:local-real:api-key` records and runs credential-free local OpenAI-compatible API-key chat and chat-abort smokes through real Electron, real cc-connect, and bundled Codex, and additionally runs the real OpenAI API-key smoke when `CLAWX_REAL_OPENAI_API_KEY` or `OPENAI_API_KEY` is available from process env or an untracked and gitignored local env file.
- `pnpm run verify:cc-connect:local-real:feishu` records and runs the real Feishu/Lark lifecycle smoke when Feishu/Lark app credentials and `CLAWX_REAL_CODEX_AUTH_JSON` are available from process env or an untracked and gitignored local env file. The smoke verifies live connected/running state, disconnect/connect reload, account deletion and process cleanup, and that project-level `admin_from` contains both `clawx-desktop` and every configured Channel administrator.
- `pnpm run verify:cc-connect:local-real:feishu-inbound` records and runs the manual real Feishu/Lark inbound marker smoke when Feishu/Lark app credentials, `CLAWX_REAL_CODEX_AUTH_JSON`, and `CLAWX_REAL_FEISHU_INBOUND_E2E=1` are available; the smoke writes `artifacts/cc-connect/feishu-inbound-marker.json` with the exact marker to send, waits for a sandbox tenant chat to send that marker, and proves the marker appears through ClawX Host API session summaries/history without reading cc-connect private session files.
- `pnpm run verify:cc-connect:local-real:scheduled-cron` records and runs a credential-free real scheduled exec cron smoke that waits for the next cc-connect scheduler minute and verifies the enabled job writes through its configured `work_dir`; when `CLAWX_REAL_CODEX_AUTH_JSON` is complete, it also verifies scheduled prompt delivery through the ClawX cc-connect bridge fallback. Both paths preserve the cc-connect PID, render the live job on the Cron page, require delete success plus a second Host API list that proves the job is absent, and write sanitized `artifacts/cc-connect/real-scheduled-{exec,prompt}-cron.{json,png}` evidence without credentials or absolute paths.
- Scheduled prompt validation must cross the Bridge idle window when necessary; deterministic adapter coverage proves 25-second client pings, 3-second reconnect, re-registration after a dropped socket, and no reconnect after intentional close.
- Deterministic lifecycle coverage proves that stopping during Bridge registration closes the in-flight socket without reconnect, and that a Bridge registration failure after process spawn terminates the managed cc-connect process and leaves runtime status `error` rather than leaking a child.
- `pnpm run verify:cc-connect:local-real:all` records and runs every available local real path, writes the external gate handoff from the same sanitized report, and keeps unavailable credential paths as explicit skipped checks and skipped command records in both JSON and Markdown reports unless `--strict-real` is used.
- `pnpm run verify:cc-connect:local-real:all-strict` exits non-zero when release-candidate real credential preconditions are missing or when replacement readiness is not achieved, while still writing the sanitized report, external gate handoff, missing-precondition rows, and coverage rows.
- `pnpm run verify:cc-connect:local-real:replacement-ready` exits non-zero when any required replacement-readiness coverage row is skipped, failed, missing, or not-run; it writes the external gate handoff and may leave missing credentials represented by the replacement-readiness failure rather than a separate strict preflight failure.
- `pnpm run verify:cc-connect:local-real:replacement-ready:check` runs the same replacement-readiness hard gate with `--no-write`, so a quick gate check cannot overwrite the last full local-real report artifact.
- `pnpm run verify:cc-connect:local-real:external-gates:check` runs only the remaining required external gate paths for real OpenAI API-key chat, Feishu/Lark live lifecycle, and Feishu/Lark inbound tenant-message delivery, but uses `--no-write` so missing credentials or partial external evidence cannot overwrite the last full local-real report. The command must still print sanitized missing-precondition ids, required variable names, and next commands to stdout.
- `pnpm run verify:cc-connect:local-real:external-gates` runs the same focused external gate paths, writes the external gate handoff, and exits non-zero unless all three external coverage rows are `PASS`.
- `pnpm run verify:cc-connect:local-real:handoff` reads the latest sanitized local real-validation report and writes `artifacts/cc-connect/local-real-external-gates.{md,json}` as credential-free human-readable and machine-readable handoff checklists for the remaining real OpenAI API-key, Feishu/Lark lifecycle, and Feishu/Lark inbound tenant-message gates. The verifier's `--write-handoff` flag must write the same checklists from the in-memory report in the same validation run.
- The local real-validation report includes a dedicated `channel-lifecycle-local-bundle` coverage row for bundled cc-connect Host API `channels.connect` and `channels.disconnect`, managed config reload without restart, real user channel credential removal, local placeholder platform preservation, and credential-free Feishu/Lark config projection for domain aliases, agent binding, account-scoped status, and workspace isolation; this local row must not satisfy or replace the `feishu-live-channel-lifecycle` coverage row.
- The local real-validation report includes a dedicated `cron-lifecycle-local-bundle` coverage row for bundled cc-connect Management API cron create/list/update/toggle/delete, non-main agent project routing, prompt and exec field mapping, explicit external delivery metadata pass-through, `work_dir`, `session_mode`, `timeout_mins`, `mute`/`silent`, stable unsupported handling for non-cron `at`/`every` schedules, asynchronous manual-trigger acknowledgement, and official `last_run`/`last_error` completion mapping; this local row must not satisfy or replace live scheduled-delivery or tenant channel-delivery evidence.
- The Cron UI preserves non-blocking manual-trigger acknowledgement and observes asynchronous completion through bounded background `cron.list` refreshes until `lastRun` changes, the runtime auto-removes the job, the user deletes it, the selected runtime changes, or the job timeout elapses. Re-triggering supersedes the prior observation, and the observer must never execute a second scheduler or call Codex directly.
- The replacement-required `channel-cron-command-local-diagnostics` row registers a simulated Feishu transport through the real bundled cc-connect public Bridge protocol, asserts the managed admin identity is projected, creates a native Cron job through Channel `/cron` as that admin, proves Host API observes it, proves a GUI-created announce job for the same Feishu target is visible in a real cc-connect `/cron` card, exercises that card's disable/enable/delete callbacks through `card_action`, preserves the cc-connect PID, and writes sanitized ignored evidence to `artifacts/cc-connect/real-channel-cron-bridge.json`. `/cron add` has a usable text acknowledgement. This proves real card/action and shared-scheduler semantics without claiming non-approval standalone buttons, upstream-triggered delete-message, or live Feishu tenant delivery.
- The local real-validation report includes a dedicated `scheduled-cron-delivery-local-bundle` coverage row for opt-in real scheduler delivery of an enabled exec cron without external credentials; when this row is PASS, the follow-up `real-scheduled-cron-delivery` validation gap must disappear. The report also includes `scheduled-prompt-cron-delivery-local-bundle` when the scheduled prompt smoke is run; PASS rows require observed cleanup after successful deletion. Manual prompt execution must not treat the asynchronous trigger acknowledgement as completion: it waits for a successful runtime-owned `lastRun`, fails with the mapped `last_error`, and only then requires the public session/history prompt and assistant response. A prompt PASS proves cc-connect scheduled prompt delivery through public session summaries/history and machine/visual evidence, but must not claim live tenant-channel delivery parity.
- The local real-validation report records sanitized missing-precondition rows with required variable names and next validation commands, without writing credential values.
- Credential-gated coverage rows such as real OpenAI API-key chat, Feishu/Lark live lifecycle, real OAuth comprehensive, and packaged OAuth smoke must be marked `skipped` with the missing-precondition reason when their required local preconditions are absent, even if the opt-in child command was not requested in that verifier run. If the preconditions are present but the command was simply not requested, the row remains `not-run`.
- The local real-validation verifier loads the same additional explicit env-file entrypoints as direct real E2E (`CLAWX_REAL_ENV_FILE` and path-delimited `CLAWX_REAL_ENV_FILES`) in addition to `--env-file=<path>`, while preserving process-env precedence and reporting only file basenames plus variable names.
- Loaded local env files inside the repository must be untracked and gitignored; unsafe repo-local env files must not be parsed, must not expose variable names, and must not pass values to child validation commands. Explicit env files outside the repository may be loaded but reports identify them only as outside-repo summaries without absolute paths.
- Direct real E2E env helpers must skip unsafe repo-local env files without throwing during test module import, so API-key and Feishu/Lark specs still compile and then skip normally when credentials are unavailable.
- Direct real OpenAI API-key and Feishu/Lark E2E specs load the same default local env files as the verifier only when repository-local files are untracked and gitignored, may additionally load `CLAWX_REAL_ENV_FILE` or `CLAWX_REAL_ENV_FILES`, and must not override explicit process environment values.
- Direct E2E local env-file summaries must not expose absolute paths for explicit files outside the repository.
- `.env.cc-connect.local.example` documents local real-validation credential fields without containing real credential values.
- The Codex OAuth lifecycle local diagnostics row runs deterministic verifier coverage for explicit auth import requirement, complete refresh-token field requirement, sanitized expiry metadata, and missing token-key reporting without exposing token values. An expired access/id token with a complete refresh token is allowed into an isolated managed `CODEX_HOME`, but only a successful real cc-connect/Codex turn may prove refresh; missing refresh material remains a hard precondition failure. This row is part of replacement readiness, while refresh failure followed by browser re-login remains an external follow-up gap.
- Browser OAuth success persists the canonical ClawX provider account and encrypted secret, then dispatches provider-profile synchronization through the active runtime. cc-connect mode must materialize its account-scoped managed `CODEX_HOME` without writing OpenClaw config or restarting the OpenClaw Gateway; OpenClaw mode retains its existing projection path.
- A cc-connect provider sync with `reason=oauth` must replace same-account stale managed Codex tokens with the newly acquired ClawX vault secret. A normal runtime start must retain complete same-account managed tokens so Codex refresh-token rotation is not rolled back to the older vault snapshot. Neither public provider profiles nor Host API responses may expose either token set.
- Account-isolation coverage proves a legacy shared managed Codex home is migrated once to the selected OAuth account and removed, a second account cannot inherit it, and runtime profile sync remains unsupported when only a matching user-global auth file exists until `importCodexOAuth` is explicitly invoked.
- Multi-Agent project coverage proves provider-account identity and effective model are independent: two Agents may bind different OAuth/API-key accounts and different model overrides, generated project blocks use each Agent's model and account launcher, and no credential environment crosses between projects.
- The `codex-oauth-host-api-lifecycle-local` row runs a real Electron Host API E2E for `providers.codexOAuthStatus`, `providers.importCodexOAuth`, and `providers.logoutCodexOAuth` using isolated synthetic Codex auth state. It must verify managed auth-file creation/deletion, provider OAuth secret cleanup, public provider-profile redaction, response redaction, and that stopped-runtime profile sync does not require a dev Codex bundle.
- The local real-validation report includes `coverage` JSON and a Markdown `Runtime Parity Coverage` table that maps runtime parity areas to evidence commands for current bundles, BridgePlatform-only runtime boundary diagnostics, session/history parity local diagnostics, compile/skip paths, Codex OAuth lifecycle local diagnostics, Codex OAuth Host API lifecycle, provider/model profile local diagnostics, operation-level capability diagnostics, token usage contract local diagnostics, runtime management bundle local diagnostics, BridgePlatform image/file/audio/video packet diagnostics, real bundled `cc-connect send` media delivery, BridgePlatform rich packet diagnostics, real bundled cc-connect preview/update progress, channel lifecycle local bundle semantics, cron lifecycle local bundle semantics, scheduled exec cron delivery, scheduled prompt delivery through public session APIs, OAuth core parity, generated-file card real OAuth delivery, local OpenAI-compatible API-key chat, local OpenAI-compatible chat abort, real OpenAI API-key provider/model chat, Feishu/Lark channel lifecycle, and packaged OAuth smoke.
- The `bridge-rich-card-action-real-bundle` row records real bundled cc-connect `/cron` list card output plus disable/enable/delete `card_action` callbacks observed through Host API. It is distinct from adapter-level rich packet fixtures and does not claim non-approval standalone buttons, upstream-triggered delete-message, or native tenant rendering.
- The local OpenAI-compatible API-key row verifies OpenAI API-key provider `baseUrl`, model propagation, bearer auth, secret redaction, and chat delivery through real cc-connect plus bundled Codex against a local Responses-compatible server, but it must not satisfy or replace the real OpenAI API-key provider/model chat row in replacement readiness.
- The `chat-abort-local-openai-compatible` coverage row verifies a delayed local OpenAI-compatible Responses stream through real cc-connect plus bundled Codex, the GUI Stop button, Host API `chat.abort`, BridgePlatform `/stop` delivery, upstream stream closure before the server releases completion, late assistant output suppression, an unchanged cc-connect PID, and recovery to `running`; the test writes sanitized ignored evidence to `artifacts/cc-connect/real-local-chat-abort.json` and `.png`.
- The provider/model profile local diagnostics row runs deterministic unit coverage for API-key/OAuth/custom Responses materialization, unsupported-provider diagnostics, secret redaction, and running-runtime provider/model sync restart, but it is not a replacement for the real OpenAI API-key provider/model chat row and must not be counted as replacement-ready live credential evidence.
- The token usage contract local diagnostics row verifies that Host API usage is owned by `RuntimeProvider.listUsage` for both runtimes, inferred totals use `input + output` without adding cache subsets again, reasoning tokens remain a subset of output, cc-connect returns explicit `usageStatus: missing` entries for public-history assistant turns while v1.4.1 lacks public counters, maps public usage when present, never reads cc-connect private session JSON or managed/user-global Codex transcripts, never leaks OpenClaw usage into a cc-connect query, and leaves OpenClaw transcript usage intact. Real bundled cc-connect must produce Host API and Models-page missing-usage evidence from public Management history. The row remains `PARTIAL` and replacement-required until a pinned cc-connect public payload can be mapped and verified against real OAuth/API-key usage.
- The runtime management bundle local diagnostics row runs real bundled cc-connect E2E coverage for startup, diagnostics redaction, fallback ports, Management API sessions/providers/models across main and non-main projects, read-only Host API `providers.profile`/`models.profile` without restart, provider/model response field allowlisting without upstream secret pass-through, Management API channel reload/status, Channel `/cron` plus Host API shared-scheduler semantics, Management API cron lifecycle, managed cc-connect user-isolation plus bundled Codex `doctor --json`, quit cleanup, and rollback cleanup, but it is not a replacement for real Feishu/Lark tenant-delivery coverage.
- The real runtime-management E2E writes sanitized ignored evidence to `artifacts/cc-connect/real-management-profiles.json`, `artifacts/cc-connect/real-runtime-doctor.json`, and `artifacts/cc-connect/real-token-usage-runtime-contract.{json,png}`; these files may record project names, endpoint/Host API success flags, PID preservation, audit mode, Doctor success/report-presence, public-history usage status, and GUI missing-state presence, but must not contain management tokens, provider secrets, OAuth tokens, or absolute temporary paths.
- The `bridge-media-packets-local-diagnostics` row runs deterministic BridgePlatform adapter coverage for image/file/audio/video packets, cc-connect managed media writes, image data-URL previews, and file/audio/video preview suppression. The separate `bridge-media-send-real-bundle` row invokes the bundled `cc-connect send` CLI against an active managed session and proves all four packet types enter Host history and GUI Chat through public BridgePlatform, with exact managed byte copies and sanitized evidence in `artifacts/cc-connect/real-cli-media-bridge.{json,png}`. Neither row replaces non-approval standalone-button or upstream-triggered delete-message evidence.
- The `bridge-rich-packets-local-diagnostics` row runs deterministic BridgePlatform adapter coverage for card/buttons, preview acknowledgements, first-frame and update-message replacements, text-preview deletion, structured-progress retention, and typing no-op stability. The separate `bridge-rich-progress-real-bundle` row runs the real bundled cc-connect v1.4.1 engine against a deterministic Codex app-server protocol boundary and proves public `preview_start`/`update_message`, normalized thinking/tool events, the GUI execution graph, final assistant delivery, and sanitized `artifacts/cc-connect/real-rich-progress-bridge.{json,png}` evidence. This does not claim a real OpenAI credential or an upstream-triggered `delete_message`.
- The local real-validation report includes `ccConnectCliSurface` JSON and a Markdown `cc-connect Upstream CLI Surface` section from the bundled binary, including command, cron, sessions, providers, Feishu/Lark, channel lifecycle evidence, and missing upstream primitives such as undocumented per-platform channel connect/disconnect.
- The local real-validation report includes a top-level `runtimeMatrixStatus`, a `replacementReadiness` JSON object, and a Markdown `Replacement Readiness` section derived from required replacement rows; skipped or not-run OpenAI API-key and Feishu/Lark rows must keep `runtimeMatrixStatus` `partial`, include the next command to run, and may set the overall report status to `fail` only when `--require-replacement-ready` is used as a hard gate.
- The local real-validation report includes a machine-readable `replacementContract` checklist and Markdown `Replacement Contract Checklist` section that maps the current cc-connect replacement decisions to evidence: Developer Mode gating remains unchanged, Doctor Fix non-parity is explicit, BridgePlatform-only runtime ownership forbids direct ClawX-to-Codex execution and Codex transcripts as a real-time or usage source, the bounded Channel tool-history supplement remains degraded and cannot claim replacement readiness, Codex OAuth/OpenAI API-key verification is tracked separately, provider/model matrix limitations are not implied parity, Feishu/Lark local projection is not live tenant delivery, cron lifecycle/scheduled exec/scheduled prompt BridgePlatform delivery is not live tenant-channel delivery parity, session/history rename/delete/title/cross-agent contracts and token usage contracts are tied to runtime-owned evidence, real validation remains opt-in, and all-platform packaging smoke remains a release-validation item.
- The local real-validation check table always includes a `replacement-readiness` row. It must be `PARTIAL` for informational partial reports and `FAIL` only when `--require-replacement-ready` is used as the hard gate, so `required-coverage` success for a selected subset cannot be mistaken for full replacement readiness.
- `--no-write` must preserve the last JSON/Markdown report artifacts while still returning the same hard-gate exit status and printing a sanitized console summary, allowing non-destructive replacement-readiness checks after a full local-real run.
- The local real-validation report includes `validationGaps` JSON and a Markdown `Validation Gaps` table that distinguishes required local replacement-gate gaps from follow-up full-parity evidence gaps. The required replacement gate includes public cc-connect token usage, real OpenAI API-key chat, real Feishu/Lark lifecycle, and real Feishu/Lark inbound marker delivery; follow-up full-parity evidence gaps include real scheduled prompt/channel cron delivery as a separate gap from scheduled exec delivery, non-approval standalone buttons, upstream-triggered delete-message delivery, and notarized macOS dmg/zip smoke. Native target release-smoke evidence is recorded from workflow run `29176833065`.
- The local real-validation report includes sanitized `nextActions` JSON and a Markdown `Next Actions` section that turns missing OpenAI API-key, Feishu/Lark credentials, non-PASS replacement-readiness coverage, and upstream primitive gaps into concrete follow-up commands or actions without writing secret values.
- The external gate handoff artifacts must be generated only from sanitized report metadata, must include the follow-up commands and required environment variable names for the remaining external gates, and must not include API-key values, OAuth token values, app secret values, generated auth file contents, or tenant-private data beyond the intentionally sanitized Feishu/Lark marker artifact path. The JSON artifact must be stable enough for local CI/handoff automation to consume without parsing Markdown. `--no-write` must suppress handoff output even when `--write-handoff` is present.
- `--external-gates-only` must skip the safe local baseline commands and execute only explicitly included credential-gated paths, so external gate reruns after credentials are configured do not require rerunning the full local matrix.
- The `operation-capabilities-local-diagnostics` row is replacement-required and passes only when the operation contract/helper/channel-store tests and real bundled runtime status E2E both pass. Before status publication, legacy renderer state remains compatible; after a runtime publishes an operation map, undeclared methods are fail-closed instead of silently treated as supported. Explicitly unsupported `channels.add` and `channels.requestQr` must stop before runtime RPC and must not create local placeholder channel state.
- Unit coverage protects local real-verifier argument parsing, deterministic coverage-id expansion, unknown coverage-id failure, skipped/not-run required coverage failure, command-to-coverage mapping, replacement-readiness summaries, structured validation-gap output, sanitized Codex OAuth expiry summaries, incomplete-auth and expired-auth precondition handling, next-action generation, direct E2E local env-file loading precedence, explicit env-file expansion, and explicit outside-repo path redaction.
- `pnpm run verify:cc-connect:local-real:packaged-oauth` records and runs packaged macOS cc-connect real OAuth smoke when the packaged app is available and `CLAWX_REAL_CODEX_AUTH_JSON` points at a token-bearing Codex auth file.
- `pnpm run test:e2e:cc-connect` passes without real network credentials.
- Real bundle E2E proves channel config reload keeps the same runtime pid/port and that ClawX channel status reads cc-connect project platform `connected`/`running` state; deterministic unit coverage must also protect same-project multi-account Feishu/Lark status mapping.
- Real bundle E2E proves cc-connect cron create/list/update/toggle/delete for a non-main project, exec/work_dir/session_mode/timeout field preservation, ClawX `continue` to cc-connect `reuse` session-mode translation, and `cc-connect doctor user-isolation` through Host API; deterministic unit coverage must also protect explicit external delivery metadata pass-through.
- `tests/e2e/cc-connect-real-comprehensive.spec.ts` remains skipped by default and passes only when explicitly enabled with isolated OAuth state.
- `tests/e2e/cc-connect-real-oauth-chat.spec.ts` copies only an explicitly supplied auth file into an isolated managed `CODEX_HOME`, selects Agent permission mode `suggest`, runs one real file-writing Patch turn through cc-connect's Codex app-server backend, clicks the real Bridge approval, asserts cc-connect `tools=1`, Bridge-derived `approval.updated` request/resolution plus `tool.started`/`tool.completed`, the managed workspace file, and the visible Chat execution graph, then writes sanitized PNG/JSON evidence without token material or temporary absolute workspace paths. Screenshot masking must preserve the tool type, approval controls, generated filename, lifecycle state, and final assistant result.
- Deterministic Electron E2E renders a cc-connect Bridge `buttons` approval in the Chat execution graph, clicks an offered action, verifies `chat.approval.respond` reaches the runtime provider, captures the exact public `card_action` packet, and verifies assistant delivery resumes. It also changes the Main Agent permission mode in GUI and proves the managed cc-connect project config changes to `mode = "suggest"`.
- Real bundled cc-connect Electron E2E sends `/lang` from the GUI Chat box, renders the public Bridge card select options as a capability-aware runtime choice, clicks `act:/lang ja`, verifies the public `card_action -> card` loop, confirms live `language: ja` through the public Management project API, preserves the runtime PID, closes the Chat run, and writes sanitized before/after screenshots plus structured evidence. This row must not claim that cc-connect v1.4.1 persists manual language changes to `config.toml`; upstream registers `SaveLanguage` only for auto-detection.
- The real comprehensive OAuth test verifies chat box delivery, direct cross-agent research chat/session summary, prompt cron paths, a real Codex file-writing tool turn with run-correlated cc-connect Bridge tool events, and an `apply_patch` generated-file card rendered in GUI chat through cc-connect and Codex using `auth_mode: chatgpt`. Token usage remains a separate upstream-blocked replacement row and is not inferred from Codex transcripts.
- Bridge-adapter production code contains no cc-connect private session-store or Codex-transcript parser. Deterministic adapter coverage is limited to the public Bridge protocol and real-time in-memory delivery; provider unit/E2E coverage proves named, cross-agent, channel, title, ordinary history, and delete parity through public Management session APIs plus ClawX-owned label metadata, and separately proves the bounded historical Channel tool supplement rejects stale cross-workspace evidence and never becomes a real-time or usage source.
- `tests/e2e/cc-connect-real-openai-api-key.spec.ts` includes a default local OpenAI-compatible API-key smoke and also validates real OpenAI API-key chat, secret redaction, and managed runtime process cleanup when explicitly enabled.
- `tests/e2e/cc-connect-real-feishu-channel.spec.ts` remains skipped by default and validates real Feishu/Lark config projection, runtime status, lifecycle reload, delete cleanup, domain alias mapping, managed runtime process cleanup, and canonical configuration ownership: an existing OpenClaw compatibility file is imported read-only, `runtime-config.json` retains non-secret metadata, channel secrets exist only in the encrypted vault without plaintext bytes, cc-connect-mode import/delete never changes the compatibility source, and sanitized `artifacts/cc-connect/real-feishu-lifecycle.json` records only boolean lifecycle/ownership evidence. When `CLAWX_REAL_FEISHU_INBOUND_E2E=1` is enabled, the same spec writes a sanitized marker handoff artifact then verifies the manual inbound tenant-message marker is stored by cc-connect; it still does not prove undocumented per-platform connect/disconnect primitives.
- Packaged smoke supports macOS, Windows, and Linux unpacked layouts; `--real-oauth=1` validates packaged GUI chat through the platform-specific managed Codex OAuth launcher while asserting public provider-profile output excludes token material, and the sanitized evidence `checks` list must include `real-oauth-chat-through-managed-launcher` only when that real turn completed.
- Provider-profile output includes `CODEX_HOME` for OAuth mode but excludes `access_token`, `refresh_token`, and `id_token`.
- The validation report or architecture doc lists real-runtime gaps that remain unverified after mock E2E and gated real-credential E2E, including live Feishu inbound delivery, live tenant-channel scheduled cron delivery, non-approval standalone buttons, upstream-triggered delete-message delivery, and notarized dmg/zip validation. It also records the observed five-target native packaged smoke evidence from workflow run `29176833065`.
docs:
required: true
---
cc-connect validation has three layers:
1. Unit and mock E2E coverage for deterministic runtime behavior.
2. Real bundled binary smoke tests for local dev and packaging regressions.
3. Opt-in real OpenAI/Codex OAuth, OpenAI API-key, and Feishu/Lark tests for end-to-end credential and network validation.
The real credential layer must never be part of default CI. OAuth requires a developer to explicitly provide `CLAWX_REAL_CODEX_AUTH_JSON` pointing at the Codex auth file that may be copied into an isolated managed `CODEX_HOME`, then opt in with `CLAWX_REAL_OAUTH_E2E=1`. The local verifier records sanitized auth expiry metadata and must reject incomplete or clearly expired explicit auth files before child commands run. The local verifier may read untracked and gitignored `.env.cc-connect.local`, `.env.local`, `.env`, or an explicit `--env-file=<path>` and pass those values only to child validation commands. Explicit env files inside the repository must be untracked and gitignored; unsafe repo-local env files must not be loaded or parsed. Env files outside the repository are allowed but must not be reported with absolute paths. `.env.cc-connect.local.example` is a checked-in template and must contain only variable names, placeholders, and comments. OpenAI API-key and Feishu/Lark checks require explicit opt-in commands and remain skipped by default when credentials are unavailable.
Replacement-readiness follow-up validation must add coverage for:
- live operation-level capability evidence is covered by the replacement-required `operation-capabilities-local-diagnostics` row; boolean capability groups remain only the coarse navigation/feature summary;
- live expired-token refresh failure and browser re-login using ClawX-managed `CODEX_HOME`; deterministic same-account replacement and stale-vault rollback protection are covered locally;
- real cc-connect doctor output and Codex doctor JSON are covered by the runtime-management bundle row; the mode-0600 composite audit is stored only under the ClawX-managed runtime directory;
- graceful in-process Codex app-server turn cancellation remains upstream-owned; cc-connect v1.4.1 handles `/stop` by closing only the selected session's Codex child and preserving its resumable AgentSessionID;
- real cc-connect Management API sessions/providers/models endpoints are covered for main and non-main projects; runtime-facing Host API profile reads preserve the cc-connect PID, and cross-agent session fidelity remains covered by the session/history row;
- real cc-connect Management API reload and project platform status for channels;
- live Feishu/Lark inbound message delivery through a tenant chat must be covered by the opt-in inbound marker smoke before replacement readiness can pass;
- non-approval standalone-button and upstream-triggered delete-message delivery;
- notarized macOS dmg/zip validation plus observed PASS results from all native packaged release-smoke jobs.
@@ -0,0 +1,101 @@
---
id: runtime-abstraction-cc-connect
title: Make cc-connect a usable ClawX replacement runtime
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Keep OpenClaw as the default and rollback runtime while making cc-connect plus Codex satisfy ClawX core workflows through one runtime contract.
touchedAreas:
- .env.cc-connect.local.example
- .github/workflows/**
- .gitignore
- .prettierrc
- AGENTS.md
- README*.md
- docs/**
- harness/reference/**
- harness/src/**
- harness/specs/**
- electron/extensions/**
- electron/runtime/**
- electron/services/**
- electron/main/**
- electron/shared/**
- electron/utils/**
- resources/**
- shared/**
- src/**
- scripts/**
- tests/**
- package.json
- pnpm-lock.yaml
- electron-builder.yml
- tailwind.config.js
expectedUserBehavior:
- OpenClaw remains selected by default and can be restored without deleting cc-connect data.
- cc-connect remains behind Developer Mode but can run real GUI chat without any direct ClawX-to-Codex path.
- Stable, beta, dev, and multiple installations share upgrade-stable data under ~/.clawx with one active writer.
- Existing OpenClaw workspaces are reused by reference; new Agents receive managed ~/.clawx workspaces.
- Different Agents can bind different OAuth or API-key accounts without credential, session, workspace, or usage crossover.
- Each Agent can independently select cc-connect `full-auto` or approval-required `suggest` mode; the latter has real OAuth GUI approval evidence.
- Sessions and ordinary history use cc-connect public APIs; tools and approval responses use public Bridge events/`card_action`. When a Channel session's public history omits tool packets, a bounded historical compatibility supplement may restore only workspace- and turn-matched tool calls/results from the owning Codex transcript. Per-run cancellation and usage remain explicit replacement blockers until cc-connect exposes public APIs/events.
- Feishu/Lark messages reach the bound Agent through cc-connect and replies return through cc-connect.
- GUI and Channel /cron manage the same native cron-expression jobs.
- Skills are shared across runtimes and a real skill can be invoked in cc-connect chat.
- cc-connect Doctor, health, stdout/stderr, runtime events, and diagnostics are visible without leaking secrets.
- Packaged applications contain verified cc-connect and Codex binaries and run without runtime downloads.
requiredProfiles:
- fast
- comms
requiredTests:
- tests/unit/runtime-manager.test.ts
- tests/unit/chat-runtime-routing.test.ts
- tests/unit/runtime-chat-execution-graph.test.tsx
- tests/unit/cc-connect-runtime-provider.test.ts
- tests/unit/cc-connect-bridge-adapter.test.ts
- tests/unit/cc-connect-provider-profile.test.ts
- tests/unit/cc-connect-bundle.test.ts
- tests/unit/runtime-packaging.test.ts
- tests/unit/packaged-cc-connect-smoke.test.ts
- tests/unit/cc-connect-paths.test.ts
- tests/unit/process-instance-lock.test.ts
- tests/unit/token-usage.test.ts
- tests/unit/token-usage-scan.test.ts
- tests/e2e/clawx-shared-root-single-writer.spec.ts
- tests/e2e/cc-connect-codex-runtime.spec.ts
- tests/e2e/cc-connect-real-bundle-smoke.spec.ts
- tests/e2e/cc-connect-real-comprehensive.spec.ts
- tests/e2e/cc-connect-real-openai-api-key.spec.ts
- tests/e2e/cc-connect-real-feishu-channel.spec.ts
- tests/e2e/cc-connect-real-scheduled-cron.spec.ts
acceptance:
- The dependency and bundled binary are pinned to the same verified stable cc-connect version.
- electron-builder `afterPack` verifies copied cc-connect and Codex resources for the target architecture; final macOS x64/arm64, Windows x64, and Linux x64/arm64 unpacked resources pass the packaged-resource verifier, including signed Mach-O section and code-signature validation where whole-file SHA changes.
- Release publishing depends on native packaged smoke for macOS x64/arm64, Windows x64, and Linux x64/arm64. Each smoke launches the packaged Electron app, starts cc-connect through Host API, checks managed runtime state plus Cron and Doctor, rolls back to OpenClaw, and proves PID/ports/runtime-directory processes are cleaned.
- No cc-connect runtime code launches Codex for chat or uses Codex transcripts as a real-time event source, an ordinary message-history authority, or a usage source. A bounded historical Channel compatibility supplement may read matching Codex JSONL only after public cc-connect history loads and omits tool packets; it must not create user/assistant turns, cross Agent workspaces, or claim replacement readiness.
- No cc-connect runtime code writes OpenClaw config or cc-connect private session stores.
- Canonical Agent/channel saves in cc-connect mode update only the ClawX runtime config and encrypted vault; OpenClaw start/restart explicitly rebuilds the compatibility projection before Gateway startup, and a newer projection never overrides existing canonical state by mtime.
- The shared-root writer lock is acquired before layout initialization or migration and fails closed on acquisition errors. A real two-Electron E2E proves the duplicate exits before runtime/scheduler construction, cannot replace the live owner, and a successor acquires the lock after clean shutdown.
- Host API calls are routed through RuntimeManager and the active RuntimeProvider.
- OpenClaw may use the Main-owned ACP Chat path, but cc-connect GUI Chat must render the Runtime Chat path and Main must reject ACP operations while cc-connect is active.
- Runtime events carry stable event/run/turn/session/project sequencing and survive Bridge reconnect without duplication.
- The cc-connect Bridge adapter sends the protocol-compatible 25-second client ping, reconnects after an unexpected disconnect, and never reconnects after an intentional runtime stop.
- Account-level OAuth homes and encrypted API keys are isolated per Provider Account.
- cc-connect project work_dir always resolves from the Agent workspace registry and never from process.cwd or the source checkout.
- Native cron-expression jobs are shared between GUI and Channel; a real bundled cc-connect Bridge channel proves `/cron` add/list/disable/enable/delete and GUI/Channel bidirectional visibility for one Feishu target without runtime restart. Manual run, at, and every remain capability-aware and non-mutating because cc-connect v1.4.1 does not expose equivalent Host API schedule operations.
- Pinned cc-connect Bridge capabilities match its public protocol; ClawX opts into progress-card payloads and maps only events emitted by cc-connect, with an explicitly marked terminal inference when a final reply closes a tool lacking a result entry.
- The degraded Channel tool-history supplement is Main-owned, best-effort, bounded by recent public user-turn hints, exact Agent workspace, transcript date, cache limits, and output truncation. Missing or ambiguous evidence leaves public history unchanged, and the exception must be removed when pinned cc-connect exposes durable public tool history.
- Token usage maps only a published, versioned runtime payload with project, session/turn, provider/model, counters, and reconnect/replay or durable-history semantics; absent cc-connect counters produce explicit `missing` turn records and never footer- or transcript-derived estimates.
- The unmerged cc-connect usage-observer proposal in upstream PR #1428 is tracked as design evidence, not treated as a supported API, because it lacks release provenance, project/provider/model attribution, and durable replay semantics.
- Real OAuth, real external API-key, Feishu inbound/reply, native Channel Cron, Doctor, workspace, and packaged evidence paths are recorded in a sanitized report.
- Manual release-workflow validation is evidence-only and cannot publish GitHub or OSS artifacts; tag pushes remain the only publishing path.
- pnpm harness validate --spec harness/specs/tasks/runtime-abstraction-cc-connect.md passes.
- pnpm harness run --spec harness/specs/tasks/runtime-abstraction-cc-connect.md passes or records explicit external-credential/release-platform gaps without claiming replacement readiness.
docs:
required: true
---
The implementation contract is `docs/runtime-abstraction-cc-connect.md`.
Temporary compatibility behavior must be labeled degraded and must not satisfy a
replacement-readiness row. Any direct Codex bridge, ClawX-owned prompt
scheduler, private cc-connect session-store write, or transcript-based real-time
event path is a migration target, not an accepted final implementation.
@@ -0,0 +1,88 @@
---
id: upgrade-openclaw-2026-7-1
title: Upgrade the bundled OpenClaw runtime to 2026.7.1
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Keep ClawX runtime and bundled channel plugins aligned with OpenClaw 2026.7.1 across supported platforms.
touchedAreas:
- .github/workflows/check.yml
- package.json
- pnpm-lock.yaml
- scripts/download-bundled-node.mjs
- scripts/bundle-openclaw.mjs
- electron/services/acp-chat-service.ts
- electron/services/cron-api.ts
- src/lib/cron-session-history.ts
- src/stores/acp-chat-session.ts
- shared/acp-chat/types.ts
- electron/utils/openclaw-upgrade-snapshot.ts
- electron/utils/plugin-install-index.ts
- electron/utils/plugin-install.ts
- electron/gateway/config-sync.ts
- electron/gateway/startup-recovery.ts
- electron/gateway/startup-orchestrator.ts
- electron/gateway/manager.ts
- electron/gateway/process-policy.ts
- tests/unit/acp-chat-service.test.ts
- tests/unit/acp-chat-store.test.ts
- tests/unit/cron-schedule.test.ts
- tests/e2e/cron-run-live-status.spec.ts
- tests/unit/gateway-startup-recovery.test.ts
- tests/unit/gateway-startup-orchestrator.test.ts
- tests/unit/openclaw-cli.test.ts
- tests/unit/openclaw-bundle-config.test.ts
- tests/unit/openclaw-upgrade-snapshot.test.ts
- tests/unit/plugin-install-index.test.ts
- tests/unit/plugin-install.test.ts
- tests/unit/gateway-process-policy.test.ts
- README.md
- README.zh-CN.md
- README.ja-JP.md
- README.ru-RU.md
- harness/specs/scenarios/gateway-backend-communication.md
- harness/specs/rules/acp-chat-state-and-history.md
- harness/specs/tasks/upgrade-openclaw-2026-7-1.md
expectedUserBehavior:
- Existing OpenClaw 2026.6.10 configuration, authentication, sessions, and channel credentials remain usable after upgrade, with a one-time pre-migration snapshot of migration-critical config/auth/SQLite state that is removed after Gateway startup succeeds.
- ClawX reconciles old managed channel-plugin install records with its current mirrored extensions, removes records for unconfigured mirrors, and links declared `openclaw` peers to the bundled runtime before OpenClaw's post-core payload smoke check.
- ClawX starts and communicates with the bundled OpenClaw 2026.7.1 Gateway, including migration and control-plane safe-mode startup states.
- ClawX registers the compatibility-patched WeCom mirror as a local-path install with static channel metadata so OpenClaw startup migration does not replace it with the raw mismatched npm package.
- Fatal runtime/SQLite incompatibility, EX_CONFIG exits, invalid migrations, and active migration leases do not enter unbounded Gateway restart loops.
- ACP chat initializes, replays, prompts, cancels, requests permission, and forwards unknown ACP 1.1 session updates without dropping the NDJSON connection.
- Cron sessions use OpenClaw 2026.7.1's SQLite-backed `cron.runs` history when ACP replay is empty, so immediate and scheduled executions show their prompt and completed summaries instead of an empty timeline.
- Bundled channel plugins use versions compatible with OpenClaw 2026.7.1 while existing WeCom and Open Lark manifest-ID compatibility behavior remains unchanged.
- Packaged builds use Electron and Windows Node runtimes that satisfy OpenClaw 2026.7.1 Node and SQLite requirements.
requiredProfiles:
- fast
- comms
requiredTests:
- tests/unit/acp-chat-service.test.ts
- tests/unit/acp-chat-store.test.ts
- tests/unit/cron-schedule.test.ts
- tests/e2e/cron-run-live-status.spec.ts
- tests/unit/gateway-startup-recovery.test.ts
- tests/unit/gateway-startup-orchestrator.test.ts
- tests/unit/openclaw-cli.test.ts
- tests/unit/openclaw-bundle-config.test.ts
- tests/unit/openclaw-upgrade-snapshot.test.ts
- tests/unit/plugin-install-index.test.ts
- tests/unit/plugin-install.test.ts
- tests/unit/channel-config.test.ts
acceptance:
- OpenClaw, ACP SDK, Electron, Windows Node, and official OpenClaw channel plugins are pinned to compatible runtime versions.
- DingTalk is pinned to 3.6.6, WeCom to 2026.7.2, and Open Lark to 2026.7.9 without changing ClawX's effective manifest-ID mappings.
- The lockfile resolves OpenClaw and all bundled channel plugins without incompatible peers or stale 2026.6.10 plugin packages.
- Electron embeds Node 24.15.0 or newer within the Node 24 line and a WAL-reset-safe SQLite runtime.
- The bundled Windows Node version satisfies OpenClaw 2026.7.1's declared engine range.
- ClawX snapshots OpenClaw config and SQLite databases with WAL/SHM sidecars plus per-agent auth files once before the first 2026.7.1 prelaunch sync, excludes channel credentials under `credentials/`, and removes the snapshot after Gateway startup succeeds.
- ClawX writes plugin install metadata to OpenClaw 2026.7.1's canonical `state/openclaw.sqlite` index, removes legacy config records, and represents the patched WeCom and official Feishu mirrors as local paths rather than stale npm-managed installs.
- Configured mirrored plugins that declare an `openclaw` peer have a runtime link to the current bundled OpenClaw package before migration validation; stale install records for unconfigured mirrors are removed so missing directories cannot block startup.
- Gateway recovery performs at most one doctor repair per startup flow and does not retry fatal runtime, EX_CONFIG, invalid migration, or active migration-lease failures indefinitely.
- Electron Main reads current cron history through Gateway `cron.runs`, retains legacy JSONL as a compatibility fallback, and supplements only empty cron ACP replay in memory without replacing non-empty replay.
- ACP 1.1 type checks, targeted runtime tests, communication regression checks, and harness validation pass.
docs:
required: true
---
Use this task spec for the coordinated runtime, official plugin, lockfile, and
Windows Node baseline upgrade required by OpenClaw 2026.7.1.
+59 -16
View File
@@ -40,10 +40,38 @@
"predev": "node scripts/generate-ext-bridge.mjs && zx scripts/prepare-preinstalled-skills-dev.mjs",
"dev": "vite",
"ext:bridge": "node scripts/generate-ext-bridge.mjs",
"build": "node scripts/generate-ext-bridge.mjs && pnpm run build:vite && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && node scripts/run-electron-builder.mjs",
"build": "node scripts/generate-ext-bridge.mjs && pnpm run build:vite && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current && pnpm run verify:runtime-bundles && node scripts/run-electron-builder.mjs",
"build:vite": "node --max-old-space-size=6144 ./node_modules/vite/bin/vite.js build",
"bundle:openclaw-plugins": "zx scripts/bundle-openclaw-plugins.mjs",
"bundle:preinstalled-skills": "zx scripts/bundle-preinstalled-skills.mjs",
"bundle:cc-connect:current": "zx scripts/bundle-cc-connect.mjs",
"bundle:cc-connect:mac": "zx scripts/bundle-cc-connect.mjs --platform=mac",
"bundle:cc-connect:win": "zx scripts/bundle-cc-connect.mjs --platform=win",
"bundle:cc-connect:linux": "zx scripts/bundle-cc-connect.mjs --platform=linux",
"bundle:cc-connect:all": "zx scripts/bundle-cc-connect.mjs --all",
"bundle:codex:current": "zx scripts/bundle-codex.mjs",
"bundle:codex:mac": "zx scripts/bundle-codex.mjs --platform=mac",
"bundle:codex:win": "zx scripts/bundle-codex.mjs --platform=win",
"bundle:codex:linux": "zx scripts/bundle-codex.mjs --platform=linux",
"bundle:codex:all": "zx scripts/bundle-codex.mjs --all",
"verify:runtime-bundles": "node scripts/verify-runtime-bundles.mjs",
"verify:packaged-runtime-resources": "node scripts/verify-packaged-runtime-resources.mjs",
"verify:cc-connect:local-real": "node scripts/verify-cc-connect-local-real.mjs",
"verify:cc-connect:local-real:run": "node scripts/verify-cc-connect-local-real.mjs --run",
"verify:cc-connect:local-real:oauth": "node scripts/verify-cc-connect-local-real.mjs --run --include-oauth",
"verify:cc-connect:local-real:oauth-all": "node scripts/verify-cc-connect-local-real.mjs --run --include-oauth --include-packaged-oauth",
"verify:cc-connect:local-real:api-key": "node scripts/verify-cc-connect-local-real.mjs --run --include-openai-api-key",
"verify:cc-connect:local-real:feishu": "node scripts/verify-cc-connect-local-real.mjs --run --include-feishu",
"verify:cc-connect:local-real:feishu-inbound": "node scripts/verify-cc-connect-local-real.mjs --run --include-feishu-inbound",
"verify:cc-connect:local-real:scheduled-cron": "node scripts/verify-cc-connect-local-real.mjs --run --include-scheduled-cron",
"verify:cc-connect:local-real:all": "node scripts/verify-cc-connect-local-real.mjs --run --include-oauth --include-packaged-oauth --include-openai-api-key --include-feishu --include-feishu-inbound --include-scheduled-cron --write-handoff",
"verify:cc-connect:local-real:all-strict": "node scripts/verify-cc-connect-local-real.mjs --run --include-oauth --include-packaged-oauth --include-openai-api-key --include-feishu --include-feishu-inbound --include-scheduled-cron --strict-real --require-replacement-ready --write-handoff",
"verify:cc-connect:local-real:replacement-ready": "node scripts/verify-cc-connect-local-real.mjs --run --include-oauth --include-packaged-oauth --include-openai-api-key --include-feishu --include-feishu-inbound --include-scheduled-cron --require-replacement-ready --write-handoff",
"verify:cc-connect:local-real:replacement-ready:check": "node scripts/verify-cc-connect-local-real.mjs --run --include-oauth --include-packaged-oauth --include-openai-api-key --include-feishu --include-feishu-inbound --include-scheduled-cron --require-replacement-ready --no-write",
"verify:cc-connect:local-real:packaged-oauth": "node scripts/verify-cc-connect-local-real.mjs --run --include-packaged-oauth",
"verify:cc-connect:local-real:external-gates:check": "node scripts/verify-cc-connect-local-real.mjs --run --external-gates-only --include-openai-api-key --include-feishu --include-feishu-inbound --require-coverage=openai-api-key-provider-model-chat,feishu-live-channel-lifecycle,feishu-live-inbound-delivery --no-write",
"verify:cc-connect:local-real:external-gates": "node scripts/verify-cc-connect-local-real.mjs --run --external-gates-only --include-openai-api-key --include-feishu --include-feishu-inbound --require-coverage=openai-api-key-provider-model-chat,feishu-live-channel-lifecycle,feishu-live-inbound-delivery --write-handoff",
"verify:cc-connect:local-real:handoff": "node scripts/cc-connect-real-gate-handoff.mjs",
"lint": "eslint . --fix",
"lint:check": "eslint .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
@@ -51,6 +79,17 @@
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"test": "vitest run",
"test:e2e": "pnpm run build:vite && playwright test",
"test:e2e:cc-connect": "pnpm run test:e2e -- tests/e2e/cc-connect-codex-runtime.spec.ts tests/e2e/cc-connect-real-bundle-smoke.spec.ts",
"test:e2e:cc-connect:codex-oauth-lifecycle": "pnpm run test:e2e -- tests/e2e/cc-connect-codex-oauth-lifecycle.spec.ts",
"test:e2e:cc-connect:real-oauth": "pnpm run test:e2e -- tests/e2e/cc-connect-real-oauth-chat.spec.ts",
"test:e2e:cc-connect:real-comprehensive": "pnpm run test:e2e -- tests/e2e/cc-connect-real-comprehensive.spec.ts",
"test:e2e:cc-connect:real-openai-api-key": "pnpm run test:e2e -- tests/e2e/cc-connect-real-openai-api-key.spec.ts",
"test:e2e:cc-connect:real-feishu": "pnpm run test:e2e -- tests/e2e/cc-connect-real-feishu-channel.spec.ts",
"test:e2e:cc-connect:real-feishu-inbound": "pnpm run build:vite && playwright test tests/e2e/cc-connect-real-feishu-channel.spec.ts -g \"real inbound Feishu/Lark tenant message\"",
"test:e2e:cc-connect:real-scheduled-cron": "pnpm run test:e2e -- tests/e2e/cc-connect-real-scheduled-cron.spec.ts",
"test:e2e:cc-connect:real-scheduled-prompt-cron": "pnpm run build:vite && playwright test tests/e2e/cc-connect-real-scheduled-cron.spec.ts -g \"scheduled prompt cron through the cc-connect runtime\"",
"test:e2e:cc-connect:real-scheduled-prompt-cron-probe": "pnpm run test:e2e:cc-connect:real-scheduled-prompt-cron",
"smoke:cc-connect:packaged": "node scripts/smoke-packaged-cc-connect.mjs",
"test:e2e:headed": "pnpm run build:vite && playwright test --headed",
"harness": "pnpm --filter @clawx/harness start --",
"harness:ci": "pnpm harness list && pnpm harness validate --spec harness/specs/scenarios/gateway-backend-communication.md && pnpm harness validate --spec harness/specs/tasks/fix-chat-history-gateway-timeout.example.md --no-diff && pnpm harness run --spec harness/specs/scenarios/gateway-backend-communication.md --dry-run && pnpm exec vitest run tests/unit/harness-specs.test.ts tests/unit/harness-git.test.ts",
@@ -70,12 +109,13 @@
"node:download:win": "zx scripts/download-bundled-node.mjs --platform=win",
"prep:win-binaries": "pnpm run uv:download:win && pnpm run agent-browser:download:win && pnpm run node:download:win",
"icons": "zx scripts/generate-icons.mjs",
"package": "node scripts/generate-ext-bridge.mjs && pnpm run build:vite && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs",
"package:mac": "pnpm run package && node scripts/run-electron-builder.mjs --mac --publish never",
"package:mac:local": "SKIP_PREINSTALLED_SKILLS=1 pnpm run package && node scripts/run-electron-builder.mjs --mac --publish never",
"package:win": "pnpm run prep:win-binaries && pnpm run package && node scripts/patch-nsis-win.mjs && node scripts/run-electron-builder.mjs --win --publish never",
"package:linux": "pnpm run package && node scripts/run-electron-builder.mjs --linux --publish never",
"release": "pnpm run uv:download && pnpm run agent-browser:download && pnpm run package && node scripts/run-electron-builder.mjs --publish always",
"package": "node scripts/generate-ext-bridge.mjs && pnpm run build:vite && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current && pnpm run verify:runtime-bundles",
"package:mac": "pnpm run package && pnpm run bundle:cc-connect:mac && pnpm run bundle:codex:mac && pnpm run verify:runtime-bundles -- --platform=mac && node scripts/run-electron-builder.mjs --mac --publish never",
"package:mac:dir": "SKIP_PREINSTALLED_SKILLS=1 pnpm run package && pnpm run bundle:cc-connect:mac && pnpm run bundle:codex:mac && pnpm run verify:runtime-bundles -- --platform=mac && node scripts/run-electron-builder.mjs --mac dir --publish never",
"package:mac:local": "SKIP_PREINSTALLED_SKILLS=1 pnpm run package && pnpm run bundle:cc-connect:mac && pnpm run bundle:codex:mac && pnpm run verify:runtime-bundles -- --platform=mac && node scripts/run-electron-builder.mjs --mac --publish never",
"package:win": "pnpm run prep:win-binaries && pnpm run package && pnpm run bundle:cc-connect:win && pnpm run bundle:codex:win && pnpm run verify:runtime-bundles -- --platform=win && node scripts/patch-nsis-win.mjs && node scripts/run-electron-builder.mjs --win --publish never",
"package:linux": "pnpm run package && pnpm run bundle:cc-connect:linux && pnpm run bundle:codex:linux && pnpm run verify:runtime-bundles -- --platform=linux && node scripts/run-electron-builder.mjs --linux --publish never",
"release": "pnpm run uv:download && pnpm run agent-browser:download && pnpm run package && pnpm run verify:runtime-bundles && node scripts/run-electron-builder.mjs --publish always",
"preversion": "node scripts/pre-version-fetch-tags.mjs",
"version": "node scripts/assert-release-version.mjs",
"version:patch": "pnpm version patch",
@@ -88,9 +128,10 @@
"postversion": "node scripts/post-version-push.mjs"
},
"dependencies": {
"@agentclientprotocol/sdk": "~0.17.0",
"@agentclientprotocol/sdk": "1.1.0",
"electron-store": "^11.0.2",
"electron-updater": "^6.8.3",
"croner": "^10.0.1",
"node-machine-id": "^1.1.12",
"posthog-node": "^5.28.0",
"tar": "^6.2.1",
@@ -106,12 +147,13 @@
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.7",
"@larksuite/openclaw-lark": "2026.6.10",
"@larksuite/openclaw-lark": "2026.7.9",
"@larksuiteoapi/node-sdk": "^1.61.1",
"@monaco-editor/react": "^4.7.0",
"@openclaw/discord": "2026.6.10",
"@openclaw/qqbot": "2026.6.10",
"@openclaw/whatsapp": "2026.6.10",
"@openai/codex": "0.137.0",
"@openclaw/discord": "2026.7.1",
"@openclaw/qqbot": "2026.7.1",
"@openclaw/whatsapp": "2026.7.1",
"@playwright/test": "^1.56.1",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -126,7 +168,7 @@
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@sinclair/typebox": "^0.34.48",
"@soimy/dingtalk": "^3.6.3",
"@soimy/dingtalk": "3.6.6",
"@tencent-connect/qqbot-connector": "^1.1.0",
"@tencent-weixin/openclaw-weixin": "^2.4.6",
"@testing-library/jest-dom": "^6.9.1",
@@ -139,10 +181,11 @@
"@typescript-eslint/eslint-plugin": "^8.56.0",
"@typescript-eslint/parser": "^8.56.0",
"@vitejs/plugin-react": "^5.1.4",
"@wecom/wecom-openclaw-plugin": "^2026.6.23",
"@wecom/wecom-openclaw-plugin": "2026.7.2",
"@whiskeysockets/baileys": "7.0.0-rc.9",
"acpx": "0.5.3",
"autoprefixer": "^10.4.24",
"cc-connect": "1.4.1",
"chart.js": "^4.5.1",
"chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1",
@@ -150,7 +193,7 @@
"diff": "^9.0.0",
"discord-api-types": "^0.38.47",
"docx-preview": "^0.4.0",
"electron": "^40.6.0",
"electron": "40.10.6",
"electron-builder": "^26.8.1",
"eslint": "^10.0.0",
"eslint-plugin-react-hooks": "^7.0.1",
@@ -169,7 +212,7 @@
"monaco-editor": "^0.55.1",
"mpg123-decoder": "^1.0.3",
"ms": "^2.1.3",
"openclaw": "2026.6.10",
"openclaw": "2026.7.1",
"opusscript": "^0.1.1",
"pdfjs-dist": "^5.7.284",
"playwright-core": "1.59.1",
+294 -212
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -655,6 +655,13 @@ exports.default = async function afterPack(context) {
resourcesDir = join(appOutDir, 'resources');
}
const { verifyPackagedRuntimeResources } = await import('./verify-packaged-runtime-resources.mjs');
const runtimeResourceProblems = verifyPackagedRuntimeResources({ resources: resourcesDir, platform, arch });
if (runtimeResourceProblems.length > 0) {
throw new Error(`Packaged runtime resource verification failed:\n- ${runtimeResourceProblems.join('\n- ')}`);
}
console.log(`[after-pack] ✅ cc-connect and Codex resources verified for ${platform}/${arch}.`);
const openclawRoot = join(resourcesDir, 'openclaw');
const dest = join(openclawRoot, 'node_modules');
const nodeModulesRoot = join(__dirname, '..', 'node_modules');
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env zx
import 'zx/globals';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
import {
CC_CONNECT_VERSION_FALLBACK,
buildArchiveExtractionCommand,
buildCcConnectAssetName,
buildVersionCommand,
getCcConnectDownloadUrls,
normalizeCcConnectTarget,
parseCcConnectBundleArgs,
} from './cc-connect-bundle-lib.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const OUTPUT_ROOT = path.join(ROOT, 'build', 'cc-connect');
const DOWNLOAD_TIMEOUT_MS = Number.parseInt(process.env.CLAWX_CC_CONNECT_DOWNLOAD_TIMEOUT_MS || '30000', 10);
const execFileAsync = promisify(execFile);
function readCcConnectVersion() {
const pkgPath = path.join(ROOT, 'node_modules', 'cc-connect', 'package.json');
if (!fs.existsSync(pkgPath)) return CC_CONNECT_VERSION_FALLBACK;
return JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version || CC_CONNECT_VERSION_FALLBACK;
}
function nodeTargetDir(nodePlatform, nodeArch) {
return path.join(OUTPUT_ROOT, `${nodePlatform}-${nodeArch}`);
}
async function download(urls) {
for (const url of urls) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), Number.isFinite(DOWNLOAD_TIMEOUT_MS) ? DOWNLOAD_TIMEOUT_MS : 30000);
try {
echo` Downloading ${url}`;
return { url, data: await fetch(url, { signal: controller.signal }).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
}).then((buffer) => Buffer.from(buffer)) };
} catch (error) {
const message = error?.name === 'AbortError'
? `timed out after ${DOWNLOAD_TIMEOUT_MS}ms`
: error.message;
echo` WARN ${url} failed: ${message}`;
} finally {
clearTimeout(timeout);
}
}
throw new Error(`Could not download cc-connect from ${urls.join(', ')}`);
}
async function extractArchive(archivePath, outputDir, isWindows) {
const { command, args } = buildArchiveExtractionCommand(archivePath, outputDir, isWindows);
await execFileAsync(command, args);
}
function canExecuteTargetOnHost(nodePlatform, nodeArch) {
return nodePlatform === process.platform && nodeArch === process.arch;
}
async function bundleTarget(version, nodePlatform, nodeArch) {
const target = normalizeCcConnectTarget(nodePlatform, nodeArch);
const assetName = buildCcConnectAssetName(version, target);
const urls = getCcConnectDownloadUrls(version, assetName);
const outputDir = nodeTargetDir(nodePlatform, nodeArch);
fs.rmSync(outputDir, { recursive: true, force: true });
fs.mkdirSync(outputDir, { recursive: true });
const { url, data } = await download(urls);
const archivePath = path.join(outputDir, assetName);
fs.writeFileSync(archivePath, data);
await extractArchive(archivePath, outputDir, target.platform === 'windows');
fs.rmSync(archivePath, { force: true });
const binaryName = target.platform === 'windows' ? 'cc-connect.exe' : 'cc-connect';
const extracted = fs.readdirSync(outputDir).find((name) => name.startsWith('cc-connect') && name !== binaryName);
if (extracted) {
fs.renameSync(path.join(outputDir, extracted), path.join(outputDir, binaryName));
}
const binaryPath = path.join(outputDir, binaryName);
if (!fs.existsSync(binaryPath)) {
throw new Error(`cc-connect binary missing after extraction: ${binaryPath}`);
}
if (target.platform !== 'windows') {
fs.chmodSync(binaryPath, 0o755);
}
let verifiedWithVersionCommand = false;
if (canExecuteTargetOnHost(nodePlatform, nodeArch)) {
const { command, args } = buildVersionCommand(binaryPath);
const { stdout, stderr } = await execFileAsync(command, args);
const versionOutput = `${stdout}${stderr}`;
if (!versionOutput.includes(version)) {
throw new Error(`cc-connect version mismatch: expected ${version}, got ${versionOutput.trim()}`);
}
verifiedWithVersionCommand = true;
} else {
echo` Skipping --version for cross target ${nodePlatform}-${nodeArch}`;
}
const sha256 = crypto.createHash('sha256').update(fs.readFileSync(binaryPath)).digest('hex');
fs.writeFileSync(path.join(outputDir, 'manifest.json'), JSON.stringify({
name: 'cc-connect',
version,
nodePlatform,
nodeArch,
platform: target.platform,
arch: target.arch,
sourceUrl: url,
assetName,
binaryName,
sha256,
verifiedWithVersionCommand,
}, null, 2));
echo` OK cc-connect ${version} bundled for ${nodePlatform}-${nodeArch}`;
}
const version = readCcConnectVersion();
const { targets } = parseCcConnectBundleArgs();
echo`Bundling cc-connect v${version}...`;
for (const { nodePlatform, nodeArch } of targets) {
await bundleTarget(version, nodePlatform, nodeArch);
}
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env zx
import 'zx/globals';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
import {
CODEX_VERSION_FALLBACK,
buildCodexArchiveExtractionCommand,
buildCodexVersionCommand,
getCodexNativeTarballUrl,
normalizeCodexTarget,
parseCodexBundleArgs,
} from './codex-bundle-lib.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const OUTPUT_ROOT = path.join(ROOT, 'build', 'codex');
const execFileAsync = promisify(execFile);
function readCodexVersion() {
const pkgPath = path.join(ROOT, 'node_modules', '.pnpm', '@openai+codex@0.137.0', 'node_modules', '@openai', 'codex', 'package.json');
if (fs.existsSync(pkgPath)) {
return JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version || CODEX_VERSION_FALLBACK;
}
const appPackage = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
return appPackage.devDependencies?.['@openai/codex']?.replace(/^[^\d]*/, '') || CODEX_VERSION_FALLBACK;
}
function outputDirFor(nodePlatform, nodeArch) {
return path.join(OUTPUT_ROOT, `${nodePlatform}-${nodeArch}`);
}
function installedNativePackageRoot(version, packageSuffix) {
const root = path.join(
ROOT,
'node_modules',
'.pnpm',
`@openai+codex@${version}-${packageSuffix}`,
'node_modules',
'@openai',
'codex',
);
return fs.existsSync(root) ? root : null;
}
async function extractNativePackage(version, packageSuffix, tempDir) {
const installed = installedNativePackageRoot(version, packageSuffix);
if (installed) return installed;
fs.rmSync(tempDir, { recursive: true, force: true });
fs.mkdirSync(tempDir, { recursive: true });
const url = getCodexNativeTarballUrl(version, packageSuffix);
const archivePath = path.join(tempDir, `codex-${version}-${packageSuffix}.tgz`);
echo` Downloading ${url}`;
const data = await fetch(url).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
}).then((buffer) => Buffer.from(buffer));
fs.writeFileSync(archivePath, data);
const { command, args } = buildCodexArchiveExtractionCommand(archivePath, tempDir);
await execFileAsync(command, args);
return path.join(tempDir, 'package');
}
function canExecuteTargetOnHost(nodePlatform, nodeArch) {
return nodePlatform === process.platform && nodeArch === process.arch;
}
async function bundleTarget(version, nodePlatform, nodeArch) {
const target = normalizeCodexTarget(nodePlatform, nodeArch);
const outputDir = outputDirFor(nodePlatform, nodeArch);
const tempDir = path.join(ROOT, 'temp_codex_extract', `${nodePlatform}-${nodeArch}`);
fs.rmSync(outputDir, { recursive: true, force: true });
fs.mkdirSync(path.join(outputDir, 'bin'), { recursive: true });
const packageRoot = await extractNativePackage(version, target.packageSuffix, tempDir);
const vendorRoot = path.join(packageRoot, 'vendor', target.targetTriple);
const sourceBinary = path.join(vendorRoot, 'bin', target.binaryName);
const sourcePathDir = path.join(vendorRoot, 'codex-path');
if (!fs.existsSync(sourceBinary)) {
throw new Error(`Codex native binary missing: ${sourceBinary}`);
}
fs.copyFileSync(sourceBinary, path.join(outputDir, 'bin', target.binaryName));
if (fs.existsSync(sourcePathDir)) {
fs.cpSync(sourcePathDir, path.join(outputDir, 'codex-path'), { recursive: true });
}
if (target.nodePlatform !== 'win32') {
fs.chmodSync(path.join(outputDir, 'bin', target.binaryName), 0o755);
const rgPath = path.join(outputDir, 'codex-path', 'rg');
if (fs.existsSync(rgPath)) fs.chmodSync(rgPath, 0o755);
}
let verifiedWithVersionCommand = false;
if (canExecuteTargetOnHost(nodePlatform, nodeArch)) {
const binaryPath = path.join(outputDir, 'bin', target.binaryName);
const { command, args } = buildCodexVersionCommand(binaryPath);
const { stdout, stderr } = await execFileAsync(command, args);
const versionOutput = `${stdout}${stderr}`;
if (!versionOutput.includes(version)) {
throw new Error(`Codex version mismatch: expected ${version}, got ${versionOutput.trim()}`);
}
verifiedWithVersionCommand = true;
} else {
echo` Skipping --version for cross target ${nodePlatform}-${nodeArch}`;
}
const binaryPath = path.join(outputDir, 'bin', target.binaryName);
const sha256 = crypto.createHash('sha256').update(fs.readFileSync(binaryPath)).digest('hex');
fs.writeFileSync(path.join(outputDir, 'manifest.json'), JSON.stringify({
name: 'codex',
version,
nodePlatform,
nodeArch,
packageSuffix: target.packageSuffix,
targetTriple: target.targetTriple,
binaryName: target.binaryName,
sha256,
verifiedWithVersionCommand,
}, null, 2));
fs.rmSync(tempDir, { recursive: true, force: true });
echo` OK Codex ${version} bundled for ${nodePlatform}-${nodeArch}`;
}
const version = readCodexVersion();
const { targets } = parseCodexBundleArgs();
echo`Bundling Codex v${version}...`;
for (const { nodePlatform, nodeArch } of targets) {
await bundleTarget(version, nodePlatform, nodeArch);
}
+11 -60
View File
@@ -841,30 +841,6 @@ function patchBrokenModules(nodeModulesDir) {
}
}
function findFirstFileByName(rootDir, matcher) {
const stack = [rootDir];
while (stack.length > 0) {
const current = stack.pop();
let entries = [];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
continue;
}
if (entry.isFile() && matcher.test(entry.name)) {
return fullPath;
}
}
}
return null;
}
function findFilesByName(rootDir, matcher) {
const matches = [];
const stack = [rootDir];
@@ -891,36 +867,11 @@ function findFilesByName(rootDir, matcher) {
}
function patchBundledRuntime(outputDir) {
const replacePatches = [
{
label: 'workspace command runner',
target: () => findFirstFileByName(path.join(outputDir, 'dist'), /^workspace-.*\.js$/),
search: `\tconst child = spawn(resolvedCommand, finalArgv.slice(1), {
\t\tstdio,
\t\tcwd,
\t\tenv: resolvedEnv,
\t\twindowsVerbatimArguments,
\t\t...shouldSpawnWithShell({
\t\t\tresolvedCommand,
\t\t\tplatform: process$1.platform
\t\t}) ? { shell: true } : {}
\t});`,
replace: `\tconst child = spawn(resolvedCommand, finalArgv.slice(1), {
\t\tstdio,
\t\tcwd,
\t\tenv: resolvedEnv,
\t\twindowsVerbatimArguments,
\t\twindowsHide: true,
\t\t...shouldSpawnWithShell({
\t\t\tresolvedCommand,
\t\t\tplatform: process$1.platform
\t\t}) ? { shell: true } : {}
\t});`,
},
// Note: OpenClaw 3.31 removed the hash-suffixed agent-scope-*.js, chrome-*.js,
// and qmd-manager-*.js files from dist/plugin-sdk/. Patches for those spawn
// sites are no longer needed — the runtime now uses windowsHide natively.
];
// OpenClaw 2026.7.1 routes ordinary child-process execution through
// resolveChildProcessInvocation(), which already sets windowsHide=true.
// PTY execution remains patched below because node-pty follows a separate
// launch path and is disabled on Windows in ClawX packaged builds.
const replacePatches = [];
let count = 0;
for (const patch of replacePatches) {
@@ -949,21 +900,21 @@ function patchBundledRuntime(outputDir) {
const ptyTargets = findFilesByName(
path.join(outputDir, 'dist'),
/^(subagent-registry|reply|pi-embedded)-.*\.js$/,
/^(supervisor|bash-tools)-.*\.js$/,
);
const ptyPatches = [
{
label: 'pty launcher windowsHide',
search: `\tconst pty = spawn(params.shell, params.args, {
search: `\tconst pty = spawn(preparedSpawn.command, preparedSpawn.args, {
\t\tcwd: params.cwd,
\t\tenv: params.env ? toStringEnv(params.env) : void 0,
\t\tenv: preparedSpawn.env ? toStringEnv(preparedSpawn.env) : void 0,
\t\tname: params.name ?? process.env.TERM ?? "xterm-256color",
\t\tcols: params.cols ?? 120,
\t\trows: params.rows ?? 30
\t});`,
replace: `\tconst pty = spawn(params.shell, params.args, {
replace: `\tconst pty = spawn(preparedSpawn.command, preparedSpawn.args, {
\t\tcwd: params.cwd,
\t\tenv: params.env ? toStringEnv(params.env) : void 0,
\t\tenv: preparedSpawn.env ? toStringEnv(preparedSpawn.env) : void 0,
\t\tname: params.name ?? process.env.TERM ?? "xterm-256color",
\t\tcols: params.cols ?? 120,
\t\trows: params.rows ?? 30,
@@ -996,7 +947,7 @@ function patchBundledRuntime(outputDir) {
}
}
if (!matchedAny) {
echo` ⚠️ Skipped patch for ${patch.label}: expected source snippet not found`;
throw new Error(`Required OpenClaw 2026.7.1 patch not found: ${patch.label}`);
}
}
+79
View File
@@ -0,0 +1,79 @@
import os from 'node:os';
export const CC_CONNECT_VERSION_FALLBACK = '1.4.1';
const PLATFORM_MAP = {
darwin: 'darwin',
linux: 'linux',
win32: 'windows',
};
const ARCH_MAP = {
x64: 'amd64',
arm64: 'arm64',
};
const PRESETS = {
current: [{ nodePlatform: process.platform, nodeArch: process.arch }],
mac: [
{ nodePlatform: 'darwin', nodeArch: 'x64' },
{ nodePlatform: 'darwin', nodeArch: 'arm64' },
],
win: [{ nodePlatform: 'win32', nodeArch: 'x64' }],
linux: [
{ nodePlatform: 'linux', nodeArch: 'x64' },
{ nodePlatform: 'linux', nodeArch: 'arm64' },
],
all: [
{ nodePlatform: 'darwin', nodeArch: 'x64' },
{ nodePlatform: 'darwin', nodeArch: 'arm64' },
{ nodePlatform: 'linux', nodeArch: 'x64' },
{ nodePlatform: 'linux', nodeArch: 'arm64' },
{ nodePlatform: 'win32', nodeArch: 'x64' },
],
};
export function normalizeCcConnectTarget(nodePlatform = os.platform(), nodeArch = os.arch()) {
const platform = PLATFORM_MAP[nodePlatform];
const arch = ARCH_MAP[nodeArch];
if (!platform || !arch) {
throw new Error(`Unsupported cc-connect target: ${nodePlatform}-${nodeArch}`);
}
return { platform, arch };
}
export function buildCcConnectAssetName(version, target) {
const ext = target.platform === 'windows' ? '.zip' : '.tar.gz';
return `cc-connect-v${version}-${target.platform}-${target.arch}${ext}`;
}
export function buildArchiveExtractionCommand(archivePath, outputDir, isWindows) {
return {
command: 'tar',
args: [isWindows ? '-xf' : '-xzf', archivePath, '-C', outputDir],
};
}
export function buildVersionCommand(binaryPath) {
return { command: binaryPath, args: ['--version'] };
}
export function parseCcConnectBundleArgs(argv = process.argv.slice(2)) {
let preset = 'current';
for (const arg of argv) {
if (arg === '--all') preset = 'all';
else if (arg.startsWith('--platform=')) preset = arg.slice('--platform='.length);
}
const targets = PRESETS[preset];
if (!targets) {
throw new Error(`Unsupported cc-connect bundle preset: ${preset}`);
}
return { preset, targets };
}
export function getCcConnectDownloadUrls(version, assetName) {
return [
`https://github.com/chenhg5/cc-connect/releases/download/v${version}/${assetName}`,
`https://gitee.com/cg33/cc-connect/releases/download/v${version}/${assetName}`,
];
}
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env node
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const root = resolve(new URL('..', import.meta.url).pathname);
const defaultReportPath = join(root, 'artifacts', 'cc-connect', 'local-real-validation-report.json');
const defaultOutputPath = join(root, 'artifacts', 'cc-connect', 'local-real-external-gates.md');
const defaultJsonOutputPath = join(root, 'artifacts', 'cc-connect', 'local-real-external-gates.json');
function deriveJsonOutputPath(outputPath) {
return outputPath.endsWith('.md')
? `${outputPath.slice(0, -'.md'.length)}.json`
: `${outputPath}.json`;
}
function parseArgs(argv) {
const result = {
reportPath: defaultReportPath,
outputPath: defaultOutputPath,
jsonOutputPath: defaultJsonOutputPath,
};
let jsonOutputExplicit = false;
for (const arg of argv) {
if (arg === '--help' || arg === '-h') result.help = true;
else if (arg.startsWith('--report=')) result.reportPath = resolve(root, arg.slice('--report='.length));
else if (arg.startsWith('--output=')) {
result.outputPath = resolve(root, arg.slice('--output='.length));
if (!jsonOutputExplicit) result.jsonOutputPath = deriveJsonOutputPath(result.outputPath);
}
else if (arg.startsWith('--json-output=')) {
result.jsonOutputPath = resolve(root, arg.slice('--json-output='.length));
jsonOutputExplicit = true;
}
else throw new Error(`Unknown argument: ${arg}`);
}
return result;
}
function usage() {
return [
'Usage: node scripts/cc-connect-real-gate-handoff.mjs [--report=<path>] [--output=<path>] [--json-output=<path>]',
'',
'Reads the latest sanitized cc-connect local real-validation report and writes a',
'credential-free handoff checklist plus a machine-readable JSON handoff for',
'the remaining external replacement gates.',
].join('\n');
}
function markdownCell(value) {
return String(value ?? '')
.replaceAll('\\', '\\\\')
.replaceAll('|', '\\|')
.replaceAll('\n', ' ');
}
function missingPreconditionIds(report) {
return new Set((report.missingPreconditions ?? []).map((item) => item.id));
}
function coverageStatus(report, id) {
return (report.coverage ?? []).find((item) => item.id === id)?.status ?? 'not-run';
}
function buildExternalGateHandoff(report) {
const missing = missingPreconditionIds(report);
const codexAuthReady = !missing.has('codex-oauth-auth-json')
&& ['pass', 'partial'].includes(report.checks?.find((check) => check.id === 'codex-oauth-auth-json')?.status ?? 'pass');
return [
{
id: 'openai-api-key-provider-model-chat',
title: 'Real OpenAI API-key provider/model chat',
required: ['CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY'],
optional: ['CLAWX_REAL_OPENAI_MODEL'],
command: 'pnpm run verify:cc-connect:local-real:api-key',
currentStatus: coverageStatus(report, 'openai-api-key-provider-model-chat'),
missingPreconditions: missing.has('openai-api-key-env') ? ['openai-api-key-env'] : [],
handoff: [
'Put the API key in process env or an untracked and gitignored .env.cc-connect.local file.',
'Set CLAWX_REAL_OPENAI_MODEL only when the default model is unavailable for the test account.',
'Do not commit real key material or paste it into report artifacts.',
],
},
{
id: 'feishu-live-channel-lifecycle',
title: 'Real Feishu/Lark channel lifecycle',
required: [
'CLAWX_REAL_CODEX_AUTH_JSON with complete non-expired Codex OAuth tokens',
'CLAWX_REAL_FEISHU_APP_ID',
'CLAWX_REAL_FEISHU_APP_SECRET',
],
optional: ['CLAWX_REAL_FEISHU_DOMAIN', 'CLAWX_REAL_FEISHU_ACCOUNT_ID', 'CLAWX_REAL_FEISHU_ALLOW_FROM'],
command: 'pnpm run verify:cc-connect:local-real:feishu',
currentStatus: coverageStatus(report, 'feishu-live-channel-lifecycle'),
missingPreconditions: [
...(!codexAuthReady ? ['codex-oauth-auth-json'] : []),
...(missing.has('feishu-env') ? ['feishu-env'] : []),
],
handoff: [
'Use a sandbox Feishu/Lark app and bot credentials.',
'The test writes managed runtime config under isolated Electron userData and does not reuse user ~/.cc-connect.',
'The app secret must stay in process env or an untracked and gitignored local env file.',
],
},
{
id: 'feishu-live-inbound-delivery',
title: 'Real Feishu/Lark inbound tenant-message delivery',
required: [
'CLAWX_REAL_CODEX_AUTH_JSON with complete non-expired Codex OAuth tokens',
'CLAWX_REAL_FEISHU_APP_ID',
'CLAWX_REAL_FEISHU_APP_SECRET',
'CLAWX_REAL_FEISHU_INBOUND_E2E=1',
'sandbox tenant chat that can send the verifier marker to the configured bot',
],
optional: ['CLAWX_REAL_FEISHU_INBOUND_MARKER', 'CLAWX_REAL_FEISHU_INBOUND_TIMEOUT_MS'],
command: 'pnpm run verify:cc-connect:local-real:feishu-inbound',
currentStatus: coverageStatus(report, 'feishu-live-inbound-delivery'),
missingPreconditions: [
...(!codexAuthReady ? ['codex-oauth-auth-json'] : []),
...(missing.has('feishu-env') ? ['feishu-env'] : []),
...(missing.has('feishu-inbound-fixture') ? ['feishu-inbound-fixture'] : []),
],
handoff: [
'When the E2E starts, read artifacts/cc-connect/feishu-inbound-marker.json.',
'Send the marker exactly as message text to the configured Feishu/Lark bot before timeout.',
'The marker artifact is intentionally sanitized and must not contain app secrets or OAuth tokens.',
],
},
];
}
function toMarkdown(report, gates = buildExternalGateHandoff(report)) {
const lines = [
'# cc-connect External Gate Handoff',
'',
`- Source report generated at: ${report.generatedAt ?? 'unknown'}`,
`- Source report status: ${(report.status ?? 'unknown').toUpperCase()}`,
`- Runtime matrix status: ${(report.runtimeMatrixStatus ?? 'unknown').toUpperCase()}`,
`- Replacement ready: ${report.replacementReadiness?.replacementReady ? 'yes' : 'no'}`,
'- Non-destructive check: `pnpm run verify:cc-connect:local-real:external-gates:check`',
'- Report-writing rerun: `pnpm run verify:cc-connect:local-real:external-gates`',
'',
'## Required External Gates',
'',
'| Gate | Current Status | Missing Preconditions | Command | Required Inputs | Optional Inputs |',
'|---|---|---|---|---|---|',
...gates.map((gate) => [
`| ${markdownCell(gate.title)}`,
markdownCell(gate.currentStatus),
markdownCell(gate.missingPreconditions.length > 0 ? gate.missingPreconditions.join(', ') : 'none'),
markdownCell(gate.command),
markdownCell(gate.required.join(', ')),
`${markdownCell(gate.optional.join(', '))} |`,
].join(' | ')),
'',
'## Handoff Notes',
'',
];
for (const gate of gates) {
lines.push(`### ${gate.title}`, '');
for (const note of gate.handoff) {
lines.push(`- ${note}`);
}
lines.push('');
}
lines.push(
'## Safety',
'',
'- This file is generated from sanitized report metadata only.',
'- Do not add real API keys, OAuth tokens, app secrets, generated auth files, or tenant-specific private data to this artifact.',
'- Prefer process env, `.env.cc-connect.local`, or an explicit outside-repo env file for real credentials.',
'',
);
return lines.join('\n');
}
function toJsonPayload(report, gates = buildExternalGateHandoff(report)) {
return {
schemaVersion: 1,
sourceReport: {
generatedAt: report.generatedAt ?? null,
status: report.status ?? 'unknown',
runtimeMatrixStatus: report.runtimeMatrixStatus ?? 'unknown',
replacementReady: Boolean(report.replacementReadiness?.replacementReady),
},
commands: {
nonDestructiveCheck: 'pnpm run verify:cc-connect:local-real:external-gates:check',
reportWritingRerun: 'pnpm run verify:cc-connect:local-real:external-gates',
},
requiredExternalGates: gates.map((gate) => ({
id: gate.id,
title: gate.title,
currentStatus: gate.currentStatus,
missingPreconditions: gate.missingPreconditions,
command: gate.command,
requiredInputs: gate.required,
optionalInputs: gate.optional,
handoff: gate.handoff,
})),
safety: {
sanitized: true,
forbidden: [
'real API keys',
'OAuth tokens',
'app secrets',
'generated auth files',
'tenant-specific private data',
],
},
};
}
function toJson(report, gates = buildExternalGateHandoff(report)) {
return `${JSON.stringify(toJsonPayload(report, gates), null, 2)}\n`;
}
async function readReport(path) {
return JSON.parse(await readFile(path, 'utf8'));
}
async function writeHandoff(reportPath, outputPath, jsonOutputPath = deriveJsonOutputPath(outputPath)) {
const report = await readReport(reportPath);
const gates = buildExternalGateHandoff(report);
const markdown = toMarkdown(report, gates);
const json = toJson(report, gates);
await mkdir(dirname(outputPath), { recursive: true });
await mkdir(dirname(jsonOutputPath), { recursive: true });
await writeFile(outputPath, markdown, 'utf8');
await writeFile(jsonOutputPath, json, 'utf8');
return { outputPath, jsonOutputPath, markdown, json };
}
function isCliEntryPoint() {
return process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false;
}
export {
buildExternalGateHandoff,
parseArgs,
toJson,
toJsonPayload,
toMarkdown,
writeHandoff,
};
if (isCliEntryPoint()) {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(usage());
} else {
writeHandoff(args.reportPath, args.outputPath, args.jsonOutputPath)
.then(({ outputPath, jsonOutputPath }) => {
console.log(`Wrote ${outputPath}`);
console.log(`Wrote ${jsonOutputPath}`);
})
.catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
}
}
+113
View File
@@ -0,0 +1,113 @@
import os from 'node:os';
export const CODEX_VERSION_FALLBACK = '0.137.0';
const TARGETS = {
'darwin-x64': {
nodePlatform: 'darwin',
nodeArch: 'x64',
packageSuffix: 'darwin-x64',
targetTriple: 'x86_64-apple-darwin',
binaryName: 'codex',
},
'darwin-arm64': {
nodePlatform: 'darwin',
nodeArch: 'arm64',
packageSuffix: 'darwin-arm64',
targetTriple: 'aarch64-apple-darwin',
binaryName: 'codex',
},
'linux-x64': {
nodePlatform: 'linux',
nodeArch: 'x64',
packageSuffix: 'linux-x64',
targetTriple: 'x86_64-unknown-linux-musl',
binaryName: 'codex',
},
'linux-arm64': {
nodePlatform: 'linux',
nodeArch: 'arm64',
packageSuffix: 'linux-arm64',
targetTriple: 'aarch64-unknown-linux-musl',
binaryName: 'codex',
},
'win32-x64': {
nodePlatform: 'win32',
nodeArch: 'x64',
packageSuffix: 'win32-x64',
targetTriple: 'x86_64-pc-windows-msvc',
binaryName: 'codex.exe',
},
'win32-arm64': {
nodePlatform: 'win32',
nodeArch: 'arm64',
packageSuffix: 'win32-arm64',
targetTriple: 'aarch64-pc-windows-msvc',
binaryName: 'codex.exe',
},
};
const PRESETS = {
current: [{ nodePlatform: process.platform, nodeArch: process.arch }],
mac: [
{ nodePlatform: 'darwin', nodeArch: 'x64' },
{ nodePlatform: 'darwin', nodeArch: 'arm64' },
],
win: [
{ nodePlatform: 'win32', nodeArch: 'x64' },
{ nodePlatform: 'win32', nodeArch: 'arm64' },
],
linux: [
{ nodePlatform: 'linux', nodeArch: 'x64' },
{ nodePlatform: 'linux', nodeArch: 'arm64' },
],
all: [
{ nodePlatform: 'darwin', nodeArch: 'x64' },
{ nodePlatform: 'darwin', nodeArch: 'arm64' },
{ nodePlatform: 'linux', nodeArch: 'x64' },
{ nodePlatform: 'linux', nodeArch: 'arm64' },
{ nodePlatform: 'win32', nodeArch: 'x64' },
{ nodePlatform: 'win32', nodeArch: 'arm64' },
],
};
export function normalizeCodexTarget(nodePlatform = os.platform(), nodeArch = os.arch()) {
const target = TARGETS[`${nodePlatform}-${nodeArch}`];
if (!target) {
throw new Error(`Unsupported Codex target: ${nodePlatform}-${nodeArch}`);
}
return target;
}
export function parseCodexBundleArgs(argv = process.argv.slice(2)) {
let preset = 'current';
for (const arg of argv) {
if (arg === '--all') preset = 'all';
else if (arg.startsWith('--platform=')) preset = arg.slice('--platform='.length);
}
const targets = PRESETS[preset];
if (!targets) {
throw new Error(`Unsupported Codex bundle preset: ${preset}`);
}
return { preset, targets };
}
export function getCodexNativePackageName(packageSuffix, version = CODEX_VERSION_FALLBACK) {
return `@openai/codex@${version}-${packageSuffix}`;
}
export function buildCodexNativeTarballName(version, packageSuffix) {
return `codex-${version}-${packageSuffix}.tgz`;
}
export function getCodexNativeTarballUrl(version, packageSuffix) {
return `https://registry.npmjs.org/@openai/codex/-/${buildCodexNativeTarballName(version, packageSuffix)}`;
}
export function buildCodexArchiveExtractionCommand(archivePath, outputDir) {
return { command: 'tar', args: ['-xzf', archivePath, '-C', outputDir] };
}
export function buildCodexVersionCommand(binaryPath) {
return { command: binaryPath, args: ['--version'] };
}
+1 -1
View File
@@ -3,7 +3,7 @@
import 'zx/globals';
const ROOT_DIR = path.resolve(__dirname, '..');
const NODE_VERSION = '22.19.0';
const NODE_VERSION = '22.22.3';
const BASE_URL = `https://nodejs.org/dist/v${NODE_VERSION}`;
const OUTPUT_BASE = path.join(ROOT_DIR, 'resources', 'bin');
+38
View File
@@ -0,0 +1,38 @@
import path from 'node:path';
export function defaultPackagedAppPath({ rootDir, platform = process.platform, arch = process.arch }) {
if (!rootDir) throw new Error('rootDir is required');
if (platform === 'darwin') {
return path.join(rootDir, 'release', arch === 'arm64' ? 'mac-arm64' : 'mac', 'ClawX.app');
}
if (platform === 'win32') return path.join(rootDir, 'release', 'win-unpacked');
if (platform === 'linux') {
return path.join(rootDir, 'release', arch === 'arm64' ? 'linux-arm64-unpacked' : 'linux-unpacked');
}
throw new Error(`Unsupported packaged smoke platform: ${platform}`);
}
export function packagedExecutablePath(appPath, platform = process.platform) {
if (platform === 'darwin') return path.join(appPath, 'Contents', 'MacOS', 'ClawX');
if (platform === 'win32') return path.join(appPath, 'ClawX.exe');
if (platform === 'linux') return path.join(appPath, 'clawx');
throw new Error(`Unsupported packaged smoke platform: ${platform}`);
}
export function packagedResourcesPath(appPath, platform = process.platform) {
if (platform === 'darwin') return path.join(appPath, 'Contents', 'Resources');
if (platform === 'win32' || platform === 'linux') return path.join(appPath, 'resources');
throw new Error(`Unsupported packaged smoke platform: ${platform}`);
}
export function shouldVerifyPackagedCodeSignature(platform = process.platform, allowUnsigned = false) {
return platform === 'darwin' && !allowUnsigned;
}
export function escapeTomlBasicString(value) {
return value
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r');
}
+682
View File
@@ -0,0 +1,682 @@
#!/usr/bin/env node
import { _electron as electron, expect } from '@playwright/test';
import { constants as fsConstants } from 'node:fs';
import { access, copyFile, mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises';
import { createConnection, createServer } from 'node:net';
import { execFile } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import { tmpdir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
import {
defaultPackagedAppPath,
escapeTomlBasicString,
packagedExecutablePath,
packagedResourcesPath,
shouldVerifyPackagedCodeSignature,
} from './packaged-runtime-layout.mjs';
const execFileAsync = promisify(execFile);
const root = resolve(fileURLToPath(new URL('..', import.meta.url)));
function parseArgs(argv) {
const result = {};
for (const arg of argv) {
if (arg === '--') continue;
const match = arg.match(/^--([^=]+)=(.*)$/);
if (match) {
result[match[1]] = match[2];
} else if (arg.startsWith('--')) {
result[arg.slice(2)] = '1';
}
}
return result;
}
export function packagedSmokeChecks({
platform = process.platform,
allowUnsigned = false,
realOAuthChatCompleted = false,
} = {}) {
return [
'runtime-binary-version',
...(shouldVerifyPackagedCodeSignature(platform, allowUnsigned) ? ['code-signature'] : []),
'packaged-electron-start',
'runtime-start-status',
'workspace-projection',
...(realOAuthChatCompleted ? ['real-oauth-chat-through-managed-launcher'] : []),
'cron-crud',
'doctor',
'openclaw-rollback',
'pid-port-process-cleanup',
];
}
function binaryName(base) {
return process.platform === 'win32' ? `${base}.exe` : base;
}
async function allocatePort() {
return await new Promise((resolvePort, reject) => {
const server = createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Failed to allocate an ephemeral port')));
return;
}
const { port } = address;
server.close((error) => {
if (error) reject(error);
else resolvePort(port);
});
});
});
}
async function isPortOpen(port) {
return await new Promise((resolveOpen) => {
const socket = createConnection({ host: '127.0.0.1', port });
socket.once('connect', () => {
socket.destroy();
resolveOpen(true);
});
socket.once('error', () => resolveOpen(false));
socket.setTimeout(500, () => {
socket.destroy();
resolveOpen(false);
});
});
}
function isPidAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function listProcessCommandsContaining(needle) {
if (process.platform === 'win32') {
const command = [
'$needle = $env:CLAWX_SMOKE_PROCESS_NEEDLE;',
'Get-CimInstance Win32_Process',
'| Where-Object { $_.CommandLine -and $_.CommandLine.Contains($needle) }',
'| ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId) $($_.CommandLine)" }',
].join(' ');
const { stdout } = await execFileAsync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], {
env: { ...process.env, CLAWX_SMOKE_PROCESS_NEEDLE: needle },
maxBuffer: 2 * 1024 * 1024,
});
return stdout.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
}
const { stdout } = await execFileAsync('ps', ['-axo', 'pid=,ppid=,command='], {
maxBuffer: 2 * 1024 * 1024,
});
return stdout
.split('\n')
.map((line) => line.trim())
.filter((line) => line.includes(needle))
.filter((line) => !line.includes('ps -axo'));
}
async function waitForStableWindow(app) {
const deadline = Date.now() + 30_000;
let page = await app.firstWindow();
while (Date.now() < deadline) {
const openWindows = app.windows().filter((candidate) => !candidate.isClosed());
const currentWindow = openWindows.at(-1) ?? page;
if (currentWindow && !currentWindow.isClosed()) {
try {
await currentWindow.waitForLoadState('domcontentloaded', { timeout: 2_000 });
return currentWindow;
} catch (error) {
if (!String(error).includes('has been closed')) throw error;
}
}
try {
page = await app.waitForEvent('window', { timeout: 2_000 });
} catch {
// Keep polling.
}
}
throw new Error('No stable packaged Electron window became available');
}
async function closeElectronApp(app, timeoutMs = 5_000) {
let closed = false;
await Promise.race([
(async () => {
const [closeResult] = await Promise.allSettled([
app.waitForEvent('close', { timeout: timeoutMs }),
app.evaluate(({ app: electronApp }) => {
electronApp.quit();
}),
]);
if (closeResult.status === 'fulfilled') closed = true;
})(),
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
]);
if (closed) return;
try {
await app.close();
return;
} catch {
// Fall through.
}
try {
app.process().kill('SIGKILL');
} catch {
// ignore
}
}
async function seedCcConnectSettings(userDataDir, options = {}) {
const createdAt = '2026-06-07T00:00:00.000Z';
const providerAccounts = options.realOAuth
? {
'openai-oauth': {
id: 'openai-oauth',
vendorId: 'openai',
label: 'OpenAI Codex OAuth',
authMode: 'oauth_browser',
model: process.env.CLAWX_REAL_OPENAI_MODEL?.trim() || 'gpt-5.5',
enabled: true,
isDefault: true,
metadata: { resourceUrl: 'openai-codex' },
createdAt,
updatedAt: createdAt,
},
}
: {
'ollama-local': {
id: 'ollama-local',
vendorId: 'ollama',
label: 'Ollama',
authMode: 'local',
model: 'qwen3:latest',
enabled: true,
isDefault: true,
createdAt,
updatedAt: createdAt,
},
};
await mkdir(userDataDir, { recursive: true });
await writeFile(join(userDataDir, 'settings.json'), JSON.stringify({
language: 'en',
devModeUnlocked: true,
runtimeKind: 'cc-connect',
gatewayAutoStart: false,
}, null, 2), 'utf8');
await writeFile(join(userDataDir, 'clawx-providers.json'), JSON.stringify({
schemaVersion: 0,
providerAccounts,
providerSecrets: {},
apiKeys: {},
defaultProviderAccountId: options.realOAuth ? 'openai-oauth' : 'ollama-local',
}, null, 2), 'utf8');
}
async function copyLocalCodexAuthToManagedHome(userDataDir) {
const source = process.env.CLAWX_REAL_CODEX_AUTH_JSON?.trim();
if (!source) {
throw new Error('Set CLAWX_REAL_CODEX_AUTH_JSON before running packaged real OAuth smoke.');
}
const managedCodexHome = join(userDataDir, 'credentials', 'oauth', 'openai-oauth', 'codex-home');
await mkdir(managedCodexHome, { recursive: true });
await copyFile(source, join(managedCodexHome, 'auth.json'));
return source;
}
async function verifyExecutable(path) {
await access(path, fsConstants.X_OK);
}
async function sha256File(path) {
return createHash('sha256').update(await readFile(path)).digest('hex');
}
async function readManifest(path) {
return JSON.parse(await readFile(path, 'utf8'));
}
async function pathExists(path) {
try {
await access(path);
return true;
} catch {
return false;
}
}
async function verifyCodeSignature(binaryPath, allowUnsigned) {
if (!shouldVerifyPackagedCodeSignature(process.platform, allowUnsigned)) return;
await execFileAsync('codesign', ['--verify', '--deep', '--strict', binaryPath], { timeout: 30_000 });
}
async function verifyBundleManifest({
manifestPath,
binaryPath,
sourceBinaryPath,
expectedName,
expectedBinaryName,
allowUnsigned,
}) {
const manifest = await readManifest(manifestPath);
expect(manifest).toMatchObject({
name: expectedName,
nodePlatform: process.platform,
nodeArch: process.arch,
binaryName: expectedBinaryName,
verifiedWithVersionCommand: true,
});
expect(typeof manifest.version).toBe('string');
expect(manifest.version.length).toBeGreaterThan(0);
expect(manifest.sha256).toMatch(/^[a-f0-9]{64}$/);
if (sourceBinaryPath && await pathExists(sourceBinaryPath)) {
expect(await sha256File(sourceBinaryPath)).toBe(manifest.sha256);
}
const { stdout } = await execFileAsync(binaryPath, ['--version'], { timeout: 30_000 });
expect(stdout).toContain(manifest.version);
await verifyCodeSignature(binaryPath, allowUnsigned);
return manifest;
}
async function waitForCleanup({ pid, runtimeDir, ports }) {
if (typeof pid === 'number') {
await expect.poll(() => isPidAlive(pid), {
timeout: 15_000,
intervals: [250, 500, 1_000],
message: `packaged cc-connect pid ${pid} should exit`,
}).toBe(false);
}
for (const port of ports.filter((value) => typeof value === 'number')) {
await expect.poll(async () => await isPortOpen(port), {
timeout: 15_000,
intervals: [250, 500, 1_000],
message: `packaged cc-connect port ${port} should close`,
}).toBe(false);
}
await expect.poll(async () => await listProcessCommandsContaining(runtimeDir), {
timeout: 15_000,
intervals: [250, 500, 1_000],
message: `no packaged runtime process should reference ${runtimeDir}`,
}).toEqual([]);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const appPath = resolve(args.app || defaultPackagedAppPath({ rootDir: root }));
const executablePath = packagedExecutablePath(appPath);
const resourcesPath = packagedResourcesPath(appPath);
const ccConnectPath = join(resourcesPath, 'cc-connect', binaryName('cc-connect'));
const codexPath = join(resourcesPath, 'codex', 'bin', binaryName('codex'));
const codexRipgrepPath = join(resourcesPath, 'codex', 'codex-path', binaryName('rg'));
const platformArch = `${process.platform}-${process.arch}`;
const sourceCcConnectPath = join(root, 'build', 'cc-connect', platformArch, binaryName('cc-connect'));
const sourceCodexPath = join(root, 'build', 'codex', platformArch, 'bin', binaryName('codex'));
const realOAuth = args['real-oauth'] === '1' || args['real-oauth'] === 'true';
const allowUnsigned = args['allow-unsigned'] === '1' || args['allow-unsigned'] === 'true';
await verifyExecutable(executablePath);
await verifyExecutable(ccConnectPath);
await verifyExecutable(codexPath);
await verifyExecutable(codexRipgrepPath);
const ccConnectManifest = await verifyBundleManifest({
manifestPath: join(resourcesPath, 'cc-connect', 'manifest.json'),
binaryPath: ccConnectPath,
sourceBinaryPath: sourceCcConnectPath,
expectedName: 'cc-connect',
expectedBinaryName: binaryName('cc-connect'),
allowUnsigned,
});
const codexManifest = await verifyBundleManifest({
manifestPath: join(resourcesPath, 'codex', 'manifest.json'),
binaryPath: codexPath,
sourceBinaryPath: sourceCodexPath,
expectedName: 'codex',
expectedBinaryName: binaryName('codex'),
allowUnsigned,
});
expect(ccConnectManifest.sourceUrl).toContain(ccConnectManifest.assetName);
expect(codexManifest.packageSuffix).toContain(process.arch === 'arm64' ? 'arm64' : 'x64');
const homeDir = await mkdtemp(join(tmpdir(), 'clawx-packaged-smoke-home-'));
const userDataDir = await mkdtemp(join(tmpdir(), 'clawx-packaged-smoke-user-data-'));
const mainWorkspace = join(userDataDir, 'packaged-workspaces', 'main');
const researchWorkspace = join(userDataDir, 'packaged-workspaces', 'research');
const runtimeDir = join(userDataDir, 'runtimes', 'cc-connect');
let pid;
let managementPort;
let bridgePort;
let app;
let smokeError;
let realOAuthChatCompleted = false;
try {
await mkdir(join(homeDir, '.config'), { recursive: true });
await mkdir(join(homeDir, 'AppData', 'Local'), { recursive: true });
await mkdir(join(homeDir, 'AppData', 'Roaming'), { recursive: true });
await seedCcConnectSettings(userDataDir, { realOAuth });
if (realOAuth) {
await access(await copyLocalCodexAuthToManagedHome(userDataDir));
}
const openClawConfigDir = join(homeDir, '.openclaw');
await mkdir(openClawConfigDir, { recursive: true });
await mkdir(mainWorkspace, { recursive: true });
await mkdir(researchWorkspace, { recursive: true });
await writeFile(join(openClawConfigDir, 'openclaw.json'), JSON.stringify({
agents: {
defaults: { workspace: mainWorkspace },
list: [
{ id: 'main', name: 'Main Agent', default: true, workspace: mainWorkspace },
{ id: 'research', name: 'Research Agent', workspace: researchWorkspace },
],
},
}, null, 2), 'utf8');
const hostApiPort = await allocatePort();
app = await electron.launch({
executablePath,
args: ['--lang=en-US'],
env: {
...process.env,
HOME: homeDir,
USERPROFILE: homeDir,
APPDATA: join(homeDir, 'AppData', 'Roaming'),
LOCALAPPDATA: join(homeDir, 'AppData', 'Local'),
XDG_CONFIG_HOME: join(homeDir, '.config'),
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
LANGUAGE: 'en',
CLAWX_E2E: '1',
CLAWX_E2E_CREDENTIAL_KEY: randomUUID(),
CLAWX_E2E_SKIP_SETUP: '1',
CLAWX_USER_DATA_DIR: userDataDir,
CLAWX_PORT_CLAWX_HOST_API: String(hostApiPort),
},
timeout: 90_000,
});
const page = await waitForStableWindow(app);
await expect(page.getByTestId('main-layout')).toBeVisible({ timeout: 60_000 });
await page.waitForFunction(() => Boolean(window.clawx?.hostInvoke), null, { timeout: 30_000 });
const startResult = await page.evaluate(async () => {
return await window.clawx.hostInvoke({
id: 'packaged-cc-connect-start',
module: 'gateway',
action: 'start',
});
});
expect(startResult).toMatchObject({ ok: true, data: { success: true } });
const statusResult = await page.evaluate(async () => {
return await window.clawx.hostInvoke({
id: 'packaged-cc-connect-status',
module: 'gateway',
action: 'status',
});
});
expect(statusResult).toMatchObject({
ok: true,
data: { runtimeKind: 'cc-connect' },
});
pid = statusResult.data?.pid;
managementPort = statusResult.data?.port;
expect(pid).toBeGreaterThan(0);
expect(managementPort).toBeGreaterThan(0);
const managedConfig = await readFile(join(runtimeDir, 'config.toml'), 'utf8');
const managedLauncherPath = join(
runtimeDir,
'config',
'launchers',
process.platform === 'win32' ? 'codex-openai-oauth.cmd' : 'codex-openai-oauth',
);
const expectedCodexCommand = realOAuth ? managedLauncherPath : codexPath;
expect(managedConfig).toContain(`cmd = "${escapeTomlBasicString(expectedCodexCommand)}"`);
expect(managedConfig).toContain(`work_dir = "${escapeTomlBasicString(mainWorkspace)}"`);
expect(managedConfig).toContain('name = "clawx-research"');
expect(managedConfig).toContain(`work_dir = "${escapeTomlBasicString(researchWorkspace)}"`);
const bridgeMatch = managedConfig.match(/\[bridge\][\s\S]*?port = (\d+)/);
bridgePort = bridgeMatch ? Number(bridgeMatch[1]) : undefined;
if (realOAuth) {
expect(managedConfig).not.toContain('access_token');
expect(managedConfig).not.toContain('refresh_token');
expect(managedConfig).not.toContain('id_token');
await access(join(userDataDir, 'credentials', 'oauth', 'openai-oauth', 'codex-home', 'auth.json'));
await access(managedLauncherPath, fsConstants.X_OK);
const managedLauncher = await readFile(managedLauncherPath, 'utf8');
const managedCodexHome = join(userDataDir, 'credentials', 'oauth', 'openai-oauth', 'codex-home');
if (process.platform === 'win32') {
expect(managedLauncher).toContain(`set "CODEX_HOME=${managedCodexHome}"`);
expect(managedLauncher).toContain(`"${codexPath}" %*`);
} else {
expect(managedLauncher).toContain(`export CODEX_HOME='${managedCodexHome}'`);
expect(managedLauncher).toContain(`exec '${codexPath}' "$@"`);
}
const publicProfile = await readFile(join(runtimeDir, 'provider-profile.json'), 'utf8');
expect(publicProfile).toContain('"authMode": "oauth_browser"');
expect(publicProfile).toContain('"CODEX_HOME"');
expect(publicProfile).not.toContain('access_token');
expect(publicProfile).not.toContain('refresh_token');
expect(publicProfile).not.toContain('id_token');
await expect(page.getByTestId('chat-composer-input')).toBeEnabled({ timeout: 60_000 });
await page.getByTestId('chat-composer-input').fill('Reply exactly: CLAWX_PACKAGED_REAL_OAUTH_OK');
await page.getByTestId('chat-composer-send').click();
await expect(page.getByText('CLAWX_PACKAGED_REAL_OAUTH_OK')).toBeVisible({ timeout: 180_000 });
realOAuthChatCompleted = true;
}
const createCron = await page.evaluate(async () => {
return await window.clawx.hostInvoke({
id: 'packaged-cc-connect-cron-create',
module: 'cron',
action: 'create',
payload: {
name: 'Packaged cc-connect research cron',
message: 'Packaged cron prompt',
schedule: { kind: 'cron', expr: '0 12 * * *' },
enabled: true,
delivery: { mode: 'none' },
agentId: 'research',
},
});
});
expect(createCron).toMatchObject({
ok: true,
data: expect.objectContaining({
name: 'Packaged cc-connect research cron',
message: 'Packaged cron prompt',
enabled: true,
agentId: 'research',
}),
});
const cronId = createCron.data?.id;
expect(cronId).toBeTruthy();
const updateCron = await page.evaluate(async (id) => {
return await window.clawx.hostInvoke({
id: 'packaged-cc-connect-cron-update',
module: 'cron',
action: 'update',
payload: {
id,
input: {
name: 'Packaged cc-connect research cron updated',
message: 'Updated packaged cron prompt',
schedule: { kind: 'cron', expr: '30 12 * * *' },
enabled: true,
delivery: { mode: 'announce' },
agentId: 'research',
},
},
});
}, cronId);
expect(updateCron).toMatchObject({
ok: true,
data: expect.objectContaining({
id: cronId,
name: 'Packaged cc-connect research cron updated',
message: 'Updated packaged cron prompt',
delivery: { mode: 'announce' },
agentId: 'research',
}),
});
const toggleCron = await page.evaluate(async (id) => {
return await window.clawx.hostInvoke({
id: 'packaged-cc-connect-cron-toggle',
module: 'cron',
action: 'toggle',
payload: { id, enabled: false },
});
}, cronId);
expect(toggleCron).toMatchObject({
ok: true,
data: expect.objectContaining({
id: cronId,
enabled: false,
agentId: 'research',
}),
});
const listCron = await page.evaluate(async () => {
return await window.clawx.hostInvoke({
id: 'packaged-cc-connect-cron-list',
module: 'cron',
action: 'list',
});
});
expect(listCron).toMatchObject({
ok: true,
data: expect.arrayContaining([
expect.objectContaining({ id: cronId, agentId: 'research' }),
]),
});
const deleteCron = await page.evaluate(async (id) => {
return await window.clawx.hostInvoke({
id: 'packaged-cc-connect-cron-delete',
module: 'cron',
action: 'delete',
payload: { id },
});
}, cronId);
expect(deleteCron).toMatchObject({ ok: true, data: { success: true } });
const doctorResult = await page.evaluate(async () => {
return await window.clawx.hostInvoke({
id: 'packaged-cc-connect-doctor',
module: 'app',
action: 'openClawDoctor',
payload: { mode: 'diagnose' },
});
});
expect(doctorResult).toMatchObject({
ok: true,
data: expect.objectContaining({
mode: 'diagnose',
command: expect.stringContaining('cc-connect doctor user-isolation --config'),
cwd: expect.stringContaining(join('runtimes', 'cc-connect')),
}),
});
expect(doctorResult.data?.timedOut).not.toBe(true);
expect(doctorResult.data?.error || '').not.toContain('spawn');
const switchResult = await page.evaluate(async () => {
return await window.clawx.hostInvoke({
id: 'packaged-cc-connect-switch-openclaw',
module: 'settings',
action: 'set',
payload: {
key: 'runtimeKind',
value: 'openclaw',
},
});
});
expect(switchResult).toMatchObject({ ok: true, data: { success: true } });
const openClawStatus = await page.evaluate(async () => {
return await window.clawx.hostInvoke({
id: 'packaged-openclaw-status-after-rollback',
module: 'gateway',
action: 'status',
});
});
expect(openClawStatus).toMatchObject({
ok: true,
data: { runtimeKind: 'openclaw' },
});
await waitForCleanup({
pid,
runtimeDir,
ports: [managementPort, bridgePort],
});
} catch (error) {
smokeError = error;
} finally {
if (app) await closeElectronApp(app);
try {
await waitForCleanup({
pid,
runtimeDir,
ports: [managementPort, bridgePort],
});
} catch (error) {
if (!smokeError) {
smokeError = error;
} else {
console.warn(`[packaged-smoke] cleanup check failed after primary error: ${error instanceof Error ? error.message : String(error)}`);
}
}
await rm(userDataDir, { recursive: true, force: true });
await rm(homeDir, { recursive: true, force: true });
}
if (smokeError) throw smokeError;
const evidencePath = resolve(args.report || join(
root,
'artifacts',
'cc-connect',
`packaged-smoke-${process.platform}-${process.arch}.json`,
));
const evidence = {
schema: 'clawx-packaged-runtime-smoke',
version: 1,
generatedAt: new Date().toISOString(),
target: `${process.platform}-${process.arch}`,
application: basename(appPath),
ccConnectVersion: ccConnectManifest.version,
codexVersion: codexManifest.version,
realOAuth,
codeSignature: process.platform === 'darwin'
? allowUnsigned ? 'explicitly-skipped-unsigned-smoke' : 'verified'
: 'not-applicable',
checks: packagedSmokeChecks({ allowUnsigned, realOAuthChatCompleted }),
status: 'pass',
};
await mkdir(dirname(evidencePath), { recursive: true });
await writeFile(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
console.log(`Packaged cc-connect smoke passed for ${evidence.target}; evidence: ${evidencePath}`);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,183 @@
#!/usr/bin/env node
import crypto from 'node:crypto';
import childProcess from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { validateRuntimeBundleManifest } from './verify-runtime-bundles.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export function parsePackagedResourceArgs(argv) {
const values = {};
for (const arg of argv) {
const match = arg.match(/^--(resources|platform|arch)=(.+)$/);
if (match) values[match[1]] = match[2];
}
if (!values.resources || !values.platform || !values.arch) {
throw new Error('Usage: verify-packaged-runtime-resources.mjs --resources=<path> --platform=<darwin|win32|linux> --arch=<x64|arm64>');
}
if (!['darwin', 'win32', 'linux'].includes(values.platform)) {
throw new Error(`Unsupported platform: ${values.platform}`);
}
if (!['x64', 'arm64'].includes(values.arch)) {
throw new Error(`Unsupported arch: ${values.arch}`);
}
return values;
}
function pinnedVersion(packageName) {
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const raw = packageJson.devDependencies?.[packageName] ?? packageJson.dependencies?.[packageName];
if (typeof raw !== 'string' || !raw.trim()) throw new Error(`Missing pinned package version for ${packageName}`);
return raw.replace(/^[^\d]*/, '');
}
function sha256(file) {
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
}
function fixedString(buffer, offset, length) {
const end = buffer.indexOf(0, offset);
return buffer.subarray(offset, end >= offset && end < offset + length ? end : offset + length).toString('utf8');
}
export function hashMachOSections(buffer) {
if (buffer.length < 32 || buffer.readUInt32LE(0) !== 0xfeedfacf) {
throw new Error('expected a thin little-endian 64-bit Mach-O binary');
}
const commandCount = buffer.readUInt32LE(16);
const commandsSize = buffer.readUInt32LE(20);
const commandsEnd = 32 + commandsSize;
if (commandsEnd > buffer.length) throw new Error('Mach-O load commands exceed file size');
const hash = crypto.createHash('sha256');
let commandOffset = 32;
let sectionCount = 0;
for (let commandIndex = 0; commandIndex < commandCount; commandIndex += 1) {
if (commandOffset + 8 > commandsEnd) throw new Error('truncated Mach-O load command');
const command = buffer.readUInt32LE(commandOffset);
const commandSize = buffer.readUInt32LE(commandOffset + 4);
if (commandSize < 8 || commandOffset + commandSize > commandsEnd) {
throw new Error('invalid Mach-O load command size');
}
if (command === 0x19) {
if (commandSize < 72) throw new Error('truncated LC_SEGMENT_64 command');
const segmentName = fixedString(buffer, commandOffset + 8, 16);
const segmentSectionCount = buffer.readUInt32LE(commandOffset + 64);
if (72 + segmentSectionCount * 80 > commandSize) throw new Error('truncated Mach-O section table');
for (let sectionIndex = 0; sectionIndex < segmentSectionCount; sectionIndex += 1) {
const sectionOffset = commandOffset + 72 + sectionIndex * 80;
const sectionName = fixedString(buffer, sectionOffset, 16);
const sectionSegmentName = fixedString(buffer, sectionOffset + 16, 16);
const sectionSize = Number(buffer.readBigUInt64LE(sectionOffset + 40));
if (!Number.isSafeInteger(sectionSize)) throw new Error(`Mach-O section ${sectionName} size is not a safe integer`);
const fileOffset = buffer.readUInt32LE(sectionOffset + 48);
const flags = buffer.readUInt32LE(sectionOffset + 64);
const sectionType = flags & 0xff;
const zeroFill = sectionType === 0x1 || sectionType === 0xc || sectionType === 0x12;
hash.update(`${segmentName}\0${sectionSegmentName}\0${sectionName}\0${sectionSize}\0${flags}\0`);
if (!zeroFill) {
if (fileOffset + sectionSize > buffer.length) throw new Error(`Mach-O section ${sectionName} exceeds file size`);
hash.update(buffer.subarray(fileOffset, fileOffset + sectionSize));
}
sectionCount += 1;
}
}
commandOffset += commandSize;
}
if (sectionCount === 0) throw new Error('Mach-O binary contains no sections');
return hash.digest('hex');
}
function verifySignedDarwinPayload({ runtime, arch, binaryPath, manifest }) {
const sourceBinaryPath = runtime === 'codex'
? path.join(root, 'build', runtime, `darwin-${arch}`, 'bin', 'codex')
: path.join(root, 'build', runtime, `darwin-${arch}`, 'cc-connect');
if (!fs.existsSync(sourceBinaryPath)) {
return [`signed Darwin verification requires source bundle ${sourceBinaryPath}`];
}
const sourceSha = sha256(sourceBinaryPath);
if (sourceSha !== manifest.sha256) {
return [`source bundle sha256 mismatch: manifest=${manifest.sha256}, source=${sourceSha}`];
}
try {
const sourcePayloadSha = hashMachOSections(fs.readFileSync(sourceBinaryPath));
const packagedPayloadSha = hashMachOSections(fs.readFileSync(binaryPath));
if (sourcePayloadSha !== packagedPayloadSha) {
return [`signed Mach-O section digest mismatch: source=${sourcePayloadSha}, packaged=${packagedPayloadSha}`];
}
} catch (error) {
return [`signed Mach-O section verification failed: ${error.message}`];
}
try {
childProcess.execFileSync('codesign', ['--verify', '--strict', binaryPath], { stdio: 'pipe' });
} catch (error) {
const detail = error.stderr?.toString().trim() || error.message;
return [`codesign verification failed: ${detail}`];
}
return [];
}
export function verifyPackagedRuntimeResources({ resources, platform, arch }) {
const resourcesRoot = path.resolve(resources);
const problems = [];
for (const runtime of ['cc-connect', 'codex']) {
const binaryName = platform === 'win32'
? `${runtime === 'cc-connect' ? 'cc-connect' : 'codex'}.exe`
: runtime === 'cc-connect' ? 'cc-connect' : 'codex';
const runtimeRoot = path.join(resourcesRoot, runtime);
const binaryPath = runtime === 'codex'
? path.join(runtimeRoot, 'bin', binaryName)
: path.join(runtimeRoot, binaryName);
const manifestPath = path.join(runtimeRoot, 'manifest.json');
if (!fs.existsSync(binaryPath)) {
problems.push(`${runtime}: missing binary ${binaryPath}`);
continue;
}
if (!fs.existsSync(manifestPath)) {
problems.push(`${runtime}: missing manifest ${manifestPath}`);
continue;
}
let manifest;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
} catch (error) {
problems.push(`${runtime}: invalid manifest JSON: ${error.message}`);
continue;
}
const binaryBuffer = fs.readFileSync(binaryPath);
const packagedSha = crypto.createHash('sha256').update(binaryBuffer).digest('hex');
const issues = validateRuntimeBundleManifest({
name: runtime,
version: pinnedVersion(runtime === 'cc-connect' ? 'cc-connect' : '@openai/codex'),
nodePlatform: platform,
nodeArch: arch,
binaryName,
}, manifest, packagedSha, fs.statSync(binaryPath).mode, binaryBuffer);
const shaMismatchIndex = issues.findIndex(issue => issue.startsWith('sha256 mismatch:'));
if (platform === 'darwin' && shaMismatchIndex >= 0) {
const signedPayloadIssues = verifySignedDarwinPayload({ runtime, arch, binaryPath, manifest });
if (signedPayloadIssues.length === 0) issues.splice(shaMismatchIndex, 1);
else issues.push(...signedPayloadIssues);
}
for (const issue of issues) problems.push(`${runtime}: ${issue}`);
}
return problems;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
try {
const args = parsePackagedResourceArgs(process.argv.slice(2));
const problems = verifyPackagedRuntimeResources(args);
if (problems.length > 0) {
console.error('Packaged runtime resource verification failed:');
for (const problem of problems) console.error(`- ${problem}`);
process.exit(1);
}
console.log(`Packaged runtime resources verified for ${args.platform}-${args.arch}: ${path.resolve(args.resources)}`);
} catch (error) {
console.error(error.message);
process.exit(1);
}
}
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export function parseArgs(argv) {
const platforms = new Set();
let explicitPlatform = false;
for (const arg of argv) {
if (arg === '--all') {
explicitPlatform = true;
platforms.add('darwin');
platforms.add('win32');
platforms.add('linux');
continue;
}
const match = arg.match(/^--platform=(.+)$/);
if (match) {
explicitPlatform = true;
const value = match[1];
if (value === 'mac') platforms.add('darwin');
else if (value === 'win') platforms.add('win32');
else if (value === 'linux') platforms.add('linux');
else platforms.add(value);
}
}
if (platforms.size === 0) platforms.add(process.platform);
return { platforms: Array.from(platforms), explicitPlatform };
}
export function archesForPlatform(platform, { explicitPlatform = false } = {}) {
if (!explicitPlatform && platform === process.platform) return [process.arch];
if (platform === 'darwin') return ['x64', 'arm64'];
if (platform === 'linux') return ['x64', 'arm64'];
if (platform === 'win32') return ['x64'];
throw new Error(`Unsupported runtime bundle platform: ${platform}`);
}
function binaryName(platform, base) {
return platform === 'win32' ? `${base}.exe` : base;
}
function packageVersion(name) {
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const raw = packageJson.devDependencies?.[name] ?? packageJson.dependencies?.[name];
if (typeof raw !== 'string' || !raw.trim()) {
throw new Error(`Missing pinned package version for ${name}`);
}
return raw.replace(/^[^\d]*/, '');
}
function sha256File(file) {
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
}
export function detectExecutableTarget(buffer) {
if (buffer.length >= 8 && buffer.readUInt32LE(0) === 0xfeedfacf) {
const arch = new Map([
[0x01000007, 'x64'],
[0x0100000c, 'arm64'],
]).get(buffer.readUInt32LE(4));
return arch ? { platform: 'darwin', arch } : null;
}
if (
buffer.length >= 20 &&
buffer[0] === 0x7f && buffer[1] === 0x45 && buffer[2] === 0x4c && buffer[3] === 0x46 &&
buffer[4] === 2 && buffer[5] === 1
) {
const arch = new Map([
[62, 'x64'],
[183, 'arm64'],
]).get(buffer.readUInt16LE(18));
return arch ? { platform: 'linux', arch } : null;
}
if (buffer.length >= 64 && buffer[0] === 0x4d && buffer[1] === 0x5a) {
const peOffset = buffer.readUInt32LE(0x3c);
if (peOffset + 6 <= buffer.length && buffer.toString('ascii', peOffset, peOffset + 4) === 'PE\0\0') {
const arch = new Map([
[0x8664, 'x64'],
[0xaa64, 'arm64'],
]).get(buffer.readUInt16LE(peOffset + 4));
return arch ? { platform: 'win32', arch } : null;
}
}
return null;
}
export function validateRuntimeBundleManifest(required, manifest, binarySha256, binaryMode = 0, binaryBuffer = null) {
const issues = [];
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
return ['manifest must be a JSON object'];
}
for (const [field, expected] of Object.entries({
name: required.name,
version: required.version,
nodePlatform: required.nodePlatform,
nodeArch: required.nodeArch,
binaryName: required.binaryName,
})) {
if (manifest[field] !== expected) {
issues.push(`${field} must be ${JSON.stringify(expected)}, got ${JSON.stringify(manifest[field])}`);
}
}
if (!/^[a-f0-9]{64}$/.test(manifest.sha256 ?? '')) {
issues.push('sha256 must be a lowercase 64-character digest');
} else if (manifest.sha256 !== binarySha256) {
issues.push(`sha256 mismatch: manifest=${manifest.sha256}, binary=${binarySha256}`);
}
if (typeof manifest.verifiedWithVersionCommand !== 'boolean') {
issues.push('verifiedWithVersionCommand must be boolean');
} else if (
required.nodePlatform === process.platform &&
required.nodeArch === process.arch &&
!manifest.verifiedWithVersionCommand
) {
issues.push('verifiedWithVersionCommand must be true for the current executable target');
}
if (required.nodePlatform !== 'win32' && (binaryMode & 0o111) === 0) {
issues.push('binary must have an executable bit');
}
if (required.name === 'cc-connect') {
if (typeof manifest.sourceUrl !== 'string' || !/^https:\/\//.test(manifest.sourceUrl)) {
issues.push('sourceUrl must be an HTTPS URL');
}
if (typeof manifest.assetName !== 'string' || !manifest.assetName) {
issues.push('assetName must be present');
}
} else if (required.name === 'codex') {
if (typeof manifest.packageSuffix !== 'string' || !manifest.packageSuffix) {
issues.push('packageSuffix must be present');
}
if (typeof manifest.targetTriple !== 'string' || !manifest.targetTriple) {
issues.push('targetTriple must be present');
}
}
if (binaryBuffer) {
const target = detectExecutableTarget(binaryBuffer);
if (!target) {
issues.push('binary header must identify a supported Mach-O, ELF, or PE target');
} else if (target.platform !== required.nodePlatform || target.arch !== required.nodeArch) {
issues.push(`binary target must be ${required.nodePlatform}-${required.nodeArch}, got ${target.platform}-${target.arch}`);
}
}
return issues;
}
export function checkPath(required, missing) {
if (!fs.existsSync(required.path)) missing.push(required);
}
export function collectMissingRuntimeBundles(argv = process.argv.slice(2)) {
const { platforms, explicitPlatform } = parseArgs(argv);
const missing = [];
for (const platform of platforms) {
for (const arch of archesForPlatform(platform, { explicitPlatform })) {
const target = `${platform}-${arch}`;
checkPath({
label: `cc-connect binary (${target})`,
path: path.join(root, 'build', 'cc-connect', target, binaryName(platform, 'cc-connect')),
fix: platform === process.platform && arch === process.arch && !explicitPlatform
? 'pnpm run bundle:cc-connect:current'
: `pnpm run bundle:cc-connect:${platform === 'darwin' ? 'mac' : platform === 'win32' ? 'win' : platform}`,
}, missing);
checkPath({
label: `Codex binary (${target})`,
path: path.join(root, 'build', 'codex', target, 'bin', binaryName(platform, 'codex')),
fix: platform === process.platform && arch === process.arch && !explicitPlatform
? 'pnpm run bundle:codex:current'
: `pnpm run bundle:codex:${platform === 'darwin' ? 'mac' : platform === 'win32' ? 'win' : platform}`,
}, missing);
}
}
return missing;
}
export function collectInvalidRuntimeBundles(argv = process.argv.slice(2)) {
const { platforms, explicitPlatform } = parseArgs(argv);
const versions = {
'cc-connect': packageVersion('cc-connect'),
codex: packageVersion('@openai/codex'),
};
const invalid = [];
for (const platform of platforms) {
for (const arch of archesForPlatform(platform, { explicitPlatform })) {
const target = `${platform}-${arch}`;
for (const runtime of ['cc-connect', 'codex']) {
const name = runtime;
const targetRoot = path.join(root, 'build', runtime, target);
const expectedBinaryName = binaryName(platform, runtime === 'cc-connect' ? 'cc-connect' : 'codex');
const binaryPath = runtime === 'codex'
? path.join(targetRoot, 'bin', expectedBinaryName)
: path.join(targetRoot, expectedBinaryName);
const manifestPath = path.join(targetRoot, 'manifest.json');
if (!fs.existsSync(binaryPath) || !fs.existsSync(manifestPath)) continue;
let manifest;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
} catch (error) {
invalid.push({ label: `${runtime} manifest (${target})`, path: manifestPath, issues: [`invalid JSON: ${error.message}`] });
continue;
}
const binaryBuffer = fs.readFileSync(binaryPath);
const issues = validateRuntimeBundleManifest({
name,
version: versions[runtime],
nodePlatform: platform,
nodeArch: arch,
binaryName: expectedBinaryName,
}, manifest, sha256File(binaryPath), fs.statSync(binaryPath).mode, binaryBuffer);
if (issues.length > 0) invalid.push({ label: `${runtime} bundle (${target})`, path: targetRoot, issues });
}
}
}
return invalid;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const missing = collectMissingRuntimeBundles();
const invalid = collectInvalidRuntimeBundles();
if (missing.length > 0) {
console.error('Missing bundled runtime artifact(s):');
for (const item of missing) {
console.error(`- ${item.label}: ${item.path}`);
console.error(` Fix: ${item.fix}`);
}
}
if (invalid.length > 0) {
console.error('Invalid bundled runtime artifact(s):');
for (const item of invalid) {
console.error(`- ${item.label}: ${item.path}`);
for (const issue of item.issues) console.error(` ${issue}`);
}
}
if (missing.length > 0 || invalid.length > 0) process.exit(1);
console.log('Runtime bundle verification passed.');
}
+9
View File
@@ -6,6 +6,11 @@ export type ChatRuntimeEventBase = {
};
export type ChatRuntimeEvent =
| (ChatRuntimeEventBase & {
type: 'session.updated';
updatedAt?: number;
reason?: string;
})
| (ChatRuntimeEventBase & {
type: 'run.started';
startedAt?: number;
@@ -85,4 +90,8 @@ export type ChatRuntimeEvent =
phase?: string;
status?: string;
message?: string;
actions?: Array<{
action: string;
label?: string;
}>;
});
+1
View File
@@ -77,6 +77,7 @@ export interface ChatSession {
sessionId?: string;
label?: string;
displayName?: string;
agentId?: string;
derivedTitle?: string;
lastMessagePreview?: string;
thinkingLevel?: string;
+57 -4
View File
@@ -8,7 +8,7 @@ import type {
import type { RawMessage } from '../chat/types';
import type { AgentsSnapshot } from '../types/agent';
import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron';
import type { GatewayHealth, GatewayStatus } from '../types/gateway';
import type { GatewayHealth, GatewayStatus, RuntimeKind } from '../types/gateway';
import type { MarketplaceSkill, QuickAccessSkill, Skill } from '../types/skill';
import type { WebBrowserNavigatePayload } from '../web-browser';
@@ -26,6 +26,8 @@ export type OpenClawDoctorResult = HostSuccess & {
cwd: string;
durationMs: number;
timedOut?: boolean;
auditPath?: string;
audit?: JsonRecord;
};
export type OpenClawDoctorPayload = { mode: OpenClawDoctorMode };
@@ -111,6 +113,7 @@ export type SettingsSnapshot = Partial<{
launchAtStartup: boolean;
telemetryEnabled: boolean;
gatewayAutoStart: boolean;
runtimeKind: RuntimeKind;
gatewayPort: number;
proxyEnabled: boolean;
proxyServer: string;
@@ -251,7 +254,12 @@ export type ChannelConfiguredResult = HostSuccess & { channels?: Array<string |
export type AgentSnapshotResult = AgentsSnapshot & OptionalHostSuccess;
export type AgentCreatePayload = { name: string; inheritWorkspace?: boolean };
export type AgentUpdatePayload = { id: string; name: string };
export type AgentUpdateModelPayload = { id: string; modelRef: string | null };
export type AgentUpdateModelPayload = {
id: string;
modelRef: string | null;
providerAccountId?: string | null;
permissionMode?: 'suggest' | 'full-auto';
};
export type AgentIdPayload = { id: string };
export type AgentChannelPayload = { id: string; channelType: string };
@@ -419,6 +427,34 @@ export type ProviderOAuthRequestPayload = {
label?: string;
};
export type ProviderOAuthSubmitPayload = { code: string };
export type ProviderCodexOAuthPayload = { accountId?: string };
export type ProviderCodexOAuthLogoutPayload = ProviderCodexOAuthPayload & { managedOnly?: boolean };
export type ProviderCodexOAuthAuthFileSummary = {
path: string;
exists: boolean;
complete: boolean;
accountId?: string;
authMode?: string;
lastRefresh?: string;
updatedAt?: string;
error?: string;
};
export type ProviderCodexOAuthStatusResult = HostSuccess & {
managedCodexHome?: string;
authPath?: string;
managed?: ProviderCodexOAuthAuthFileSummary;
user?: ProviderCodexOAuthAuthFileSummary;
provider?: {
accountId: string;
vendorId: string;
authMode?: string;
hasOAuthSecret: boolean;
subject?: string;
email?: string;
managedMatchesAccount?: boolean;
userMatchesAccount?: boolean;
};
};
export type StagedFileResult = {
id: string;
@@ -755,6 +791,14 @@ export type SkillsStatusResult = {
}[];
};
export type LocalSkillsResult = HostSuccess & { skills?: Skill[] };
export type SkillsRuntimeTargetResult = HostSuccess & {
runtimeKind: RuntimeKind;
sourceDir: string;
openDir: string;
runtimeDir?: string;
manifestPath?: string;
mirrorMode: 'source' | 'runtime-mirror';
};
export type SkillConfigsResult = Record<string, { enabled?: boolean; apiKey?: string; env?: Record<string, string> }>;
export type SkillKeyPayload = { skillKey: string };
export type SkillUpdateConfigPayload = SkillKeyPayload & {
@@ -788,9 +832,13 @@ export type ClawHubOpenPayload = {
};
export type UsageHistoryEntry = {
runtimeKind?: 'openclaw' | 'cc-connect';
timestamp: string;
sessionId: string;
runtimeSessionId?: string;
turnId?: string;
agentId: string;
providerAccountId?: string;
model?: string;
provider?: string;
content?: string;
@@ -799,10 +847,11 @@ export type UsageHistoryEntry = {
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
reasoningTokens?: number;
totalTokens: number;
costUsd?: number;
};
export type UsageHistoryPayload = { limit?: number };
export type UsageHistoryPayload = { limit?: number; runtimeKind?: RuntimeKind };
export type DeliveryChannelAccount = {
accountId: string;
@@ -942,6 +991,9 @@ export type HostApiContract = {
requestOAuth: (payload: ProviderOAuthRequestPayload) => HostSuccess;
cancelOAuth: () => HostSuccess;
submitOAuth: (payload: ProviderOAuthSubmitPayload) => HostSuccess;
codexOAuthStatus: (payload?: ProviderCodexOAuthPayload) => ProviderCodexOAuthStatusResult;
importCodexOAuth: (payload?: ProviderCodexOAuthPayload) => ProviderCodexOAuthStatusResult;
logoutCodexOAuth: (payload?: ProviderCodexOAuthLogoutPayload) => ProviderCodexOAuthStatusResult;
};
files: {
stagePaths: (payload: StagePathsPayload) => StagedFileResult[];
@@ -999,13 +1051,14 @@ export type HostApiContract = {
create: (payload: CronJobCreateInput) => CronJob;
update: (payload: CronUpdatePayload) => CronJob;
delete: (payload: CronIdPayload) => HostSuccess;
toggle: (payload: CronTogglePayload) => HostSuccess;
toggle: (payload: CronTogglePayload) => CronJob | HostSuccess;
trigger: (payload: CronIdPayload) => HostSuccess;
sessionHistory: (payload: CronSessionHistoryPayload) => CronSessionHistoryResult;
deliveryTargets: () => DeliveryTargetsResult;
};
skills: {
local: () => LocalSkillsResult;
target: () => SkillsRuntimeTargetResult;
configs: () => SkillConfigsResult;
allConfigs: () => SkillConfigsResult;
getConfig: (payload: SkillKeyPayload) => JsonRecord | undefined;
+11 -1
View File
@@ -44,6 +44,15 @@
"closeWithoutSaving": "Close without saving",
"saveModelOverride": "Save model",
"useDefaultModel": "Use default model",
"permissionModeLabel": "Tool permissions",
"permissionModes": {
"full-auto": "Full auto",
"suggest": "Ask for approval"
},
"permissionModeDescriptions": {
"full-auto": "Codex can use tools inside the managed workspace without prompting.",
"suggest": "Codex asks before tool use and runs in a read-only sandbox until approved."
},
"channelsTitle": "Channels",
"channelsDescription": "This list is read-only. Manage channel accounts and bindings in the Channels page.",
"mainAccount": "Main account",
@@ -69,9 +78,10 @@
"agentModelUpdateFailed": "Failed to update agent model: {{error}}",
"agentModelReset": "Agent model reset to default",
"agentModelResetFailed": "Failed to reset agent model: {{error}}",
"agentRuntimeUpdated": "Agent runtime settings updated",
"channelAssigned": "{{channel}} assigned to agent",
"channelAssignFailed": "Failed to assign channel: {{error}}",
"channelRemoved": "{{channel}} removed",
"channelRemoveFailed": "Failed to remove channel: {{error}}"
}
}
}
+5
View File
@@ -240,6 +240,11 @@
"appSecret": {
"label": "App Secret",
"placeholder": "Your app secret"
},
"adminUsers": {
"label": "Cron administrator IDs",
"placeholder": "ou_xxx, ou_yyy",
"description": "Comma-separated user IDs allowed to create, edit, run, or delete cc-connect cron jobs from Feishu/Lark."
}
},
"instructions": [

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