diff --git a/.env.cc-connect.local.example b/.env.cc-connect.local.example new file mode 100644 index 000000000..f13ac5c01 --- /dev/null +++ b/.env.cc-connect.local.example @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab62e961e..9a0f91635 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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=${{ github.event_name == 'workflow_dispatch' && '1' || '0' }} + + - 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 diff --git a/README.ja-JP.md b/README.ja-JP.md index 46891d8dd..77863ea79 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -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 を使うため、Codex transcript を読まずに tool progress を共通 Chat execution graph へ反映できます。承認ボタンと cc-connect card の選択肢は実行グラフに表示され、応答はすべて cc-connect の公開 `card_action` プロトコルを通じて返されます。Runtime が生成した画像、ファイル、音声、動画の packet も BridgePlatform 経由で返り、Chat の添付として表示され続けます。各 Agent は既定でフルオートを使用し、Agent のモデル/runtime 設定で「承認を求める」(`suggest`)を個別に選択できます。新しい agent は `~/.clawx/workspaces/agents/` を使い、既存の 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 専用のままです。 --- @@ -117,12 +127,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など)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuth(Codex サブスクリプション)の両方に対応しています。 @@ -183,7 +195,7 @@ ClawXを初めて起動すると、**セットアップウィザード**が以 ### プロキシ設定 -ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネルがローカルプロキシクライアントを介してインターネットにアクセスする必要がある環境向けに、組み込みのプロキシ設定が含まれています。 +ClawXには、Electron、OpenClaw Gateway、任意の cc-connect/Codex runtime、またはTelegramなどのチャネルがローカルプロキシクライアントを介してインターネットにアクセスする必要がある環境向けに、組み込みのプロキシ設定が含まれています。 **設定 → ゲートウェイ → プロキシ**を開いて以下を設定します: @@ -204,10 +216,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` エントリーポイント経由で実行されます。 --- @@ -273,7 +286,7 @@ ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を ### プロセスモデルと 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 として表示します。 @@ -340,6 +353,8 @@ AI を開発ワークフローに統合できます。エージェントを使 ``` ### 利用可能なコマンド +cc-connect の実環境検証はローカル env ファイルを読み込めますが、リポジトリ内の認証情報ファイルは gitignore されている必要があります。リポジトリ外の `--env-file` パスは利用でき、レポートには書き込まれません。`.env.cc-connect.local.example` は `.env.cc-connect.local` のフィールドテンプレートです。 + ```bash # 開発 pnpm run init # 依存関係のインストール + バンドルバイナリ(uv、agent-browser)のダウンロード @@ -352,10 +367,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=、 +# または CLAWX_REAL_ENV_FILE / CLAWX_REAL_ENV_FILES で渡せます。明示的な process env が優先されます。 +# API-key スモークでは、デフォルトモデルが利用できない場合に CLAWX_REAL_OPENAI_MODEL を設定できます。 # ビルド&パッケージ pnpm run build:vite # フロントエンドのみビルド @@ -364,6 +408,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= --arch= # 最終 Electron runtime resources を検証 +pnpm run smoke:cc-connect:packaged # ネイティブ unpacked app を起動し、cc-connect の起動/状態/Cron/Doctor/ロールバック/クリーンアップを検証 ``` ヘッドレス Linux では Electron テストに表示サーバーが必要です。`xvfb-run -a pnpm run test:e2e` を利用してください。 diff --git a/README.md b/README.md index 4666d088e..e62c75882 100644 --- a/README.md +++ b/README.md @@ -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 tool progress can drive the shared Chat execution graph without reading Codex transcripts. 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/`; 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. --- @@ -117,12 +127,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, and more) with credentials stored securely in your system's native keychain. OpenAI supports both API key and browser OAuth (Codex subscription) sign-in. @@ -186,7 +198,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: @@ -207,10 +219,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. --- @@ -276,7 +289,7 @@ ClawX employs a **dual-process architecture** with a unified host API layer. The ### 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. @@ -343,6 +356,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) @@ -355,10 +370,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=, 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 @@ -367,6 +411,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= --platform= --arch= # 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`. diff --git a/README.zh-CN.md b/README.zh-CN.md index 4928a270a..54dea5347 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 bundle;runtime 启动不依赖全局安装、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,而不读取 Codex transcript。审批按钮和 cc-connect card 选项都会显示在执行图中,响应统一通过 cc-connect 公共 `card_action` 协议返回。Runtime 生成的图片、文件、音频和视频包也通过 BridgePlatform 返回,并持续显示为 Chat 附件。每个 Agent 默认使用全自动模式,也可以在 Agent 的模型/runtime 设置中独立选择“需要审批”(`suggest`)。新 agent 使用 `~/.clawx/workspaces/agents/`;已有 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 Admin,OpenClaw Dreams 入口仍只在 OpenClaw runtime 下显示。 --- @@ -118,12 +128,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 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuth(Codex 订阅)登录。 @@ -187,7 +199,7 @@ pnpm dev ### 代理设置 -ClawX 内置了代理设置,适用于需要通过本地代理客户端访问外网的场景,包括 Electron 本身、OpenClaw Gateway,以及 Telegram 这类频道的联网请求。 +ClawX 内置了代理设置,适用于需要通过本地代理客户端访问外网的场景,包括 Electron 本身、OpenClaw Gateway、可选的 cc-connect/Codex runtime,以及 Telegram 这类频道的联网请求。 打开 **设置 → 网关 → 代理**,配置以下内容: @@ -208,10 +220,11 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问 - 只填写 `host:port` 时,会按 HTTP 代理处理。 - 高级代理项留空时,会自动回退到“代理服务器”。 - 保存代理设置后,Electron 网络层会立即重新应用代理,并自动重启 Gateway。 +- 在 cc-connect runtime 模式下,Codex 子进程会继承同一组 `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY` 和绕过规则环境变量。 - 如果启用了 Telegram,ClawX 还会把代理同步到 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` 入口运行,以保证终端输入行为稳定。 --- @@ -277,7 +290,7 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用 ### 进程模型与 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 故障。 @@ -344,6 +357,8 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用 ``` ### 常用命令 +cc-connect 真实验证可以加载本地 env 文件,但仓库内的凭据文件必须被 gitignore;仓库外 `--env-file` 路径可以使用且不会写入报告。`.env.cc-connect.local.example` 是 `.env.cc-connect.local` 的字段模板。 + ```bash # 开发 pnpm run init # 安装依赖并下载捆绑二进制(uv、agent-browser) @@ -356,10 +371,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=、 +# 或 CLAWX_REAL_ENV_FILE / CLAWX_REAL_ENV_FILES 提供;显式进程环境变量优先。 +# API-key 冒烟在默认模型不可用时可设置 CLAWX_REAL_OPENAI_MODEL。 # 构建与打包 pnpm run build:vite # 仅构建前端 @@ -368,6 +412,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= --arch= # 校验最终 Electron runtime resources +pnpm run smoke:cc-connect:packaged # 启动当前平台 unpacked app,验证 cc-connect 启动/状态/Cron/Doctor/回滚/清理 ``` 在无头 Linux 环境下,Electron 测试需要显示服务;可使用 `xvfb-run -a pnpm run test:e2e`。 diff --git a/docs/assets/clawx-runtime-architecture.png b/docs/assets/clawx-runtime-architecture.png new file mode 100644 index 000000000..aea9d9fcb Binary files /dev/null and b/docs/assets/clawx-runtime-architecture.png differ diff --git a/docs/runtime-abstraction-cc-connect.md b/docs/runtime-abstraction-cc-connect.md new file mode 100644 index 000000000..187fcb850 --- /dev/null +++ b/docs/runtime-abstraction-cc-connect.md @@ -0,0 +1,831 @@ +# 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= --platform= --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//codex-home/ + skills/ + installed/ + configs.json + workspaces/ + agents// + 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 + stop(): Promise + restart(): Promise + getStatus(): RuntimeStatus + checkHealth(options?: RuntimeHealthOptions): Promise + rpc(method: string, params?: unknown): Promise + sendMessageWithMedia(payload: RuntimeSendPayload): Promise + abortRun(payload: RuntimeAbortPayload): Promise + resolveApproval(payload: RuntimeApprovalResponse): Promise + listSessions(query?: RuntimeSessionQuery): Promise + loadHistory(query: RuntimeHistoryQuery): Promise + deleteSession(payload: RuntimeSessionMutation): Promise + listUsage(query?: RuntimeUsageQuery): Promise + listLogs(query?: RuntimeLogQuery): Promise + runDoctor(mode: 'diagnose' | 'fix'): Promise + 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//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/`. 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: + +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, +wrapping Codex stdout, or spawning a second Codex bridge remains forbidden. + +The local-real verifier performs runtime checks with real filesystem paths but +replaces repository, home, and temporary roots with ``, ``, and +`` 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, history, and deletion use only 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/history/delete +always come from the provider's public Management session client. + +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, history, switch, and delete. 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-13. npm still marks `1.4.1` as +`latest`; `1.5.0-beta.1` is prerelease. Both 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 either published version. +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::` 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//`: + +- `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. diff --git a/electron-builder.yml b/electron-builder.yml index f80ba206c..c452d2aa5 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -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 diff --git a/electron/extensions/builtin/diagnostics.ts b/electron/extensions/builtin/diagnostics.ts index c17ef9af3..e1136220d 100644 --- a/electron/extensions/builtin/diagnostics.ts +++ b/electron/extensions/builtin/diagnostics.ts @@ -15,7 +15,10 @@ class DiagnosticsExtension implements HostApiProviderExtension { getHostApiContributions(ctx: ExtensionContext) { return [{ module: 'diagnostics', - actions: createDiagnosticsApi({ gatewayManager: ctx.gatewayManager }), + actions: createDiagnosticsApi({ + gatewayManager: ctx.gatewayManager, + runtimeManager: ctx.runtimeManager, + }), }]; } } diff --git a/electron/extensions/types.ts b/electron/extensions/types.ts index 3505a7aa9..b2429dbfb 100644 --- a/electron/extensions/types.ts +++ b/electron/extensions/types.ts @@ -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; } diff --git a/electron/gateway/chat-runtime-events.ts b/electron/gateway/chat-runtime-events.ts index 03cc6cb30..76bddfa8a 100644 --- a/electron/gateway/chat-runtime-events.ts +++ b/electron/gateway/chat-runtime-events.ts @@ -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; + const value = readString(record.action); + if (!value) return []; + const label = readString(record.label); + return [{ action: value, ...(label ? { label } : {}) }]; + }) + : undefined, } : null; } diff --git a/electron/main/index.ts b/electron/main/index.ts index 5271e1fdc..bc557aed4 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -5,6 +5,9 @@ import { app, BrowserWindow, nativeImage, session, shell } 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'; @@ -51,19 +54,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). @@ -102,12 +108,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; @@ -123,14 +134,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 mainWindowFocusState = createMainWindowFocusState(); @@ -310,6 +335,17 @@ async function initialize(): Promise { 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`); + } if (!isE2EMode) { // Warm up network optimization (non-blocking) @@ -336,6 +372,8 @@ async function initialize(): Promise { createTray(window); } + await runtimeManager.getActiveKind(); + // Override security headers ONLY for the OpenClaw Gateway Control UI. // The URL filter ensures this callback only fires for gateway requests, // avoiding unnecessary overhead on every other HTTP response. @@ -360,11 +398,12 @@ async function initialize(): Promise { ); // Register IPC handlers - registerIpcHandlers(gatewayManager, clawHubService, window, hostApiRegistry); + registerIpcHandlers(gatewayManager, runtimeManager, clawHubService, window, hostApiRegistry); // Initialize extension system await extensionRegistry.initialize({ gatewayManager, + runtimeManager, getMainWindow: () => mainWindow, hostApi: { register: (extensionId, contributions) => ( @@ -441,44 +480,44 @@ async function initialize(): Promise { // 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 }); }); @@ -522,12 +561,14 @@ async function initialize(): Promise { 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) { @@ -580,6 +621,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 @@ -646,8 +691,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); @@ -655,14 +700,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(); @@ -676,6 +723,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 } @@ -695,4 +743,4 @@ if (gotTheLock) { } // Export for testing -export { mainWindow, gatewayManager }; +export { mainWindow, gatewayManager, runtimeManager }; diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index b211e37b1..45dd02f8d 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -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, @@ -63,7 +64,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 { isLaunchAtStartupKey, isProxyKey, @@ -80,24 +81,25 @@ const gatewayRpcBackpressure = new GatewayRpcBackpressure(); */ export function registerIpcHandlers( gatewayManager: GatewayManager, + runtimeManager: RuntimeManager, clawHubService: ClawHubService, mainWindow: BrowserWindow, hostApiRegistry: HostApiRegistry, ): 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, clawHubService, mainWindow, hostApiRegistry); + registerTypedHostHandlers(gatewayManager, runtimeManager, clawHubService, mainWindow, hostApiRegistry); // Gateway handlers - registerGatewayHandlers(gatewayManager); + registerGatewayHandlers(runtimeManager); // OpenClaw handlers registerOpenClawHandlers(); // Provider handlers - registerProviderHandlers(gatewayManager); + registerProviderHandlers(gatewayManager, runtimeManager); // Shell handlers registerShellHandlers(); @@ -112,7 +114,7 @@ export function registerIpcHandlers( registerSettingsHandlers(gatewayManager); // Usage handlers - registerUsageHandlers(); + registerUsageHandlers(runtimeManager); // Cron task handlers (proxy to Gateway RPC) registerCronHandlers(gatewayManager); @@ -129,36 +131,37 @@ export function registerIpcHandlers( function registerTypedHostHandlers( gatewayManager: GatewayManager, + runtimeManager: RuntimeManager, clawHubService: ClawHubService, mainWindow: BrowserWindow, hostApiRegistry: HostApiRegistry, ): void { hostApiRegistry.registerCoreServices({ - app: createAppApi(), + app: createAppApi(runtimeManager), openclaw: createOpenClawApi(), shell: createShellApi(), dialog: createDialogApi(), 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 }), - files: createFilesApi(), - media: createMediaApi(), - sessions: createSessionsApi(), - chat: createChatApi({ gatewayManager }), - cron: createCronApi({ gatewayManager }), - skills: createSkillsApi({ clawHubService, gatewayManager }), - usage: createUsageApi(), + channels: createChannelsApi({ gatewayManager, runtimeManager, mainWindow }), + agents: createAgentsApi({ gatewayManager, runtimeManager }), + providers: createProvidersApi({ gatewayManager, runtimeManager, mainWindow }), + files: createFilesApi({ runtimeManager }), + media: createMediaApi({ runtimeManager }), + sessions: createSessionsApi(runtimeManager), + chat: createChatApi({ gatewayManager, runtimeManager }), + 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(); @@ -508,12 +511,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { } case 'usage': { if (request.action === 'recentTokenHistory') { - const payload = request.payload as { limit?: number } | number | undefined; - const limit = typeof payload === 'number' ? payload : payload?.limit; - const safeLimit = typeof limit === 'number' && Number.isFinite(limit) - ? Math.max(Math.floor(limit), 1) - : undefined; - data = await getRecentTokenUsageHistory(safeLimit); + data = await getRecentTokenHistoryForRuntime(request.payload, runtimeManager); break; } return { @@ -686,10 +684,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 @@ -699,7 +697,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) { @@ -778,7 +776,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(); const logLegacyProviderChannel = (channel: string): void => { @@ -796,9 +797,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 @@ -1197,12 +1203,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); }); } /** @@ -1286,7 +1289,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) ────────────────────────────────────────── // @@ -1355,14 +1358,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; } diff --git a/electron/main/process-instance-lock.ts b/electron/main/process-instance-lock.ts index 2929424c5..d7a111d43 100644 --- a/electron/main/process-instance-lock.ts +++ b/electron/main/process-instance-lock.ts @@ -1,30 +1,50 @@ +import { randomUUID } from 'node:crypto'; import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +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; 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,124 @@ 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 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 { + writeFileSync(lockPath, JSON.stringify(structuredContent), { encoding: 'utf8', mode: 0o600 }); + } 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 +216,7 @@ export function acquireProcessInstanceFileLock( lockPath, ownerPid, ownerFormat, - release: () => { - // no-op when lock wasn't acquired - }, + ownerDetails, + release: () => {}, }; } diff --git a/electron/runtime/cc-connect-agent-bindings.ts b/electron/runtime/cc-connect-agent-bindings.ts new file mode 100644 index 000000000..a818dd408 --- /dev/null +++ b/electron/runtime/cc-connect-agent-bindings.ts @@ -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; +}; + +function bindingsPath(): string { + const layout = getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData'))); + return join(layout.appDir, 'agent-bindings.json'); +} + +async function readDocument(): Promise { + try { + const parsed = JSON.parse(await readFile(bindingsPath(), 'utf8')) as Partial; + 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 { + 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> { + const document = await readDocument(); + return Object.fromEntries(Object.entries(document.agents).flatMap(([agentId, binding]) => ( + binding.providerAccountId ? [[agentId, binding.providerAccountId]] : [] + ))); +} + +export async function listCcConnectAgentPermissionModes(): Promise> { + 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 { + 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 { + 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 { + const normalizedAgentId = agentId.trim(); + if (!normalizedAgentId) return; + const document = await readDocument(); + if (!(normalizedAgentId in document.agents)) return; + delete document.agents[normalizedAgentId]; + await writeDocument(document); +} diff --git a/electron/runtime/cc-connect-bridge-adapter.ts b/electron/runtime/cc-connect-bridge-adapter.ts new file mode 100644 index 000000000..f00b99571 --- /dev/null +++ b/electron/runtime/cc-connect-bridge-adapter.ts @@ -0,0 +1,1630 @@ +import { EventEmitter } from 'node:events'; +import { randomUUID } from 'node:crypto'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { basename, join } from 'node:path'; +import WebSocket from 'ws'; +import type { AttachedFileMeta, RawMessage } from '@shared/chat/types'; +import type { RuntimeSendWithMediaPayload } from './types'; +import { getCcConnectMediaDir } from '../utils/runtime-media-paths'; +import * as logger from '../utils/logger'; + +type BridgeAdapterOptions = { + port: number; + token: string; + project: string; + projectForSessionKey?: (sessionKey: string) => string; + emit: EventEmitter['emit']; + heartbeatIntervalMs?: number; + reconnectDelayMs?: number; +}; + +type SessionMetadata = { + key: string; + displayName?: string; + derivedTitle?: string; + lastMessagePreview?: string; + agentId?: string; + createdAt: number; + updatedAt: number; +}; + +type PendingRun = { + runId: string; + sessionKey: string; + prompt: string; + startedAt: number; + seq: number; +}; + +type PendingRunResolution = { + runId: string; + pending: PendingRun; +}; + +type AbortedRun = { + sessionKey: string; + abortedAt: number; +}; + +type BridgeMediaKind = 'image' | 'file' | 'audio' | 'video'; + +type BridgeToolEventKind = 'started' | 'updated' | 'completed' | 'command-output' | 'patch-completed'; + +type BridgeProgressItem = { + kind: 'info' | 'thinking' | 'tool_use' | 'tool_result' | 'error'; + text: string; + tool?: string; + status?: string; + exit_code?: number; + success?: boolean; +}; + +type BridgeProgressState = { + runId: string; + sessionKey: string; + seenByIndex: Map; + toolCallIdByIndex: Map; + toolNameByIndex: Map; + completedToolIndexes: Set; + windowBase: number; + windowFingerprints: string[]; +}; + +type BridgeTextPreviewState = { + runId: string; + sessionKey: string; +}; + +type BridgeApprovalAction = { + action: string; + label?: string; +}; + +type BridgeInteractionKind = 'permission' | 'question' | 'choice'; + +type PendingApproval = { + runId: string; + sessionKey: string; + bridgeSessionKey: string; + replyCtx: string; + project: string; + itemId: string; + title: string; + kind: BridgeInteractionKind; + message: string; + actions: BridgeApprovalAction[]; + terminalCardActions: string[]; +}; + +type BridgeCardActionSet = { + actions: BridgeApprovalAction[]; + terminalCardActions: string[]; +}; + +const CONNECT_TIMEOUT_MS = 15_000; +const BRIDGE_HEARTBEAT_INTERVAL_MS = 25_000; +const BRIDGE_RECONNECT_DELAY_MS = 3_000; +const CLAWX_PROJECT_PREFIX = 'clawx-'; +export const CLAWX_BRIDGE_ADMIN_USER_ID = 'clawx-desktop'; +const ABORTED_RUN_TTL_MS = 10 * 60_000; +const PROGRESS_CARD_PAYLOAD_PREFIX = '__cc_connect_progress_card_v1__:'; +const TOOL_START_TYPES = new Set(['tool_start', 'tool.started', 'tool_call', 'tool_call_start', 'tool_use', 'tool_use_start']); +const TOOL_UPDATE_TYPES = new Set(['tool_update', 'tool.updated', 'tool_call_update', 'tool_use_update']); +const TOOL_COMPLETE_TYPES = new Set(['tool_result', 'tool.completed', 'tool_finish', 'tool_end', 'tool_call_result', 'tool_use_result']); +const COMMAND_OUTPUT_TYPES = new Set(['command_output', 'command.output', 'cmd_output', 'terminal_output']); +const PATCH_COMPLETED_TYPES = new Set(['patch_completed', 'patch.completed', 'patch_applied', 'file_patch_completed']); +const ARTIFACT_TYPES = new Set(['artifact', 'artifact_generated', 'generated_file', 'file_generated']); +const TOOL_ID_KEYS = ['tool_call_id', 'toolCallId', 'call_id', 'callId', 'tool_id', 'toolId', 'item_id', 'itemId', 'id']; +const TOOL_NAME_KEYS = ['tool_name', 'toolName', 'name', 'tool', 'command', 'title']; +const TOOL_ARG_KEYS = ['args', 'arguments', 'input', 'parameters', 'params', 'tool_input', 'toolInput']; +const FILE_ARG_KEYS = ['file_path', 'filePath', 'filepath', 'path', 'target_path', 'targetPath', 'file_name', 'fileName', 'filename']; +const EDIT_ARG_KEYS = [ + 'old_string', + 'oldString', + 'new_string', + 'newString', + 'old_text', + 'oldText', + 'new_text', + 'newText', + 'content', + 'contents', + 'diff', + 'patch', +]; + +function messageText(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .flatMap((block) => { + if (!block || typeof block !== 'object') return []; + const record = block as Record; + if (typeof record.text === 'string') return [record.text]; + if (typeof record.thinking === 'string') return [record.thinking]; + return []; + }) + .join('\n') + .trim(); +} + +function normalizeTimestamp(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value < 1e12 ? value * 1000 : value; + } + if (typeof value === 'string' && value.trim()) { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function firstString(record: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return undefined; +} + +function normalizedType(record: Record): string { + return typeof record.type === 'string' ? record.type.trim().toLowerCase() : ''; +} + +function firstRecord(record: Record, keys: string[]): Record | undefined { + for (const key of keys) { + const value = record[key]; + if (isRecord(value)) return value; + } + return undefined; +} + +function firstPayloadValue(record: Record, keys: string[]): unknown { + for (const key of keys) { + if (record[key] !== undefined) return record[key]; + } + return undefined; +} + +function numberFromUnknown(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; +} + +function extensionForMimeType(mimeType: string, fallback: string): string { + const normalized = mimeType.toLowerCase(); + if (normalized === 'image/png') return 'png'; + if (normalized === 'image/jpeg' || normalized === 'image/jpg') return 'jpg'; + if (normalized === 'image/gif') return 'gif'; + if (normalized === 'image/webp') return 'webp'; + if (normalized === 'application/pdf') return 'pdf'; + if (normalized === 'audio/mpeg') return 'mp3'; + if (normalized === 'audio/ogg') return 'ogg'; + if (normalized === 'audio/wav') return 'wav'; + return fallback; +} + +function sanitizeFileName(fileName: string): string { + return basename(fileName).replace(/[^\w.\- ()[\]]+/g, '_').replace(/^_+/, '') || 'attachment'; +} + +function bridgeToolObject(message: Record): Record | undefined { + return firstRecord(message, ['tool_call', 'toolCall', 'tool_use', 'toolUse', 'tool', 'call']); +} + +function bridgeToolCallId(message: Record): string { + const tool = bridgeToolObject(message); + return firstString(message, TOOL_ID_KEYS) + || (tool ? firstString(tool, TOOL_ID_KEYS) : undefined) + || `tool-${randomUUID()}`; +} + +function bridgeToolName(message: Record, fallback: string): string { + const tool = bridgeToolObject(message); + return firstString(message, TOOL_NAME_KEYS) + || (tool ? firstString(tool, TOOL_NAME_KEYS) : undefined) + || fallback; +} + +function normalizeBridgeToolArgs(message: Record): unknown { + const tool = bridgeToolObject(message); + const direct = firstPayloadValue(message, TOOL_ARG_KEYS); + if (direct !== undefined) return parseJsonToolArgs(direct); + if (tool) { + const nested = firstPayloadValue(tool, TOOL_ARG_KEYS); + if (nested !== undefined) return parseJsonToolArgs(nested); + } + + const collected: Record = {}; + for (const key of [...FILE_ARG_KEYS, ...EDIT_ARG_KEYS, 'cwd', 'exit_code', 'exitCode', 'output', 'status']) { + if (message[key] !== undefined) collected[key] = message[key]; + if (tool?.[key] !== undefined) collected[key] = tool[key]; + } + return Object.keys(collected).length > 0 ? collected : undefined; +} + +function parseJsonToolArgs(value: unknown): unknown { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + if ((!trimmed.startsWith('{') || !trimmed.endsWith('}')) + && (!trimmed.startsWith('[') || !trimmed.endsWith(']'))) { + return value; + } + try { + return JSON.parse(trimmed); + } catch { + return value; + } +} + +function isBridgeToolMessage(message: Record): BridgeToolEventKind | null { + const type = normalizedType(message); + if (TOOL_START_TYPES.has(type) || ARTIFACT_TYPES.has(type)) return 'started'; + if (TOOL_UPDATE_TYPES.has(type)) return 'updated'; + if (TOOL_COMPLETE_TYPES.has(type)) return 'completed'; + if (COMMAND_OUTPUT_TYPES.has(type)) return 'command-output'; + if (PATCH_COMPLETED_TYPES.has(type)) return 'patch-completed'; + return null; +} + +function parseBridgeProgressItems(content: unknown): BridgeProgressItem[] | null { + if (typeof content !== 'string' || !content.startsWith(PROGRESS_CARD_PAYLOAD_PREFIX)) return null; + try { + const payload = JSON.parse(content.slice(PROGRESS_CARD_PAYLOAD_PREFIX.length)); + if (!isRecord(payload) || !Array.isArray(payload.items)) return null; + return payload.items.flatMap((value): BridgeProgressItem[] => { + if (!isRecord(value)) return []; + const kind = firstString(value, ['kind']); + const text = firstString(value, ['text']); + if (!kind || !text || !['info', 'thinking', 'tool_use', 'tool_result', 'error'].includes(kind)) return []; + return [{ + kind: kind as BridgeProgressItem['kind'], + text, + tool: firstString(value, ['tool']), + status: firstString(value, ['status']), + ...(typeof value.exit_code === 'number' ? { exit_code: value.exit_code } : {}), + ...(typeof value.success === 'boolean' ? { success: value.success } : {}), + }]; + }); + } catch { + return null; + } +} + +function bridgeButtonActions(message: Record): BridgeApprovalAction[] { + if (!Array.isArray(message.buttons)) return []; + return message.buttons.flatMap((row) => { + const entries = Array.isArray(row) ? row : [row]; + return entries.flatMap((button) => { + if (!isRecord(button)) return []; + const action = firstString(button, ['data', 'Data', 'value', 'Value', 'action', 'Action']); + if (!action) return []; + const label = firstString(button, ['text', 'Text', 'label', 'Label', 'title', 'Title']); + return [{ action, ...(label ? { label } : {}) }]; + }); + }); +} + +function bridgeCardContent(card: Record): string { + const parts: string[] = []; + const header = isRecord(card.header) ? card.header : undefined; + const title = header ? firstString(header, ['title', 'Title']) : undefined; + if (title) parts.push(`**${title}**`); + if (!Array.isArray(card.elements)) return parts.join('\n\n'); + for (const value of card.elements) { + if (!isRecord(value)) continue; + const type = normalizedType(value); + if (type === 'markdown') { + const content = firstString(value, ['content', 'text']); + if (content) parts.push(content); + continue; + } + if (type === 'note' || type === 'list_item') { + const text = firstString(value, ['text', 'content']); + if (text) parts.push(text); + continue; + } + if (type === 'select') { + const placeholder = firstString(value, ['placeholder']); + if (placeholder) parts.push(placeholder); + } + } + return parts.join('\n\n').trim(); +} + +function bridgeCardActions(card: Record): BridgeCardActionSet { + if (!Array.isArray(card.elements)) return { actions: [], terminalCardActions: [] }; + const actions: BridgeApprovalAction[] = []; + const terminalCardActions: string[] = []; + for (const value of card.elements) { + if (!isRecord(value)) continue; + const type = normalizedType(value); + if (type === 'actions') { + actions.push(...bridgeButtonActions({ buttons: value.buttons })); + continue; + } + if (type === 'list_item') { + const action = firstString(value, ['btn_value', 'btnValue']); + if (!action) continue; + const label = firstString(value, ['btn_text', 'btnText']); + actions.push({ action, ...(label ? { label } : {}) }); + continue; + } + if (type === 'select' && Array.isArray(value.options)) { + for (const option of value.options) { + if (!isRecord(option)) continue; + const action = firstString(option, ['value', 'Value']); + if (!action) continue; + const label = firstString(option, ['text', 'Text', 'label', 'Label']); + actions.push({ action, ...(label ? { label } : {}) }); + terminalCardActions.push(action); + } + } + } + return { actions, terminalCardActions }; +} + +function supportedBridgeActions(actions: BridgeApprovalAction[]): BridgeApprovalAction[] { + return actions.filter(({ action }) => /^(perm:|askq:|cmd:|nav:|act:)/.test(action)); +} + +function toolArgsMentionFile(args: unknown): boolean { + if (!isRecord(args)) return false; + for (const key of FILE_ARG_KEYS) { + if (typeof args[key] === 'string' && args[key].trim()) return true; + } + return false; +} + +function looksLikeGeneratedFileTool(message: Record, name: string, args: unknown): boolean { + if (ARTIFACT_TYPES.has(normalizedType(message))) return true; + if (toolArgsMentionFile(args)) return true; + return /^(write|writefile|write_file|create_file|edit|editfile|edit_file|str_replace|strreplace|multi_edit|multiedit|apply_patch|applypatch|patch)$/i.test(name); +} + +function mimeTypeForBridgeMedia(kind: BridgeMediaKind, message: Record): string { + const explicit = firstString(message, ['mime_type', 'mimeType', 'content_type', 'contentType']); + if (explicit) return explicit; + if (kind === 'image') return 'image/png'; + if (kind === 'audio') { + const format = firstString(message, ['format']); + return format ? `audio/${format.replace(/^\./, '')}` : 'audio/mpeg'; + } + if (kind === 'video') { + const format = firstString(message, ['format']); + return format ? `video/${format.replace(/^\./, '')}` : 'video/mp4'; + } + return 'application/octet-stream'; +} + +export function toCcConnectBridgeSessionKey(sessionKey: string): string { + if (sessionKey.startsWith('clawx:')) return sessionKey; + if (!sessionKey.startsWith('agent:')) return sessionKey; + const [, scope = 'main', ...userParts] = sessionKey.split(':'); + const user = userParts.join(':') || 'main'; + return `clawx:${scope || 'main'}:${user || 'main'}`; +} + +function normalizeClawXAgentId(value: string | undefined): string { + const normalized = (value || 'main') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + return normalized || 'main'; +} + +export function ccConnectProjectNameForAgent(agentId: string): string { + return `${CLAWX_PROJECT_PREFIX}${normalizeClawXAgentId(agentId)}`; +} + +export function ccConnectProjectNameForSessionKey(sessionKey: string): string { + const bridgeKey = toCcConnectBridgeSessionKey(sessionKey); + if (!bridgeKey.startsWith('clawx:')) return ccConnectProjectNameForAgent('main'); + const [, agentId] = bridgeKey.split(':'); + return ccConnectProjectNameForAgent(agentId); +} + +function fromBridgeSessionKey(sessionKey: string): string { + if (sessionKey.startsWith('clawx:')) { + const [, scope = 'main', ...userParts] = sessionKey.split(':'); + const user = userParts.join(':') || 'main'; + return `agent:${scope || 'main'}:${user || 'main'}`; + } + return sessionKey; +} + +export class CcConnectBridgeAdapter { + private readonly port: number; + private readonly token: string; + private readonly project: string; + private readonly projectForSessionKey: (sessionKey: string) => string; + private readonly emitRuntimeEvent: EventEmitter['emit']; + private readonly heartbeatIntervalMs: number; + private readonly reconnectDelayMs: number; + private socket: WebSocket | null = null; + private readonly connectingSockets = new Set(); + private connectInFlight: Promise | null = null; + private heartbeatTimer: ReturnType | null = null; + private reconnectTimer: ReturnType | null = null; + private shouldReconnect = false; + private readonly messagesBySession = new Map(); + private readonly pendingRuns = new Map(); + private readonly abortedRuns = new Map(); + private readonly progressByHandle = new Map(); + private readonly textPreviewByHandle = new Map(); + private readonly pendingApprovals = new Map(); + private readonly terminalCardRuns = new Set(); + + constructor(options: BridgeAdapterOptions) { + this.port = options.port; + this.token = options.token; + this.project = options.project; + this.projectForSessionKey = options.projectForSessionKey ?? (() => options.project); + this.emitRuntimeEvent = options.emit; + this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? BRIDGE_HEARTBEAT_INTERVAL_MS; + this.reconnectDelayMs = options.reconnectDelayMs ?? BRIDGE_RECONNECT_DELAY_MS; + } + + async connect(): Promise { + this.shouldReconnect = true; + if (this.socket?.readyState === WebSocket.OPEN) return; + if (this.connectInFlight) return await this.connectInFlight; + const attempt = this.connectWithRetry(); + this.connectInFlight = attempt; + try { + await attempt; + } finally { + if (this.connectInFlight === attempt) this.connectInFlight = null; + } + } + + private async connectWithRetry(): Promise { + const startedAt = Date.now(); + let lastError: unknown; + while (this.shouldReconnect && Date.now() - startedAt < CONNECT_TIMEOUT_MS) { + try { + await this.connectOnce(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError || 'cc-connect bridge did not become ready')); + } + + async close(): Promise { + this.shouldReconnect = false; + this.clearHeartbeat(); + this.clearReconnectTimer(); + const sockets = new Set([ + ...this.connectingSockets, + ...(this.socket ? [this.socket] : []), + ]); + this.socket = null; + await Promise.all(Array.from(sockets, async (socket) => { + await new Promise((resolve) => { + if (socket.readyState === WebSocket.CLOSED) { + resolve(); + return; + } + socket.once('close', () => resolve()); + socket.close(); + setTimeout(resolve, 500); + }); + })); + await this.connectInFlight?.catch(() => undefined); + this.progressByHandle.clear(); + this.textPreviewByHandle.clear(); + this.pendingApprovals.clear(); + this.terminalCardRuns.clear(); + } + + isConnected(): boolean { + return this.socket?.readyState === WebSocket.OPEN; + } + + async send(payload: RuntimeSendWithMediaPayload): Promise<{ runId: string }> { + this.pruneAbortedRuns(); + await this.connect(); + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new Error('cc-connect bridge is not connected'); + } + const runId = `cc-connect-${randomUUID()}`; + const now = Date.now(); + const userMessage: RawMessage = { + id: `${runId}:user`, + role: 'user', + content: payload.message, + timestamp: now, + }; + this.appendMessage(payload.sessionKey, userMessage); + this.pendingRuns.set(runId, { + runId, + sessionKey: payload.sessionKey, + prompt: payload.message, + startedAt: now, + seq: 0, + }); + this.emitRuntimeEvent('chat:runtime-event', { + type: 'run.started', + runId, + sessionKey: payload.sessionKey, + startedAt: now, + ts: now, + }); + this.socket.send(JSON.stringify({ + type: 'message', + msg_id: payload.idempotencyKey || runId, + session_key: toCcConnectBridgeSessionKey(payload.sessionKey), + user_id: CLAWX_BRIDGE_ADMIN_USER_ID, + user_name: 'ClawX', + content: payload.message, + reply_ctx: runId, + project: this.projectForSessionKey(payload.sessionKey), + images: [], + files: payload.media?.map((item) => ({ + mime_type: item.mimeType, + file_name: item.fileName, + path: item.filePath, + })) ?? [], + })); + return { runId }; + } + + async abort(payload?: unknown): Promise<{ + success: true; + abortedRuns: string[]; + stoppedSessions: string[]; + upstreamStopRequested: boolean; + }> { + const body = isRecord(payload) ? payload : {}; + const requestedRunId = typeof body.runId === 'string' && body.runId.trim() + ? body.runId.trim() + : undefined; + const requestedSessionKey = typeof body.sessionKey === 'string' && body.sessionKey.trim() + ? body.sessionKey.trim() + : undefined; + const matches = Array.from(this.pendingRuns.entries()).filter(([runId, pending]) => { + if (requestedRunId) return runId === requestedRunId; + if (requestedSessionKey) { + return pending.sessionKey === requestedSessionKey + || toCcConnectBridgeSessionKey(pending.sessionKey) === requestedSessionKey + || pending.sessionKey === fromBridgeSessionKey(requestedSessionKey); + } + return true; + }); + const now = Date.now(); + const abortedRuns: string[] = []; + const controlRunsBySession = new Map(); + for (const [runId, pending] of matches) { + this.pendingRuns.delete(runId); + this.abortedRuns.set(runId, { sessionKey: pending.sessionKey, abortedAt: now }); + this.clearProgressForRun(runId); + this.clearApprovalsForRun(runId); + this.terminalCardRuns.delete(runId); + abortedRuns.push(runId); + if (!controlRunsBySession.has(pending.sessionKey)) { + controlRunsBySession.set(pending.sessionKey, runId); + } + this.emitRuntimeEvent('chat:runtime-event', { + type: 'run.ended', + runId, + sessionKey: pending.sessionKey, + status: 'aborted', + endedAt: now, + seq: pending.seq + 1, + ts: now, + stopReason: 'user', + }); + } + const stoppedSessions: string[] = []; + let upstreamStopRequested = true; + for (const [sessionKey, runId] of controlRunsBySession.entries()) { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + upstreamStopRequested = false; + continue; + } + try { + const socket = this.socket; + await new Promise((resolve, reject) => { + socket.send(JSON.stringify({ + type: 'message', + msg_id: `cc-connect-stop-${randomUUID()}`, + session_key: toCcConnectBridgeSessionKey(sessionKey), + user_id: CLAWX_BRIDGE_ADMIN_USER_ID, + user_name: 'ClawX', + content: '/stop', + reply_ctx: runId, + project: this.projectForSessionKey(sessionKey), + images: [], + files: [], + }), (error) => error ? reject(error) : resolve()); + }); + stoppedSessions.push(sessionKey); + } catch (error) { + upstreamStopRequested = false; + logger.warn('[cc-connect bridge] failed to send session stop command', { + sessionKey, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return { success: true, abortedRuns, stoppedSessions, upstreamStopRequested }; + } + + async respondApproval(payload?: unknown): Promise<{ + success: true; + runId: string; + action: string; + status: 'approved' | 'denied' | 'answered'; + }> { + const body = isRecord(payload) ? payload : {}; + const runId = firstString(body, ['runId']); + const action = firstString(body, ['action']); + if (!runId || !action) { + throw new Error('cc-connect approval response requires runId and action'); + } + const pending = this.pendingApprovals.get(runId); + if (!pending) { + throw new Error(`No pending cc-connect approval for run ${runId}`); + } + if (!pending.actions.some((candidate) => candidate.action === action)) { + throw new Error(`cc-connect approval action is not available for run ${runId}`); + } + if (!this.pendingRuns.has(runId)) { + this.pendingApprovals.delete(runId); + throw new Error(`cc-connect run ${runId} is no longer active`); + } + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new Error('cc-connect bridge is not connected'); + } + + this.socket.send(JSON.stringify({ + type: 'card_action', + session_key: pending.bridgeSessionKey, + action, + reply_ctx: pending.replyCtx, + project: pending.project, + })); + this.pendingApprovals.delete(runId); + if (pending.terminalCardActions.includes(action)) this.terminalCardRuns.add(runId); + + const status = action === 'perm:deny' + ? 'denied' + : pending.kind === 'choice' || action.startsWith('askq:') + ? 'answered' + : 'approved'; + const run = this.pendingRuns.get(runId); + const now = Date.now(); + if (run) run.seq += 1; + this.emitRuntimeEvent('chat:runtime-event', { + type: 'approval.updated', + runId, + sessionKey: pending.sessionKey, + itemId: pending.itemId, + title: pending.title, + kind: pending.kind, + phase: 'resolved', + status, + message: pending.message, + actions: pending.actions, + ...(run ? { seq: run.seq } : {}), + ts: now, + }); + return { success: true, runId, action, status }; + } + + async listSessions(): Promise { + return Array.from(this.messagesBySession.entries()) + .filter(([, messages]) => messages.length > 0) + .map(([key, messages]) => { + const firstUser = messages.find((message) => message.role === 'user'); + const updatedAt = messages.reduce((latest, message) => { + const timestamp = normalizeTimestamp(message.timestamp); + return timestamp ? Math.max(latest, timestamp) : latest; + }, 0); + return { + key, + displayName: messageText(firstUser?.content).slice(0, 80) || key, + derivedTitle: messageText(firstUser?.content).slice(0, 80) || undefined, + lastMessagePreview: messageText(messages.at(-1)?.content).slice(0, 160) || undefined, + agentId: ccConnectProjectNameForSessionKey(key).slice(CLAWX_PROJECT_PREFIX.length) || 'main', + createdAt: normalizeTimestamp(messages[0]?.timestamp) ?? updatedAt, + updatedAt, + }; + }) + .sort((left, right) => right.updatedAt - left.updatedAt); + } + + async loadHistory(sessionKey: string, limit = 200): Promise { + const messages = this.messagesBySession.get(sessionKey) ?? []; + return messages.slice(-Math.max(1, Math.min(Math.floor(limit), 1000))); + } + + forgetSession(sessionKey: string): void { + this.messagesBySession.delete(sessionKey); + for (const [runId, approval] of this.pendingApprovals.entries()) { + if (approval.sessionKey === sessionKey) this.pendingApprovals.delete(runId); + } + } + + private connectOnce(): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(`ws://127.0.0.1:${this.port}/bridge/ws?token=${encodeURIComponent(this.token)}`); + this.connectingSockets.add(socket); + let settled = false; + const timeout = setTimeout(() => { + finish(new Error('cc-connect bridge connection timed out')); + }, 1500); + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + this.connectingSockets.delete(socket); + if (error) { + if (socket.readyState !== WebSocket.CLOSING && socket.readyState !== WebSocket.CLOSED) socket.close(); + reject(error); + } else { + resolve(); + } + }; + socket.once('open', () => { + socket.send(JSON.stringify({ + type: 'register', + platform: 'clawx', + project: this.project, + capabilities: [ + 'text', + 'image', + 'file', + 'audio', + 'video', + 'card', + 'buttons', + 'typing', + 'preview', + 'update_message', + 'delete_message', + 'reconstruct_reply', + ], + metadata: { + protocol_version: 1, + adapter: 'clawx', + progress_style: 'card', + supports_progress_card_payload: true, + description: 'ClawX GUI bridge adapter', + }, + })); + }); + socket.on('message', (data) => { + const parsed = this.parseMessage(data); + if (!parsed) return; + if (parsed.type === 'register_ack') { + if (parsed.ok === true) { + this.socket = socket; + this.startHeartbeat(socket); + finish(); + } else { + finish(new Error(typeof parsed.error === 'string' ? parsed.error : 'cc-connect bridge registration failed')); + } + return; + } + this.handleServerMessage(parsed); + }); + socket.once('error', (error) => finish(error)); + socket.once('close', () => { + if (this.socket === socket) { + this.socket = null; + this.clearHeartbeat(); + this.scheduleReconnect(); + } + finish(new Error('cc-connect bridge connection closed')); + }); + }); + } + + private startHeartbeat(socket: WebSocket): void { + this.clearHeartbeat(); + this.heartbeatTimer = setInterval(() => { + if (this.socket !== socket || socket.readyState !== WebSocket.OPEN) return; + try { + socket.send(JSON.stringify({ type: 'ping', ts: Date.now() })); + } catch { + socket.terminate(); + } + }, this.heartbeatIntervalMs); + this.heartbeatTimer.unref?.(); + } + + private clearHeartbeat(): void { + if (!this.heartbeatTimer) return; + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + + private scheduleReconnect(): void { + if (!this.shouldReconnect || this.reconnectTimer) return; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + if (!this.shouldReconnect || this.socket?.readyState === WebSocket.OPEN) return; + void this.connect().catch(() => this.scheduleReconnect()); + }, this.reconnectDelayMs); + this.reconnectTimer.unref?.(); + } + + private clearReconnectTimer(): void { + if (!this.reconnectTimer) return; + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + private parseMessage(data: WebSocket.RawData): Record | null { + try { + const text = Array.isArray(data) + ? Buffer.concat(data).toString('utf8') + : Buffer.isBuffer(data) + ? data.toString('utf8') + : String(data); + const parsed = JSON.parse(text); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } + } + + private handleServerMessage(message: Record): void { + const progressItems = parseBridgeProgressItems(message.content); + if (progressItems) { + logger.debug( + `[cc-connect bridge] progress packet type=${String(message.type || 'unknown')}` + + ` itemKinds=${progressItems.map((item) => item.kind).join(',')}`, + ); + } + if (message.type === 'ping') { + this.socket?.send(JSON.stringify({ type: 'pong', ts: Date.now() })); + return; + } + if (message.type === 'preview_start') { + const handle = String(message.ref_id || randomUUID()); + const handledAsProgress = this.handleBridgeProgress(message, handle); + if (!handledAsProgress) this.emitTextPreview(message, handle); + this.socket?.send(JSON.stringify({ + type: 'preview_ack', + ref_id: message.ref_id, + preview_handle: handle, + })); + return; + } + if (message.type === 'update_message') { + const handle = firstString(message, ['preview_handle', 'previewHandle']); + if (handle && this.handleBridgeProgress(message, handle)) return; + if (handle) { + this.emitTextPreview(message, handle); + } else { + this.emitAssistantDelta({ + ...message, + full_text: typeof message.content === 'string' ? message.content : '', + }); + } + return; + } + if (message.type === 'delete_message') { + const handle = firstString(message, ['preview_handle', 'previewHandle']); + if (handle) this.deletePreview(handle); + return; + } + if (message.type === 'typing_start' || message.type === 'typing_stop') { + return; + } + const toolEventKind = isBridgeToolMessage(message); + if (toolEventKind) { + this.handleBridgeToolMessage(message, toolEventKind); + return; + } + if (message.type === 'reply') { + void this.finishRun(message, typeof message.content === 'string' ? message.content : ''); + return; + } + if (message.type === 'reply_stream' && message.done !== true) { + this.emitAssistantDelta(message); + return; + } + if (message.type === 'reply_stream' && message.done === true) { + const text = typeof message.full_text === 'string' + ? message.full_text + : typeof message.delta === 'string' + ? message.delta + : ''; + void this.finishRun(message, text); + return; + } + if (message.type === 'card') { + const card = isRecord(message.card) ? message.card : {}; + const content = bridgeCardContent(card); + const resolved = this.resolvePendingRun(message); + if (resolved && this.terminalCardRuns.delete(resolved.runId)) { + void this.finishPendingRun(resolved.pending, { text: content || JSON.stringify(card) }); + return; + } + const header = isRecord(card.header) ? card.header : undefined; + const cardActions = bridgeCardActions(card); + if (this.handleBridgeInteraction({ + ...message, + content, + interaction_title: header ? firstString(header, ['title', 'Title']) : undefined, + }, cardActions.actions, cardActions.terminalCardActions)) return; + void this.finishRun(message, content || JSON.stringify(card)); + return; + } + if (message.type === 'buttons') { + if (this.handleBridgeInteraction(message)) return; + void this.finishRun(message, typeof message.content === 'string' ? message.content : ''); + return; + } + if (message.type === 'image' || message.type === 'file' || message.type === 'audio' || message.type === 'video') { + void this.finishMediaRun(message, message.type); + return; + } + if (message.type === 'error') { + void this.finishRun(message, typeof message.message === 'string' ? message.message : 'cc-connect bridge error', true); + } + } + + private handleBridgeProgress(message: Record, handle: string): boolean { + const items = parseBridgeProgressItems(message.content); + if (!items) return false; + let state = this.progressByHandle.get(handle); + if (!state) { + const resolved = this.resolvePendingRun(message); + if (!resolved) return true; + state = { + runId: resolved.runId, + sessionKey: resolved.pending.sessionKey, + seenByIndex: new Map(), + toolCallIdByIndex: new Map(), + toolNameByIndex: new Map(), + completedToolIndexes: new Set(), + windowBase: 0, + windowFingerprints: [], + }; + this.progressByHandle.set(handle, state); + } + const pending = this.pendingRuns.get(state.runId); + if (!pending || this.abortedRuns.has(state.runId)) return true; + + const windowBase = this.reconcileProgressWindow(state, items); + for (let localIndex = 0; localIndex < items.length; localIndex += 1) { + const item = items[localIndex]; + const index = windowBase + localIndex; + const fingerprint = JSON.stringify(item); + const previous = state.seenByIndex.get(index); + if (previous === fingerprint) continue; + state.seenByIndex.set(index, fingerprint); + const now = Date.now(); + + if (item.kind === 'thinking' || item.kind === 'info') { + pending.seq += 1; + this.emitRuntimeEvent('chat:runtime-event', { + type: 'thinking.delta', + runId: state.runId, + sessionKey: state.sessionKey, + text: item.text, + seq: pending.seq, + ts: now, + }); + continue; + } + + if (item.kind === 'tool_use') { + if (previous !== undefined) continue; + const toolCallId = `${state.runId}:progress:${index}`; + state.toolCallIdByIndex.set(index, toolCallId); + state.toolNameByIndex.set(index, item.tool || 'tool'); + this.handleBridgeToolMessage({ + type: 'tool_call', + reply_ctx: state.runId, + session_key: toCcConnectBridgeSessionKey(state.sessionKey), + tool_call_id: toolCallId, + tool_name: item.tool || 'tool', + args: item.text, + }, 'started'); + continue; + } + + const matchingToolIndex = this.findProgressToolIndex(items, state, localIndex, windowBase, item.tool); + const toolCallId = matchingToolIndex === null + ? `${state.runId}:progress:${index}` + : state.toolCallIdByIndex.get(matchingToolIndex) || `${state.runId}:progress:${matchingToolIndex}`; + if (matchingToolIndex !== null) state.completedToolIndexes.add(matchingToolIndex); + this.handleBridgeToolMessage({ + type: 'tool_result', + reply_ctx: state.runId, + session_key: toCcConnectBridgeSessionKey(state.sessionKey), + tool_call_id: toolCallId, + tool_name: item.tool || 'tool', + result: item.text, + status: item.status, + exit_code: item.exit_code, + is_error: item.kind === 'error' || item.success === false, + meta: { + status: item.status, + exitCode: item.exit_code, + success: item.success, + }, + }, 'completed'); + } + return true; + } + + private emitTextPreview(message: Record, handle: string): void { + const known = this.textPreviewByHandle.get(handle); + const pending = known ? this.pendingRuns.get(known.runId) : undefined; + const resolved = known && pending + ? { runId: known.runId, pending } + : this.resolvePendingRun(message); + if (!resolved || this.abortedRuns.has(resolved.runId)) return; + this.textPreviewByHandle.set(handle, { + runId: resolved.runId, + sessionKey: resolved.pending.sessionKey, + }); + this.emitAssistantDelta({ + ...message, + reply_ctx: resolved.runId, + session_key: toCcConnectBridgeSessionKey(resolved.pending.sessionKey), + full_text: typeof message.content === 'string' ? message.content : '', + }); + } + + private deletePreview(handle: string): void { + const preview = this.textPreviewByHandle.get(handle); + if (!preview) return; + this.textPreviewByHandle.delete(handle); + const pending = this.pendingRuns.get(preview.runId); + if (!pending || this.abortedRuns.has(preview.runId)) return; + const now = Date.now(); + pending.seq += 1; + this.emitRuntimeEvent('chat:runtime-event', { + type: 'assistant.delta', + runId: preview.runId, + sessionKey: preview.sessionKey, + text: '', + replace: true, + seq: pending.seq, + ts: now, + }); + } + + private findProgressToolIndex( + items: BridgeProgressItem[], + state: BridgeProgressState, + resultLocalIndex: number, + windowBase: number, + toolName?: string, + ): number | null { + for (let localIndex = resultLocalIndex - 1; localIndex >= 0; localIndex -= 1) { + if (items[localIndex]?.kind !== 'tool_use') continue; + if (toolName && items[localIndex]?.tool && items[localIndex].tool !== toolName) continue; + const index = windowBase + localIndex; + if (state.toolCallIdByIndex.has(index)) return index; + } + const knownIndexes = Array.from(state.toolCallIdByIndex.keys()).sort((left, right) => right - left); + for (const index of knownIndexes) { + if (state.completedToolIndexes.has(index)) continue; + const knownName = state.toolNameByIndex.get(index); + if (!toolName || !knownName || knownName === toolName) return index; + } + return null; + } + + private reconcileProgressWindow(state: BridgeProgressState, items: BridgeProgressItem[]): number { + const next = items.map((item) => JSON.stringify(item)); + const previous = state.windowFingerprints; + let overlap = Math.min(previous.length, next.length); + while (overlap > 0) { + const previousStart = previous.length - overlap; + let matches = true; + for (let index = 0; index < overlap; index += 1) { + if (previous[previousStart + index] !== next[index]) { + matches = false; + break; + } + } + if (matches) break; + overlap -= 1; + } + const nextBase = previous.length === 0 + ? 0 + : state.windowBase + previous.length - overlap; + state.windowBase = nextBase; + state.windowFingerprints = next; + return nextBase; + } + + private handleBridgeInteraction( + message: Record, + candidateActions = bridgeButtonActions(message), + terminalCardActions: string[] = [], + ): boolean { + const actions = supportedBridgeActions(candidateActions); + if (actions.length === 0) return false; + const resolved = this.resolvePendingRun(message); + if (!resolved) return true; + const hasPermission = actions.some(({ action }) => action.startsWith('perm:')); + const kind: BridgeInteractionKind = hasPermission + ? 'permission' + : actions.some(({ action }) => action.startsWith('askq:')) + ? 'question' + : 'choice'; + const supportedActions = kind === 'permission' + ? actions.filter(({ action }) => action.startsWith('perm:')) + : kind === 'question' + ? actions.filter(({ action }) => action.startsWith('askq:')) + : actions; + const itemId = `${resolved.runId}:${kind === 'choice' ? 'interaction' : 'approval'}`; + const title = kind === 'choice' + ? firstString(message, ['interaction_title']) || '' + : 'Codex approval'; + const content = typeof message.content === 'string' ? message.content : ''; + const bridgeSessionKey = typeof message.session_key === 'string' + ? message.session_key + : toCcConnectBridgeSessionKey(resolved.pending.sessionKey); + this.pendingApprovals.set(resolved.runId, { + runId: resolved.runId, + sessionKey: resolved.pending.sessionKey, + bridgeSessionKey, + replyCtx: firstString(message, ['reply_ctx']) || resolved.runId, + project: firstString(message, ['project']) || this.projectForSessionKey(resolved.pending.sessionKey), + itemId, + title, + kind, + message: content, + actions: supportedActions, + terminalCardActions: terminalCardActions.filter((action) => ( + supportedActions.some((candidate) => candidate.action === action) + )), + }); + resolved.pending.seq += 1; + const now = Date.now(); + this.emitRuntimeEvent('chat:runtime-event', { + type: 'approval.updated', + runId: resolved.runId, + sessionKey: resolved.pending.sessionKey, + itemId, + title, + kind, + phase: 'requested', + status: 'pending', + message: content, + actions: supportedActions, + seq: resolved.pending.seq, + ts: now, + }); + return true; + } + + private handleBridgeToolMessage(message: Record, kind: BridgeToolEventKind): void { + if (this.isAbortedBridgeMessage(message)) return; + const resolved = this.resolvePendingRun(message); + const runId = resolved?.runId || firstString(message, ['reply_ctx', 'run_id', 'runId']) || `cc-connect-${randomUUID()}`; + const sessionKey = resolved?.pending.sessionKey + || (typeof message.session_key === 'string' ? fromBridgeSessionKey(message.session_key) : 'agent:main:main'); + const now = Date.now(); + const seq = resolved ? (resolved.pending.seq += 1) : undefined; + const toolCallId = bridgeToolCallId(message); + const fallbackName = kind === 'patch-completed' ? 'patch' : kind === 'command-output' ? 'command' : 'tool'; + const name = bridgeToolName(message, fallbackName); + const args = normalizeBridgeToolArgs(message); + const result = firstPayloadValue(message, ['result', 'output', 'content', 'data', 'error']); + const output = typeof message.output === 'string' + ? message.output + : typeof message.content === 'string' + ? message.content + : typeof result === 'string' + ? result + : undefined; + + if (kind === 'started') { + this.emitRuntimeEvent('chat:runtime-event', { + type: 'tool.started', + runId, + sessionKey, + toolCallId, + name, + ...(args !== undefined ? { args } : {}), + ...(seq !== undefined ? { seq } : {}), + ts: now, + }); + if (looksLikeGeneratedFileTool(message, name, args)) { + this.emitToolCallMessage({ runId, sessionKey, toolCallId, name, args, timestamp: now }); + } + return; + } + + if (kind === 'updated') { + this.emitRuntimeEvent('chat:runtime-event', { + type: 'tool.updated', + runId, + sessionKey, + toolCallId, + name, + ...(result !== undefined ? { partialResult: result } : {}), + ...(seq !== undefined ? { seq } : {}), + ts: now, + }); + return; + } + + if (kind === 'completed') { + this.emitRuntimeEvent('chat:runtime-event', { + type: 'tool.completed', + runId, + sessionKey, + toolCallId, + name, + ...(result !== undefined ? { result } : {}), + ...(message.meta !== undefined ? { meta: message.meta } : {}), + ...(message.is_error === true || message.isError === true ? { isError: true } : {}), + ...(seq !== undefined ? { seq } : {}), + ts: now, + }); + return; + } + + if (kind === 'command-output') { + this.emitRuntimeEvent('chat:runtime-event', { + type: 'command.output', + runId, + sessionKey, + toolCallId, + itemId: firstString(message, ['item_id', 'itemId', 'id']) || toolCallId, + name, + title: firstString(message, ['title']) || name, + ...(output !== undefined ? { output } : {}), + ...(firstString(message, ['status', 'phase']) ? { status: firstString(message, ['status', 'phase']) } : {}), + ...(typeof message.phase === 'string' ? { phase: message.phase } : {}), + ...(typeof message.exit_code === 'number' ? { exitCode: message.exit_code } : {}), + ...(typeof message.exitCode === 'number' ? { exitCode: message.exitCode } : {}), + ...(typeof message.duration_ms === 'number' ? { durationMs: message.duration_ms } : {}), + ...(typeof message.durationMs === 'number' ? { durationMs: message.durationMs } : {}), + ...(typeof message.cwd === 'string' ? { cwd: message.cwd } : {}), + ...(seq !== undefined ? { seq } : {}), + ts: now, + }); + return; + } + + this.emitRuntimeEvent('chat:runtime-event', { + type: 'patch.completed', + runId, + sessionKey, + toolCallId, + itemId: firstString(message, ['item_id', 'itemId', 'id']) || toolCallId, + name, + title: firstString(message, ['title']) || name, + summary: firstString(message, ['summary', 'content', 'output']), + added: numberFromUnknown(message.added ?? message.additions), + modified: numberFromUnknown(message.modified ?? message.changed), + deleted: numberFromUnknown(message.deleted ?? message.deletions), + ...(seq !== undefined ? { seq } : {}), + ts: now, + }); + } + + private emitToolCallMessage(options: { + runId: string; + sessionKey: string; + toolCallId: string; + name: string; + args: unknown; + timestamp: number; + }): void { + const message: RawMessage = { + id: `${options.runId}:${options.toolCallId}:tool`, + role: 'assistant', + content: [{ + type: 'toolCall', + id: options.toolCallId, + name: options.name, + arguments: options.args ?? {}, + }], + timestamp: options.timestamp, + stopReason: 'tool_use', + }; + this.appendMessage(options.sessionKey, message); + this.emitRuntimeEvent('chat:message', { + state: 'final', + runId: options.runId, + sessionKey: options.sessionKey, + message, + }); + } + + private async finishMediaRun(message: Record, kind: BridgeMediaKind): Promise { + if (this.isAbortedBridgeMessage(message)) return; + const attachment = await this.normalizeBridgeMediaAttachment(message, kind); + const label = firstString(message, ['content', 'caption', 'title', 'file_name', 'fileName', 'name']) + || attachment.fileName; + const resolved = this.resolvePendingRun(message); + if (resolved) { + await this.finishPendingRun(resolved.pending, { + text: label, + attachedFiles: [attachment], + }); + return; + } + + const runId = typeof message.reply_ctx === 'string' ? message.reply_ctx : ''; + const sessionKey = typeof message.session_key === 'string' + ? fromBridgeSessionKey(message.session_key) + : 'agent:main:main'; + const now = Date.now(); + const assistantMessage: RawMessage = { + id: `${runId || randomUUID()}:${kind}`, + role: 'assistant', + content: label, + timestamp: now, + _attachedFiles: [attachment], + }; + this.appendMessage(sessionKey, assistantMessage); + this.emitRuntimeEvent('chat:message', { + state: 'final', + runId, + sessionKey, + message: assistantMessage, + }); + this.emitRuntimeEvent('chat:runtime-event', { + type: 'run.ended', + runId, + sessionKey, + status: 'completed', + endedAt: now, + ts: now, + }); + } + + private async normalizeBridgeMediaAttachment( + message: Record, + kind: BridgeMediaKind, + ): Promise { + const mimeType = mimeTypeForBridgeMedia(kind, message); + const fileName = sanitizeFileName( + firstString(message, ['file_name', 'fileName', 'name', 'filename']) + || `${kind}.${extensionForMimeType(mimeType, kind === 'audio' ? 'mp3' : kind === 'video' ? 'mp4' : 'bin')}`, + ); + const filePath = firstString(message, ['path', 'file_path', 'filePath', 'local_path', 'localPath']); + const url = firstString(message, ['url', 'file_url', 'fileUrl']); + const rawData = firstString(message, ['data', 'base64']); + const dataMatch = rawData?.match(/^data:([^;]+);base64,(.*)$/); + const base64Data = dataMatch ? dataMatch[2] : rawData; + const dataMimeType = dataMatch?.[1] || mimeType; + + if (base64Data) { + const buffer = Buffer.from(base64Data, 'base64'); + const outputDir = join(getCcConnectMediaDir(), 'outgoing', 'bridge'); + await mkdir(outputDir, { recursive: true }); + const outputPath = join(outputDir, `${Date.now()}-${randomUUID()}-${fileName}`); + await writeFile(outputPath, buffer); + return { + fileName, + mimeType: dataMimeType, + fileSize: buffer.byteLength, + preview: dataMimeType.startsWith('image/') ? `data:${dataMimeType};base64,${base64Data}` : null, + filePath: outputPath, + source: 'gateway-media', + }; + } + + if (filePath) { + return { + fileName, + mimeType, + fileSize: numberFromUnknown(message.file_size ?? message.fileSize ?? message.size), + preview: null, + filePath, + source: 'gateway-media', + }; + } + + return { + fileName, + mimeType, + fileSize: numberFromUnknown(message.file_size ?? message.fileSize ?? message.size), + preview: kind === 'image' && url && !url.startsWith('/') ? url : null, + ...(url?.startsWith('/') ? { gatewayUrl: url } : {}), + source: 'gateway-media', + }; + } + + private emitAssistantDelta(message: Record): void { + const resolved = this.resolvePendingRun(message); + if (!resolved) return; + const { runId, pending } = resolved; + const fullText = typeof message.full_text === 'string' ? message.full_text : ''; + const delta = typeof message.delta === 'string' ? message.delta : ''; + const text = typeof message.text === 'string' ? message.text : fullText; + if (!text && !delta) return; + const now = Date.now(); + pending.seq += 1; + this.emitRuntimeEvent('chat:runtime-event', { + type: 'assistant.delta', + runId, + sessionKey: pending.sessionKey, + seq: pending.seq, + ts: now, + ...(text ? { text, replace: true } : { delta }), + }); + } + + private async finishRun(message: Record, text: string, isError = false): Promise { + if (this.isAbortedBridgeMessage(message)) return; + const resolved = this.resolvePendingRun(message); + if (resolved) { + await this.finishPendingRun(resolved.pending, { text, isError }); + return; + } + const runId = typeof message.reply_ctx === 'string' ? message.reply_ctx : ''; + const now = Date.now(); + const assistantMessage: RawMessage = { + id: `${runId || randomUUID()}:assistant`, + role: isError ? 'system' : 'assistant', + content: text, + timestamp: now, + ...(isError ? { isError: true, errorMessage: text } : {}), + }; + this.appendMessage('agent:main:main', assistantMessage); + this.emitRuntimeEvent('chat:message', { + state: 'final', + runId, + sessionKey: 'agent:main:main', + message: assistantMessage, + }); + this.emitRuntimeEvent('chat:runtime-event', { + type: 'run.ended', + runId, + sessionKey: 'agent:main:main', + status: isError ? 'error' : 'completed', + endedAt: now, + ts: now, + ...(isError ? { error: text } : {}), + }); + } + + private resolvePendingRun(message: Record): PendingRunResolution | null { + const replyCtx = typeof message.reply_ctx === 'string' ? message.reply_ctx : ''; + const byReplyCtx = replyCtx ? this.pendingRuns.get(replyCtx) : undefined; + if (byReplyCtx) return { runId: replyCtx, pending: byReplyCtx }; + + const bridgeSessionKey = typeof message.session_key === 'string' ? message.session_key : ''; + if (bridgeSessionKey) { + const sessionKey = fromBridgeSessionKey(bridgeSessionKey); + for (const [runId, pending] of this.pendingRuns.entries()) { + if (pending.sessionKey === sessionKey || toCcConnectBridgeSessionKey(pending.sessionKey) === bridgeSessionKey) { + return { runId, pending }; + } + } + } + + if (this.pendingRuns.size === 1) { + const [runId, pending] = Array.from(this.pendingRuns.entries())[0]; + return { runId, pending }; + } + return null; + } + + private async finishPendingRun(pending: PendingRun, options: { + text: string; + isError?: boolean; + appendMessage?: boolean; + messageId?: string; + timestamp?: number; + attachedFiles?: AttachedFileMeta[]; + }): Promise { + const now = options.timestamp ?? Date.now(); + const isError = options.isError === true; + this.completeOpenProgressTools(pending.runId, isError); + const assistantMessage: RawMessage = { + id: options.messageId || `${pending.runId}:assistant`, + role: isError ? 'system' : 'assistant', + content: options.text, + timestamp: now, + ...(isError ? { isError: true, errorMessage: options.text } : {}), + ...(options.attachedFiles?.length ? { _attachedFiles: options.attachedFiles } : {}), + }; + if (options.appendMessage !== false) { + this.appendMessage(pending.sessionKey, assistantMessage); + this.emitRuntimeEvent('chat:message', { + state: 'final', + runId: pending.runId, + sessionKey: pending.sessionKey, + message: assistantMessage, + }); + } + this.emitRuntimeEvent('chat:runtime-event', { + type: 'run.ended', + runId: pending.runId, + sessionKey: pending.sessionKey, + status: isError ? 'error' : 'completed', + endedAt: now, + seq: pending.seq + 1, + ts: now, + ...(isError ? { error: options.text } : {}), + }); + this.pendingRuns.delete(pending.runId); + this.clearProgressForRun(pending.runId); + this.clearApprovalsForRun(pending.runId); + this.terminalCardRuns.delete(pending.runId); + } + + private completeOpenProgressTools(runId: string, isError: boolean): void { + for (const state of this.progressByHandle.values()) { + if (state.runId !== runId) continue; + for (const [index, toolCallId] of state.toolCallIdByIndex.entries()) { + if (state.completedToolIndexes.has(index)) continue; + state.completedToolIndexes.add(index); + this.handleBridgeToolMessage({ + type: 'tool_result', + reply_ctx: runId, + session_key: toCcConnectBridgeSessionKey(state.sessionKey), + tool_call_id: toolCallId, + tool_name: state.toolNameByIndex.get(index) || 'tool', + result: '', + status: isError ? 'failed' : 'completed', + is_error: isError, + meta: { + status: isError ? 'failed' : 'completed', + success: !isError, + inferredFromRunCompletion: true, + }, + }, 'completed'); + } + } + } + + private isAbortedBridgeMessage(message: Record): boolean { + this.pruneAbortedRuns(); + const replyCtx = typeof message.reply_ctx === 'string' ? message.reply_ctx : ''; + if (replyCtx && this.abortedRuns.has(replyCtx)) return true; + if (replyCtx) return false; + const bridgeSessionKey = typeof message.session_key === 'string' ? message.session_key : ''; + if (!bridgeSessionKey) return false; + const sessionKey = fromBridgeSessionKey(bridgeSessionKey); + const hasPendingForSession = Array.from(this.pendingRuns.values()).some((pending) => ( + pending.sessionKey === sessionKey || toCcConnectBridgeSessionKey(pending.sessionKey) === bridgeSessionKey + )); + if (hasPendingForSession) return false; + return Array.from(this.abortedRuns.values()).some((aborted) => aborted.sessionKey === sessionKey); + } + + private pruneAbortedRuns(): void { + const cutoff = Date.now() - ABORTED_RUN_TTL_MS; + for (const [runId, aborted] of this.abortedRuns.entries()) { + if (aborted.abortedAt < cutoff) this.abortedRuns.delete(runId); + } + } + + private clearProgressForRun(runId: string): void { + for (const [handle, state] of this.progressByHandle.entries()) { + if (state.runId === runId) this.progressByHandle.delete(handle); + } + for (const [handle, state] of this.textPreviewByHandle.entries()) { + if (state.runId === runId) this.textPreviewByHandle.delete(handle); + } + } + + private clearApprovalsForRun(runId: string): void { + this.pendingApprovals.delete(runId); + } + + private appendMessage(sessionKey: string, message: RawMessage): void { + this.messagesBySession.set(sessionKey, [ + ...(this.messagesBySession.get(sessionKey) ?? []), + message, + ]); + } +} diff --git a/electron/runtime/cc-connect-codex-launcher.ts b/electron/runtime/cc-connect-codex-launcher.ts new file mode 100644 index 000000000..21eb623a5 --- /dev/null +++ b/electron/runtime/cc-connect-codex-launcher.ts @@ -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; +}): Promise { + 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; +} diff --git a/electron/runtime/cc-connect-control-ui.ts b/electron/runtime/cc-connect-control-ui.ts new file mode 100644 index 000000000..920429ddd --- /dev/null +++ b/electron/runtime/cc-connect-control-ui.ts @@ -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}/`; +} diff --git a/electron/runtime/cc-connect-paths.ts b/electron/runtime/cc-connect-paths.ts new file mode 100644 index 000000000..00923758d --- /dev/null +++ b/electron/runtime/cc-connect-paths.ts @@ -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; +} diff --git a/electron/runtime/cc-connect-provider-profile.ts b/electron/runtime/cc-connect-provider-profile.ts new file mode 100644 index 000000000..96c9d7a4a --- /dev/null +++ b/electron/runtime/cc-connect-provider-profile.ts @@ -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; + envKeys?: string[]; + launcherEnv?: Record; + 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 { + 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; + modelReasoningEffort?: string; +}): Promise { + 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 }): { + env: Record; + envHttpHeaders: Record; +} { + 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 = {}; + const envHttpHeaders: Record = {}; + const used = new Set(); + 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).session_id; + return typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : undefined; + } catch { + return undefined; + } +} + +function buildModelHubEnv(account: ProviderAccount, apiKey: string): { + env: Record; + envHttpHeaders: Record; +} { + 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 { + const accountHome = getCcConnectAccountCodexHomeDir(accountId); + await mkdir(accountHome, { recursive: true }); + return accountHome; +} + +async function migrateLegacyCodexHomeToAccount(accountId: string): Promise { + 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 { + 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 { + 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 { + 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, +): 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; +}> { + 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, + options?: OpenAIOAuthTokenResolutionOptions, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 = {}; + 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 = 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 { + 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 { + 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); +} diff --git a/electron/runtime/cc-connect-provider.ts b/electron/runtime/cc-connect-provider.ts new file mode 100644 index 000000000..63fef2d26 --- /dev/null +++ b/electron/runtime/cc-connect-provider.ts @@ -0,0 +1,2888 @@ +import { EventEmitter } from 'node:events'; +import { execFile, spawn, type ChildProcess } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { appendFile, chmod, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { createServer } from 'node:net'; +import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; +import type { OpenClawDoctorMode, OpenClawDoctorResult } from '@shared/host-api/contract'; +import type { RawMessage } from '@shared/chat/types'; +import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '@shared/types/cron'; +import type { + RuntimeProvider, + RuntimeConfigRefreshPayload, + RuntimeSendWithMediaPayload, + RuntimeStatus, +} from './types'; +import { + CC_CONNECT_RUNTIME_CAPABILITIES, + withRuntimeStatus, +} from './types'; +import { getRuntimeOperationCapabilities } from './rpc-contract'; +import { + assertCcConnectBinaryPath, + getCcConnectAgentWorkspaceDir, + getCcConnectCodexHomeDir, + getCcConnectConfigPath, + getCcConnectManagedDir, + getCcConnectProviderProfilePath, +} from './cc-connect-paths'; +import { buildCcConnectWebAdminUrl, CC_CONNECT_MANAGEMENT_PORT } from './cc-connect-control-ui'; +import { + ccConnectProjectNameForAgent, + ccConnectProjectNameForSessionKey, + CLAWX_BRIDGE_ADMIN_USER_ID, + CcConnectBridgeAdapter, +} from './cc-connect-bridge-adapter'; +import { syncCcConnectSkills } from './cc-connect-skills'; +import { assertCodexBundle, prependCodexPathDir, type CodexBundle } from './codex-paths'; +import { ensureCcConnectCodexLauncher } from './cc-connect-codex-launcher'; +import { + buildCcConnectProviderProfileForAccount, + syncCcConnectProviderProfile, + toPublicCodexProviderProfile, + type CodexProviderProfile, +} from './cc-connect-provider-profile'; +import { + listCcConnectAgentPermissionModes, + listCcConnectAgentProviderBindings, + type CcConnectPermissionMode, +} from './cc-connect-agent-bindings'; +import { + FileCcConnectSessionMetadataStore, + type CcConnectSessionMetadataStore, +} from './cc-connect-session-metadata'; +import { readOpenClawConfig, type ChannelConfigData, type OpenClawConfig } from '../utils/channel-config'; +import { expandPath, getOpenClawConfigDir } from '../utils/paths'; +import * as logger from '../utils/logger'; +import { parseUsageEntriesFromMessages } from '../utils/token-usage-core'; +import { runtimeUsageLimit, toRuntimeUsageRecords } from './usage'; + +type CcConnectRuntimeProviderOptions = { + binaryPath?: string; + codexPath?: string; + workDir?: string; + codexBundle?: CodexBundle; + bridgeAdapter?: Pick; + sessionApi?: { + listSessions: () => Promise; + loadHistory: (session: CcConnectApiSessionRef) => Promise; + deleteSession: (session: CcConnectApiSessionRef) => Promise; + }; + skillSyncer?: typeof syncCcConnectSkills; + providerProfileLoader?: (payload?: { providerId?: string; reason?: string }) => Promise; + sessionMetadataStore?: CcConnectSessionMetadataStore; +}; + +const CC_CONNECT_DOCTOR_TIMEOUT_MS = 60_000; +const MAX_DOCTOR_OUTPUT_BYTES = 10 * 1024 * 1024; +const CC_CONNECT_DOCTOR_AUDIT_SCHEMA = 'clawx-cc-connect-runtime-doctor'; +const CLAWX_PROJECT_NAME = ccConnectProjectNameForAgent('main'); +const CC_CONNECT_BRIDGE_PORT = 9810; +const CC_CONNECT_PORT_SCAN_LIMIT = 50; +const CC_CONNECT_CRASH_RESTART_DELAY_MS = 1_000; +const CC_CONNECT_CRASH_RESTART_WINDOW_MS = 60_000; +const CC_CONNECT_MAX_CRASH_RESTARTS = 3; +const CC_CONNECT_ORPHAN_CLEANUP_DELAY_MS = 500; +const CLAWX_LOCAL_PLACEHOLDER_SECRET = 'clawx-local-placeholder'; +const CLAWX_LOCAL_CRON_SESSION_KEY = 'line:clawx-scheduled-cron'; +const SESSION_SYNC_POLL_INTERVAL_MS = 2_000; +const MAX_RUNTIME_LOG_LINES = 1_000; +const MAX_RUNTIME_LOG_FILE_BYTES = 5 * 1024 * 1024; +const CC_CONNECT_SUPPORTED_CHANNELS = new Set([ + 'dingtalk', + 'discord', + 'feishu', + 'lark', + 'line', + 'qq', + 'qqbot', + 'slack', + 'telegram', + 'wecom', + 'weixin', +]); + +const execFileAsync = promisify(execFile); + +type CcConnectChannelPlatform = { + channelType: string; + accountId: string; + agentId: string; + projectName: string; + platformType: string; + options: Record; + optionEnvKeys: Record; + env: Record; + adminFrom?: string; + error?: string; +}; + +type CcConnectAgentProject = { + agentId: string; + projectName: string; + workDir: string; + model?: string; + providerProfile?: CodexProviderProfile | null; + codexPath?: string; + permissionMode?: CcConnectPermissionMode; +}; + +type CcConnectProjectPlatformStatus = { + type?: string; + connected: boolean; + running: boolean; + error?: string; +}; + +type CcConnectApiSessionRef = { + projectName: string; + agentId: string; + id: string; + sessionKey: string; + logicalKey: string; + name?: string; + userName?: string; + chatName?: string; + active: boolean; + createdAt: number; + updatedAt: number; + lastMessage?: Record; +}; + +type CcConnectProjectRuntimeStatus = { + platforms: Map; + platformList: CcConnectProjectPlatformStatus[]; + error?: string; +}; + +function unsupported(method: string): never { + throw new Error(`cc-connect runtime does not support RPC method: ${method}`); +} + +function resolveCcConnectWorkspace(agentId = 'main', fallbackWorkDir?: string): string { + return expandPath(fallbackWorkDir || process.env.CLAWX_CODEX_WORKDIR || getCcConnectAgentWorkspaceDir(agentId)); +} + +function isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + server.once('error', () => resolve(false)); + server.once('listening', () => { + server.close(() => resolve(true)); + }); + server.listen(port, '127.0.0.1'); + }); +} + +async function findAvailablePort(preferredPort: number, reserved = new Set()): Promise { + for (let offset = 0; offset <= CC_CONNECT_PORT_SCAN_LIMIT; offset += 1) { + const port = preferredPort + offset; + if (reserved.has(port)) continue; + if (await isPortAvailable(port)) return port; + } + throw new Error(`No available localhost port found near ${preferredPort}`); +} + +function isRealSpawnedChild(child: ChildProcess): boolean { + return 'spawnfile' in child && typeof (child as ChildProcess & { spawnfile?: unknown }).spawnfile === 'string'; +} + +function terminateProcessTree(child: ChildProcess): void { + if (process.platform === 'win32') { + if (typeof child.pid === 'number' && isRealSpawnedChild(child)) { + const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { + stdio: 'ignore', + windowsHide: true, + }); + killer.on('error', () => { + try { + child.kill(); + } catch { + // ignore + } + }); + return; + } + } else if (typeof child.pid === 'number' && isRealSpawnedChild(child)) { + try { + process.kill(-child.pid, 'SIGTERM'); + return; + } catch { + // Fall back to killing the direct child below. + } + } + + try { + child.kill(); + } catch { + // ignore + } +} + +async function delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function listProcessCommandsContaining(needle: string): Promise> { + if (process.platform === 'win32') return []; + let stdout: string; + try { + const result = await execFileAsync('ps', ['-axo', 'pid=,command='], { + maxBuffer: 2 * 1024 * 1024, + }); + stdout = typeof result === 'string' + ? result + : typeof result.stdout === 'string' + ? result.stdout + : ''; + } catch { + return []; + } + return stdout + .split('\n') + .map((line) => line.trim()) + .flatMap((line) => { + const match = line.match(/^(\d+)\s+(.+)$/); + if (!match) return []; + const pid = Number(match[1]); + const command = match[2] || ''; + if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return []; + if (!command.includes(needle)) return []; + if (command.includes('ps -axo')) return []; + return [{ pid, command }]; + }); +} + +async function terminateManagedRuntimeProcesses(runtimeDir: string): Promise { + const terminate = (signal: NodeJS.Signals) => async (entry: { pid: number }) => { + try { + process.kill(entry.pid, signal); + } catch { + // Process may have already exited. + } + }; + const matches = await listProcessCommandsContaining(runtimeDir); + await Promise.all(matches.map(terminate('SIGTERM'))); + if (matches.length === 0) return; + await delay(CC_CONNECT_ORPHAN_CLEANUP_DELAY_MS); + const remaining = await listProcessCommandsContaining(runtimeDir); + await Promise.all(remaining.map(terminate('SIGKILL'))); +} + +function existingConfiguredWorkspace(value: unknown): string | null { + if (typeof value !== 'string' || !value.trim()) return null; + const expanded = expandPath(value.trim()); + return existsSync(expanded) ? expanded : null; +} + +function getConfiguredOpenClawWorkspace(config: OpenClawConfig, agentId: string, entry?: Record): string | null { + if (typeof entry?.workspace === 'string' && entry.workspace.trim()) { + return existingConfiguredWorkspace(entry.workspace); + } + const agents = isRecord(config.agents) ? config.agents : {}; + const defaults = isRecord(agents.defaults) ? agents.defaults : {}; + if (agentId === 'main' && typeof defaults.workspace === 'string' && defaults.workspace.trim()) { + return existingConfiguredWorkspace(defaults.workspace); + } + if (!entry) return null; + const defaultWorkspaceName = agentId === 'main' ? 'workspace' : `workspace-${agentId}`; + return existingConfiguredWorkspace(join(getOpenClawConfigDir(), defaultWorkspaceName)); +} + +function getAgentEntries(config: OpenClawConfig): Record[] { + const agents = isRecord(config.agents) ? config.agents : {}; + return Array.isArray(agents.list) + ? agents.list.filter((entry): entry is Record => ( + isRecord(entry) && typeof entry.id === 'string' && entry.id.trim().length > 0 + )) + : []; +} + +function appendBoundedOutput(current: string, currentBytes: number, data: Buffer | string) { + const chunk = typeof data === 'string' ? Buffer.from(data) : data; + if (currentBytes + chunk.length <= MAX_DOCTOR_OUTPUT_BYTES) { + return { + output: current + chunk.toString(), + bytes: currentBytes + chunk.length, + }; + } + const remaining = Math.max(0, MAX_DOCTOR_OUTPUT_BYTES - currentBytes); + return { + output: current + (remaining > 0 ? chunk.subarray(0, remaining).toString() : ''), + bytes: MAX_DOCTOR_OUTPUT_BYTES, + }; +} + +function doctorOutput(value: unknown): string { + if (typeof value === 'string') return value; + if (Buffer.isBuffer(value)) return value.toString(); + return ''; +} + +function doctorExitCode(error: unknown): number | null { + return isRecord(error) && typeof error.code === 'number' ? error.code : null; +} + +function execDoctorCommand( + file: string, + args: string[], + options: Parameters[2], +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFile(file, args, options, (error, stdout, stderr) => { + if (error) { + Object.assign(error, { stdout, stderr }); + reject(error); + return; + } + resolve({ stdout: doctorOutput(stdout), stderr: doctorOutput(stderr) }); + }); + }); +} + +async function readDoctorJson(path: string): Promise | undefined> { + try { + const value = JSON.parse(await readFile(path, 'utf8')) as unknown; + return isRecord(value) ? value : undefined; + } catch { + return undefined; + } +} + +function publicManagementProviders(value: unknown) { + const body = isRecord(value) ? value : {}; + const providers = Array.isArray(body.providers) + ? body.providers.flatMap((entry) => { + if (!isRecord(entry) || typeof entry.name !== 'string') return []; + return [{ + name: entry.name, + ...(typeof entry.active === 'boolean' ? { active: entry.active } : {}), + ...(typeof entry.model === 'string' ? { model: entry.model } : {}), + ...(typeof entry.base_url === 'string' ? { baseUrl: entry.base_url } : {}), + }]; + }) + : []; + return { + providers, + ...(typeof body.active_provider === 'string' ? { activeProvider: body.active_provider } : {}), + }; +} + +function publicManagementModels(value: unknown) { + const body = isRecord(value) ? value : {}; + return { + models: Array.isArray(body.models) + ? body.models.filter((model): model is string => typeof model === 'string') + : [], + ...(typeof body.current === 'string' ? { current: body.current } : {}), + }; +} + +function escapeToml(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function ccConnectProviderConfig(providerProfile?: CodexProviderProfile | null): string[] { + const provider = providerProfile?.ccConnectProvider; + if (!provider?.name) return []; + return [ + `provider = "${escapeToml(provider.name)}"`, + '', + '[[projects.agent.providers]]', + `name = "${escapeToml(provider.name)}"`, + ...(provider.apiKeyEnvKey ? [`api_key = "\${${escapeToml(provider.apiKeyEnvKey)}}"`] : []), + ...(provider.baseUrl ? [`base_url = "${escapeToml(provider.baseUrl)}"`] : []), + ...(provider.model ? [`model = "${escapeToml(provider.model)}"`] : []), + ...(provider.wireApi ? [`wire_api = "${escapeToml(provider.wireApi)}"`] : []), + '', + ]; +} + +function withProjectModel( + profile: CodexProviderProfile | null | undefined, + model: string | undefined, +): CodexProviderProfile | null | undefined { + if (!profile || !model) return profile; + const codexArgs: string[] = []; + for (let index = 0; index < profile.codexArgs.length; index += 1) { + if (profile.codexArgs[index] === '--model') { + index += 1; + continue; + } + codexArgs.push(profile.codexArgs[index]); + } + codexArgs.push('--model', model); + return { + ...profile, + model, + modelRef: profile.vendorId ? `${profile.vendorId}/${model}` : model, + codexArgs, + ...(profile.ccConnectProvider + ? { ccConnectProvider: { ...profile.ccConnectProvider, model } } + : {}), + }; +} + +function ccConnectProjectConfig(options: { + project: CcConnectAgentProject; + codexPath: string; + providerProfile?: CodexProviderProfile | null; + channelPlatforms: CcConnectChannelPlatform[]; +}): string[] { + const providerProfile = options.project.providerProfile ?? options.providerProfile; + const model = providerProfile?.model; + const projectPlatforms = options.channelPlatforms.filter((platform) => platform.projectName === options.project.projectName); + const adminFrom = Array.from(new Set([ + CLAWX_BRIDGE_ADMIN_USER_ID, + ...projectPlatforms.flatMap((platform) => ( + platform.adminFrom?.split(',').map((value) => value.trim()).filter(Boolean) ?? [] + )), + ])).join(','); + return [ + '[[projects]]', + `name = "${escapeToml(options.project.projectName)}"`, + 'reply_footer = false', + `admin_from = "${escapeToml(adminFrom)}"`, + '', + '[projects.agent]', + 'type = "codex"', + '', + '[projects.agent.options]', + `work_dir = "${escapeToml(options.project.workDir)}"`, + `mode = "${options.project.permissionMode ?? 'full-auto'}"`, + 'backend = "app_server"', + 'app_server_url = "stdio://"', + `cmd = "${escapeToml(options.project.codexPath ?? options.codexPath)}"`, + ...(model ? [`model = "${escapeToml(model)}"`] : []), + ...ccConnectProviderConfig(providerProfile), + '', + ...ccConnectPlatformConfig(projectPlatforms), + '# cc-connect requires at least one project platform before the bridge can start.', + '# ClawX GUI traffic is delivered by the local [bridge] adapter above; this LINE webhook', + '# placeholder listens only on an ephemeral local port and is filtered from channel status.', + '[[projects.platforms]]', + 'type = "line"', + '', + '[projects.platforms.options]', + `channel_secret = "${CLAWX_LOCAL_PLACEHOLDER_SECRET}"`, + `channel_token = "${CLAWX_LOCAL_PLACEHOLDER_SECRET}"`, + 'port = "0"', + '', + ]; +} + +function defaultConfig(options: { + codexPath: string; + providerProfile?: CodexProviderProfile | null; + managementToken: string; + bridgeToken: string; + managementPort: number; + bridgePort: number; + fallbackWorkDir?: string; + agentProjects?: CcConnectAgentProject[]; + channelPlatforms?: CcConnectChannelPlatform[]; +}): string { + const managedDir = getCcConnectManagedDir(); + const dataDir = join(managedDir, 'data').replace(/\\/g, '\\\\'); + const fallbackWorkDir = resolveCcConnectWorkspace('main', options.fallbackWorkDir); + const agentProjects = options.agentProjects && options.agentProjects.length > 0 + ? options.agentProjects + : [{ + agentId: 'main', + projectName: CLAWX_PROJECT_NAME, + workDir: fallbackWorkDir, + }]; + const channelPlatforms = options.channelPlatforms ?? []; + return [ + '# Managed by ClawX. Do not edit while ClawX is running.', + '# ClawX stores this file under app userData and does not modify ~/.cc-connect.', + '# ClawX GUI chat connects through cc-connect BridgePlatform.', + '# ClawX stores the active Codex provider/model profile in provider-profile.json.', + '', + `data_dir = "${dataDir}"`, + '', + '[log]', + 'level = "info"', + '', + '[management]', + 'enabled = true', + `port = ${options.managementPort}`, + `token = "${escapeToml(options.managementToken)}"`, + '', + '[bridge]', + 'enabled = true', + `port = ${options.bridgePort}`, + `token = "${escapeToml(options.bridgeToken)}"`, + 'path = "/bridge/ws"', + '', + ...agentProjects.flatMap((project) => ccConnectProjectConfig({ + project, + codexPath: options.codexPath, + providerProfile: options.providerProfile, + channelPlatforms, + })), + ].join('\n'); +} + +export class CcConnectRuntimeProvider extends EventEmitter implements RuntimeProvider { + readonly kind = 'cc-connect' as const; + private child: ChildProcess | null = null; + private bridgeAdapter: NonNullable; + private readonly injectedBridgeAdapter: boolean; + private readonly skillSyncer: NonNullable; + private readonly providerProfileLoader: NonNullable; + private readonly sessionApi: NonNullable; + private readonly sessionMetadataStore: CcConnectSessionMetadataStore; + private readonly managementToken = randomUUID(); + private readonly bridgeToken = randomUUID(); + private managementPort = CC_CONNECT_MANAGEMENT_PORT; + private bridgePort = CC_CONNECT_BRIDGE_PORT; + private status = withRuntimeStatus({ + state: 'stopped', + port: this.managementPort, + }, this.kind, CC_CONNECT_RUNTIME_CAPABILITIES, getCcConnectManagedDir(), this.listOperationCapabilities()); + private readonly binaryPath?: string; + private readonly codexPath?: string; + private readonly workDir?: string; + private readonly codexBundle?: CodexBundle; + private currentProviderProfile: CodexProviderProfile | null = null; + private currentProjectProfiles: CodexProviderProfile[] = []; + private currentProjectProfileByAgent = new Map(); + private currentChannelEnv: Record = {}; + private sessionSyncTimer: ReturnType | null = null; + private sessionSyncPolling = false; + private sessionSyncSeq = 0; + private sessionSyncSnapshot = new Map(); + private apiSessionRefs = new Map(); + private crashRestartTimer: ReturnType | null = null; + private crashRestartTimestamps: number[] = []; + private runtimeLogLines: string[] = []; + private lifecycleTail: Promise = Promise.resolve(); + + constructor(options: CcConnectRuntimeProviderOptions = {}) { + super(); + this.binaryPath = options.binaryPath; + this.codexPath = options.codexPath; + this.workDir = options.workDir; + this.codexBundle = options.codexBundle; + this.injectedBridgeAdapter = Boolean(options.bridgeAdapter); + this.bridgeAdapter = options.bridgeAdapter ?? this.createBridgeAdapter(this.bridgePort); + this.skillSyncer = options.skillSyncer ?? syncCcConnectSkills; + this.providerProfileLoader = options.providerProfileLoader ?? syncCcConnectProviderProfile; + this.sessionMetadataStore = options.sessionMetadataStore ?? new FileCcConnectSessionMetadataStore(); + this.sessionApi = options.sessionApi ?? { + listSessions: this.listPublicApiSessions.bind(this), + loadHistory: this.loadPublicApiHistory.bind(this), + deleteSession: async (session) => { + await this.managementRequest( + 'DELETE', + `/projects/${encodeURIComponent(session.projectName)}/sessions/${encodeURIComponent(session.id)}`, + ); + }, + }; + } + + listCapabilities() { + return CC_CONNECT_RUNTIME_CAPABILITIES; + } + + listOperationCapabilities() { + return getRuntimeOperationCapabilities(this.kind); + } + + getStatus() { + return this.status; + } + + start(): Promise { + return this.enqueueLifecycle(() => this.startInternal()); + } + + private async startInternal(): Promise { + if (this.status.state === 'running' || this.status.state === 'starting') return; + this.clearCrashRestartTimer(); + const codexPath = this.resolveCodexPath(); + const providerProfile = await this.loadAndApplyProviderProfile({ reason: 'runtime-start' }); + await this.ensureRuntimePorts(); + const configPath = await this.ensureManagedConfig(providerProfile, codexPath); + const binaryPath = assertCcConnectBinaryPath(this.binaryPath); + this.setStatus({ state: 'starting', error: undefined, port: this.managementPort }); + + try { + await this.syncSkillsForCurrentProjects(); + this.child = await this.spawnCcConnect(binaryPath, configPath, providerProfile); + await this.bridgeAdapter.connect(); + const child = this.child; + if (!child) { + throw new Error('cc-connect exited before the bridge adapter connected'); + } + + this.setStatus({ + state: 'running', + pid: child.pid, + connectedAt: Date.now(), + gatewayReady: true, + error: undefined, + }); + this.startSessionSyncWatcher(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await this.stopInternal().catch(() => undefined); + this.setStatus({ + state: 'error', + pid: undefined, + connectedAt: undefined, + gatewayReady: false, + error: message, + }); + throw error; + } + } + + stop(): Promise { + return this.enqueueLifecycle(() => this.stopInternal()); + } + + private async stopInternal(): Promise { + this.clearCrashRestartTimer(); + this.stopSessionSyncWatcher(); + const child = this.child; + this.child = null; + if (child) { + terminateProcessTree(child); + } + await this.bridgeAdapter.close(); + await terminateManagedRuntimeProcesses(getCcConnectManagedDir()); + this.setStatus({ state: 'stopped', pid: undefined, connectedAt: undefined, gatewayReady: undefined }); + } + + restart(): Promise { + return this.enqueueLifecycle(async () => { + await this.stopInternal(); + await this.startInternal(); + }); + } + + async checkHealth(options?: { probe?: boolean }) { + const uptime = this.status.connectedAt ? Date.now() - this.status.connectedAt : undefined; + if (this.status.state !== 'running' || !this.child || this.child.killed || this.child.exitCode !== null) { + return { + ok: false, + error: this.status.error || `cc-connect process is ${this.status.state}`, + uptime, + }; + } + + const failures: string[] = []; + if (!this.bridgeAdapter.isConnected()) failures.push('Bridge is disconnected'); + try { + const projectNames = await this.listCcConnectCronProjectNames(); + await Promise.all(projectNames.map(async (projectName) => { + await this.managementRequest('GET', `/projects/${encodeURIComponent(projectName)}`); + })); + } catch (error) { + failures.push(`Management API probe failed: ${error instanceof Error ? error.message : String(error)}`); + } + + const health = { + ok: failures.length === 0, + ...(failures.length ? { error: failures.join('; ') } : {}), + uptime, + }; + if (options?.probe) this.emit('gateway:health', health); + return health; + } + + async rpc(method: string, params?: unknown): Promise { + switch (method) { + case 'chat.send': + return await this.sendMessageWithMedia(toSendPayload(params)) as T; + case 'chat.abort': + return await this.abortChatRun(params) as T; + case 'chat.approval.respond': + return await this.bridgeAdapter.respondApproval(params) as T; + case 'sessions.list': + return await this.listSessions(params) as T; + case 'chat.history': + return await this.loadHistory(params) as T; + case 'sessions.delete': + case 'session.delete': + case 'chat.session.delete': + return await this.deleteSession(params) as T; + case 'sessions.rename': + case 'session.rename': + return await this.renameSession(params) as T; + case 'providers.sync': + case 'models.sync': + return await this.syncProviderProfile(toProviderSyncPayload(params)) as T; + case 'providers.profile': + case 'models.profile': + return await this.getProviderModelProfile() as T; + case 'skills.status': + return await this.syncSkillsForCurrentProjects() as T; + case 'skills.update': + await this.syncSkillsForCurrentProjects(); + return { + success: true, + runtimeKind: this.kind, + } as T; + case 'channels.status': + return await this.getChannelStatus() as T; + case 'channels.connect': + case 'channels.disconnect': + case 'channels.delete': + return await this.refreshChannelLifecycle(method, params) as T; + case 'runtime.controlUi': + return await this.getControlUi(isRecord(params) ? params : undefined) as T; + case 'cron.list': + return await this.listCronJobs() as T; + case 'cron.create': + case 'cron.add': + return await this.createCronJob(params) as T; + case 'cron.update': + return await this.updateCronJob(params) as T; + case 'cron.delete': + case 'cron.remove': + return await this.deleteCronJob(params) as T; + case 'cron.toggle': + return await this.toggleCronJob(params) as T; + case 'cron.run': + return await this.triggerCronJob(params) as T; + default: + return unsupported(method); + } + } + + async sendMessageWithMedia(payload: RuntimeSendWithMediaPayload) { + const projectProfile = this.profileForSessionKey(payload.sessionKey); + if (projectProfile && !projectProfile.supported) { + throw new Error(projectProfile.unsupportedReason || 'Selected provider is not supported by the cc-connect Codex runtime'); + } + return await this.bridgeAdapter.send(payload); + } + + abortChatRun(payload?: unknown) { + return this.enqueueLifecycle(async () => { + const result = await this.bridgeAdapter.abort(payload); + if (result.abortedRuns.length > 0 && !result.upstreamStopRequested && this.status.state === 'running') { + await this.stopInternal(); + await this.startInternal(); + } + return result; + }); + } + + async listSessions(payload?: unknown) { + if (isRecord(payload) && Array.isArray(payload.sessionKeys)) { + const sessionKeys = payload.sessionKeys.filter((value): value is string => typeof value === 'string'); + const sessions = await this.sessionApi.listSessions(); + const byKey = new Map(sessions.map((session) => [session.logicalKey, session])); + const bridgeSummaries = await Promise.all(sessionKeys.map(async (sessionKey) => { + const session = byKey.get(sessionKey); + if (!session) { + return { sessionKey, firstUserText: null, lastTimestamp: null }; + } + const messages = await this.sessionApi.loadHistory(session); + const firstUser = messages.find((message) => message.role === 'user'); + return { + sessionKey, + firstUserText: runtimeMessageText(firstUser?.content) || null, + lastTimestamp: messages.reduce((latest, message) => { + const timestamp = runtimeTimestamp(message.timestamp); + return timestamp == null ? latest : latest == null ? timestamp : Math.max(latest, timestamp); + }, null), + }; + })); + return { + success: true, + summaries: bridgeSummaries, + }; + } + const apiSessions = await this.sessionApi.listSessions(); + this.apiSessionRefs = new Map(apiSessions.map((session) => [session.logicalKey, session])); + const sessions = await Promise.all(apiSessions.map(async (session) => ccConnectApiSessionMetadata( + session, + await this.sessionMetadataStore.getLabel(session.logicalKey), + ))); + if (this.status.state === 'running') { + await this.applySessionSyncSnapshot(sessions, true); + } + return { + success: true, + sessions: sessions.map((session) => ({ + key: session.key, + displayName: session.displayName, + derivedTitle: session.derivedTitle, + lastMessagePreview: session.lastMessagePreview, + agentId: session.agentId, + updatedAt: session.updatedAt, + })), + }; + } + + async loadHistory(payload?: unknown) { + const body = isRecord(payload) ? payload : {}; + const sessionKey = typeof body.sessionKey === 'string' && body.sessionKey.trim() + ? body.sessionKey.trim() + : 'agent:main:main'; + const limit = typeof body.limit === 'number' && Number.isFinite(body.limit) + ? Math.max(1, Math.min(Math.floor(body.limit), 1000)) + : 200; + const session = await this.resolvePublicApiSession(sessionKey); + if (!session) { + return { success: false, error: 'Session not found' }; + } + const publicMessages = await this.sessionApi.loadHistory(session); + const bridgeMessages = await this.bridgeAdapter.loadHistory(sessionKey, limit); + const messages = mergeCcConnectHistory(publicMessages, bridgeMessages).slice(-limit); + return { + success: true, + messages, + }; + } + + async listUsage(payload?: unknown) { + const limit = runtimeUsageLimit(payload); + const sessions = await this.sessionApi.listSessions(); + const records = (await Promise.all(sessions.map(async (session) => { + const profile = this.profileForSessionKey(session.logicalKey); + const messages = (await this.sessionApi.loadHistory(session)).map((message) => ( + message.role === 'assistant' && !('usage' in message) + ? { ...message, usage: {} } + : message + )); + const entries = parseUsageEntriesFromMessages(messages, { + runtimeKind: this.kind, + sessionId: session.logicalKey, + agentId: session.agentId, + provider: profile?.vendorId ?? profile?.ccConnectProvider?.name, + model: profile?.model, + }); + return toRuntimeUsageRecords(entries, { + runtimeKind: this.kind, + logicalSessionId: session.logicalKey, + runtimeSessionId: session.id, + ...(profile?.providerId ? { providerAccountId: profile.providerId } : {}), + provider: profile?.vendorId ?? profile?.ccConnectProvider?.name, + model: profile?.model, + }); + }))).flat(); + records.sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp)); + return { + success: true, + records: records.slice(0, limit ?? records.length), + }; + } + + async deleteSession(payload?: unknown) { + const sessionKey = getSessionKey(payload); + const session = await this.resolvePublicApiSession(sessionKey); + if (!session) throw new Error(`cc-connect session not found: ${sessionKey}`); + await this.sessionApi.deleteSession(session); + await this.sessionMetadataStore.deleteLabel(sessionKey); + this.bridgeAdapter.forgetSession(sessionKey); + this.apiSessionRefs.delete(sessionKey); + return { success: true }; + } + + async renameSession(payload?: unknown) { + const body = isRecord(payload) ? payload : {}; + const sessionKey = getSessionKey(body); + const label = typeof body.label === 'string' + ? body.label + : typeof body.title === 'string' + ? body.title + : ''; + if (!label.trim()) { + return { success: false, error: 'Label cannot be empty' }; + } + const session = await this.resolvePublicApiSession(sessionKey); + if (!session) return { success: false, error: 'Session not found' }; + await this.sessionMetadataStore.setLabel(sessionKey, label); + return { success: true }; + } + + async getChannelStatus(): Promise<{ + channels: Record; + channelAccounts: Record>; + channelDefaultAccountId: Record; + }> { + const openClawConfig = await readOpenClawConfig().catch(() => ({} as OpenClawConfig)); + const configuredPlatforms = collectCcConnectChannelPlatforms(openClawConfig); + const running = this.status.state === 'running'; + const runtimeProjectStatus = running + ? await this.getCcConnectProjectRuntimeStatus(configuredPlatforms) + : new Map(); + const channels: Record = {}; + const channelAccounts: Record> = {}; + const channelDefaultAccountId: Record = {}; + const platformStatusCursor = new Map(); + + for (const platform of configuredPlatforms) { + const projectStatus = runtimeProjectStatus.get(platform.projectName); + const platformStatusKey = `${platform.projectName}:${platform.platformType}`; + const platformStatusIndex = platformStatusCursor.get(platformStatusKey) ?? 0; + platformStatusCursor.set(platformStatusKey, platformStatusIndex + 1); + const platformStatusCandidates = projectStatus?.platformList.filter((status) => status.type === platform.platformType) ?? []; + const platformStatus = platformStatusCandidates[platformStatusIndex] + ?? projectStatus?.platforms.get(platform.platformType); + const statusError = platform.error || platformStatus?.error || projectStatus?.error; + const platformRunning = Boolean(running && !platform.error && (platformStatus ? platformStatus.running : false)); + const platformConnected = Boolean(running && !platform.error && (platformStatus ? platformStatus.connected : false)); + const existingChannel = channels[platform.channelType]; + channels[platform.channelType] = { + configured: true, + running: Boolean(existingChannel?.running || platformRunning), + }; + const accounts = channelAccounts[platform.channelType] ?? []; + accounts.push({ + accountId: platform.accountId, + configured: true, + connected: platformConnected, + running: platformRunning, + linked: !platform.error, + name: platform.platformType, + ...(statusError ? { lastError: statusError } : {}), + }); + channelAccounts[platform.channelType] = accounts; + channelDefaultAccountId[platform.channelType] = getDefaultChannelAccountId( + openClawConfig, + platform.channelType, + ); + } + + return { channels, channelAccounts, channelDefaultAccountId }; + } + + async refreshChannelLifecycle(method: string, payload?: unknown): Promise<{ success: true }> { + const target = getChannelLifecycleTarget(payload); + await this.refreshConfig({ + scope: 'channels', + reason: `runtime:${method}${target.channelType ? `:${target.channelType}` : ''}${target.accountId ? `:${target.accountId}` : ''}`, + ...(target.channelType ? { channelType: target.channelType } : {}), + ...(target.accountId ? { accountId: target.accountId } : {}), + forceRestart: true, + }); + return { success: true }; + } + + async listLogs() { + const configPath = getCcConnectConfigPath(); + const content = existsSync(configPath) + ? await readFile(configPath, 'utf8').catch(() => '') + : ''; + const managerLogs = logger.getRecentLogs(500) + .filter((line) => /cc-connect|runtime/i.test(line)) + .map((line) => this.redactRuntimeOutput(line)); + const persistedRuntimeLog = await readFile(this.runtimeLogPath(), 'utf8') + .then((value) => this.redactRuntimeOutput(value.split(/\r?\n/).slice(-MAX_RUNTIME_LOG_LINES).join('\n').trim())) + .catch(() => ''); + return { + content: [ + '## cc-connect stdout/stderr', + persistedRuntimeLog || this.runtimeLogLines.join('\n') || '(no runtime output captured)', + '', + '## ClawX runtime manager', + managerLogs.join('\n') || '(no matching manager logs captured)', + '', + '## Managed config (redacted)', + `[cc-connect] config=${configPath}`, + `[cc-connect] providerProfile=${getCcConnectProviderProfilePath()}`, + '', + redactCcConnectConfigForLogs(content), + ].join('\n'), + }; + } + + async runDoctor(mode: OpenClawDoctorMode): Promise { + const startedAt = Date.now(); + const cwd = getCcConnectManagedDir(); + const configPath = await this.ensureManagedConfig(null, this.resolveCodexPath()); + const binaryPath = assertCcConnectBinaryPath(this.binaryPath); + const auditDir = join(cwd, 'audits'); + await mkdir(auditDir, { recursive: true }); + const auditId = `${Date.now()}-${randomUUID()}`; + const nativeAuditPath = join(auditDir, `cc-connect-${auditId}.json`); + const auditPath = join(auditDir, `runtime-${auditId}.json`); + const args = ['doctor', 'user-isolation', '--config', configPath, '--out', nativeAuditPath]; + const command = `cc-connect ${args.join(' ')}`; + + if (mode === 'fix') { + return { + mode, + success: false, + exitCode: null, + stdout: '', + stderr: '', + command, + cwd, + durationMs: Date.now() - startedAt, + error: 'cc-connect Doctor does not support fix mode', + }; + } + + const nativeResult = await new Promise((resolve) => { + const child = spawn(binaryPath, args, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let stdoutBytes = 0; + let stderrBytes = 0; + let settled = false; + + const finish = (result: Omit) => { + if (settled) return; + settled = true; + resolve({ + ...result, + durationMs: Date.now() - startedAt, + }); + }; + + const timeout = setTimeout(() => { + try { + child.kill(); + } catch { + // ignore + } + finish({ + mode, + success: false, + exitCode: null, + stdout, + stderr, + command, + cwd, + timedOut: true, + error: `Timed out after ${CC_CONNECT_DOCTOR_TIMEOUT_MS}ms`, + }); + }, CC_CONNECT_DOCTOR_TIMEOUT_MS); + + child.stdout?.on('data', (data) => { + const next = appendBoundedOutput(stdout, stdoutBytes, data); + stdout = next.output; + stdoutBytes = next.bytes; + }); + child.stderr?.on('data', (data) => { + const next = appendBoundedOutput(stderr, stderrBytes, data); + stderr = next.output; + stderrBytes = next.bytes; + }); + child.on('error', (error) => { + clearTimeout(timeout); + finish({ + mode, + success: false, + exitCode: null, + stdout, + stderr, + command, + cwd, + error: error instanceof Error ? error.message : String(error), + }); + }); + child.on('exit', (code) => { + clearTimeout(timeout); + finish({ + mode, + success: code === 0, + exitCode: code, + stdout, + stderr, + command, + cwd, + }); + }); + }); + + const profile = this.currentProjectProfileByAgent.get('main') ?? this.currentProjectProfiles[0]; + const codexPath = this.resolveCodexPath(); + const codexArgs = ['doctor', '--json']; + const codexExecution: { + stdout: string; + stderr: string; + success: boolean; + exitCode: number | null; + error?: string; + } = await (async () => { + try { + const result = await execDoctorCommand(codexPath, codexArgs, { + cwd, + env: prependCodexPathDir({ + ...process.env, + ...(profile?.env ?? {}), + CODEX_HOME: profile?.codexHomeDir ?? getCcConnectCodexHomeDir(), + }, this.codexBundle), + encoding: 'utf8', + maxBuffer: MAX_DOCTOR_OUTPUT_BYTES, + timeout: CC_CONNECT_DOCTOR_TIMEOUT_MS, + }); + return { + stdout: doctorOutput(result.stdout), + stderr: doctorOutput(result.stderr), + success: true, + exitCode: 0, + }; + } catch (error) { + return { + stdout: isRecord(error) ? doctorOutput(error.stdout) : '', + stderr: isRecord(error) ? doctorOutput(error.stderr) : '', + success: false, + exitCode: doctorExitCode(error), + error: error instanceof Error ? error.message : String(error), + }; + } + })(); + const { stdout: codexStdout, stderr: codexStderr, exitCode: codexExitCode } = codexExecution; + let codexSuccess = codexExecution.success; + let codexError = codexExecution.error; + + let codexReport: Record | undefined; + try { + const parsed = JSON.parse(codexStdout) as unknown; + if (!isRecord(parsed)) throw new Error('Codex doctor did not return a JSON object'); + codexReport = parsed; + } catch { + if (!codexError) codexError = 'Codex doctor did not return a JSON object'; + codexSuccess = false; + } + + const nativeAudit = await readDoctorJson(nativeAuditPath); + const audit = { + schema: CC_CONNECT_DOCTOR_AUDIT_SCHEMA, + version: 1, + generatedAt: new Date().toISOString(), + runtimeKind: this.kind, + managedConfigPath: configPath, + ccConnect: { + success: nativeResult.success, + exitCode: nativeResult.exitCode, + auditGenerated: Boolean(nativeAudit), + ...(nativeAudit ? { auditPath: nativeAuditPath, report: nativeAudit } : {}), + }, + codex: { + success: codexSuccess, + exitCode: codexExitCode, + ...(codexReport ? { report: codexReport } : {}), + ...(codexError ? { error: codexError } : {}), + }, + } satisfies Record; + await writeFile(auditPath, `${JSON.stringify(audit, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); + await chmod(auditPath, 0o600); + + const stdout = [ + '## cc-connect doctor', + nativeResult.stdout.trim() || '(empty)', + '## Codex doctor', + codexStdout.trim() || '(empty)', + ].join('\n'); + const stderr = [ + nativeResult.stderr.trim(), + codexStderr.trim(), + ].filter(Boolean).join('\n'); + const errors = [nativeResult.error, codexError].filter(Boolean); + + return { + ...nativeResult, + success: nativeResult.success && codexSuccess, + exitCode: nativeResult.success ? codexExitCode : nativeResult.exitCode, + stdout, + stderr, + command: `${command}; codex ${codexArgs.join(' ')}`, + durationMs: Date.now() - startedAt, + auditPath, + audit, + ...(errors.length ? { error: errors.join('; ') } : {}), + }; + } + + private async ensureManagedConfig(providerProfile: CodexProviderProfile | null, codexPath: string): Promise { + const configPath = getCcConnectConfigPath(); + await mkdir(dirname(configPath), { recursive: true }); + const openClawConfig = await readOpenClawConfig().catch(() => ({} as OpenClawConfig)); + const agentProjects = collectCcConnectAgentProjects(openClawConfig, this.workDir); + await Promise.all(agentProjects.map((project) => mkdir(project.workDir, { recursive: true }))); + const [bindings, permissionModes] = await Promise.all([ + listCcConnectAgentProviderBindings(), + listCcConnectAgentPermissionModes(), + ]); + const configuredProjects = await Promise.all(agentProjects.map(async (project) => { + const accountId = bindings[project.agentId] ?? providerProfile?.providerId; + const accountProfile = accountId + ? accountId === providerProfile?.providerId + ? providerProfile + : await buildCcConnectProviderProfileForAccount(accountId) + : providerProfile; + const projectProfile = withProjectModel(accountProfile, project.model); + const projectCodexPath = projectProfile?.providerId && projectProfile.codexHomeDir + ? await ensureCcConnectCodexLauncher({ + accountId: projectProfile.providerId, + codexHomeDir: projectProfile.codexHomeDir, + codexPath, + envAliases: projectProfile.launcherEnv, + }) + : codexPath; + return { + ...project, + permissionMode: permissionModes[project.agentId] ?? 'full-auto', + providerProfile: projectProfile, + codexPath: projectCodexPath, + }; + })); + this.currentProjectProfiles = configuredProjects + .map((project) => project.providerProfile) + .filter((profile): profile is CodexProviderProfile => Boolean(profile)); + this.currentProjectProfileByAgent = new Map(configuredProjects.flatMap((project) => ( + project.providerProfile ? [[project.agentId, project.providerProfile] as const] : [] + ))); + const channelPlatforms = collectCcConnectChannelPlatforms(openClawConfig).filter((platform) => !platform.error); + this.currentChannelEnv = Object.assign({}, ...channelPlatforms.map((platform) => platform.env)); + await writeFile(configPath, defaultConfig({ + codexPath, + providerProfile, + managementToken: this.managementToken, + bridgeToken: this.bridgeToken, + managementPort: this.managementPort, + bridgePort: this.bridgePort, + fallbackWorkDir: this.workDir, + agentProjects: configuredProjects, + channelPlatforms, + }), { encoding: 'utf8', mode: 0o600 }); + await chmod(configPath, 0o600).catch(() => {}); + return configPath; + } + + async syncProviderProfile(payload?: { providerId?: string; reason?: string }) { + const profile = await this.loadAndApplyProviderProfile(payload); + if (this.status.state === 'running') { + await this.restart(); + } else { + try { + await this.ensureManagedConfig(profile, this.resolveCodexPath()); + } catch { + // Stopped-runtime profile sync must not require the optional dev Codex bundle. + } + } + return { + success: true, + profile: toPublicCodexProviderProfile(profile), + }; + } + + private async getProviderModelProfile() { + const profile = this.currentProviderProfile ?? await this.loadAndApplyProviderProfile({ reason: 'profile-read' }); + if (this.status.state !== 'running') { + return { + success: true, + profile: toPublicCodexProviderProfile(profile), + runtimeState: this.status.state, + projects: [], + }; + } + + const projectNames = await this.listCcConnectCronProjectNames(); + const projects = await Promise.all(projectNames.map(async (projectName) => { + const encodedProject = encodeURIComponent(projectName); + const [providers, models] = await Promise.all([ + this.managementRequest('GET', `/projects/${encodedProject}/providers`), + this.managementRequest('GET', `/projects/${encodedProject}/models`), + ]); + return { + projectName, + providers: publicManagementProviders(providers), + models: publicManagementModels(models), + }; + })); + + return { + success: true, + profile: toPublicCodexProviderProfile(profile), + runtimeState: this.status.state, + projects, + }; + } + + async refreshConfig(_payload: RuntimeConfigRefreshPayload): Promise { + if (this.status.state === 'stopped') return; + await this.reloadManagedConfig(); + } + + private async reloadManagedConfig(): Promise { + await this.ensureManagedConfig(this.currentProviderProfile, this.resolveCodexPath()); + try { + await this.managementRequest('POST', '/reload'); + return; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.emit('notification', { + type: 'log', + message: `[cc-connect] config reload failed; restarting runtime instead: ${message}`, + }); + } + await this.restart(); + } + + async getControlUi(payload?: { view?: string }) { + if (!this.listCapabilities().controlUi) { + return { success: false, error: 'cc-connect runtime does not support Web Admin' }; + } + if (payload?.view === 'dreams') { + return { success: false, error: 'cc-connect runtime does not support the OpenClaw Dreams view' }; + } + return { + success: true, + url: buildCcConnectWebAdminUrl(this.managementPort), + token: this.managementToken, + port: this.managementPort, + }; + } + + private createBridgeAdapter(port: number): CcConnectBridgeAdapter { + return new CcConnectBridgeAdapter({ + port, + token: this.bridgeToken, + project: CLAWX_PROJECT_NAME, + projectForSessionKey: ccConnectProjectNameForSessionKey, + emit: this.emit.bind(this), + }); + } + + private async ensureRuntimePorts(): Promise { + const managementPort = await findAvailablePort(CC_CONNECT_MANAGEMENT_PORT); + const bridgePort = await findAvailablePort(CC_CONNECT_BRIDGE_PORT, new Set([managementPort])); + this.managementPort = managementPort; + this.bridgePort = bridgePort; + if (!this.injectedBridgeAdapter) { + await this.bridgeAdapter.close().catch(() => undefined); + this.bridgeAdapter = this.createBridgeAdapter(this.bridgePort); + } + } + + private async loadAndApplyProviderProfile(payload?: { providerId?: string; reason?: string }): Promise { + const profile = await this.providerProfileLoader(payload); + this.currentProviderProfile = profile; + return profile; + } + + private profileForSessionKey(sessionKey: string): CodexProviderProfile | null { + const parts = sessionKey.split(':'); + const agentId = parts[0] === 'agent' && parts[1] ? normalizeAgentId(parts[1]) : 'main'; + return this.currentProjectProfileByAgent.get(agentId) ?? this.currentProviderProfile; + } + + private async syncSkillsForCurrentProjects() { + const skillHomes = new Set(this.currentProjectProfiles + .map((profile) => profile.codexHomeDir) + .filter((home): home is string => Boolean(home))); + if (skillHomes.size === 0 && this.currentProviderProfile?.codexHomeDir) { + skillHomes.add(this.currentProviderProfile.codexHomeDir); + } + if (skillHomes.size === 0) return await this.skillSyncer(); + const results = await Promise.all(Array.from(skillHomes).map((home) => this.skillSyncer(home))); + return results[0] ?? { skills: [] }; + } + + private async managementRequest(method: string, path: string, body?: unknown): Promise { + const response = await fetch(`http://127.0.0.1:${this.managementPort}/api/v1${path}`, { + method, + headers: { + Authorization: `Bearer ${this.managementToken}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const text = await response.text(); + const data = text.trim() ? JSON.parse(text) : {}; + if (!response.ok) { + const message = isRecord(data) && typeof data.error === 'string' ? data.error : text || `HTTP ${response.status}`; + throw new Error(`cc-connect management API failed: ${message}`); + } + if (isRecord(data) && data.ok === false) { + const message = typeof data.error === 'string' ? data.error : text || 'request failed'; + throw new Error(`cc-connect management API failed: ${message}`); + } + if (isRecord(data) && data.ok === true && 'data' in data) { + return data.data as T; + } + return data as T; + } + + private async listPublicApiSessions(): Promise { + if (this.status.state !== 'running') { + throw new Error('cc-connect runtime must be running to access sessions'); + } + const projectNames = await this.listCcConnectCronProjectNames(); + const sessions = (await Promise.all(projectNames.map(async (projectName) => { + const result = await this.managementRequest( + 'GET', + `/projects/${encodeURIComponent(projectName)}/sessions`, + ); + return parseCcConnectApiSessions(projectName, result); + }))).flat(); + this.apiSessionRefs = new Map(sessions.map((session) => [session.logicalKey, session])); + return sessions; + } + + private async resolvePublicApiSession(sessionKey: string): Promise { + const cached = this.apiSessionRefs.get(sessionKey); + if (cached) return cached; + const sessions = await this.sessionApi.listSessions(); + this.apiSessionRefs = new Map(sessions.map((session) => [session.logicalKey, session])); + return sessions.find((session) => session.logicalKey === sessionKey); + } + + private async loadPublicApiHistory(session: CcConnectApiSessionRef) { + const result = await this.managementRequest( + 'GET', + `/projects/${encodeURIComponent(session.projectName)}/sessions/${encodeURIComponent(session.id)}?history_limit=1000`, + ); + return parseCcConnectApiHistory(result); + } + + private async getCcConnectProjectRuntimeStatus( + platforms: CcConnectChannelPlatform[], + ): Promise> { + const projectNames = Array.from(new Set(platforms.filter((platform) => !platform.error).map((platform) => platform.projectName))); + const entries = await Promise.all(projectNames.map(async (projectName) => { + try { + const result = await this.managementRequest('GET', `/projects/${encodeURIComponent(projectName)}`); + return [projectName, parseCcConnectProjectRuntimeStatus(result)] as const; + } catch (error) { + const fallbackStatus: CcConnectProjectRuntimeStatus = { + platforms: new Map(), + platformList: [], + error: error instanceof Error ? error.message : String(error), + }; + return [projectName, fallbackStatus] as const; + } + })); + return new Map(entries); + } + + private async listCronJobs(): Promise { + return (await this.listCcConnectCronJobRecords()).map((job) => transformCcConnectCronJob(job)); + } + + private async listCcConnectCronProjectNames(): Promise { + try { + const config = await readOpenClawConfig(); + const defaultWorkspace = resolveCcConnectWorkspace('main', this.workDir); + const projects = collectCcConnectAgentProjects(config, defaultWorkspace) + .map((project) => project.projectName); + return Array.from(new Set([CLAWX_PROJECT_NAME, ...projects])); + } catch { + return [CLAWX_PROJECT_NAME]; + } + } + + private async listCcConnectCronJobRecords(): Promise { + const projects = await this.listCcConnectCronProjectNames(); + const results = await Promise.all(projects.map(async (project) => { + const result = await this.managementRequest('GET', `/cron?project=${encodeURIComponent(project)}`); + const jobs = Array.isArray(result) + ? result + : isRecord(result) && Array.isArray(result.jobs) + ? result.jobs + : []; + return jobs.map((job) => isRecord(job) && !ccString(job, ['project']) + ? { ...job, project } + : job); + })); + return results.flat(); + } + + private async createCronJob(payload: unknown): Promise { + const input = isRecord(payload) ? payload as unknown as CronJobCreateInput : {} as CronJobCreateInput; + const schedule = assertCcConnectCronSchedule(input.schedule); + const agentId = normalizeAgentId(input.agentId); + const inputRecord = input as unknown as Record; + const sessionKey = ccConnectCronSessionKey(inputRecord); + const requestBody: Record = { + project: ccConnectProjectNameForAgent(agentId), + session_key: sessionKey, + cron_expr: schedule, + ...ccConnectCronExecutionFields(inputRecord, { includePrompt: true }), + ...ccConnectCronDeliveryFields(inputRecord), + description: input.name || 'Scheduled task', + silent: input.delivery?.mode !== 'announce', + enabled: input.enabled !== false, + }; + const result = await this.managementRequest('POST', '/cron', requestBody); + const createdJob = transformCcConnectCronJob(isRecord(result) && 'job' in result ? result.job : result); + if (input.mute === undefined) return createdJob; + const patched = await this.managementRequest('PATCH', `/cron/${encodeURIComponent(createdJob.id)}`, { + mute: input.mute === true, + }); + return transformCcConnectCronJob(isRecord(patched) && 'job' in patched ? patched.job : patched); + } + + private async updateCronJob(payload: unknown): Promise { + const body = isRecord(payload) ? payload : {}; + const id = getPayloadId(body); + const input = isRecord(body.input) ? body.input as unknown as CronJobUpdateInput : {}; + const inputRecord = input as unknown as Record; + const patch: Record = await this.shouldHydrateCronUpdate(inputRecord) + ? await this.createCronUpdateBaseline(id) + : {}; + if (input.name !== undefined) patch.description = input.name; + if (input.message !== undefined) patch.prompt = input.message; + if (input.schedule !== undefined) { + patch.cron_expr = assertCcConnectCronSchedule(input.schedule); + } + if (input.enabled !== undefined) patch.enabled = input.enabled === true; + if (input.delivery?.mode !== undefined) patch.silent = input.delivery.mode !== 'announce'; + if (input.mute !== undefined) patch.mute = input.mute === true; + Object.assign(patch, ccConnectCronExecutionFields(inputRecord)); + Object.assign(patch, ccConnectCronDeliveryFields(inputRecord)); + if (ccString(patch, ['exec'])) delete patch.prompt; + if (ccString(patch, ['prompt'])) delete patch.exec; + if (input.agentId !== undefined || hasOwn(inputRecord, 'exec') || hasOwn(inputRecord, 'command') || hasOwn(inputRecord, 'delivery')) { + const agentId = input.agentId !== undefined + ? normalizeAgentId(input.agentId) + : agentIdFromCcConnectProjectName(ccString(patch, ['project']) || '') || 'main'; + patch.project = ccConnectProjectNameForAgent(agentId); + patch.session_key = ccConnectCronSessionKey(inputRecord); + } + const result = await this.managementRequest('PATCH', `/cron/${encodeURIComponent(id)}`, patch); + return transformCcConnectCronJob(isRecord(result) && 'job' in result ? result.job : result); + } + + private async createCronUpdateBaseline(id: string): Promise> { + try { + const jobs = await this.listCcConnectCronJobRecords(); + const rawJob = jobs.find((job) => transformCcConnectCronJob(job).id === id); + return ccConnectCronUpdateBaseline(rawJob); + } catch { + return {}; + } + } + + private shouldHydrateCronUpdate(input: Record): boolean { + return hasOwn(input, 'exec') + || hasOwn(input, 'command') + || hasOwn(input, 'workDir') + || hasOwn(input, 'work_dir') + || hasOwn(input, 'sessionMode') + || hasOwn(input, 'session_mode') + || hasOwn(input, 'timeoutMins') + || hasOwn(input, 'timeout_mins') + || hasOwn(input, 'agentId'); + } + + private async toggleCronJob(payload: unknown): Promise { + const body = isRecord(payload) ? payload : {}; + return await this.updateCronJob({ + id: getPayloadId(body), + input: { enabled: body.enabled === true }, + }); + } + + private async deleteCronJob(payload: unknown): Promise<{ success: true }> { + await this.managementRequest('DELETE', `/cron/${encodeURIComponent(getPayloadId(payload))}`); + return { success: true }; + } + + private async triggerCronJob(payload: unknown): Promise<{ success: true }> { + const id = getPayloadId(payload); + await this.managementRequest('POST', `/cron/${encodeURIComponent(id)}/exec`); + return { success: true }; + } + + private resolveCodexPath(): string { + if (this.codexPath) return this.codexPath; + return assertCodexBundle(this.codexBundle).binaryPath; + } + + private async spawnCcConnect(binaryPath: string, configPath: string, providerProfile: CodexProviderProfile): Promise { + const cwd = getCcConnectManagedDir(); + await mkdir(cwd, { recursive: true }); + await this.rotateRuntimeLogIfNeeded(); + const projectEnv = Object.assign({}, ...this.currentProjectProfiles.map((profile) => profile.env ?? {})); + const baseEnv = prependCodexPathDir({ + ...process.env, + ...this.currentChannelEnv, + ...projectEnv, + ...(providerProfile.env ?? {}), + CODEX_HOME: providerProfile.env?.CODEX_HOME ?? getCcConnectCodexHomeDir(), + }, this.codexBundle); + const codexBinDir = dirname(this.resolveCodexPath()); + const delimiter = process.platform === 'win32' ? ';' : ':'; + const env = { + ...baseEnv, + PATH: [codexBinDir, baseEnv.PATH || ''].filter(Boolean).join(delimiter), + }; + const child = spawn(binaryPath, ['-config', configPath], { + cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + }); + child.stdout?.on('data', (data) => { + this.captureRuntimeOutput('stdout', data); + }); + child.stderr?.on('data', (data) => { + this.captureRuntimeOutput('stderr', data); + }); + child.on('exit', (code, signal) => { + if (this.child !== child) return; + this.child = null; + this.stopSessionSyncWatcher(); + void this.bridgeAdapter.close().catch(() => undefined); + const error = code === 0 + ? undefined + : `cc-connect exited with ${typeof code === 'number' ? `code ${code}` : `signal ${signal ?? 'unknown'}`}`; + this.setStatus({ + state: code === 0 ? 'stopped' : 'error', + pid: undefined, + connectedAt: undefined, + gatewayReady: undefined, + ...(error ? { error } : { error: undefined }), + }); + this.emit('exit', code); + if (error) this.scheduleCrashRestart(error); + }); + return await new Promise((resolve, reject) => { + child.once('spawn', () => resolve(child)); + child.once('error', reject); + }); + } + + private runtimeLogPath(): string { + return join(getCcConnectManagedDir(), 'logs', 'runtime.log'); + } + + private redactRuntimeOutput(value: string): string { + let redacted = value; + const scopedEnv = { + ...this.currentChannelEnv, + ...Object.assign({}, ...this.currentProjectProfiles.map((profile) => profile.env ?? {})), + ...(this.currentProviderProfile?.env ?? {}), + }; + for (const [key, secret] of Object.entries(scopedEnv)) { + if (!/(?:api[_-]?key|token|secret|password|authorization|credential)/i.test(key)) continue; + if (typeof secret === 'string' && secret.length >= 4) redacted = redacted.split(secret).join(''); + } + return redacted + .replace(/\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer ') + .replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, '') + .replace(/((?:api[_-]?key|token|secret|password|authorization)\s*[=:]\s*)[^\s,;]+/gi, '$1'); + } + + private captureRuntimeOutput(stream: 'stdout' | 'stderr', data: unknown): void { + const sanitized = this.redactRuntimeOutput(String(data)).trimEnd(); + if (!sanitized) return; + const lines = sanitized.split(/\r?\n/).map((line) => `${new Date().toISOString()} [${stream}] ${line}`); + this.runtimeLogLines.push(...lines); + if (this.runtimeLogLines.length > MAX_RUNTIME_LOG_LINES) { + this.runtimeLogLines.splice(0, this.runtimeLogLines.length - MAX_RUNTIME_LOG_LINES); + } + const output = `${lines.join('\n')}\n`; + void mkdir(dirname(this.runtimeLogPath()), { recursive: true }) + .then(() => appendFile(this.runtimeLogPath(), output, { encoding: 'utf8', mode: 0o600 })) + .then(() => chmod(this.runtimeLogPath(), 0o600).catch(() => {})) + .catch(() => {}); + this.emit('notification', { type: 'log', message: sanitized }); + } + + private async rotateRuntimeLogIfNeeded(): Promise { + const path = this.runtimeLogPath(); + const size = await stat(path).then((value) => value.size).catch(() => 0); + if (size < MAX_RUNTIME_LOG_FILE_BYTES) return; + await rename(path, `${path}.1`).catch(() => {}); + } + + private setStatus(patch: Partial): void { + this.status = { + ...this.status, + ...patch, + port: this.managementPort, + runtimeKind: this.kind, + capabilities: this.listCapabilities(), + operationCapabilities: this.listOperationCapabilities(), + configDir: getCcConnectManagedDir(), + }; + this.emit('status', this.status); + } + + private clearCrashRestartTimer(): void { + if (!this.crashRestartTimer) return; + clearTimeout(this.crashRestartTimer); + this.crashRestartTimer = null; + } + + private enqueueLifecycle(operation: () => Promise): Promise { + const result = this.lifecycleTail.then(operation, operation); + this.lifecycleTail = result.then(() => undefined, () => undefined); + return result; + } + + private scheduleCrashRestart(error: string): void { + this.clearCrashRestartTimer(); + const now = Date.now(); + this.crashRestartTimestamps = this.crashRestartTimestamps + .filter((timestamp) => now - timestamp <= CC_CONNECT_CRASH_RESTART_WINDOW_MS); + if (this.crashRestartTimestamps.length >= CC_CONNECT_MAX_CRASH_RESTARTS) { + this.setStatus({ + state: 'error', + error: `${error}; restart limit reached`, + }); + this.emit('notification', { + type: 'log', + message: `[cc-connect] ${error}; restart limit reached`, + }); + return; + } + + this.crashRestartTimestamps.push(now); + this.emit('notification', { + type: 'log', + message: `[cc-connect] ${error}; restarting`, + }); + this.crashRestartTimer = setTimeout(() => { + this.crashRestartTimer = null; + if (this.child || this.status.state === 'running' || this.status.state === 'starting') return; + void this.start().catch((restartError) => { + const message = restartError instanceof Error ? restartError.message : String(restartError); + this.setStatus({ + state: 'error', + pid: undefined, + connectedAt: undefined, + gatewayReady: undefined, + error: `Failed to restart cc-connect after crash: ${message}`, + }); + }); + }, CC_CONNECT_CRASH_RESTART_DELAY_MS); + this.crashRestartTimer.unref?.(); + } + + private startSessionSyncWatcher(): void { + this.stopSessionSyncWatcher(); + void this.refreshSessionSyncSnapshot(false); + this.sessionSyncTimer = setInterval(() => { + void this.refreshSessionSyncSnapshot(true); + }, SESSION_SYNC_POLL_INTERVAL_MS); + this.sessionSyncTimer.unref?.(); + } + + private stopSessionSyncWatcher(): void { + if (this.sessionSyncTimer) { + clearInterval(this.sessionSyncTimer); + this.sessionSyncTimer = null; + } + this.sessionSyncPolling = false; + this.sessionSyncSnapshot = new Map(); + } + + private async refreshSessionSyncSnapshot(emitChanges: boolean): Promise { + if (this.sessionSyncPolling) return; + this.sessionSyncPolling = true; + try { + const apiSessions = await this.sessionApi.listSessions(); + this.apiSessionRefs = new Map(apiSessions.map((session) => [session.logicalKey, session])); + const sessions = await Promise.all(apiSessions.map(async (session) => ccConnectApiSessionMetadata( + session, + await this.sessionMetadataStore.getLabel(session.logicalKey), + ))); + await this.applySessionSyncSnapshot(sessions, emitChanges); + } catch { + // Session sync is a best-effort UI refresh signal; chat/history RPCs still work on demand. + } finally { + this.sessionSyncPolling = false; + } + } + + private async applySessionSyncSnapshot( + sessions: Array<{ key: string; updatedAt: number }>, + emitChanges: boolean, + ): Promise { + const next = new Map(); + const changed: Array<{ key: string; updatedAt: number }> = []; + for (const session of sessions) { + const key = typeof session.key === 'string' ? session.key : ''; + const updatedAt = typeof session.updatedAt === 'number' && Number.isFinite(session.updatedAt) + ? session.updatedAt + : 0; + if (!key) continue; + next.set(key, updatedAt); + const previousUpdatedAt = this.sessionSyncSnapshot.get(key); + if (emitChanges && (previousUpdatedAt == null || updatedAt > previousUpdatedAt)) { + changed.push({ key, updatedAt }); + } + } + this.sessionSyncSnapshot = next; + for (const session of changed) { + const now = Date.now(); + this.emit('chat:runtime-event', { + type: 'session.updated', + runId: `cc-connect-session-sync-${++this.sessionSyncSeq}`, + sessionKey: session.key, + updatedAt: session.updatedAt, + reason: 'cc-connect-session-api', + seq: this.sessionSyncSeq, + ts: now, + }); + } + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function tomlValue(value: string | number | boolean): string { + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return `"${escapeToml(String(value))}"`; +} + +function ccConnectPlatformConfig(platforms: CcConnectChannelPlatform[]): string[] { + return platforms.flatMap((platform) => [ + '[[projects.platforms]]', + `type = "${escapeToml(platform.platformType)}"`, + '', + '[projects.platforms.options]', + ...Object.entries(platform.options).map(([key, value]) => { + const envKey = platform.optionEnvKeys[key]; + return `${key} = ${envKey ? `"\${${envKey}}"` : tomlValue(value)}`; + }), + '', + ]); +} + +function normalizeAgentId(value: unknown): string { + if (typeof value !== 'string') return 'main'; + const normalized = value.trim().toLowerCase(); + return normalized || 'main'; +} + +function getConfiguredDefaultAgentId(config: OpenClawConfig): string { + const agents = isRecord(config.agents) ? config.agents : {}; + const entries = Array.isArray(agents.list) + ? agents.list.filter((entry): entry is Record => isRecord(entry)) + : []; + const explicitDefault = entries.find((entry) => entry.default === true); + return normalizeAgentId(explicitDefault?.id ?? entries[0]?.id ?? 'main'); +} + +function getDefaultWorkspaceFromConfig(config: OpenClawConfig, fallbackWorkDir?: string): string { + if (fallbackWorkDir || process.env.CLAWX_CODEX_WORKDIR) return resolveCcConnectWorkspace('main', fallbackWorkDir); + const mainEntry = getAgentEntries(config).find((entry) => normalizeAgentId(entry.id) === 'main'); + return getConfiguredOpenClawWorkspace(config, 'main', mainEntry) ?? resolveCcConnectWorkspace('main'); +} + +function collectCcConnectAgentProjects(config: OpenClawConfig, fallbackWorkDir?: string): CcConnectAgentProject[] { + const entries = getAgentEntries(config); + const defaultWorkspace = getDefaultWorkspaceFromConfig(config, fallbackWorkDir); + const agents = isRecord(config.agents) ? config.agents : {}; + const defaults = isRecord(agents.defaults) ? agents.defaults : {}; + const defaultModel = modelIdFromConfig(defaults.model); + const rawProjects = entries.length > 0 + ? entries.map((entry) => { + const agentId = normalizeAgentId(entry.id); + return { + agentId, + projectName: ccConnectProjectNameForAgent(agentId), + workDir: agentId === 'main' + ? defaultWorkspace + : getConfiguredOpenClawWorkspace(config, agentId, entry) ?? resolveCcConnectWorkspace(agentId), + model: modelIdFromConfig(entry.model) ?? defaultModel, + }; + }) + : [{ + agentId: 'main', + projectName: CLAWX_PROJECT_NAME, + workDir: defaultWorkspace, + model: defaultModel, + }]; + + const projects = new Map(); + for (const project of rawProjects) { + projects.set(project.agentId, project); + } + if (!projects.has('main')) { + projects.set('main', { + agentId: 'main', + projectName: CLAWX_PROJECT_NAME, + workDir: defaultWorkspace, + }); + } + return Array.from(projects.values()).sort((left, right) => ( + left.agentId === 'main' ? -1 : right.agentId === 'main' ? 1 : left.agentId.localeCompare(right.agentId) + )); +} + +function modelIdFromConfig(value: unknown): string | undefined { + const modelRef = typeof value === 'string' + ? value.trim() + : isRecord(value) && typeof value.primary === 'string' + ? value.primary.trim() + : ''; + if (!modelRef) return undefined; + const separator = modelRef.indexOf('/'); + return separator >= 0 && separator < modelRef.length - 1 + ? modelRef.slice(separator + 1) + : modelRef; +} + +function resolveBoundAgentId(config: OpenClawConfig, channelType: string, accountId: string): string { + const defaultAgentId = getConfiguredDefaultAgentId(config); + const bindings = Array.isArray(config.bindings) ? config.bindings : []; + let channelWideAgentId: string | null = null; + for (const binding of bindings) { + if (!isRecord(binding) || typeof binding.agentId !== 'string') continue; + const match = isRecord(binding.match) ? binding.match : {}; + if (match.channel !== channelType) continue; + if (match.accountId === accountId) return normalizeAgentId(binding.agentId); + if (typeof match.accountId !== 'string' || !match.accountId.trim()) { + channelWideAgentId = normalizeAgentId(binding.agentId); + } + } + return channelWideAgentId || defaultAgentId; +} + +function collectCcConnectChannelPlatforms(config: OpenClawConfig): CcConnectChannelPlatform[] { + const channels = config.channels; + if (!channels || typeof channels !== 'object') return []; + + const platforms: CcConnectChannelPlatform[] = []; + for (const [channelType, section] of Object.entries(channels)) { + if (!section || section.enabled === false) continue; + const accounts = getCcConnectChannelAccounts(section); + for (const [accountId, accountConfig] of accounts) { + const agentId = resolveBoundAgentId(config, channelType, accountId); + platforms.push(buildCcConnectChannelPlatform(channelType, accountId, agentId, accountConfig)); + } + } + return platforms.sort((left, right) => + left.channelType.localeCompare(right.channelType) || left.accountId.localeCompare(right.accountId) + ); +} + +function getCcConnectChannelAccounts(section: ChannelConfigData): Array<[string, ChannelConfigData]> { + const accounts = isRecord(section.accounts) ? section.accounts as Record : undefined; + if (accounts && Object.keys(accounts).length > 0) { + return Object.entries(accounts) + .filter(([, account]) => account && account.enabled !== false); + } + + const legacyAccount: ChannelConfigData = {}; + for (const [key, value] of Object.entries(section)) { + if (key === 'accounts' || key === 'defaultAccount' || key === 'enabled') continue; + legacyAccount[key] = value; + } + return Object.keys(legacyAccount).length > 0 && section.enabled !== false + ? [['default', legacyAccount]] + : []; +} + +function getDefaultChannelAccountId(config: OpenClawConfig, channelType: string): string { + const section = config.channels?.[channelType]; + if (section && typeof section.defaultAccount === 'string' && section.defaultAccount.trim()) { + return section.defaultAccount.trim(); + } + const firstAccount = section ? getCcConnectChannelAccounts(section)[0]?.[0] : undefined; + return firstAccount ?? 'default'; +} + +function buildCcConnectChannelPlatform( + channelType: string, + accountId: string, + agentId: string, + accountConfig: ChannelConfigData, +): CcConnectChannelPlatform { + const platformType = resolveCcConnectPlatformType(channelType, accountConfig); + if (!CC_CONNECT_SUPPORTED_CHANNELS.has(platformType)) { + return { + channelType, + accountId, + agentId, + projectName: ccConnectProjectNameForAgent(agentId), + platformType, + options: {}, + optionEnvKeys: {}, + env: {}, + error: `cc-connect does not support channel "${channelType}" yet`, + }; + } + + const options = mapCcConnectPlatformOptions(platformType, accountConfig); + const { optionEnvKeys, env } = projectCcConnectChannelSecrets(channelType, accountId, options); + const adminFrom = getAdminFromOption(accountConfig); + const missing = getMissingRequiredOptions(platformType, options); + return { + channelType, + accountId, + agentId, + projectName: ccConnectProjectNameForAgent(agentId), + platformType, + options, + optionEnvKeys, + env, + ...(adminFrom ? { adminFrom } : {}), + ...(missing.length > 0 ? { error: `Missing cc-connect channel option(s): ${missing.join(', ')}` } : {}), + }; +} + +const CC_CONNECT_SENSITIVE_CHANNEL_OPTIONS = new Set([ + 'app_secret', + 'app_token', + 'bot_secret', + 'bot_token', + 'callback_aes_key', + 'callback_token', + 'channel_secret', + 'channel_token', + 'client_secret', + 'corp_secret', + 'encrypt_key', + 'token', + 'ws_url', +]); + +function channelEnvKey(channelType: string, accountId: string, optionKey: string): string { + const part = (value: string) => value + .trim() + .replace(/[^A-Za-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .toUpperCase() || 'DEFAULT'; + return `CLAWX_CHANNEL_${part(channelType)}_${part(accountId)}_${part(optionKey)}`; +} + +function projectCcConnectChannelSecrets( + channelType: string, + accountId: string, + options: Record, +): { optionEnvKeys: Record; env: Record } { + const optionEnvKeys: Record = {}; + const env: Record = {}; + for (const [optionKey, value] of Object.entries(options)) { + if (!CC_CONNECT_SENSITIVE_CHANNEL_OPTIONS.has(optionKey) || typeof value !== 'string') continue; + const envKey = channelEnvKey(channelType, accountId, optionKey); + optionEnvKeys[optionKey] = envKey; + env[envKey] = value; + } + return { optionEnvKeys, env }; +} + +function resolveCcConnectPlatformType(channelType: string, accountConfig: ChannelConfigData): string { + if (channelType === 'openclaw-weixin' || channelType === 'wechat') return 'weixin'; + if (channelType === 'feishu' && isLarkAccount(accountConfig)) return 'lark'; + return channelType; +} + +function isLarkAccount(accountConfig: ChannelConfigData): boolean { + const domain = getStringOption(accountConfig, 'domain'); + const normalized = domain?.toLowerCase(); + return Boolean(normalized && ( + normalized === 'lark' + || normalized === 'global' + || normalized.includes('larksuite.com') + )); +} + +function getStringOption(record: Record, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + } + return undefined; +} + +function getBooleanOption(record: Record, ...keys: string[]): boolean | undefined { + for (const key of keys) { + if (typeof record[key] === 'boolean') return record[key] as boolean; + } + return undefined; +} + +function getAllowFromOption(record: Record): string | undefined { + const value = record.allowFrom ?? record.allow_from; + if (Array.isArray(value)) { + const entries = value.map((item) => typeof item === 'string' ? item.trim() : '').filter(Boolean); + return entries.length > 0 ? entries.join(',') : undefined; + } + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function getAdminFromOption(record: Record): string | undefined { + const value = record.adminFrom ?? record.admin_from; + if (Array.isArray(value)) { + const entries = value.map((item) => typeof item === 'string' ? item.trim() : '').filter(Boolean); + return entries.length > 0 ? entries.join(',') : undefined; + } + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function setStringOption( + target: Record, + targetKey: string, + source: Record, + ...sourceKeys: string[] +): void { + const value = getStringOption(source, ...sourceKeys); + if (value) target[targetKey] = value; +} + +function setBooleanOption( + target: Record, + targetKey: string, + source: Record, + ...sourceKeys: string[] +): void { + const value = getBooleanOption(source, ...sourceKeys); + if (value !== undefined) target[targetKey] = value; +} + +function setAllowFromOption( + target: Record, + source: Record, +): void { + const value = getAllowFromOption(source); + if (value) target.allow_from = value; +} + +function setCommonSessionOptions( + target: Record, + source: Record, +): void { + setAllowFromOption(target, source); + setBooleanOption(target, 'share_session_in_channel', source, 'shareSessionInChannel', 'share_session_in_channel'); +} + +function mapCcConnectPlatformOptions( + platformType: string, + accountConfig: ChannelConfigData, +): Record { + const options: Record = {}; + switch (platformType) { + case 'feishu': + case 'lark': + setStringOption(options, 'app_id', accountConfig, 'appId', 'app_id'); + setStringOption(options, 'app_secret', accountConfig, 'appSecret', 'app_secret'); + setFeishuDomainOption(options, platformType, accountConfig); + setBooleanOption(options, 'enable_feishu_card', accountConfig, 'enableFeishuCard', 'enable_feishu_card'); + setBooleanOption(options, 'group_reply_all', accountConfig, 'groupReplyAll', 'group_reply_all'); + setBooleanOption(options, 'thread_isolation', accountConfig, 'threadIsolation', 'thread_isolation'); + setBooleanOption(options, 'reply_in_thread', accountConfig, 'replyInThread', 'reply_in_thread'); + setStringOption(options, 'reaction_emoji', accountConfig, 'reactionEmoji', 'reaction_emoji'); + setStringOption(options, 'done_emoji', accountConfig, 'doneEmoji', 'done_emoji'); + setStringOption(options, 'progress_style', accountConfig, 'progressStyle', 'progress_style'); + setStringOption(options, 'port', accountConfig, 'port'); + setStringOption(options, 'callback_path', accountConfig, 'callbackPath', 'callback_path'); + setStringOption(options, 'encrypt_key', accountConfig, 'encryptKey', 'encrypt_key'); + setCommonSessionOptions(options, accountConfig); + break; + case 'dingtalk': + setStringOption(options, 'client_id', accountConfig, 'clientId', 'client_id'); + setStringOption(options, 'client_secret', accountConfig, 'clientSecret', 'client_secret'); + setCommonSessionOptions(options, accountConfig); + break; + case 'telegram': + setStringOption(options, 'token', accountConfig, 'token', 'botToken', 'bot_token'); + setCommonSessionOptions(options, accountConfig); + break; + case 'slack': + setStringOption(options, 'bot_token', accountConfig, 'botToken', 'bot_token'); + setStringOption(options, 'app_token', accountConfig, 'appToken', 'app_token'); + setCommonSessionOptions(options, accountConfig); + break; + case 'discord': + setStringOption(options, 'token', accountConfig, 'token', 'botToken', 'bot_token'); + setStringOption(options, 'guild_id', accountConfig, 'guildId', 'guild_id'); + setStringOption(options, 'channel_id', accountConfig, 'channelId', 'channel_id'); + setDiscordGuildOptions(options, accountConfig); + setBooleanOption(options, 'group_reply_all', accountConfig, 'groupReplyAll', 'group_reply_all'); + setCommonSessionOptions(options, accountConfig); + break; + case 'line': + setStringOption(options, 'channel_secret', accountConfig, 'channelSecret', 'channel_secret'); + setStringOption(options, 'channel_token', accountConfig, 'channelToken', 'channel_token'); + setStringOption(options, 'port', accountConfig, 'port'); + setStringOption(options, 'callback_path', accountConfig, 'callbackPath', 'callback_path'); + break; + case 'wecom': + setWeComOptions(options, accountConfig); + setCommonSessionOptions(options, accountConfig); + break; + case 'weixin': + setStringOption(options, 'token', accountConfig, 'token', 'botToken', 'bot_token'); + setStringOption(options, 'base_url', accountConfig, 'baseUrl', 'base_url'); + setStringOption(options, 'cdn_base_url', accountConfig, 'cdnBaseUrl', 'cdn_base_url'); + setCommonSessionOptions(options, accountConfig); + break; + case 'qq': + setStringOption(options, 'ws_url', accountConfig, 'wsUrl', 'ws_url'); + setStringOption(options, 'token', accountConfig, 'token'); + setCommonSessionOptions(options, accountConfig); + break; + case 'qqbot': + setStringOption(options, 'app_id', accountConfig, 'appId', 'app_id'); + setStringOption(options, 'app_secret', accountConfig, 'appSecret', 'app_secret'); + setBooleanOption(options, 'sandbox', accountConfig, 'sandbox'); + setCommonSessionOptions(options, accountConfig); + break; + default: + break; + } + return options; +} + +function setFeishuDomainOption( + target: Record, + platformType: string, + source: Record, +): void { + const domain = getStringOption(source, 'domain'); + if (!domain) return; + const normalized = domain.toLowerCase(); + if (normalized === 'lark' || normalized === 'global') { + target.domain = 'https://open.larksuite.com'; + return; + } + if (normalized === 'feishu' || normalized === 'cn') { + target.domain = 'https://open.feishu.cn'; + return; + } + target.domain = domain; + if (platformType === 'lark' && !domain.includes('larksuite.com')) { + target.domain = 'https://open.larksuite.com'; + } +} + +function setDiscordGuildOptions( + target: Record, + source: Record, +): void { + if (target.guild_id) return; + if (!isRecord(source.guilds)) return; + const guildId = Object.keys(source.guilds)[0]; + if (!guildId) return; + target.guild_id = guildId; + const guild = source.guilds[guildId]; + if (!isRecord(guild) || !isRecord(guild.channels)) return; + const channelId = Object.keys(guild.channels).find((id) => id !== '*'); + if (channelId) target.channel_id = channelId; +} + +function setWeComOptions( + target: Record, + source: Record, +): void { + setStringOption(target, 'mode', source, 'mode'); + setStringOption(target, 'bot_id', source, 'botId', 'bot_id'); + setStringOption(target, 'bot_secret', source, 'botSecret', 'bot_secret'); + setStringOption(target, 'corp_id', source, 'corpId', 'corp_id'); + setStringOption(target, 'corp_secret', source, 'corpSecret', 'corp_secret'); + setStringOption(target, 'agent_id', source, 'agentId', 'agent_id'); + setStringOption(target, 'callback_token', source, 'callbackToken', 'callback_token'); + setStringOption(target, 'callback_aes_key', source, 'callbackAesKey', 'callback_aes_key'); + setStringOption(target, 'port', source, 'port'); + setStringOption(target, 'callback_path', source, 'callbackPath', 'callback_path'); + if (!target.mode && target.bot_id && target.bot_secret) { + target.mode = 'websocket'; + } +} + +function getMissingRequiredOptions( + platformType: string, + options: Record, +): string[] { + const requiredByPlatform: Record = { + dingtalk: ['client_id', 'client_secret'], + discord: ['token'], + feishu: ['app_id', 'app_secret'], + lark: ['app_id', 'app_secret'], + line: ['channel_secret', 'channel_token'], + qq: ['ws_url'], + qqbot: ['app_id', 'app_secret'], + slack: ['bot_token', 'app_token'], + telegram: ['token'], + weixin: ['token'], + }; + if (platformType === 'wecom') { + const websocketReady = Boolean(options.bot_id && options.bot_secret); + const webhookReady = Boolean(options.corp_id && options.corp_secret && options.agent_id); + return websocketReady || webhookReady ? [] : ['bot_id/bot_secret or corp_id/corp_secret/agent_id']; + } + return (requiredByPlatform[platformType] ?? []).filter((key) => !options[key]); +} + +function redactCcConnectConfigForLogs(content: string): string { + const sensitiveKeyPattern = /^(?\s*(?:api_key|app_id|app_secret|app_token|bot_id|bot_secret|bot_token|callback_aes_key|callback_token|channel_secret|channel_token|client_id|client_secret|corp_id|corp_secret|agent_id|encrypt_key|token|ws_url)\s*=\s*)"[^"]*"/i; + return content.split('\n').map((line) => line.replace(sensitiveKeyPattern, '$""')).join('\n'); +} + +function runtimeTimestamp(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value < 1e12 ? value * 1000 : value; + } + if (typeof value === 'string' && value.trim()) { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function hasRuntimeToolCall(message: RawMessage): boolean { + if (message.role !== 'assistant' || !Array.isArray(message.content)) return false; + return message.content.some((block) => { + if (!isRecord(block)) return false; + return block.type === 'toolCall' || block.type === 'tool_use'; + }); +} + +function runtimeToolCallKey(message: RawMessage): string { + if (!Array.isArray(message.content)) return String(message.id || ''); + const ids = message.content.flatMap((block) => { + if (!isRecord(block) || (block.type !== 'toolCall' && block.type !== 'tool_use')) return []; + return [String(block.id || `${block.name || 'tool'}:${JSON.stringify(block.arguments ?? block.input ?? {})}`)]; + }); + return ids.join('|') || String(message.id || ''); +} + +function hasRuntimeAttachments(message: RawMessage): boolean { + return Array.isArray(message._attachedFiles) && message._attachedFiles.length > 0; +} + +function runtimeSupplementKey(message: RawMessage): string { + if (hasRuntimeToolCall(message)) return `tool:${runtimeToolCallKey(message)}`; + if (message.id) return `attachment:${message.id}`; + const attachments = (message._attachedFiles ?? []).map((file) => [ + file.filePath, + file.gatewayUrl, + file.fileName, + file.mimeType, + file.fileSize, + ]); + return `attachment:${String(message.timestamp ?? '')}:${JSON.stringify(attachments)}`; +} + +function mergeCcConnectHistory(publicMessages: RawMessage[], bridgeMessages: RawMessage[]): RawMessage[] { + const supplementalMessages = bridgeMessages.filter((message) => ( + hasRuntimeToolCall(message) || hasRuntimeAttachments(message) + )); + if (supplementalMessages.length === 0) return publicMessages; + const seenSupplements = new Set( + publicMessages + .filter((message) => hasRuntimeToolCall(message) || hasRuntimeAttachments(message)) + .map(runtimeSupplementKey), + ); + const merged = [...publicMessages]; + for (const message of supplementalMessages) { + const key = runtimeSupplementKey(message); + if (seenSupplements.has(key)) continue; + seenSupplements.add(key); + merged.push(message); + } + return merged + .map((message, index) => ({ message, index })) + .sort((left, right) => { + const leftTimestamp = runtimeTimestamp(left.message.timestamp) ?? 0; + const rightTimestamp = runtimeTimestamp(right.message.timestamp) ?? 0; + return leftTimestamp === rightTimestamp ? left.index - right.index : leftTimestamp - rightTimestamp; + }) + .map(({ message }) => message); +} + +function runtimeMessageText(value: unknown): string { + if (typeof value === 'string') return value.trim(); + if (!Array.isArray(value)) return ''; + return value.flatMap((item) => { + if (typeof item === 'string') return [item]; + if (!isRecord(item)) return []; + const text = ccString(item, ['text', 'content', 'thinking']); + return text ? [text] : []; + }).join('\n').trim(); +} + +export function ccConnectSessionLogicalKey( + projectName: string, + sessionKey: string, + id: string, + active: boolean, +): string { + const agentId = agentIdFromCcConnectProjectName(projectName) || 'main'; + if (sessionKey === CLAWX_LOCAL_CRON_SESSION_KEY) { + const baseKey = `agent:${agentId}:cron:scheduled`; + return active ? baseKey : `${baseKey}:${id}`; + } + if (!sessionKey.startsWith('clawx:')) return sessionKey; + const [, keyAgentId = 'main', ...keyParts] = sessionKey.split(':'); + const scopedAgentId = agentIdFromCcConnectProjectName(projectName) || normalizeAgentId(keyAgentId) || 'main'; + const baseKey = `agent:${scopedAgentId}:${keyParts.join(':') || 'main'}`; + return active ? baseKey : `agent:${agentId}:${id}`; +} + +function parseCcConnectApiSessions(projectName: string, value: unknown): CcConnectApiSessionRef[] { + const body = isRecord(value) ? value : {}; + const rawSessions = Array.isArray(value) + ? value + : Array.isArray(body.sessions) + ? body.sessions + : []; + const agentId = agentIdFromCcConnectProjectName(projectName) || 'main'; + return rawSessions.flatMap((candidate): CcConnectApiSessionRef[] => { + if (!isRecord(candidate)) return []; + const id = ccString(candidate, ['id', 'session_id', 'sessionId']); + const sessionKey = ccString(candidate, ['session_key', 'sessionKey', 'key']); + if (!id || !sessionKey) return []; + const active = ccBoolean(candidate, ['active', 'is_active', 'isActive'], false); + const createdAt = runtimeTimestamp(candidate.created_at ?? candidate.createdAt) ?? Date.now(); + const updatedAt = runtimeTimestamp(candidate.updated_at ?? candidate.updatedAt) ?? createdAt; + const lastMessage = isRecord(candidate.last_message) + ? candidate.last_message + : isRecord(candidate.lastMessage) + ? candidate.lastMessage + : undefined; + return [{ + projectName, + agentId, + id, + sessionKey, + logicalKey: ccConnectSessionLogicalKey(projectName, sessionKey, id, active), + name: ccString(candidate, ['name', 'title']) || undefined, + userName: ccString(candidate, ['user_name', 'userName']) || undefined, + chatName: ccString(candidate, ['chat_name', 'chatName']) || undefined, + active, + createdAt, + updatedAt, + ...(lastMessage ? { lastMessage } : {}), + }]; + }); +} + +function ccConnectApiSessionMetadata(session: CcConnectApiSessionRef, label?: string) { + const preview = runtimeMessageText(session.lastMessage?.content).slice(0, 120) || undefined; + const baseDisplayName = session.chatName || session.name || preview || session.logicalKey; + const displayName = label || (session.userName && session.userName !== baseDisplayName + ? `${baseDisplayName} / ${session.userName}` + : baseDisplayName || session.userName); + const providerTitle = session.name && !/^default$/i.test(session.name.trim()) + ? session.name + : undefined; + return { + key: session.logicalKey, + displayName, + derivedTitle: label || providerTitle || preview, + lastMessagePreview: preview, + agentId: session.agentId, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }; +} + +function parseCcConnectApiHistory(value: unknown): RawMessage[] { + const body = isRecord(value) ? value : {}; + const rawHistory = Array.isArray(value) + ? value + : Array.isArray(body.history) + ? body.history + : Array.isArray(body.messages) + ? body.messages + : []; + return rawHistory.flatMap((candidate, index): RawMessage[] => { + if (!isRecord(candidate)) return []; + const role = ccString(candidate, ['role']); + if (!['user', 'assistant', 'system', 'toolresult', 'toolResult'].includes(role)) return []; + const content = typeof candidate.content === 'string' || Array.isArray(candidate.content) + ? candidate.content + : typeof candidate.text === 'string' + ? candidate.text + : ''; + const timestamp = runtimeTimestamp(candidate.timestamp ?? candidate.created_at ?? candidate.createdAt) ?? Date.now(); + return [{ + ...candidate, + id: ccString(candidate, ['id', 'message_id', 'messageId']) || `cc-connect-api-${timestamp}-${index}`, + role: role === 'toolResult' ? 'toolresult' : role, + content, + timestamp, + } as RawMessage]; + }); +} + +function getSessionKey(payload: unknown): string { + if (typeof payload === 'string' && payload.trim()) return payload.trim(); + if (isRecord(payload)) { + const value = payload.sessionKey ?? payload.id; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return 'agent:main:main'; +} + +function getPayloadId(payload: unknown): string { + if (typeof payload === 'string' && payload.trim()) return payload.trim(); + if (isRecord(payload) && typeof payload.id === 'string' && payload.id.trim()) return payload.id.trim(); + throw new Error('id is required'); +} + +function getChannelLifecycleTarget(payload: unknown): { channelType?: string; accountId?: string } { + if (!isRecord(payload)) return {}; + const directChannelType = typeof payload.channelType === 'string' ? payload.channelType.trim() : ''; + const directAccountId = typeof payload.accountId === 'string' ? payload.accountId.trim() : ''; + if (directChannelType) { + return { + channelType: directChannelType, + ...(directAccountId ? { accountId: directAccountId } : {}), + }; + } + + const channelId = typeof payload.channelId === 'string' ? payload.channelId.trim() : ''; + if (!channelId) return {}; + const separatorIndex = channelId.indexOf('-'); + if (separatorIndex <= 0) return { channelType: channelId }; + const channelType = channelId.slice(0, separatorIndex).trim(); + const accountId = channelId.slice(separatorIndex + 1).trim(); + return { + ...(channelType ? { channelType } : {}), + ...(accountId ? { accountId } : {}), + }; +} + +function toSendPayload(payload: unknown): RuntimeSendWithMediaPayload { + const body = isRecord(payload) ? payload : {}; + const message = typeof body.message === 'string' + ? body.message + : typeof body.content === 'string' + ? body.content + : ''; + const idempotencyKey = typeof body.idempotencyKey === 'string' && body.idempotencyKey.trim() + ? body.idempotencyKey.trim() + : `cc-connect-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const media = Array.isArray(body.media) + ? body.media + : Array.isArray(body.attachments) + ? body.attachments + : undefined; + return { + sessionKey: getSessionKey(body), + message, + deliver: typeof body.deliver === 'boolean' ? body.deliver : false, + idempotencyKey, + ...(media ? { media: media as RuntimeSendWithMediaPayload['media'] } : {}), + }; +} + +function toProviderSyncPayload(payload: unknown): { providerId?: string; reason?: string } | undefined { + if (!isRecord(payload)) return undefined; + return { + providerId: typeof payload.providerId === 'string' ? payload.providerId : undefined, + reason: typeof payload.reason === 'string' ? payload.reason : undefined, + }; +} + +function cronExprFromInput(schedule: unknown): string { + if (typeof schedule === 'string') return schedule.trim(); + if (!isRecord(schedule)) return ''; + if (schedule.kind === 'cron' && typeof schedule.expr === 'string') return schedule.expr.trim(); + return ''; +} + +function assertCcConnectCronSchedule(schedule: unknown): string { + const expr = cronExprFromInput(schedule); + if (expr) return expr; + if (isRecord(schedule) && (schedule.kind === 'at' || schedule.kind === 'every')) { + throw new Error('cc-connect cron currently supports only cron expression schedules'); + } + throw new Error('cron schedule is required'); +} + +function ccString(record: Record, keys: string[]): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return ''; +} + +function ccBoolean(record: Record, keys: string[], fallback: boolean): boolean { + for (const key of keys) { + if (typeof record[key] === 'boolean') return record[key] as boolean; + } + return fallback; +} + +function ccNumber(record: Record, keys: string[]): number | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + } + return undefined; +} + +function ccTimestamp(value: unknown, fallback = Date.now()): string { + if (typeof value === 'string' && value.trim()) { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return new Date(parsed).toISOString(); + } + if (typeof value === 'number' && Number.isFinite(value)) { + return new Date(value < 1e12 ? value * 1000 : value).toISOString(); + } + return new Date(fallback).toISOString(); +} + +function ccOptionalTimestamp(value: unknown): string | undefined { + let timestamp: number | undefined; + if (typeof value === 'string' && value.trim()) { + timestamp = Date.parse(value); + } else if (typeof value === 'number' && Number.isFinite(value)) { + timestamp = value < 1e12 ? value * 1000 : value; + } + if (timestamp === undefined || !Number.isFinite(timestamp) || timestamp <= 0) return undefined; + return new Date(timestamp).toISOString(); +} + +function parseCcConnectProjectRuntimeStatus(value: unknown): CcConnectProjectRuntimeStatus { + const project = isRecord(value) ? value : {}; + const platformsValue = Array.isArray(project.platforms) ? project.platforms : []; + const platforms = new Map(); + const platformList: CcConnectProjectPlatformStatus[] = []; + for (const entry of platformsValue) { + if (!isRecord(entry)) continue; + const type = ccString(entry, ['type', 'platform', 'name']); + if (!type) continue; + const connected = ccBoolean(entry, ['connected', 'live', 'running'], false); + const running = ccBoolean(entry, ['running', 'live', 'connected'], connected); + const error = ccString(entry, ['error', 'last_error', 'lastError']); + const status = { + type, + connected, + running, + ...(error ? { error } : {}), + }; + platformList.push(status); + if (!platforms.has(type)) { + platforms.set(type, status); + } + } + return { platforms, platformList }; +} + +function ccConnectCronExecutionFields( + input: Record, + options: { includePrompt?: boolean } = {}, +): Record { + const fields: Record = {}; + const exec = ccString(input, ['exec', 'command']); + const message = ccString(input, ['message', 'prompt', 'content']); + if (exec) { + fields.exec = exec; + } else if (message || options.includePrompt) { + fields.prompt = message; + } + const workDir = ccString(input, ['workDir', 'work_dir']); + if (workDir) fields.work_dir = expandPath(workDir); + const sessionMode = ccString(input, ['sessionMode', 'session_mode']); + if (sessionMode) fields.session_mode = toCcConnectCronSessionMode(sessionMode); + const timeoutMins = ccNumber(input, ['timeoutMins', 'timeout_mins']); + if (timeoutMins !== undefined) fields.timeout_mins = Math.max(0, Math.floor(timeoutMins)); + return fields; +} + +function ccConnectCronSessionKey(input: Record): string { + const delivery = isRecord(input.delivery) ? input.delivery : {}; + const deliveryMode = ccString(delivery, ['mode']); + const deliveryChannel = ccString(delivery, ['channel', 'channel_type', 'channelType']); + const deliveryTarget = ccString(delivery, ['to', 'target', 'recipient']); + if (deliveryMode === 'announce' && deliveryChannel && deliveryTarget) { + return `${ccConnectCronPlatformType(deliveryChannel)}:${deliveryTarget}`; + } + + return CLAWX_LOCAL_CRON_SESSION_KEY; +} + +function ccConnectCronPlatformType(channel: string): string { + const normalized = channel.trim().toLowerCase(); + if (normalized === 'openclaw-weixin' || normalized === 'wechat') return 'weixin'; + return normalized; +} + +function ccConnectCronDeliveryFields(input: Record): Record { + if (!isRecord(input.delivery)) return {}; + const delivery = input.delivery; + const mode = ccString(delivery, ['mode']); + const channel = ccString(delivery, ['channel', 'channel_type', 'channelType']); + const to = ccString(delivery, ['to', 'target', 'recipient']); + const accountId = ccString(delivery, ['accountId', 'account_id']); + if (mode !== 'announce' || !channel || !to) return {}; + return { + delivery: { + mode: 'announce', + channel, + to, + ...(accountId ? { account_id: accountId } : {}), + }, + }; +} + +function ccConnectCronUpdateBaseline(value: unknown): Record { + const job = isRecord(value) ? value : {}; + const baseline: Record = {}; + copyCcConnectCronStringField(job, baseline, ['project', 'project_name', 'projectName'], 'project'); + copyCcConnectCronStringField(job, baseline, ['session_key', 'sessionKey'], 'session_key'); + copyCcConnectCronStringField(job, baseline, ['cron_expr', 'cron', 'schedule'], 'cron_expr'); + copyCcConnectCronStringField(job, baseline, ['description', 'desc', 'name'], 'description'); + copyCcConnectCronStringField(job, baseline, ['prompt', 'message', 'content'], 'prompt'); + copyCcConnectCronStringField(job, baseline, ['exec', 'command'], 'exec'); + copyCcConnectCronStringField(job, baseline, ['work_dir', 'workDir'], 'work_dir'); + const sessionMode = ccString(job, ['session_mode', 'sessionMode']); + if (sessionMode) baseline.session_mode = toCcConnectCronSessionMode(sessionMode); + const timeoutMins = ccNumber(job, ['timeout_mins', 'timeoutMins']); + if (timeoutMins !== undefined) baseline.timeout_mins = timeoutMins; + if (hasOwn(job, 'enabled')) baseline.enabled = ccBoolean(job, ['enabled'], true); + if (hasOwn(job, 'silent')) baseline.silent = ccBoolean(job, ['silent'], true); + if (hasOwn(job, 'mute')) baseline.mute = ccBoolean(job, ['mute'], false); + const delivery = ccConnectCronDeliveryFromJob(job); + if (delivery.mode === 'announce' && delivery.channel && delivery.to) { + baseline.delivery = { + mode: 'announce', + channel: delivery.channel, + to: delivery.to, + ...(delivery.accountId ? { account_id: delivery.accountId } : {}), + }; + } + return baseline; +} + +function copyCcConnectCronStringField( + source: Record, + target: Record, + aliases: string[], + targetKey: string, +): void { + const value = ccString(source, aliases); + if (value) target[targetKey] = value; +} + +function hasOwn(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function toCcConnectCronSessionMode(sessionMode: string): string { + const normalized = sessionMode.trim(); + if (normalized === 'continue') return 'reuse'; + if (normalized === 'new-per-run') return 'new_per_run'; + return normalized; +} + +function fromCcConnectCronSessionMode(sessionMode: string): string { + const normalized = sessionMode.trim(); + if (normalized === 'reuse') return 'continue'; + if (normalized === 'new-per-run') return 'new_per_run'; + return normalized; +} + +function agentIdFromCcConnectSessionKey(sessionKey: string): string | null { + if (!sessionKey.startsWith('clawx:')) return null; + const [, agentId] = sessionKey.split(':'); + return normalizeAgentId(agentId); +} + +function agentIdFromCcConnectProjectName(projectName: string): string | null { + if (!projectName.startsWith('clawx-')) return null; + return normalizeAgentId(projectName.slice('clawx-'.length)); +} + +function agentIdFromCcConnectCronJob(job: Record): string { + const sessionKey = ccString(job, ['session_key', 'sessionKey']); + const projectName = ccString(job, ['project', 'project_name', 'projectName']); + return agentIdFromCcConnectSessionKey(sessionKey) + || agentIdFromCcConnectProjectName(projectName) + || 'main'; +} + +function ccConnectCronDeliveryFromJob(job: Record): NonNullable { + const rawDelivery = isRecord(job.delivery) ? job.delivery : {}; + const mode = ccString(rawDelivery, ['mode']) === 'announce' || job.silent === false + ? 'announce' + : 'none'; + const channel = ccString(rawDelivery, ['channel', 'channel_type', 'channelType']) + || ccString(job, ['channel', 'channel_type', 'channelType']); + const to = ccString(rawDelivery, ['to', 'target', 'recipient']) + || ccString(job, ['to', 'target', 'recipient']); + const accountId = ccString(rawDelivery, ['accountId', 'account_id']) + || ccString(job, ['accountId', 'account_id']); + if (mode !== 'announce') return { mode: 'none' }; + return { + mode: 'announce', + ...(channel ? { channel } : {}), + ...(to ? { to } : {}), + ...(accountId ? { accountId } : {}), + }; +} + +function transformCcConnectCronJob(value: unknown): CronJob { + const job = isRecord(value) ? value : {}; + const id = ccString(job, ['id', 'task_id', 'job_id']) || `cc-connect-cron-${Date.now()}`; + const name = ccString(job, ['description', 'desc', 'name']) || 'Scheduled task'; + const message = ccString(job, ['prompt', 'message', 'content']); + const exec = ccString(job, ['exec', 'command']); + const expr = ccString(job, ['cron_expr', 'cron', 'schedule']); + const enabled = ccBoolean(job, ['enabled'], true); + const delivery = ccConnectCronDeliveryFromJob(job); + const target = delivery.mode === 'announce' && delivery.channel + ? { + channelType: delivery.channel, + channelId: delivery.accountId || delivery.channel, + channelName: delivery.channel, + ...(delivery.to ? { recipient: delivery.to } : {}), + } + : undefined; + const createdAt = ccTimestamp(job.created_at ?? job.createdAt ?? job.created_at_ms); + const updatedAt = ccTimestamp(job.updated_at ?? job.updatedAt ?? job.updated_at_ms, Date.parse(createdAt)); + const nextRunAt = job.next_run_at ?? job.nextRunAt ?? job.next_run_at_ms; + const lastRunAt = job.last_run ?? job.last_run_at ?? job.lastRun ?? job.lastRunAt ?? job.last_run_at_ms; + const lastRunIso = ccOptionalTimestamp(lastRunAt); + const lastRunError = ccString(job, ['last_error', 'lastError']); + const lastRunStatus = ccString(job, ['last_status', 'lastStatus']); + const result: CronJob & Record = { + id, + name, + message: message || exec, + schedule: expr ? { kind: 'cron', expr } : '', + delivery, + ...(target ? { target } : {}), + enabled, + createdAt, + updatedAt, + ...(typeof nextRunAt === 'undefined' ? {} : { nextRun: ccTimestamp(nextRunAt) }), + ...(lastRunIso ? { + lastRun: { + time: lastRunIso, + success: lastRunStatus !== 'error' && !lastRunError, + ...(lastRunError ? { error: lastRunError } : {}), + }, + } : {}), + agentId: agentIdFromCcConnectCronJob(job), + ...(exec ? { exec } : {}), + ...(ccString(job, ['work_dir', 'workDir']) ? { workDir: ccString(job, ['work_dir', 'workDir']) } : {}), + ...(ccString(job, ['session_mode', 'sessionMode']) ? { sessionMode: fromCcConnectCronSessionMode(ccString(job, ['session_mode', 'sessionMode'])) } : {}), + ...(ccNumber(job, ['timeout_mins', 'timeoutMins']) !== undefined ? { timeoutMins: ccNumber(job, ['timeout_mins', 'timeoutMins']) } : {}), + ...(hasOwn(job, 'mute') ? { mute: ccBoolean(job, ['mute'], false) } : {}), + }; + return result; +} diff --git a/electron/runtime/cc-connect-session-metadata.ts b/electron/runtime/cc-connect-session-metadata.ts new file mode 100644 index 000000000..5e944caf1 --- /dev/null +++ b/electron/runtime/cc-connect-session-metadata.ts @@ -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; + updatedAt: string; + migratedFromLegacyAt?: string; +}; + +export interface CcConnectSessionMetadataStore { + getLabel(sessionKey: string): Promise; + setLabel(sessionKey: string, label: string): Promise; + deleteLabel(sessionKey: string): Promise; +} + +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 { + 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 { + 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 { + const document = await this.readDocument(); + return document.labels[sessionKey]; + } + + async setLabel(sessionKey: string, label: string): Promise { + 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 { + 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 { + try { + const parsed = JSON.parse(await readFile(this.metadataPath, 'utf8')) as Partial; + 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(operation: () => Promise): Promise { + const previous = this.queue; + let release!: () => void; + this.queue = new Promise((resolve) => { release = resolve; }); + await previous; + try { + return await operation(); + } finally { + release(); + } + } +} diff --git a/electron/runtime/cc-connect-skills.ts b/electron/runtime/cc-connect-skills.ts new file mode 100644 index 000000000..687b1d4c6 --- /dev/null +++ b/electron/runtime/cc-connect-skills.ts @@ -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): 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 { + const skillsRoot = join(codexHomeDir, 'skills'); + await mkdir(skillsRoot, { recursive: true }); + const enabled = records.filter((skill) => skill.enabled !== false && skill.baseDir); + const manifest: Array> = []; + + 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 { + return syncCcConnectSkillRecords(await listLocalSkills(), codexHomeDir); +} diff --git a/electron/runtime/codex-paths.ts b/electron/runtime/codex-paths.ts new file mode 100644 index 000000000..7bd67638c --- /dev/null +++ b/electron/runtime/codex-paths.ts @@ -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), + }; +} diff --git a/electron/runtime/manager.ts b/electron/runtime/manager.ts new file mode 100644 index 000000000..7c2763efb --- /dev/null +++ b/electron/runtime/manager.ts @@ -0,0 +1,130 @@ +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; + + constructor(options: RuntimeManagerOptions) { + super(); + this.providers = { + openclaw: options.openclaw, + 'cc-connect': options.ccConnect, + }; + this.forwardProviderEvents(options.openclaw); + this.forwardProviderEvents(options.ccConnect); + } + + async getActiveKind(): Promise { + 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 { + 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 ?? 'openclaw') !== nextKind) { + await previous.stop(); + } + this.activeKind = nextKind; + await setSetting('runtimeKind', nextKind); + this.emit('status', this.getStatus()); + } + + listCapabilities(): RuntimeCapabilities { + return this.getActiveProvider().listCapabilities(); + } + + listOperationCapabilities(): RuntimeOperationCapabilities { + return this.getActiveProvider().listOperationCapabilities(); + } + + getStatus(): RuntimeStatus { + return this.getActiveProvider().getStatus(); + } + + start(): Promise { + return this.getActiveProvider().start(); + } + + stop(): Promise { + return this.getActiveProvider().stop(); + } + + restart(): Promise { + return this.getActiveProvider().restart(); + } + + checkHealth(options?: { probe?: boolean }) { + return this.getActiveProvider().checkHealth(options); + } + + rpc(method: string, params?: unknown, timeoutMs?: number): Promise { + 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), + runtimeKind: provider.kind, + capabilities: provider.listCapabilities(), + operationCapabilities: provider.listOperationCapabilities(), + }); + return; + } + this.emit(eventName, payload); + }); + } + } +} diff --git a/electron/runtime/openclaw-provider.ts b/electron/runtime/openclaw-provider.ts new file mode 100644 index 000000000..9ae8a54c9 --- /dev/null +++ b/electron/runtime/openclaw-provider.ts @@ -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(method: string, params?: unknown, timeoutMs?: number): Promise { + switch (method) { + case 'cron.list': + return listCronJobs(this.gatewayManager) as Promise; + case 'cron.create': + case 'cron.add': + return createOpenClawCronJob(this.gatewayManager, params as never) as Promise; + case 'cron.update': { + const body = params && typeof params === 'object' ? params as Record : {}; + if ('input' in body) { + return updateOpenClawCronJob(this.gatewayManager, body as never) as Promise; + } + return this.gatewayManager.rpc(method, params, timeoutMs); + } + case 'cron.delete': + case 'cron.remove': + return deleteOpenClawCronJob(this.gatewayManager, params) as Promise; + case 'cron.toggle': + return toggleOpenClawCronJob(this.gatewayManager, params as never) as Promise; + case 'cron.run': + return triggerOpenClawCronJob(this.gatewayManager, params) as Promise; + case 'runtime.controlUi': + return this.getControlUi(params as never) as Promise; + case 'sessions.rename': + case 'session.rename': + return this.sessionsApi.rename(params as never) as Promise; + 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 { + 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 }; + } +} diff --git a/electron/runtime/rpc-contract.ts b/electron/runtime/rpc-contract.ts new file mode 100644 index 000000000..7435d9a3d --- /dev/null +++ b/electron/runtime/rpc-contract.ts @@ -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, + }, + ])); +} diff --git a/electron/runtime/types.ts b/electron/runtime/types.ts new file mode 100644 index 000000000..a2a19ad93 --- /dev/null +++ b/electron/runtime/types.ts @@ -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; + stop: () => Promise; + restart: () => Promise; + getStatus: () => RuntimeStatus; + checkHealth: (options?: { probe?: boolean }) => Promise; + rpc: (method: string, params?: unknown, timeoutMs?: number) => Promise; + sendMessageWithMedia: (payload: RuntimeSendWithMediaPayload) => Promise; + listSessions: (payload?: unknown) => Promise; + loadHistory: (payload?: unknown) => Promise; + deleteSession: (payload?: unknown) => Promise; + listUsage: (payload?: unknown) => Promise; + listLogs: (payload?: { tailLines?: number }) => Promise; + runDoctor: (mode: OpenClawDoctorMode) => Promise; + listCapabilities: () => RuntimeCapabilities; + listOperationCapabilities: () => RuntimeOperationCapabilities; + refreshConfig?: (payload: RuntimeConfigRefreshPayload) => Promise; + syncProviderProfile?: (payload: RuntimeProviderSyncPayload) => Promise; + getControlUi?: (payload?: RuntimeControlUiPayload) => Promise; +}; + +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 } : {}), + }; +} diff --git a/electron/runtime/usage.ts b/electron/runtime/usage.ts new file mode 100644 index 000000000..72ece3b66 --- /dev/null +++ b/electron/runtime/usage.ts @@ -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 } : {}), + }; +} diff --git a/electron/services/agents-api.ts b/electron/services/agents-api.ts index 8600b1571..4d669dd91 100644 --- a/electron/services/agents-api.ts +++ b/electron/services/agents-api.ts @@ -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 { +async function refreshActiveRuntime(ctx: AgentsApiContext, reason: string): Promise { + const provider = ctx.runtimeManager?.getActiveProvider(); + if (provider?.refreshConfig) { + await provider.refreshConfig({ scope: 'runtime', reason }); + return; + } if (ctx.gatewayManager.getStatus().state !== 'stopped') { ctx.gatewayManager.debouncedReload(); - return; } - void reason; } -async function restartGatewayForAgentDeletion(ctx: AgentsApiContext): Promise { +async function restartRuntimeForAgentDeletion(ctx: AgentsApiContext): Promise { 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 }; }, }; diff --git a/electron/services/app-api.ts b/electron/services/app-api.ts index 82a95f8ba..90557c9ef 100644 --- a/electron/services/app-api.ts +++ b/electron/services/app-api.ts @@ -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(); }, }; } diff --git a/electron/services/channels-api.ts b/electron/services/channels-api.ts index bf1baa30c..e35463746 100644 --- a/electron/services/channels-api.ts +++ b/electron/services/channels-api.ts @@ -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( + gatewayStatus = await runtimeStatusSource.rpc( '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 { + 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 { + 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 }; }, diff --git a/electron/services/chat-api.ts b/electron/services/chat-api.ts index 0ed57f9b7..bf5aab8b9 100644 --- a/electron/services/chat-api.ts +++ b/electron/services/chat-api.ts @@ -1,5 +1,7 @@ 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 { isRecord } from './payload-utils'; @@ -38,9 +40,11 @@ function normalizeMedia(media: unknown): Array<{ filePath: string; mimeType: str }); } -export function createChatApi({ gatewayManager }: { gatewayManager: GatewayManager }): CompleteHostServiceRegistry['chat'] { - return { - sendWithMedia: async (payload) => { +export function createChatSendWithMediaHandler( + gatewayManager: GatewayManager, + log = logger, +): (payload?: unknown) => ReturnType { + 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 : ''; @@ -58,7 +62,7 @@ export function createChatApi({ gatewayManager }: { gatewayManager: GatewayManag 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 file: ${item.fileName} (${item.mimeType}), path: ${item.filePath}, exists: ${exists}, isVision: ${VISION_MIME_TYPES.has(item.mimeType)}`, ); @@ -69,7 +73,7 @@ export function createChatApi({ gatewayManager }: { gatewayManager: GatewayManag 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, @@ -94,17 +98,41 @@ export function createChatApi({ gatewayManager }: { gatewayManager: GatewayManag rpcParams.attachments = imageAttachments; } - logger.info( + log.info( `[chat:sendWithMedia] Sending: message="${message.substring(0, 100)}", attachments=${imageAttachments.length}, fileRefs=${fileReferences.length}`, ); const result = await gatewayManager.rpc('chat.send', rpcParams, 120000); - logger.info(`[chat:sendWithMedia] RPC result: ${JSON.stringify(result)}`); + log.info(`[chat:sendWithMedia] RPC result: ${JSON.stringify(result)}`); const response = isRecord(result) && typeof result.runId === 'string' ? { runId: result.runId } : undefined; return { success: true, ...(response ? { result: response } : {}) }; } catch (error) { - logger.error(`[chat:sendWithMedia] Error: ${String(error)}`); + log.error(`[chat:sendWithMedia] Error: ${String(error)}`); + return { success: false, error: String(error) }; + } + }; +} + +export function createChatApi({ + gatewayManager, + runtimeManager, +}: { + gatewayManager: GatewayManager; + runtimeManager?: RuntimeManager; +}): CompleteHostServiceRegistry['chat'] { + const openClawHandler = createChatSendWithMediaHandler(gatewayManager, logger); + 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) { return { success: false, error: String(error) }; } }, diff --git a/electron/services/cron-api.ts b/electron/services/cron-api.ts index 05e389ca5..18f35a13b 100644 --- a/electron/services/cron-api.ts +++ b/electron/services/cron-api.ts @@ -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'; @@ -393,7 +395,7 @@ function transformCronJob(job: GatewayCronJob): CronJob { }; } -async function listCronJobs(gatewayManager: GatewayManager): Promise { +export async function listCronJobs(gatewayManager: GatewayManager): Promise { let jobs: GatewayCronJob[] = []; let usedFallback = false; @@ -497,67 +499,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 { + 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 { + 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 + : 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 { + return normalizeHostSuccess(await gatewayManager.rpc('cron.remove', { id: getId(payload) })); +} + +export async function toggleOpenClawCronJob(gatewayManager: GatewayManager, payload: { id: string; enabled: boolean }): Promise { + return normalizeHostSuccess(await gatewayManager.rpc('cron.update', { + id: getId(payload), + patch: { enabled: payload.enabled === true }, + })); +} + +export async function triggerOpenClawCronJob(gatewayManager: GatewayManager, payload: unknown): Promise { + 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), - 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); + list: async () => { + if (runtimeSupportsCron()) { + return await runtimeManager!.rpc('cron.list'); } - 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 listCronJobs(gatewayManager); + }, + create: async (payload) => { + if (runtimeSupportsCron()) { + return await runtimeManager!.rpc('cron.create', payload); } - 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 - : 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('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('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('cron.toggle', { id: getId(payload), enabled: payload.enabled === true }); + } + return toggleOpenClawCronJob(gatewayManager, payload); + }, + trigger: async (payload) => { + if (runtimeSupportsCron()) { + return await runtimeManager!.rpc('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,6 +629,13 @@ export function createCronApi({ gatewayManager }: { gatewayManager: GatewayManag const rawLimit = typeof body.limit === 'number' ? body.limit : Number(body.limit || 200); const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 200) : 200; + const activeProvider = runtimeManager?.getActiveProvider(); + if (activeProvider?.listCapabilities().history) { + const history = await activeProvider.loadHistory({ sessionKey, limit }); + if (history.messages && history.messages.length > 0) { + return history; + } + } const [jobsResult, runs, sessionEntry] = await Promise.all([ gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000) .catch(() => ({ jobs: [] as GatewayCronJob[] })), diff --git a/electron/services/diagnostics-api.ts b/electron/services/diagnostics-api.ts index c3ea1f5fe..9e6303f9c 100644 --- a/electron/services/diagnostics-api.ts +++ b/electron/services/diagnostics-api.ts @@ -1,16 +1,30 @@ +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 { + getCcConnectCodexHomeDir, + getCcConnectBinaryPath, + 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 { @@ -45,6 +59,204 @@ async function readTail(filePath: string, tailLines = DEFAULT_TAIL_LINES): Promi } } +async function readJsonFile(filePath: string): Promise | 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 + : null; + } finally { + await file.close(); + } + } catch { + return null; + } +} + +async function runVersionCommand(binaryPath: string): Promise> { + 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> { + const [manifest, versionCommand] = await Promise.all([ + readJsonFile(manifestPath), + runVersionCommand(binaryPath), + ]); + return { + binaryPath, + manifestPath, + manifest, + versionCommand, + }; +} + +async function probeCcConnectManagement(activeProvider: ReturnType | 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> { + 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('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).codexHomeDir === 'string' + ? (providerProfile as Record).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 () => { @@ -73,6 +285,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')), diff --git a/electron/services/files-api.ts b/electron/services/files-api.ts index 0e8345792..1f282d765 100644 --- a/electron/services/files-api.ts +++ b/electron/services/files-api.ts @@ -8,7 +8,9 @@ import type { FileReadBinaryOptions, } from '@shared/host-api/contract'; import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import type { RuntimeManager } from '../runtime/manager'; import { expandPath } from '../utils/paths'; +import { getRuntimeOutboundMediaDir } from '../utils/runtime-media-paths'; import { isRecord } from './payload-utils'; const EXT_MIME_MAP: Record = { @@ -54,7 +56,6 @@ const EXT_MIME_MAP: Record = { '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', }; -const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound'); const DIRECTORY_MIME_TYPE = 'application/x-directory'; const FILE_PREVIEW_MAX_TEXT_BYTES = 2 * 1024 * 1024; const FILE_PREVIEW_MAX_BINARY_BYTES = 50 * 1024 * 1024; @@ -143,7 +144,7 @@ function isPathInside(child: string, parent: string): boolean { return c === p || c.startsWith(p + sep); } -function getFilePreviewWriteRoots(): string[] { +function getFilePreviewWriteRoots(runtimeManager?: Pick): string[] { const roots: string[] = []; roots.push(resolve(join(homedir(), '.openclaw'))); try { @@ -151,13 +152,14 @@ function getFilePreviewWriteRoots(): string[] { } catch { // ignore } - roots.push(resolve(OUTBOUND_DIR)); + roots.push(resolve(getRuntimeOutboundMediaDir(runtimeManager))); return roots; } async function resolveSandboxedPath( input: string, mode: 'read' | 'write' = 'read', + runtimeManager?: Pick, ): Promise { if (!input.trim()) { throw new Error('outsideSandbox'); @@ -170,7 +172,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 }; } @@ -207,7 +209,8 @@ function getBinaryOptions(opts: unknown): FileReadBinaryOptions { return isRecord(opts) ? opts as FileReadBinaryOptions : {}; } -export function createFilesApi(): CompleteHostServiceRegistry['files'] { +export function createFilesApi(options: { runtimeManager?: Pick } = {}): CompleteHostServiceRegistry['files'] { + const getOutboundDir = () => getRuntimeOutboundMediaDir(options.runtimeManager); return { stagePaths: async (payload) => { const body = isRecord(payload) ? payload as StagePathsPayload : {}; @@ -215,7 +218,8 @@ export function createFilesApi(): CompleteHostServiceRegistry['files'] { ? body.filePaths.filter((value): value is string => typeof value === 'string') : []; const fsP = await import('node:fs/promises'); - await fsP.mkdir(OUTBOUND_DIR, { recursive: true }); + const outboundDir = getOutboundDir(); + await fsP.mkdir(outboundDir, { recursive: true }); const results = []; for (const filePath of filePaths) { @@ -235,7 +239,7 @@ export function createFilesApi(): CompleteHostServiceRegistry['files'] { } const ext = extname(filePath); - const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`); + const stagedPath = join(outboundDir, `${id}${ext}`); await fsP.copyFile(filePath, stagedPath); const s = await fsP.stat(stagedPath); const mimeType = getMimeType(ext); @@ -252,12 +256,13 @@ export function createFilesApi(): CompleteHostServiceRegistry['files'] { throw new Error('Invalid staged buffer payload'); } const fsP = await import('node:fs/promises'); - await fsP.mkdir(OUTBOUND_DIR, { recursive: true }); + const outboundDir = getOutboundDir(); + await fsP.mkdir(outboundDir, { recursive: true }); const id = crypto.randomUUID(); const payloadMimeType = typeof body.mimeType === 'string' ? body.mimeType : ''; const ext = extname(body.fileName) || mimeToExt(payloadMimeType); - const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`); + const stagedPath = join(outboundDir, `${id}${ext}`); const buffer = Buffer.from(body.base64, 'base64'); await fsP.writeFile(stagedPath, buffer); @@ -276,7 +281,7 @@ export function createFilesApi(): CompleteHostServiceRegistry['files'] { }, readText: async (payload) => { try { - const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read'); + const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read', options.runtimeManager); const fsP = await import('node:fs/promises'); const stat = await fsP.stat(real); if (!stat.isFile()) return { ok: false, error: 'notFound' }; @@ -301,7 +306,7 @@ export function createFilesApi(): CompleteHostServiceRegistry['files'] { 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', options.runtimeManager); const fsP = await import('node:fs/promises'); const stat = await fsP.stat(real); if (!stat.isFile()) return { ok: false, error: 'notFound' }; @@ -331,7 +336,7 @@ export function createFilesApi(): CompleteHostServiceRegistry['files'] { 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', options.runtimeManager); const fsP = await import('node:fs/promises'); let stat; try { @@ -351,7 +356,7 @@ export function createFilesApi(): CompleteHostServiceRegistry['files'] { }, stat: async (payload) => { try { - const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read'); + const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read', options.runtimeManager); const fsP = await import('node:fs/promises'); const stat = await fsP.stat(real); return { @@ -371,7 +376,7 @@ export function createFilesApi(): CompleteHostServiceRegistry['files'] { }, listDir: async (payload) => { try { - const { realPath: real } = await resolveSandboxedPath(requirePath(payload), 'read'); + const { realPath: real } = await resolveSandboxedPath(requirePath(payload), 'read', options.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) => { @@ -401,7 +406,7 @@ export function createFilesApi(): CompleteHostServiceRegistry['files'] { 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', options.runtimeManager); const fsP = await import('node:fs/promises'); const stat = await fsP.stat(real); if (!stat.isDirectory()) return { ok: false, error: 'notDirectory' }; diff --git a/electron/services/gateway-api.ts b/electron/services/gateway-api.ts index dafffbf4e..ce0febb17 100644 --- a/electron/services/gateway-api.ts +++ b/electron/services/gateway-api.ts @@ -1,10 +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 { scheduleControlUiDeviceAutoApproval } from '../utils/control-ui-device-pairing'; -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 = { @@ -30,36 +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 }); - scheduleControlUiDeviceAutoApproval(gatewayManager); - 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 : {}; @@ -72,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), ); }, }; diff --git a/electron/services/media-api.ts b/electron/services/media-api.ts index 7ffd6fef1..46457f738 100644 --- a/electron/services/media-api.ts +++ b/electron/services/media-api.ts @@ -2,6 +2,7 @@ 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 { CLAWX_OPENAI_IMAGE_DEFAULT_MODEL, CLAWX_OPENAI_IMAGE_PROVIDER_KEY, @@ -14,6 +15,7 @@ import { setImageGenerationConfig, type ImageGenerationModelConfig, } from '../utils/openclaw-image-generation'; +import { getRuntimeOutgoingMediaRecordDirs } from '../utils/runtime-media-paths'; import { isRecord } from './payload-utils'; type ThumbnailEntry = { @@ -64,15 +66,24 @@ async function generateImagePreview(filePath: string, mimeType: string): Promise async function resolveOutgoingMediaUrl( gatewayUrl: string, + runtimeManager?: Pick, ): 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 recordPath = join(homedir(), '.openclaw', 'media', 'outgoing', 'records', `${attachmentId}.json`); const fsP = await import('node:fs/promises'); - const raw = await fsP.readFile(recordPath, 'utf8'); + let raw: string | null = null; + for (const recordDir of getRuntimeOutgoingMediaRecordDirs(runtimeManager)) { + try { + raw = await fsP.readFile(join(recordDir, `${attachmentId}.json`), 'utf8'); + break; + } catch { + // Try fallback runtime media records for historical messages. + } + } + if (!raw) return null; const record = JSON.parse(raw) as { original?: { path?: string; contentType?: string }; }; @@ -94,7 +105,7 @@ function normalizeThumbnailEntries(payload: unknown): ThumbnailEntry[] { return Array.isArray(value) ? value as ThumbnailEntry[] : []; } -export function createMediaApi(): CompleteHostServiceRegistry['media'] { +export function createMediaApi(options: { runtimeManager?: Pick } = {}): CompleteHostServiceRegistry['media'] { return { thumbnails: async (payload) => { const entries = normalizeThumbnailEntries(payload); @@ -116,7 +127,7 @@ export function createMediaApi(): CompleteHostServiceRegistry['media'] { } if (typeof entry.gatewayUrl === 'string' && entry.gatewayUrl) { - const resolved = await resolveOutgoingMediaUrl(entry.gatewayUrl); + const resolved = await resolveOutgoingMediaUrl(entry.gatewayUrl, options.runtimeManager); if (!resolved) { results[entry.gatewayUrl] = { preview: null, fileSize: 0 }; continue; diff --git a/electron/services/providers-api.ts b/electron/services/providers-api.ts index d70079577..374ee2f9f 100644 --- a/electron/services/providers-api.ts +++ b/electron/services/providers-api.ts @@ -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, + payload: { providerId?: string; reason: string }, +): Promise { + 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, +): Promise { + if (await syncActiveRuntimeProviderProfile(ctx, { providerId, reason: 'api-key' })) { + return; + } + await syncProviderApiKeyToRuntime(providerType, providerId, apiKey); +} + +async function syncSavedProviderToActiveRuntime( + config: ProviderConfig, + apiKey: string | undefined, + ctx: Pick, +): Promise { + 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, + reason = 'update', +): Promise { + 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, + runtimeProviderKey?: string, +): Promise { + 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, + runtimeProviderKey?: string, +): Promise { + if (await syncActiveRuntimeProviderProfile(ctx, { providerId, reason: 'delete-api-key' })) { + return; + } + await syncDeletedProviderApiKeyToRuntime(provider, providerId, runtimeProviderKey); +} + +async function syncDefaultProviderToActiveRuntime( + providerId: string, + ctx: Pick, +): Promise { + if (await syncActiveRuntimeProviderProfile(ctx, { providerId, reason: 'set-default' })) { + return; + } + await syncDefaultProviderToRuntime(providerId, ctx.gatewayManager); +} + +async function removeProviderFromActiveRuntime( + providerKey: string, + ctx: Pick, + providerId: string, +): Promise { + 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, +) { + 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, +) { + 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), }; } diff --git a/electron/services/providers/store-instance.ts b/electron/services/providers/store-instance.ts index 1da129b08..675ad7e26 100644 --- a/electron/services/providers/store-instance.ts +++ b/electron/services/providers/store-instance.ts @@ -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, diff --git a/electron/services/secrets/credential-vault.ts b/electron/services/secrets/credential-vault.ts new file mode 100644 index 000000000..4e01b7f6f --- /dev/null +++ b/electron/services/secrets/credential-vault.ts @@ -0,0 +1,164 @@ +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; + +type CredentialVaultDocument = { + schema: typeof VAULT_SCHEMA; + version: typeof VAULT_VERSION; + secrets: Record; + channelSecrets: Record>; +}; + +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 { + 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(() => {}); +} + +export async function readCredentialVault( + cipher: CredentialCipher = defaultCredentialCipher(), +): Promise { + 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; + 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 { + 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 { + return (await readCredentialVault(cipher)).secrets[accountId] ?? null; +} + +export async function setVaultSecret( + secret: ProviderSecret, + cipher: CredentialCipher = defaultCredentialCipher(), +): Promise { + const document = await readCredentialVault(cipher); + document.secrets[secret.accountId] = secret; + await writeCredentialVault(document, cipher); +} + +export async function deleteVaultSecret( + accountId: string, + cipher: CredentialCipher = defaultCredentialCipher(), +): Promise { + 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>> { + return (await readCredentialVault(cipher)).channelSecrets; +} + +export async function replaceChannelVaultSecrets( + channelSecrets: Record>, + cipher: CredentialCipher = defaultCredentialCipher(), +): Promise { + 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); +} diff --git a/electron/services/secrets/secret-store.ts b/electron/services/secrets/secret-store.ts index ca9afecdb..b2c628af2 100644 --- a/electron/services/secrets/secret-store.ts +++ b/electron/services/secrets/secret-store.ts @@ -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; @@ -9,10 +10,19 @@ export interface SecretStore { export class ElectronStoreSecretStore implements SecretStore { async get(accountId: string): Promise { + 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; 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 { + await setVaultSecret(secret); const store = await getClawXProviderStore(); - const secrets = (store.get('providerSecrets') ?? {}) as Record; - 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; - 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 { + 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 { const secrets = (store.get('providerSecrets') ?? {}) as Record; delete secrets[accountId]; store.set('providerSecrets', secrets); @@ -63,6 +68,27 @@ export class ElectronStoreSecretStore implements SecretStore { } } +export async function migrateLegacyProviderSecretsToVault(): Promise { + const store = await getClawXProviderStore(); + const legacySecrets = (store.get('providerSecrets') ?? {}) as Record; + const legacyApiKeys = (store.get('apiKeys') ?? {}) as Record; + 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 { diff --git a/electron/services/sessions-api.ts b/electron/services/sessions-api.ts index d0242bce0..3ca85439d 100644 --- a/electron/services/sessions-api.ts +++ b/electron/services/sessions-api.ts @@ -1,6 +1,7 @@ import { openSync, closeSync, fstatSync, readSync } from 'node:fs'; import { join } from 'node:path'; import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract'; +import type { RuntimeManager } from '../runtime/manager'; import type { RawMessage } from '@shared/chat/types'; import { getOpenClawConfigDir } from '../utils/paths'; import { logger } from '../utils/logger'; @@ -411,9 +412,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); @@ -421,9 +428,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; + } 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:')) @@ -435,6 +450,10 @@ export function createSessionsApi(): CompleteHostServiceRegistry['sessions'] { }; }, history: async (payload) => { + const provider = runtimeManager?.getActiveProvider(); + if (provider?.listCapabilities().history) { + return provider.loadHistory(payload) as ReturnType; + } const body = isRecord(payload) ? payload as SessionPayload : {}; const limit = getLimit(payload); diff --git a/electron/services/settings-api.ts b/electron/services/settings-api.ts index de39de6b5..8b0b4878a 100644 --- a/electron/services/settings-api.ts +++ b/electron/services/settings-api.ts @@ -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, + runtimeManager?: RuntimeManager, ): Promise { + 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); + await runSettingsSideEffects(gatewayManager, { [key]: body?.value } as Partial, 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 () => { diff --git a/electron/services/skills-api.ts b/electron/services/skills-api.ts index 8ea14a44c..53390b43b 100644 --- a/electron/services/skills-api.ts +++ b/electron/services/skills-api.ts @@ -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) }; diff --git a/electron/services/usage-api.ts b/electron/services/usage-api.ts index ff3a444d0..d0a9ce529 100644 --- a/electron/services/usage-api.ts +++ b/electron/services/usage-api.ts @@ -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 { + 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); + }, }; } diff --git a/electron/shared/providers/types.ts b/electron/shared/providers/types.ts index 08beb43ce..bee6a55c0 100644 --- a/electron/shared/providers/types.ts +++ b/electron/shared/providers/types.ts @@ -203,6 +203,7 @@ export type ProviderSecret = accountId: string; accessToken: string; refreshToken: string; + idToken?: string; expiresAt: number; scopes?: string[]; email?: string; diff --git a/electron/utils/agent-config.ts b/electron/utils/agent-config.ts index 4678fb2fd..2e64aeb16 100644 --- a/electron/utils/agent-config.ts +++ b/electron/utils/agent-config.ts @@ -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 = {}; const channelAccountOwners: Record = {}; + const [providerBindings, permissionModes] = await Promise.all([ + listCcConnectAgentProviderBindings(), + listCcConnectAgentPermissionModes(), + ]); // Build per-agent channel lists from account-scoped bindings const agentChannelSets = new Map>(); @@ -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), }; diff --git a/electron/utils/browser-oauth.ts b/electron/utils/browser-oauth.ts index 95d3e9de2..ed0907d31 100644 --- a/electron/utils/browser-oauth.ts +++ b/electron/utils/browser-oauth.ts @@ -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) | null = null; setWindow(window: BrowserWindow) { this.mainWindow = window; } + setSuccessHandler( + handler: ((payload: BrowserOAuthSuccessPayload) => Promise) | 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, diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index 4c13333e2..22d522e5f 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -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>; + found: boolean; +} { + const sanitized = cloneConfig(config); + const secrets: Record> = {}; + let found = false; + for (const [channelType, section] of Object.entries(sanitized.channels ?? {})) { + const accounts = section.accounts && typeof section.accounts === 'object' + ? section.accounts as Record + : 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 = {}; + 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>, +): 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 + : 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 { @@ -461,7 +553,7 @@ async function ensureConfigDir(): Promise { } } -export async function readOpenClawConfig(): Promise { +async function readOpenClawCompatibilityConfig(): Promise { await ensureConfigDir(); if (!(await fileExists(CONFIG_FILE))) { @@ -478,9 +570,21 @@ export async function readOpenClawConfig(): Promise { } } -export async function writeOpenClawConfig(config: OpenClawConfig): Promise { - await ensureConfigDir(); +export async function readOpenClawConfig(): Promise { + 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 { try { // Enable graceful in-process reload authorization for SIGUSR1 flows. const commands = @@ -490,7 +594,12 @@ export async function writeOpenClawConfig(config: OpenClawConfig): Promise 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 } } +export async function writeOpenClawCompatibilityProjection(config?: OpenClawConfig): Promise { + 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 { @@ -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; diff --git a/electron/utils/clawx-data-layout.ts b/electron/utils/clawx-data-layout.ts new file mode 100644 index 000000000..f1a92cd4c --- /dev/null +++ b/electron/utils/clawx-data-layout.ts @@ -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; + 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; +} diff --git a/electron/utils/clawx-data-migration.ts b/electron/utils/clawx-data-migration.ts new file mode 100644 index 000000000..057d4fc55 --- /dev/null +++ b/electron/utils/clawx-data-migration.ts @@ -0,0 +1,87 @@ +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; +} + +async function exists(path: string): Promise { + return stat(path).then(() => true).catch(() => false); +} + +async function copyIfMissing(source: string, target: string, copied: string[]): Promise { + 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 { + return realpath(path).catch(() => resolve(path)); +} + +async function appendJournal(layout: ClawXDataLayout, record: Record): Promise { + 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 { + 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); + } + 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 }; +} diff --git a/electron/utils/clawx-runtime-config.ts b/electron/utils/clawx-runtime-config.ts new file mode 100644 index 000000000..8272103ad --- /dev/null +++ b/electron/utils/clawx-runtime-config.ts @@ -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 = { + 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 { + 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(): Promise | null> { + try { + const parsed = JSON.parse(await readFile(runtimeConfigPath(), 'utf8')) as Partial>; + if (parsed.schema === 'clawx-runtime-config' && parsed.version === 1 && parsed.config) { + return parsed as RuntimeConfigDocument; + } + } catch { + // Missing canonical config is imported from the compatibility source. + } + return null; +} + +export async function readClawXRuntimeConfig>(options: { + readOpenClawCompatibility: () => Promise; + openClawConfigPath: string; +}): Promise { + const canonicalPath = runtimeConfigPath(); + const document = await readDocument(); + 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); + return config; +} + +export async function writeClawXRuntimeConfig>(config: T): Promise { + await writeAtomic(runtimeConfigPath(), { + schema: 'clawx-runtime-config', + version: 1, + updatedAt: new Date().toISOString(), + config, + } satisfies RuntimeConfigDocument); +} + +export function getClawXRuntimeConfigPath(): string { + return runtimeConfigPath(); +} diff --git a/electron/utils/logger.ts b/electron/utils/logger.ts index 056f77dad..9fef42488 100644 --- a/electron/utils/logger.ts +++ b/electron/utils/logger.ts @@ -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 }); diff --git a/electron/utils/openai-codex-oauth.ts b/electron/utils/openai-codex-oauth.ts index 3e96642ad..0aa09a88f 100644 --- a/electron/utils/openai-codex-oauth.ts +++ b/electron/utils/openai-codex-oauth.ts @@ -26,6 +26,7 @@ const SUCCESS_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 { +): 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), diff --git a/electron/utils/paths.ts b/electron/utils/paths.ts index 428fc8cdb..bf224ab56 100644 --- a/electron/utils/paths.ts +++ b/electron/utils/paths.ts @@ -6,6 +6,7 @@ import { createRequire } from 'node:module'; import { join } 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); @@ -65,21 +66,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(); } /** diff --git a/electron/utils/runtime-media-paths.ts b/electron/utils/runtime-media-paths.ts new file mode 100644 index 000000000..4529afe8f --- /dev/null +++ b/electron/utils/runtime-media-paths.ts @@ -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'), + ]; +} diff --git a/electron/utils/secure-storage.ts b/electron/utils/secure-storage.ts index 0a9fa398c..b19cb82d4 100644 --- a/electron/utils/secure-storage.ts +++ b/electron/utils/secure-storage.ts @@ -35,10 +35,6 @@ import { getOpenClawProviderKeyForType } from './provider-keys'; export async function storeApiKey(providerId: string, apiKey: string): Promise { try { await ensureProviderStoreMigrated(); - const s = await getClawXProviderStore(); - const keys = (s.get('apiKeys') || {}) as Record; - 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 { return secret.apiKey ?? null; } - const s = await getClawXProviderStore(); - const keys = (s.get('apiKeys') || {}) as Record; - 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 { export async function deleteApiKey(providerId: string): Promise { try { await ensureProviderStoreMigrated(); - const s = await getClawXProviderStore(); - const keys = (s.get('apiKeys') || {}) as Record; - 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 { return true; } - const s = await getClawXProviderStore(); - const keys = (s.get('apiKeys') || {}) as Record; - return providerId in keys; + return secret?.type === 'local' && Boolean(secret.apiKey); } /** @@ -113,8 +101,19 @@ export async function hasApiKey(providerId: string): Promise { export async function listStoredKeyIds(): Promise { await ensureProviderStoreMigrated(); const s = await getClawXProviderStore(); - const keys = (s.get('apiKeys') || {}) as Record; - return Object.keys(keys); + const legacyKeys = (s.get('apiKeys') || {}) as Record; + 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 ==================== diff --git a/electron/utils/store.ts b/electron/utils/store.ts index 38184f78c..11604ebb0 100644 --- a/electron/utils/store.ts +++ b/electron/utils/store.ts @@ -6,6 +6,8 @@ import { randomBytes } from 'crypto'; import { app } from 'electron'; import { resolveSupportedLanguage } from '@shared/language'; +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 @@ -33,6 +35,7 @@ export interface AppSettings { // Gateway gatewayAutoStart: boolean; + runtimeKind: RuntimeKind; gatewayPort: number; gatewayToken: string; proxyEnabled: boolean; @@ -84,6 +87,7 @@ function createDefaultSettings(): AppSettings { // Gateway gatewayAutoStart: true, + runtimeKind: 'openclaw', gatewayPort: 18789, gatewayToken: generateToken(), proxyEnabled: false, @@ -118,6 +122,7 @@ async function getSettingsStore() { const Store = (await import('electron-store')).default; settingsStoreInstance = new Store({ name: 'settings', + cwd: getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData'))).appDir, defaults: createDefaultSettings(), }); } diff --git a/electron/utils/token-usage-core.ts b/electron/utils/token-usage-core.ts index c9e09ff24..3f7029aa9 100644 --- a/electron/utils/token-usage-core.ts +++ b/electron/utils/token-usage-core.ts @@ -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 & { + 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).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).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 | 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).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).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; diff --git a/electron/utils/token-usage.ts b/electron/utils/token-usage.ts index a63a365d5..5b0116942 100644 --- a/electron/utils/token-usage.ts +++ b/electron/utils/token-usage.ts @@ -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 { const openclawDir = getOpenClawConfigDir(); const agentsDir = join(openclawDir, 'agents'); @@ -48,13 +57,13 @@ async function listAgentIdsWithSessionDirs(): Promise { return [...agentIds]; } -async function listRecentSessionFiles(): Promise> { +async function listRecentSessionFiles(): Promise { 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 { - 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 { + 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); diff --git a/harness/specs/rules/cc-connect-runtime-validation.md b/harness/specs/rules/cc-connect-runtime-validation.md new file mode 100644 index 000000000..3173aff23 --- /dev/null +++ b/harness/specs/rules/cc-connect-runtime-validation.md @@ -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= --platform= --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. diff --git a/harness/specs/scenarios/gateway-backend-communication.md b/harness/specs/scenarios/gateway-backend-communication.md index 6ac01de6d..5efd6525e 100644 --- a/harness/specs/scenarios/gateway-backend-communication.md +++ b/harness/specs/scenarios/gateway-backend-communication.md @@ -34,6 +34,7 @@ requiredRules: - active-config-guards - provider-default-invariant - provider-model-metadata-preservation + - cc-connect-runtime-validation - comms-regression - docs-sync forbiddenPatterns: diff --git a/harness/specs/tasks/cc-connect-runtime-validation.md b/harness/specs/tasks/cc-connect-runtime-validation.md new file mode 100644 index 000000000..a3e3bf7a8 --- /dev/null +++ b/harness/specs/tasks/cc-connect-runtime-validation.md @@ -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 can drive the shared Chat execution graph without reading Codex transcripts. + - 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= --platform= --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= pnpm run test:e2e:cc-connect:real-oauth + - pnpm run test:e2e:cc-connect + - CLAWX_REAL_OAUTH_E2E=1 CLAWX_E2E_HOME_DIR= CLAWX_E2E_USER_DATA_DIR= pnpm run test:e2e:cc-connect:real-comprehensive + - CLAWX_REAL_OPENAI_API_KEY_E2E=1 CLAWX_REAL_OPENAI_API_KEY= pnpm run test:e2e:cc-connect:real-openai-api-key + - CLAWX_REAL_FEISHU_E2E=1 CLAWX_REAL_FEISHU_APP_ID= CLAWX_REAL_FEISHU_APP_SECRET= pnpm run test:e2e:cc-connect:real-feishu + - CLAWX_REAL_FEISHU_INBOUND_E2E=1 CLAWX_REAL_FEISHU_APP_ID= CLAWX_REAL_FEISHU_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= 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 ``, ``, and `` 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=`, 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 chat/session/history/tool execution, 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, history, and delete parity exclusively through public Management session APIs plus ClawX-owned label metadata. + - `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=` 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. diff --git a/harness/specs/tasks/runtime-abstraction-cc-connect.md b/harness/specs/tasks/runtime-abstraction-cc-connect.md new file mode 100644 index 000000000..7879bd9b5 --- /dev/null +++ b/harness/specs/tasks/runtime-abstraction-cc-connect.md @@ -0,0 +1,91 @@ +--- +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/** + - README*.md + - docs/** + - harness/src/** + - harness/specs/** + - electron/extensions/** + - electron/runtime/** + - electron/services/** + - electron/main/** + - electron/shared/** + - electron/utils/** + - shared/** + - src/** + - scripts/** + - tests/** + - package.json + - pnpm-lock.yaml + - electron-builder.yml +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 history use cc-connect public APIs; tools and approval responses use public Bridge events/`card_action`; 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/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 transcript polling as a production event/history/usage path. + - 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. + - 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. + - 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. diff --git a/harness/src/runner.mjs b/harness/src/runner.mjs index 4c956b2a4..3e3242bf2 100644 --- a/harness/src/runner.mjs +++ b/harness/src/runner.mjs @@ -1,9 +1,11 @@ import { spawn } from 'node:child_process'; +import { ROOT } from './specs.mjs'; export async function runStep(step) { const started = Date.now(); return await new Promise((resolve) => { const child = spawn(step.command, step.args, { + cwd: ROOT, stdio: 'inherit', shell: process.platform === 'win32', }); diff --git a/package.json b/package.json index 9feca55dd..d7e63cfe9 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,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", @@ -47,6 +75,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", @@ -66,12 +105,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", @@ -86,6 +126,7 @@ "dependencies": { "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", @@ -102,6 +143,7 @@ "@larksuite/openclaw-lark": "2026.6.10", "@larksuiteoapi/node-sdk": "^1.61.1", "@monaco-editor/react": "^4.7.0", + "@openai/codex": "0.137.0", "@openclaw/discord": "2026.6.10", "@openclaw/qqbot": "2026.6.10", "@openclaw/whatsapp": "2026.6.10", @@ -136,6 +178,7 @@ "@whiskeysockets/baileys": "7.0.0-rc.9", "acpx": "0.5.3", "autoprefixer": "^10.4.24", + "cc-connect": "1.4.1", "chokidar": "^5.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d9b4ee5f..4ca2648c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: .: dependencies: + croner: + specifier: ^10.0.1 + version: 10.0.1 electron-store: specifier: ^11.0.2 version: 11.0.2 @@ -60,6 +63,9 @@ importers: '@monaco-editor/react': specifier: ^4.7.0 version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@openai/codex': + specifier: 0.137.0 + version: 0.137.0 '@openclaw/discord': specifier: 2026.6.10 version: 2026.6.10(openclaw@2026.6.10(encoding@0.1.13)) @@ -162,6 +168,9 @@ importers: autoprefixer: specifier: ^10.4.24 version: 10.4.27(postcss@8.5.8) + cc-connect: + specifier: 1.4.1 + version: 1.4.1 chokidar: specifier: ^5.0.0 version: 5.0.0 @@ -1436,6 +1445,47 @@ packages: resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==} engines: {node: ^18.17.0 || >=20.5.0} + '@openai/codex@0.137.0': + resolution: {integrity: sha512-1jUsCnzDBwv7Z4VFZajIlsz41fC18qg6d5qK4PEZhiUk0zJHS90/uGBA70aQPUJLTUZShvyKVAANjw6J/D9eYQ==} + engines: {node: '>=16'} + hasBin: true + + '@openai/codex@0.137.0-darwin-arm64': + resolution: {integrity: sha512-YjKmre7DlKslQVhSfocHscgxntZKaZc1LQySKh7q+hNL8jdK+c8nSWSePi583yKFNIxZ8Z/zCkewtjFNvOpQiQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.137.0-darwin-x64': + resolution: {integrity: sha512-zjzrFV80LZby9et44dan82e3cwUd46U7u1LSVXTIz5AUcY4y1KZpAeN6cSLVKMZuOHXTDpi15MUQdRwzdeqIOg==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.137.0-linux-arm64': + resolution: {integrity: sha512-R3ZZymQQA1qpp6OpowN49XJ4scHwSckq7CjVvgmLv3bIs3X+F0XXK3xPFkC9vs2mX3wPekPi3ONpxx+yPAsJ6Q==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.137.0-linux-x64': + resolution: {integrity: sha512-n+26MUj8rekbEDUeYTGoD6HXuGS0MmLHn2LOn0i5qTNYIJvXV82B7cCLSTzVKF/RJxRMRl22se9Q0Z035JIVng==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.137.0-win32-arm64': + resolution: {integrity: sha512-Cofktt213TycdQ/v+nAUuwXUBzjMWfA/ZkXyqefyXxDgw0TMtaiM3cgDna3I8YdXnR0PM9AMbx4t7VloJ3ZZYQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.137.0-win32-x64': + resolution: {integrity: sha512-g9qZ9ERrm5OWXMWJOgojYv1kOc5jajTKq37PBMSe56aJfAr9Jk/qBvIOy7LKq3rABdXuz8k+W65PIt2E1hXilw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@openclaw/discord@2026.6.10': resolution: {integrity: sha512-NKp/j00l+rk5PC0Lv/0fOIiiQJ1c/OpG9471zqXUDKQie6pQ1Fi9KUZUouyoTMmfLh/n4S0CkEMqrON40eBKXA==} peerDependencies: @@ -2927,6 +2977,10 @@ packages: caniuse-lite@1.0.30001781: resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} + cc-connect@1.4.1: + resolution: {integrity: sha512-acoRJzBZGo9wSbOv0jykR3vp9VvrJCZ0poJtPyiK+BGjgcUZa0Mq1p3RcrDxmGPBxxneyaXacR3nQId+HFmBMA==} + hasBin: true + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -4242,8 +4296,8 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - libsignal@git+https://git@github.com:whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7: - resolution: {commit: bcea72df9ec34d9d9140ab30619cf479c7c144c7, repo: git@github.com:whiskeysockets/libsignal-node.git, type: git} + libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7: + resolution: {gitHosted: true, tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7} version: 6.0.0 lie@3.3.0: @@ -7633,6 +7687,33 @@ snapshots: dependencies: semver: 7.7.4 + '@openai/codex@0.137.0': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.137.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.137.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.137.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.137.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.137.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.137.0-win32-x64' + + '@openai/codex@0.137.0-darwin-arm64': + optional: true + + '@openai/codex@0.137.0-darwin-x64': + optional: true + + '@openai/codex@0.137.0-linux-arm64': + optional: true + + '@openai/codex@0.137.0-linux-x64': + optional: true + + '@openai/codex@0.137.0-win32-arm64': + optional: true + + '@openai/codex@0.137.0-win32-x64': + optional: true + '@openclaw/discord@2026.6.10(openclaw@2026.6.10(encoding@0.1.13))': optionalDependencies: openclaw: 2026.6.10(encoding@0.1.13) @@ -8715,7 +8796,7 @@ snapshots: '@cacheable/node-cache': 1.7.6 '@hapi/boom': 9.1.4 async-mutex: 0.5.0 - libsignal: git+https://git@github.com:whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7 + libsignal: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7 lru-cache: 11.2.7 music-metadata: 11.12.3 p-queue: 9.1.0 @@ -9166,6 +9247,8 @@ snapshots: caniuse-lite@1.0.30001781: {} + cc-connect@1.4.1: {} + ccount@2.0.1: {} cfb@1.2.2: @@ -10701,7 +10784,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - libsignal@git+https://git@github.com:whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7: + libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7: dependencies: curve25519-js: 0.0.4 protobufjs: 7.5.8 diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index 0b4c17132..6040def75 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -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'); diff --git a/scripts/bundle-cc-connect.mjs b/scripts/bundle-cc-connect.mjs new file mode 100644 index 000000000..40ebe08ab --- /dev/null +++ b/scripts/bundle-cc-connect.mjs @@ -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); +} diff --git a/scripts/bundle-codex.mjs b/scripts/bundle-codex.mjs new file mode 100644 index 000000000..1466661ae --- /dev/null +++ b/scripts/bundle-codex.mjs @@ -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); +} diff --git a/scripts/cc-connect-bundle-lib.mjs b/scripts/cc-connect-bundle-lib.mjs new file mode 100644 index 000000000..e9774cf8d --- /dev/null +++ b/scripts/cc-connect-bundle-lib.mjs @@ -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}`, + ]; +} diff --git a/scripts/cc-connect-real-gate-handoff.mjs b/scripts/cc-connect-real-gate-handoff.mjs new file mode 100644 index 000000000..457a3ed3a --- /dev/null +++ b/scripts/cc-connect-real-gate-handoff.mjs @@ -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=] [--output=] [--json-output=]', + '', + '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); + }); + } +} diff --git a/scripts/codex-bundle-lib.mjs b/scripts/codex-bundle-lib.mjs new file mode 100644 index 000000000..382dee01c --- /dev/null +++ b/scripts/codex-bundle-lib.mjs @@ -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'] }; +} diff --git a/scripts/packaged-runtime-layout.mjs b/scripts/packaged-runtime-layout.mjs new file mode 100644 index 000000000..5b12cdf7b --- /dev/null +++ b/scripts/packaged-runtime-layout.mjs @@ -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'); +} diff --git a/scripts/smoke-packaged-cc-connect.mjs b/scripts/smoke-packaged-cc-connect.mjs new file mode 100644 index 000000000..a84ab3013 --- /dev/null +++ b/scripts/smoke-packaged-cc-connect.mjs @@ -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); + }); +} diff --git a/scripts/verify-cc-connect-local-real.mjs b/scripts/verify-cc-connect-local-real.mjs new file mode 100644 index 000000000..b9447ef60 --- /dev/null +++ b/scripts/verify-cc-connect-local-real.mjs @@ -0,0 +1,2707 @@ +#!/usr/bin/env node +import { constants as fsConstants } from 'node:fs'; +import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { execFile, spawn } from 'node:child_process'; +import { homedir, tmpdir } from 'node:os'; +import { basename, delimiter, join, relative, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import { + toJson as toExternalGateHandoffJson, + toMarkdown as toExternalGateHandoffMarkdown, +} from './cc-connect-real-gate-handoff.mjs'; +import { collectMissingRuntimeBundles } from './verify-runtime-bundles.mjs'; +import { + defaultPackagedAppPath, + packagedExecutablePath, +} from './packaged-runtime-layout.mjs'; + +const execFileAsync = promisify(execFile); +const root = resolve(fileURLToPath(new URL('..', import.meta.url))); +const reportDir = join(root, 'artifacts', 'cc-connect'); +const jsonReportPath = join(reportDir, 'local-real-validation-report.json'); +const markdownReportPath = join(reportDir, 'local-real-validation-report.md'); +const externalGateHandoffPath = join(reportDir, 'local-real-external-gates.md'); +const externalGateHandoffJsonPath = join(reportDir, 'local-real-external-gates.json'); +const COVERAGE_IDS = [ + 'runtime-bundles-current-platform', + 'runtime-boundary-bridgeplatform-only', + 'session-history-parity-local-diagnostics', + 'real-spec-compile-and-skip-paths', + 'codex-oauth-lifecycle-local-diagnostics', + 'codex-oauth-host-api-lifecycle-local', + 'provider-model-profile-local-diagnostics', + 'operation-capabilities-local-diagnostics', + 'token-usage-contract-local-diagnostics', + 'runtime-management-bundle-local-diagnostics', + 'bridge-media-packets-local-diagnostics', + 'bridge-media-send-real-bundle', + 'bridge-rich-packets-local-diagnostics', + 'bridge-rich-progress-real-bundle', + 'bridge-rich-card-action-real-bundle', + 'bridge-runtime-choice-real-bundle', + 'channel-lifecycle-local-bundle', + 'channel-cron-command-local-diagnostics', + 'cron-lifecycle-local-bundle', + 'scheduled-cron-delivery-local-bundle', + 'scheduled-prompt-cron-delivery-local-bundle', + 'local-openai-compatible-api-key-chat', + 'chat-abort-local-openai-compatible', + 'oauth-core-runtime-parity', + 'generated-file-card-real-oauth', + 'openai-api-key-provider-model-chat', + 'feishu-live-channel-lifecycle', + 'feishu-live-inbound-delivery', + 'packaged-oauth-runtime-smoke', +]; +const REPLACEMENT_REQUIRED_COVERAGE_IDS = [ + 'runtime-bundles-current-platform', + 'runtime-boundary-bridgeplatform-only', + 'session-history-parity-local-diagnostics', + 'real-spec-compile-and-skip-paths', + 'codex-oauth-lifecycle-local-diagnostics', + 'codex-oauth-host-api-lifecycle-local', + 'operation-capabilities-local-diagnostics', + 'channel-cron-command-local-diagnostics', + 'chat-abort-local-openai-compatible', + 'token-usage-contract-local-diagnostics', + 'oauth-core-runtime-parity', + 'openai-api-key-provider-model-chat', + 'feishu-live-channel-lifecycle', + 'feishu-live-inbound-delivery', + 'packaged-oauth-runtime-smoke', +]; + +const REQUIRED_CODEX_AUTH_TOKEN_KEYS = ['access_token', 'account_id', 'id_token', 'refresh_token']; + +const RESIDUAL_VALIDATION_GAPS = [ + { + id: 'upstream-public-token-usage', + area: 'usage', + priority: 'required', + status: 'upstream-blocked', + requiredForLocalReplacementGate: true, + nextCommand: 'Upgrade to a pinned cc-connect release with a versioned, attributable, replayable per-turn usage API/event, wire it into RuntimeProvider.listUsage, and verify counters against a real provider oracle.', + reason: 'cc-connect v1.4.1 and v1.5.0-beta.1 expose no versioned Bridge or Management usage payload. Upstream PR #1428 proposes an unmerged observer, but omits project/provider/model attribution and durable reconnect/replay semantics. ClawX intentionally returns missing cc-connect usage instead of parsing footers or reading private session/Codex transcript files.', + }, + { + id: 'real-scheduled-prompt-channel-cron-delivery', + area: 'cron', + priority: 'follow-up', + status: 'unverified', + requiredForLocalReplacementGate: false, + nextCommand: 'Run a live tenant-channel scheduled prompt cron smoke once a safe channel fixture is available.', + reason: 'Scheduled exec cron and ClawX BridgePlatform prompt cron delivery are covered separately. Live tenant-channel scheduled prompt delivery remains distinct and requires a safe channel fixture.', + }, + { + id: 'rich-generated-media-packet-delivery', + area: 'chat', + priority: 'follow-up', + status: 'unverified', + requiredForLocalReplacementGate: false, + nextCommand: 'Add a real cc-connect non-approval standalone-buttons smoke and an upstream-triggered delete-message smoke once pinned cc-connect exposes stable triggers for those paths.', + reason: 'Real OAuth covers approval buttons and generated file cards; a real v1.4.1 Bridge channel probe proves a /cron list card plus actionable callbacks; bundled cc-connect send proves image/file/audio/video; and a real bundled cc-connect engine with a deterministic Codex app-server boundary proves preview_start/update_message through the GUI execution graph. Non-approval standalone buttons and a real upstream-triggered delete_message remain unverified. ClawX text-preview deletion semantics are covered deterministically without claiming an upstream trigger.', + }, + { + id: 'notarized-macos-dmg-zip-smoke', + area: 'packaging', + priority: 'follow-up', + status: 'unverified', + requiredForLocalReplacementGate: false, + nextCommand: 'Run notarized macOS dmg/zip smoke in release validation.', + reason: 'Current packaged smoke targets a macOS dir app; notarized dmg/zip installation behavior is not covered by the local verifier.', + }, + { + id: 'native-target-release-smoke-observation', + area: 'packaging', + priority: 'follow-up', + status: 'unverified', + requiredForLocalReplacementGate: false, + nextCommand: 'Observe the macOS x64/arm64, Windows x64, and Linux x64/arm64 native packaged smoke jobs in a release workflow run.', + reason: 'The release workflow defines native startup/health/rollback/cleanup jobs for all supported targets, but a local verifier run cannot attest to their remote outcome.', + }, +]; + +const REPLACEMENT_CONTRACT_ITEMS = [ + { + id: 'developer-mode-gate', + area: 'release-gating', + requirement: 'cc-connect remains gated behind Developer Mode.', + expectedState: 'unchanged', + requiredForLocalReplacementGate: false, + }, + { + id: 'doctor-fix-non-parity', + area: 'doctor', + requirement: 'cc-connect Doctor may exist, but cc-connect Doctor Fix is not required to replace OpenClaw Doctor Fix.', + expectedState: 'documented-non-goal', + requiredForLocalReplacementGate: false, + }, + { + id: 'runtime-boundary-bridgeplatform-only', + area: 'runtime-boundary', + requirement: 'ClawX chat/session/history/tool execution in cc-connect mode must flow through cc-connect BridgePlatform or cc-connect-owned stores, not direct Codex execution.', + expectedState: 'bridgeplatform-only', + requiredForLocalReplacementGate: true, + }, + { + id: 'codex-oauth-and-openai-api-key', + area: 'providers', + requirement: 'Codex OAuth and OpenAI API-key modes are supported and explicitly verifiable.', + expectedState: 'oauth-and-api-key-verifiable', + requiredForLocalReplacementGate: true, + }, + { + id: 'provider-model-matrix', + area: 'providers', + requirement: 'Provider/model support is narrower than OpenClaw and must stay explicit instead of being implied by runtime selection.', + expectedState: 'partial-matrix-with-stable-errors', + requiredForLocalReplacementGate: false, + }, + { + id: 'feishu-channel-lifecycle', + area: 'channels', + requirement: 'Channel lifecycle is runtime-owned, with Feishu/Lark projection and live lifecycle tracked separately.', + expectedState: 'local-projection-plus-live-gate', + requiredForLocalReplacementGate: true, + }, + { + id: 'cron-main-path', + area: 'cron', + requirement: 'Cron supports the prompt-job/main-path contract while distinguishing local BridgePlatform delivery from live tenant-channel delivery.', + expectedState: 'local-lifecycle-plus-scheduled-exec-and-prompt', + requiredForLocalReplacementGate: false, + }, + { + id: 'session-history-parity', + area: 'sessions', + requirement: 'Session/history parity covers cross-agent, named-session, title, and workspace isolation paths.', + expectedState: 'runtime-routed-session-history', + requiredForLocalReplacementGate: true, + }, + { + id: 'token-usage-contract', + area: 'usage', + requirement: 'Token usage follows the runtime contract and does not silently mix OpenClaw or user-global Codex data.', + expectedState: 'public-runtime-usage-required', + requiredForLocalReplacementGate: true, + }, + { + id: 'real-validation-opt-in', + area: 'validation', + requirement: 'Real OpenAI/Feishu/OAuth validation remains opt-in rather than default CI.', + expectedState: 'opt-in', + requiredForLocalReplacementGate: false, + }, + { + id: 'packaging-platform-smoke', + area: 'packaging', + requirement: 'Packaging smoke covers current local platform, while all-platform final artifact smoke remains release validation.', + expectedState: 'current-platform-local', + requiredForLocalReplacementGate: false, + }, +]; + +function parseArgs(argv) { + const result = { + run: false, + includeOAuth: false, + includePackaged: false, + includePackagedOAuth: false, + includeOpenAiApiKey: false, + includeFeishu: false, + includeFeishuInbound: false, + includeScheduledCron: false, + strictReal: false, + requireReplacementReady: false, + requireCoverage: [], + envFiles: [], + noWrite: false, + writeHandoff: false, + externalGatesOnly: false, + }; + for (const arg of argv) { + if (arg === '--') continue; + if (arg === '--run') result.run = true; + else if (arg === '--include-oauth') result.includeOAuth = true; + else if (arg === '--include-packaged') result.includePackaged = true; + else if (arg === '--include-packaged-oauth') result.includePackagedOAuth = true; + else if (arg === '--include-openai-api-key') result.includeOpenAiApiKey = true; + else if (arg === '--include-feishu') result.includeFeishu = true; + else if (arg === '--include-feishu-inbound') result.includeFeishuInbound = true; + else if (arg === '--include-scheduled-cron') result.includeScheduledCron = true; + else if (arg === '--strict-real') result.strictReal = true; + else if (arg === '--require-replacement-ready') result.requireReplacementReady = true; + else if (arg === '--no-write') result.noWrite = true; + else if (arg === '--write-handoff') result.writeHandoff = true; + else if (arg === '--external-gates-only') result.externalGatesOnly = true; + else if (arg.startsWith('--require-coverage=')) { + result.requireCoverage.push(...arg.slice('--require-coverage='.length).split(',').map((value) => value.trim()).filter(Boolean)); + } + else if (arg.startsWith('--env-file=')) result.envFiles.push(arg.slice('--env-file='.length)); + else if (arg === '--help' || arg === '-h') result.help = true; + else throw new Error(`Unknown argument: ${arg}`); + } + return result; +} + +function shouldWriteReport(args) { + return !args.noWrite; +} + +function shouldWriteHandoff(args) { + return shouldWriteReport(args) && args.writeHandoff; +} + +function usage() { + return [ + 'Usage: node scripts/verify-cc-connect-local-real.mjs [--run] [--external-gates-only] [--include-oauth] [--include-openai-api-key] [--include-feishu] [--include-feishu-inbound] [--include-scheduled-cron] [--include-packaged] [--include-packaged-oauth] [--env-file=] [--strict-real] [--require-coverage=] [--require-replacement-ready] [--write-handoff] [--no-write]', + '', + 'Default mode records a local cc-connect real-validation preflight report.', + 'By default it also loads untracked and gitignored local env files when present: .env.cc-connect.local, .env.local, .env.', + 'Use .env.cc-connect.local.example as the field template for local real credentials.', + '--run executes safe local commands: runtime bundle verification and real credential E2E skip/compile paths.', + '--external-gates-only skips the safe local baseline commands and runs only explicitly included credential-gated paths.', + '--include-oauth additionally runs the gated real OAuth comprehensive E2E when CLAWX_REAL_CODEX_AUTH_JSON points at a Codex auth.json.', + '--include-openai-api-key additionally runs the real OpenAI API-key E2E when CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY is available.', + '--include-feishu additionally runs the real Feishu/Lark channel E2E when required Feishu/Lark env and CLAWX_REAL_CODEX_AUTH_JSON are available.', + '--include-feishu-inbound additionally runs the manual real Feishu/Lark inbound message E2E when CLAWX_REAL_FEISHU_INBOUND_E2E=1 and required credentials are available.', + '--include-scheduled-cron additionally waits for a real cc-connect exec cron scheduler tick without external credentials.', + '--include-packaged additionally runs packaged cc-connect smoke when release/mac-/ClawX.app exists.', + '--include-packaged-oauth additionally runs packaged cc-connect smoke with real OAuth when release/mac-/ClawX.app exists and CLAWX_REAL_CODEX_AUTH_JSON points at a Codex auth.json.', + '--env-file= loads an additional local env file for this verifier; process env values still win.', + 'CLAWX_REAL_ENV_FILE and CLAWX_REAL_ENV_FILES may also point at additional local env files, matching the direct real E2E helpers.', + '--strict-real exits non-zero when real OpenAI API key or Feishu/Lark credentials are missing.', + '--require-coverage= exits non-zero unless the selected runtime parity coverage rows are PASS.', + '--require-replacement-ready exits non-zero unless every replacement-readiness coverage row is PASS.', + '--write-handoff also writes artifacts/cc-connect/local-real-external-gates.{md,json} from the same sanitized report.', + '--no-write evaluates the verifier without overwriting the last JSON/Markdown report artifacts.', + ].join('\n'); +} + +async function pathExists(path) { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function executableExists(path) { + try { + await access(path, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +function currentTarget() { + return `${process.platform}-${process.arch}`; +} + +function binaryName(base) { + return process.platform === 'win32' ? `${base}.exe` : base; +} + +function currentBundlePaths() { + const target = currentTarget(); + return { + ccConnect: join(root, 'build', 'cc-connect', target, binaryName('cc-connect')), + codex: join(root, 'build', 'codex', target, 'bin', binaryName('codex')), + }; +} + +const CC_CONNECT_CLI_PROBES = [ + ['topHelp', ['--help']], + ['cronAddHelp', ['cron', 'add', '--help']], + ['feishuHelp', ['feishu', '--help']], + ['providerHelp', ['provider', '--help']], + ['sessionsHelp', ['sessions', '--help']], + ['configExample', ['config', 'example']], +]; + +function includesAll(text, values) { + return values.every((value) => text.includes(value)); +} + +function analyzeCcConnectCliSurface(probeText) { + const topHelp = probeText.topHelp || ''; + const cronAddHelp = probeText.cronAddHelp || ''; + const feishuHelp = probeText.feishuHelp || ''; + const providerHelp = probeText.providerHelp || ''; + const sessionsHelp = probeText.sessionsHelp || ''; + const configExample = probeText.configExample || ''; + + const surface = { + commands: { + send: topHelp.includes('send'), + cron: topHelp.includes('cron'), + sessions: topHelp.includes('sessions'), + provider: topHelp.includes('provider'), + feishu: topHelp.includes('feishu'), + config: topHelp.includes('config'), + doctorUserIsolation: topHelp.includes('doctor') || configExample.includes('doctor user-isolation'), + }, + cron: { + promptJobs: cronAddHelp.includes('--prompt'), + execJobs: cronAddHelp.includes('--exec'), + sessionMode: cronAddHelp.includes('--session-mode') || configExample.includes('session_mode'), + timeoutMins: cronAddHelp.includes('--timeout-mins') || configExample.includes('timeout_mins'), + }, + sessions: { + list: includesAll(sessionsHelp, ['sessions list']), + show: includesAll(sessionsHelp, ['sessions show']), + }, + providers: { + add: providerHelp.includes('provider add'), + list: providerHelp.includes('provider list'), + remove: providerHelp.includes('provider remove'), + import: providerHelp.includes('provider import'), + presets: providerHelp.includes('provider presets'), + global: providerHelp.includes('provider global'), + }, + feishu: { + setup: feishuHelp.includes('setup'), + bind: feishuHelp.includes('bind'), + new: feishuHelp.includes('new'), + platformType: feishuHelp.includes('--platform-type'), + appIdSecret: feishuHelp.includes('--app-id') && feishuHelp.includes('--app-secret'), + }, + channelLifecycle: { + documentedConnectDisconnect: /^\s+(connect|disconnect)\b/im.test(feishuHelp) + || /\bcc-connect\s+feishu\s+(connect|disconnect)\b/i.test(feishuHelp), + documentedReloadStatus: topHelp.includes('config') && configExample.includes('[[projects.platforms]]'), + }, + }; + const missingPrimitives = []; + if (!surface.channelLifecycle.documentedConnectDisconnect) { + missingPrimitives.push('documented per-platform channel connect/disconnect'); + } + if (!surface.cron.execJobs) { + missingPrimitives.push('cron exec jobs'); + } + if (!surface.cron.promptJobs) { + missingPrimitives.push('cron prompt jobs'); + } + if (!surface.sessions.list || !surface.sessions.show) { + missingPrimitives.push('session list/show CLI'); + } + if (!surface.commands.doctorUserIsolation) { + missingPrimitives.push('doctor user-isolation evidence'); + } + return { + ...surface, + missingPrimitives, + }; +} + +async function runCcConnectCliProbe(binaryPath, args) { + try { + const result = await execFileAsync(binaryPath, args, { + cwd: root, + timeout: 10_000, + maxBuffer: 4 * 1024 * 1024, + env: { ...process.env, NO_COLOR: '1' }, + }); + return [result.stdout || '', result.stderr || ''].join('\n'); + } catch (error) { + const stdout = typeof error?.stdout === 'string' ? error.stdout : ''; + const stderr = typeof error?.stderr === 'string' ? error.stderr : ''; + const message = error instanceof Error ? error.message : String(error); + return [stdout, stderr, message].filter(Boolean).join('\n'); + } +} + +async function readCcConnectCliSurface(binaryPath) { + const probeEntries = await Promise.all(CC_CONNECT_CLI_PROBES.map(async ([name, args]) => [ + name, + await runCcConnectCliProbe(binaryPath, args), + ])); + return analyzeCcConnectCliSurface(Object.fromEntries(probeEntries)); +} + +function packagedAppPath() { + return defaultPackagedAppPath({ rootDir: root }); +} + +function defaultCodexAuthPath() { + return join(homedir(), '.codex', 'auth.json'); +} + +function stripOptionalQuotes(value) { + const trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) + || (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function parseEnvFile(contents) { + const parsed = {}; + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const normalized = line.startsWith('export ') ? line.slice('export '.length).trim() : line; + const separator = normalized.indexOf('='); + if (separator <= 0) continue; + const key = normalized.slice(0, separator).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + const value = stripOptionalQuotes(normalized.slice(separator + 1)); + parsed[key] = value; + } + return parsed; +} + +async function loadLocalEnvFiles(args) { + const defaultFiles = [ + join(root, '.env.cc-connect.local'), + join(root, '.env.local'), + join(root, '.env'), + ]; + const requestedFiles = [ + ...extraLocalRealEnvFiles(process.env), + ...args.envFiles, + ].map((file) => resolve(root, file)); + const files = [...new Set([...defaultFiles, ...requestedFiles])]; + const loaded = {}; + const summaries = []; + for (const file of files) { + const safety = await localEnvFileSafety(file); + if (!await pathExists(file)) { + summaries.push({ name: basename(file), exists: false, loaded: false, variableNames: [], safety }); + continue; + } + if (safety.location === 'repo' && safety.safe !== true) { + summaries.push({ + name: basename(file), + exists: true, + loaded: false, + variableNames: [], + safety, + skippedReason: 'repo-local env files must be untracked and gitignored before they can be loaded', + }); + continue; + } + const parsed = parseEnvFile(await readFile(file, 'utf8')); + for (const [key, value] of Object.entries(parsed)) { + if (process.env[key] === undefined && loaded[key] === undefined) { + loaded[key] = value; + } + } + summaries.push({ + name: basename(file), + exists: true, + loaded: true, + variableNames: Object.keys(parsed).sort(), + safety, + }); + } + return { env: loaded, summaries }; +} + +function extraLocalRealEnvFiles(env) { + const single = env.CLAWX_REAL_ENV_FILE?.trim(); + const multiple = env.CLAWX_REAL_ENV_FILES + ?.split(delimiter) + .map((file) => file.trim()) + .filter(Boolean) ?? []; + return [...new Set([ + ...(single ? [single] : []), + ...multiple, + ])]; +} + +function isPathInsideRoot(file) { + const rel = relative(root, file); + return Boolean(rel) && !rel.startsWith('..') && !rel.startsWith('/') && !rel.startsWith('\\'); +} + +function parseAuthExpiryTimestamp(value) { + if (typeof value === 'number' && Number.isFinite(value)) { + if (value <= 0) return undefined; + return value < 1e12 ? value * 1000 : value; + } + if (typeof value === 'string' && value.trim()) { + const numeric = Number(value.trim()); + if (Number.isFinite(numeric) && numeric > 0) return numeric < 1e12 ? numeric * 1000 : numeric; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function parseJwtExpiryTimestamp(value) { + if (typeof value !== 'string') return undefined; + const segments = value.split('.'); + if (segments.length !== 3 || !segments[1]) return undefined; + try { + const payload = JSON.parse(Buffer.from(segments[1], 'base64url').toString('utf8')); + return parseAuthExpiryTimestamp(payload?.exp); + } catch { + return undefined; + } +} + +function codexAuthExpirySummary(value, nowMs = Date.now()) { + const expirations = []; + const visit = (node) => { + if (!node || typeof node !== 'object' || Array.isArray(node)) return; + for (const [key, child] of Object.entries(node)) { + if (/^(expires_at|expiresAt|expiry|expires|expiration|expiration_time|expirationTime)$/i.test(key)) { + const ts = parseAuthExpiryTimestamp(child); + if (ts !== undefined) expirations.push(ts); + } + if (/^(access_token|id_token)$/i.test(key)) { + const ts = parseJwtExpiryTimestamp(child); + if (ts !== undefined) expirations.push(ts); + } + if (child && typeof child === 'object') visit(child); + } + }; + visit(value); + if (expirations.length === 0) { + return { expiryStatus: 'unknown', expiresAt: null, expired: false }; + } + const earliest = Math.min(...expirations); + return { + expiryStatus: earliest <= nowMs ? 'expired' : 'valid', + expiresAt: new Date(earliest).toISOString(), + expired: earliest <= nowMs, + }; +} + +async function isGitIgnored(file) { + try { + await execFileAsync('git', ['check-ignore', '--quiet', '--', file], { cwd: root }); + return true; + } catch { + return false; + } +} + +async function isGitTracked(file) { + if (!isPathInsideRoot(file)) return false; + try { + await execFileAsync('git', ['ls-files', '--error-unmatch', '--', relative(root, file)], { cwd: root }); + return true; + } catch { + return false; + } +} + +async function localEnvFileSafety(file) { + if (!isPathInsideRoot(file)) { + return { location: 'outside-repo', gitignored: true, tracked: false, safe: true }; + } + const [gitignored, tracked] = await Promise.all([ + isGitIgnored(file), + isGitTracked(file), + ]); + return { + location: 'repo', + gitignored, + tracked, + safe: gitignored && !tracked, + }; +} + +async function readCodexAuthSummary(path) { + try { + const value = JSON.parse(await readFile(path, 'utf8')); + const tokens = value && typeof value === 'object' && value.tokens && typeof value.tokens === 'object' + ? value.tokens + : null; + const tokenKeys = tokens + ? Object.keys(value.tokens).sort() + : []; + const missingTokenKeys = REQUIRED_CODEX_AUTH_TOKEN_KEYS + .filter((key) => typeof tokens?.[key] !== 'string' || !tokens[key].trim()); + const expiry = codexAuthExpirySummary(value); + return { + exists: true, + hasTokens: tokenKeys.length > 0, + completeTokens: missingTokenKeys.length === 0, + tokenKeys, + missingTokenKeys, + openAiApiKey: openAiApiKeyCandidateSummary(value?.OPENAI_API_KEY), + ...expiry, + }; + } catch { + return { + exists: false, + hasTokens: false, + completeTokens: false, + tokenKeys: [], + missingTokenKeys: REQUIRED_CODEX_AUTH_TOKEN_KEYS, + openAiApiKey: openAiApiKeyCandidateSummary(undefined), + expiryStatus: 'unknown', + expiresAt: null, + expired: false, + }; + } +} + +function openAiApiKeyCandidateSummary(value) { + const valueType = value === null + ? 'null' + : Array.isArray(value) + ? 'array' + : typeof value; + if (typeof value === 'string') { + const length = value.trim().length; + return { + present: true, + usable: length > 0, + valueType, + length, + reason: length > 0 ? 'non-empty string' : 'empty string', + }; + } + return { + present: value !== undefined, + usable: false, + valueType, + length: 0, + reason: value === undefined ? 'missing' : 'must be a non-empty string', + }; +} + +function openAiApiKeyPreconditionMessage(configured, requested, candidateSummary) { + if (configured) { + return requested + ? 'OpenAI API key real E2E environment is configured and selected for this run.' + : 'OpenAI API key real E2E environment is configured; the real API-key E2E was not requested in this run.'; + } + if (!candidateSummary?.present) { + return 'OpenAI API key real E2E environment is not configured.'; + } + if (candidateSummary.valueType === 'string' && candidateSummary.length === 0) { + return 'Codex auth metadata contains an empty OPENAI_API_KEY; configure CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY for real API-key validation.'; + } + return `Codex auth metadata contains OPENAI_API_KEY as ${candidateSummary.valueType}; configure CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY with a non-empty string for real API-key validation.`; +} + +async function readCodexAuthOpenAiApiKey(path) { + try { + const value = JSON.parse(await readFile(path, 'utf8')); + const candidate = value?.OPENAI_API_KEY; + return typeof candidate === 'string' ? candidate.trim() : ''; + } catch { + return ''; + } +} + +async function listProcessCommandsContaining(needle) { + if (process.platform === 'win32') return []; + try { + 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')); + } catch { + return []; + } +} + +function createCheck(id, status, message, details = {}) { + return { id, status, message, details }; +} + +function requiredCoverageIds(values) { + const requested = values.flatMap((value) => ( + value === 'all' ? COVERAGE_IDS : value.split(',').map((item) => item.trim()).filter(Boolean) + )); + return [...new Set(requested)]; +} + +function requiredCoverageCheck(coverage, requestedValues) { + const ids = requiredCoverageIds(requestedValues); + if (ids.length === 0) return null; + const known = new Set(COVERAGE_IDS); + const unknown = ids.filter((id) => !known.has(id)); + const byId = new Map(coverage.map((item) => [item.id, item])); + const missing = ids + .map((id) => byId.get(id) || { id, status: 'not-run', reason: 'Coverage row is missing.' }) + .filter((item) => item.status !== 'pass'); + if (unknown.length > 0 || missing.length > 0) { + return createCheck( + 'required-coverage', + 'fail', + 'One or more explicitly requested runtime parity coverage rows are not PASS.', + { required: ids, unknown, missing }, + ); + } + return createCheck( + 'required-coverage', + 'pass', + 'All explicitly requested runtime parity coverage rows are PASS.', + { required: ids }, + ); +} + +function readinessNextCommand(id) { + switch (id) { + case 'runtime-bundles-current-platform': + return 'pnpm run verify:runtime-bundles'; + case 'real-spec-compile-and-skip-paths': + return 'pnpm run verify:cc-connect:local-real:run'; + case 'runtime-boundary-bridgeplatform-only': + return 'pnpm run verify:cc-connect:local-real:run'; + case 'session-history-parity-local-diagnostics': + return 'pnpm run verify:cc-connect:local-real:run'; + case 'oauth-core-runtime-parity': + return 'pnpm run verify:cc-connect:local-real:oauth'; + case 'openai-api-key-provider-model-chat': + return 'pnpm run verify:cc-connect:local-real:api-key'; + case 'chat-abort-local-openai-compatible': + return 'pnpm run verify:cc-connect:local-real:api-key'; + case 'feishu-live-channel-lifecycle': + return 'pnpm run verify:cc-connect:local-real:feishu'; + case 'feishu-live-inbound-delivery': + return 'pnpm run verify:cc-connect:local-real:feishu-inbound'; + case 'scheduled-cron-delivery-local-bundle': + return 'pnpm run verify:cc-connect:local-real:scheduled-cron'; + case 'token-usage-contract-local-diagnostics': + return 'Upgrade to a pinned cc-connect release with a versioned, attributable, replayable per-turn usage API/event, wire it into RuntimeProvider.listUsage, and verify counters against a real provider oracle.'; + case 'packaged-oauth-runtime-smoke': + return 'pnpm run verify:cc-connect:local-real:packaged-oauth'; + default: + return 'pnpm run verify:cc-connect:local-real:all-strict'; + } +} + +function buildReplacementReadiness(coverage, missingPreconditions = []) { + const byId = new Map(coverage.map((item) => [item.id, item])); + const required = REPLACEMENT_REQUIRED_COVERAGE_IDS.map((id) => { + const row = byId.get(id) || { + id, + status: 'not-run', + reason: 'Coverage row is missing.', + }; + return { + id, + status: row.status, + ready: row.status === 'pass', + reason: row.reason || '', + evidence: row.evidence || '', + nextCommand: row.status === 'pass' ? '' : readinessNextCommand(id), + }; + }); + const missingCoverage = required.filter((item) => !item.ready); + return { + status: missingCoverage.length === 0 ? 'ready' : 'partial', + replacementReady: missingCoverage.length === 0, + requiredCoverageIds: REPLACEMENT_REQUIRED_COVERAGE_IDS, + passedCoverageIds: required.filter((item) => item.ready).map((item) => item.id), + missingCoverage, + missingPreconditions, + nextCommands: Array.from(new Set(missingCoverage.map((item) => item.nextCommand).filter(Boolean))), + note: missingCoverage.length === 0 + ? 'All local real runtime parity coverage rows passed for this verifier matrix.' + : 'cc-connect is not replacement-ready on this verifier matrix until every required runtime parity coverage row is PASS.', + }; +} + +function replacementReadinessCheck(readiness, options = {}) { + if (readiness.replacementReady) { + return createCheck( + 'replacement-readiness', + 'pass', + 'All replacement-readiness runtime parity coverage rows are PASS.', + { requiredCoverageIds: readiness.requiredCoverageIds }, + ); + } + if (!options.hardGate) { + return createCheck( + 'replacement-readiness', + 'partial', + 'Replacement-readiness coverage is incomplete; this check is informational unless --require-replacement-ready is set.', + { + missingCoverage: readiness.missingCoverage, + missingPreconditions: readiness.missingPreconditions, + nextCommands: readiness.nextCommands, + }, + ); + } + return createCheck( + 'replacement-readiness', + 'fail', + 'cc-connect is not replacement-ready for this verifier matrix.', + { + missingCoverage: readiness.missingCoverage, + missingPreconditions: readiness.missingPreconditions, + nextCommands: readiness.nextCommands, + }, + ); +} + +function runtimeMatrixStatus(coverage, replacementReadiness) { + if (coverage.some((item) => item.status === 'fail')) return 'fail'; + if (coverage.some((item) => item.status !== 'pass') || !replacementReadiness.replacementReady) { + return 'partial'; + } + return 'pass'; +} + +function coverageById(coverage, id) { + return coverage.find((item) => item.id === id) || { id, status: 'not-run', reason: 'Coverage row is missing.' }; +} + +function coverageEvidence(row) { + return row.evidence || row.reason || 'No evidence recorded.'; +} + +function buildReplacementContract(coverage, replacementReadiness, validationGaps = []) { + const row = (id) => coverageById(coverage, id); + const gapIds = new Set(validationGaps.map((gap) => gap.id)); + return REPLACEMENT_CONTRACT_ITEMS.map((item) => { + switch (item.id) { + case 'developer-mode-gate': + return { + ...item, + status: 'pass', + evidence: 'Developer Mode gating is intentionally kept as the release control for cc-connect.', + nextAction: '', + }; + case 'doctor-fix-non-parity': + return { + ...item, + status: 'pass', + evidence: 'cc-connect doctor user-isolation is covered; OpenClaw Doctor Fix replacement is an explicit non-goal.', + nextAction: '', + }; + case 'runtime-boundary-bridgeplatform-only': { + const boundary = row('runtime-boundary-bridgeplatform-only'); + return { + ...item, + status: boundary.status === 'pass' ? 'pass' : 'partial', + evidence: `Runtime boundary diagnostics: ${boundary.status} (${coverageEvidence(boundary)}).`, + nextAction: boundary.status === 'pass' ? '' : 'pnpm run verify:cc-connect:local-real:run', + }; + } + case 'codex-oauth-and-openai-api-key': { + const oauth = row('oauth-core-runtime-parity'); + const localApiKey = row('local-openai-compatible-api-key-chat'); + const realApiKey = row('openai-api-key-provider-model-chat'); + const ready = oauth.status === 'pass' && localApiKey.status === 'pass' && realApiKey.status === 'pass'; + return { + ...item, + status: ready ? 'pass' : 'partial', + evidence: [ + `OAuth: ${oauth.status} (${coverageEvidence(oauth)})`, + `Local OpenAI-compatible API-key: ${localApiKey.status} (${coverageEvidence(localApiKey)})`, + `Real OpenAI API-key: ${realApiKey.status} (${coverageEvidence(realApiKey)})`, + ].join(' | '), + nextAction: ready ? '' : 'pnpm run verify:cc-connect:local-real:api-key', + }; + } + case 'provider-model-matrix': { + const diagnostics = row('provider-model-profile-local-diagnostics'); + const realApiKey = row('openai-api-key-provider-model-chat'); + return { + ...item, + status: realApiKey.status === 'pass' ? 'pass' : 'partial', + evidence: `Local diagnostics: ${diagnostics.status} (${coverageEvidence(diagnostics)}); real OpenAI API-key row: ${realApiKey.status}. Unsupported providers remain stable errors, not implied parity.`, + nextAction: realApiKey.status === 'pass' ? '' : 'pnpm run verify:cc-connect:local-real:api-key', + }; + } + case 'feishu-channel-lifecycle': { + const local = row('channel-lifecycle-local-bundle'); + const live = row('feishu-live-channel-lifecycle'); + const inbound = row('feishu-live-inbound-delivery'); + const ready = local.status === 'pass' && live.status === 'pass' && inbound.status === 'pass'; + return { + ...item, + status: ready ? 'pass' : 'partial', + evidence: `Local projection/lifecycle: ${local.status} (${coverageEvidence(local)}); live Feishu/Lark lifecycle: ${live.status} (${coverageEvidence(live)}); live inbound delivery: ${inbound.status} (${coverageEvidence(inbound)}).`, + nextAction: ready ? '' : 'pnpm run verify:cc-connect:local-real:feishu-inbound', + }; + } + case 'cron-main-path': { + const lifecycle = row('cron-lifecycle-local-bundle'); + const scheduledExec = row('scheduled-cron-delivery-local-bundle'); + const scheduledPrompt = row('scheduled-prompt-cron-delivery-local-bundle'); + return { + ...item, + status: lifecycle.status === 'pass' && scheduledExec.status === 'pass' && scheduledPrompt.status === 'pass' ? 'pass' : 'partial', + evidence: `Lifecycle: ${lifecycle.status} (${coverageEvidence(lifecycle)}); scheduled exec: ${scheduledExec.status} (${coverageEvidence(scheduledExec)}); scheduled prompt BridgePlatform: ${scheduledPrompt.status} (${coverageEvidence(scheduledPrompt)}).`, + nextAction: scheduledExec.status === 'pass' && scheduledPrompt.status === 'pass' + ? 'Run a real tenant-channel scheduled prompt cron smoke when a safe channel fixture is available.' + : 'pnpm run verify:cc-connect:local-real:scheduled-cron', + }; + } + case 'session-history-parity': { + const local = row('session-history-parity-local-diagnostics'); + const oauth = row('oauth-core-runtime-parity'); + return { + ...item, + status: local.status === 'pass' ? 'pass' : 'partial', + evidence: [ + `Local session/history diagnostics: ${local.status} (${coverageEvidence(local)})`, + `Real OAuth comprehensive session path: ${oauth.status} (${coverageEvidence(oauth)})`, + ].join(' | '), + nextAction: local.status === 'pass' ? '' : 'pnpm run verify:cc-connect:local-real:run', + }; + } + case 'token-usage-contract': { + const usage = row('token-usage-contract-local-diagnostics'); + return { + ...item, + status: 'partial', + evidence: `Private-data boundary diagnostics: ${usage.status} (${coverageEvidence(usage)}). Public per-turn cc-connect usage remains upstream-blocked.`, + nextAction: 'Upgrade to a pinned cc-connect release with a versioned, attributable, replayable per-turn usage API/event, wire it into RuntimeProvider.listUsage, and verify counters against a real provider oracle.', + }; + } + case 'real-validation-opt-in': + return { + ...item, + status: 'pass', + evidence: 'Real OpenAI API-key and Feishu/Lark rows are recorded as explicit opt-in coverage and skipped when credentials are unavailable.', + nextAction: '', + }; + case 'packaging-platform-smoke': { + const packaged = row('packaged-oauth-runtime-smoke'); + const allPlatformOpen = gapIds.has('native-target-release-smoke-observation') || gapIds.has('notarized-macos-dmg-zip-smoke'); + return { + ...item, + status: packaged.status === 'pass' && !allPlatformOpen ? 'pass' : 'partial', + evidence: `Current packaged smoke: ${packaged.status} (${coverageEvidence(packaged)}); all-platform/release artifact gaps: ${allPlatformOpen ? 'open' : 'closed'}.`, + nextAction: allPlatformOpen ? 'Observe all native target smoke jobs and notarized macOS artifact smoke in release validation.' : '', + }; + } + default: + return { + ...item, + status: replacementReadiness.replacementReady ? 'pass' : 'partial', + evidence: replacementReadiness.note, + nextAction: '', + }; + } + }); +} + +function buildValidationGaps(replacementReadiness, missingPreconditions = [], ccConnectCliSurface = null, coverage = []) { + const gaps = []; + for (const item of missingPreconditions) { + gaps.push({ + id: `precondition-${item.id}`, + area: 'preconditions', + priority: 'required', + status: item.status || 'missing', + requiredForLocalReplacementGate: true, + nextCommand: item.nextCommand || '', + reason: item.note || '', + required: item.required ?? [], + optional: item.optional ?? [], + }); + } + for (const item of replacementReadiness.missingCoverage ?? []) { + gaps.push({ + id: `coverage-${item.id}`, + area: item.id, + priority: 'required', + status: item.status, + requiredForLocalReplacementGate: true, + nextCommand: item.nextCommand || readinessNextCommand(item.id), + reason: item.reason || item.evidence || 'Required replacement-readiness coverage is not PASS.', + required: [], + optional: [], + }); + } + for (const primitive of ccConnectCliSurface?.missingPrimitives ?? []) { + gaps.push({ + id: `upstream-${primitive.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`, + area: 'upstream', + priority: 'follow-up', + status: 'missing-upstream-primitive', + requiredForLocalReplacementGate: false, + nextCommand: 'Track upstream cc-connect support or keep the ClawX reload/status lifecycle fallback documented.', + reason: primitive, + required: [], + optional: [], + }); + } + const scheduledCron = coverage.find((item) => item.id === 'scheduled-cron-delivery-local-bundle'); + if (scheduledCron?.status !== 'pass') { + gaps.push({ + id: 'real-scheduled-cron-delivery', + area: 'cron', + priority: 'follow-up', + status: scheduledCron?.status === 'fail' ? 'fail' : 'unverified', + requiredForLocalReplacementGate: false, + nextCommand: 'pnpm run verify:cc-connect:local-real:scheduled-cron', + reason: scheduledCron?.reason + || scheduledCron?.evidence + || 'The local bundle and OAuth smokes prove cron create/list/update/toggle/delete and prompt run paths, but not actual scheduled delivery behavior.', + }); + } + const scheduledPromptDelivery = coverage.find((item) => item.id === 'scheduled-prompt-cron-delivery-local-bundle'); + gaps.push(...RESIDUAL_VALIDATION_GAPS.map((gap) => { + if (gap.id !== 'real-scheduled-prompt-channel-cron-delivery') return gap; + if (scheduledPromptDelivery?.status === 'pass') { + return { + ...gap, + status: 'unverified-channel-delivery', + reason: `${gap.reason} Local BridgePlatform prompt delivery passed: ${scheduledPromptDelivery.evidence}.`, + }; + } + if (scheduledPromptDelivery?.status === 'fail') { + return { + ...gap, + status: 'prompt-delivery-failed', + reason: scheduledPromptDelivery.reason || scheduledPromptDelivery.evidence || gap.reason, + }; + } + return gap; + })); + + const seen = new Set(); + return gaps.filter((gap) => { + const key = `${gap.id}:${gap.priority}:${gap.nextCommand}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function buildNextActions(replacementReadiness, missingPreconditions = [], ccConnectCliSurface = null) { + const actions = []; + for (const item of missingPreconditions) { + actions.push({ + id: `configure-${item.id}`, + type: 'precondition', + priority: 'required', + command: item.nextCommand || '', + reason: item.note || '', + required: item.required ?? [], + optional: item.optional ?? [], + }); + } + for (const item of replacementReadiness.missingCoverage ?? []) { + actions.push({ + id: `verify-${item.id}`, + type: 'coverage', + priority: 'required', + command: item.nextCommand || readinessNextCommand(item.id), + reason: item.reason || item.evidence || 'Required replacement-readiness coverage is not PASS.', + required: [], + optional: [], + }); + } + for (const primitive of ccConnectCliSurface?.missingPrimitives ?? []) { + actions.push({ + id: `upstream-${primitive.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`, + type: 'upstream-gap', + priority: 'follow-up', + command: 'Track upstream cc-connect support or keep the ClawX reload/status lifecycle fallback documented.', + reason: primitive, + required: [], + optional: [], + }); + } + + const seen = new Set(); + return actions.filter((action) => { + const key = `${action.type}:${action.id}:${action.command}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function redactEnvStatus(env, name) { + const value = env[name]?.trim(); + return { + name, + present: Boolean(value), + length: value ? value.length : 0, + }; +} + +function resolveOpenAiApiKeyEnv(env, codexAuthCandidate = {}) { + const clawxKey = env.CLAWX_REAL_OPENAI_API_KEY?.trim(); + const standardKey = env.OPENAI_API_KEY?.trim(); + const codexAuthKey = typeof codexAuthCandidate.value === 'string' + ? codexAuthCandidate.value.trim() + : ''; + if (clawxKey) { + return { + source: 'CLAWX_REAL_OPENAI_API_KEY', + value: clawxKey, + childEnv: { + OPENAI_API_KEY: clawxKey, + }, + }; + } + if (standardKey) { + return { + source: 'OPENAI_API_KEY', + value: standardKey, + childEnv: { + OPENAI_API_KEY: standardKey, + }, + }; + } + if (codexAuthKey) { + return { + source: codexAuthCandidate.source || 'codex-auth-json OPENAI_API_KEY', + value: codexAuthKey, + childEnv: { + OPENAI_API_KEY: codexAuthKey, + }, + }; + } + return { + source: '', + value: '', + childEnv: {}, + }; +} + +function runCommand(command, args, options = {}) { + const startedAt = Date.now(); + return new Promise((resolveResult) => { + let outputTail = ''; + const appendOutput = (stream, chunk) => { + stream.write(chunk); + outputTail = `${outputTail}${String(chunk)}`.slice(-512 * 1024); + }; + const child = spawn(command, args, { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...(options.baseEnv || process.env), ...(options.env || {}) }, + }); + child.stdout?.on('data', (chunk) => appendOutput(process.stdout, chunk)); + child.stderr?.on('data', (chunk) => appendOutput(process.stderr, chunk)); + child.on('error', (error) => { + resolveResult({ + command: commandLabel(command, args), + status: 'fail', + exitCode: null, + durationMs: Date.now() - startedAt, + error: error.message, + coverageAliases: options.coverageAliases ?? [], + }); + }); + child.on('exit', (code, signal) => { + const classification = classifyCommandExit(code, outputTail); + resolveResult({ + command: commandLabel(command, args), + status: classification.status, + exitCode: code, + signal, + durationMs: Date.now() - startedAt, + ...(classification.reason ? { reason: classification.reason } : {}), + coverageAliases: options.coverageAliases ?? [], + }); + }); + }); +} + +function classifyCommandExit(code, output) { + if (code !== 0) return { status: 'fail', reason: '' }; + const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g'); + const summaryOutput = output.replace(ansiPattern, ''); + const passed = Array.from(summaryOutput.matchAll(/^\s*(\d+)\s+passed(?:\s|$)/gim)) + .reduce((total, match) => total + Number(match[1]), 0); + const skipped = Array.from(summaryOutput.matchAll(/^\s*(\d+)\s+skipped(?:\s|$)/gim)) + .reduce((total, match) => total + Number(match[1]), 0); + if (skipped > 0 && passed === 0) { + return { + status: 'skipped', + reason: `Test command exited successfully but executed no passing tests (${skipped} skipped).`, + }; + } + return { status: 'pass', reason: '' }; +} + +function summarizeCommandAttempts(command, args, attempts) { + const finalAttempt = attempts.at(-1); + const failedAttempts = attempts.filter((attempt) => attempt.status === 'fail'); + const summary = { + ...finalAttempt, + command: commandLabel(command, args), + durationMs: attempts.reduce((total, attempt) => total + (attempt.durationMs || 0), 0), + attempts, + }; + if (attempts.length > 1 && finalAttempt.status === 'pass') { + summary.reason = `Passed after ${attempts.length} attempts; ${failedAttempts.length} previous attempt(s) failed.`; + } else if (attempts.length > 1 && finalAttempt.status === 'fail') { + summary.reason = `Failed after ${attempts.length} attempts.`; + } + return summary; +} + +async function runCommandWithRetry(command, args, optionsOrFactory = {}, retryOptions = {}) { + const maxAttempts = Math.max(1, (retryOptions.retries || 0) + 1); + const attempts = []; + for (let index = 0; index < maxAttempts; index += 1) { + const attemptNumber = index + 1; + if (attemptNumber > 1) { + console.log(`Retrying ${commandLabel(command, args)} (${attemptNumber}/${maxAttempts})`); + } + const options = typeof optionsOrFactory === 'function' + ? await optionsOrFactory({ attemptNumber, maxAttempts }) + : optionsOrFactory; + try { + attempts.push(await runCommand(command, args, options)); + } finally { + if (typeof options?.cleanup === 'function') { + await options.cleanup(); + } + } + if (attempts.at(-1).status === 'pass') break; + } + return summarizeCommandAttempts(command, args, attempts); +} + +function commandLabel(command, args) { + return [command, ...args].join(' '); +} + +function sanitizeReportPaths(value, options = {}) { + const repoRoot = options.repoRoot ?? root; + const homeDir = options.homeDir ?? homedir(); + const tempDir = options.tempDir ?? tmpdir(); + const replacements = [ + [repoRoot, ''], + [homeDir, ''], + [tempDir, ''], + ...(tempDir.startsWith('/var/') ? [[`/private${tempDir}`, '']] : []), + ] + .filter(([path]) => path) + .sort(([left], [right]) => right.length - left.length); + + const visit = (candidate) => { + if (typeof candidate === 'string') { + return replacements.reduce( + (sanitized, [path, label]) => sanitized.replaceAll(path, label), + candidate, + ); + } + if (Array.isArray(candidate)) return candidate.map(visit); + if (candidate && typeof candidate === 'object') { + return Object.fromEntries(Object.entries(candidate).map(([key, entry]) => [key, visit(entry)])); + } + return candidate; + }; + + return visit(value); +} + +function skippedCommand(command, args, reason) { + return { + command: commandLabel(command, args), + status: 'skipped', + exitCode: null, + signal: null, + durationMs: 0, + reason, + }; +} + +function missingPreconditions({ + authSummary, + authImportExplicit, + openAiConfigured, + feishuConfigured, + feishuInboundConfigured, + appPath, + packagedExecutableExists, +}) { + const missing = []; + if (!authImportExplicit || !authSummary.exists || !authSummary.completeTokens) { + missing.push({ + id: 'codex-oauth-auth-json', + status: 'missing', + required: ['CLAWX_REAL_CODEX_AUTH_JSON with a complete refresh-token set'], + nextCommand: 'pnpm run verify:cc-connect:local-real:oauth', + note: authSummary.exists && !authSummary.completeTokens + ? `The explicit Codex auth.json is missing required token fields: ${(authSummary.missingTokenKeys ?? []).join(', ')}. Refresh Codex OAuth before running real OAuth validation.` + : 'Point CLAWX_REAL_CODEX_AUTH_JSON at the auth.json that this verifier may explicitly import into an isolated managed CODEX_HOME; set it to ~/.codex/auth.json only when that import is intentional.', + }); + } + if (!openAiConfigured) { + missing.push({ + id: 'openai-api-key-env', + status: 'missing', + required: ['CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY'], + optional: ['CLAWX_REAL_OPENAI_MODEL'], + nextCommand: 'pnpm run verify:cc-connect:local-real:api-key', + note: 'Set CLAWX_REAL_OPENAI_API_KEY in process env or an untracked and gitignored local env file such as .env.cc-connect.local; OPENAI_API_KEY is also accepted for external tool compatibility. Set CLAWX_REAL_OPENAI_MODEL when the default smoke model is unavailable for the test account.', + }); + } + if (!feishuConfigured) { + missing.push({ + id: 'feishu-env', + status: 'missing', + required: ['CLAWX_REAL_FEISHU_APP_ID', 'CLAWX_REAL_FEISHU_APP_SECRET', 'CLAWX_REAL_FEISHU_ADMIN_FROM'], + optional: ['CLAWX_REAL_FEISHU_DOMAIN', 'CLAWX_REAL_FEISHU_ACCOUNT_ID', 'CLAWX_REAL_FEISHU_ALLOW_FROM'], + nextCommand: 'pnpm run verify:cc-connect:local-real:feishu', + note: 'Set Feishu/Lark app credentials in process env or an untracked and gitignored local env file; see .env.cc-connect.local.example for fields.', + }); + } + if (!feishuInboundConfigured) { + missing.push({ + id: 'feishu-inbound-fixture', + status: 'missing', + required: ['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'], + nextCommand: 'pnpm run verify:cc-connect:local-real:feishu-inbound', + note: 'Set CLAWX_REAL_FEISHU_INBOUND_E2E=1 only when a sandbox tenant chat can send the verifier marker to the configured Feishu/Lark bot during the test timeout.', + }); + } + if (!appPath || !packagedExecutableExists) { + missing.push({ + id: 'packaged-native-app', + status: 'missing', + required: [appPath ?? 'native unpacked application'], + nextCommand: 'Build the current-platform unpacked application before packaged runtime smoke.', + note: 'Packaged smoke requires a native unpacked application built from the current source tree.', + }); + } + return missing; +} + +async function buildReport(args, effectiveEnv, envFileSummaries) { + const bundles = currentBundlePaths(); + const missingBundles = collectMissingRuntimeBundles([]); + const ccConnectBundleExecutable = await executableExists(bundles.ccConnect); + const ccConnectCliSurface = ccConnectBundleExecutable + ? await readCcConnectCliSurface(bundles.ccConnect) + : null; + const appPath = packagedAppPath(); + const explicitAuthPath = effectiveEnv.CLAWX_REAL_CODEX_AUTH_JSON?.trim(); + const authPath = explicitAuthPath || defaultCodexAuthPath(); + const authSummary = await readCodexAuthSummary(authPath); + const defaultAuthSummary = explicitAuthPath ? await readCodexAuthSummary(defaultCodexAuthPath()) : authSummary; + const authImportExplicit = Boolean(explicitAuthPath); + const authImportAllowed = authImportExplicit && authSummary.exists && authSummary.completeTokens; + const codexAuthOpenAiApiKey = await readCodexAuthOpenAiApiKey(authPath); + const openAiApiKey = resolveOpenAiApiKeyEnv(effectiveEnv, { + source: explicitAuthPath ? 'CLAWX_REAL_CODEX_AUTH_JSON OPENAI_API_KEY' : 'default Codex auth.json OPENAI_API_KEY', + value: codexAuthOpenAiApiKey, + }); + const openAiEnv = [ + redactEnvStatus(effectiveEnv, 'CLAWX_REAL_OPENAI_API_KEY_E2E'), + redactEnvStatus(effectiveEnv, 'CLAWX_REAL_OPENAI_API_KEY'), + redactEnvStatus(effectiveEnv, 'OPENAI_API_KEY'), + ]; + const openAiConfigured = Boolean(openAiApiKey.value); + const openAiRequested = effectiveEnv.CLAWX_REAL_OPENAI_API_KEY_E2E === '1' || args.includeOpenAiApiKey; + const feishuEnv = [ + redactEnvStatus(effectiveEnv, 'CLAWX_REAL_FEISHU_E2E'), + redactEnvStatus(effectiveEnv, 'CLAWX_REAL_FEISHU_INBOUND_E2E'), + redactEnvStatus(effectiveEnv, 'CLAWX_REAL_FEISHU_APP_ID'), + redactEnvStatus(effectiveEnv, 'CLAWX_REAL_FEISHU_APP_SECRET'), + redactEnvStatus(effectiveEnv, 'CLAWX_REAL_FEISHU_ADMIN_FROM'), + redactEnvStatus(effectiveEnv, 'CLAWX_REAL_FEISHU_ALLOW_FROM'), + redactEnvStatus(effectiveEnv, 'CLAWX_REAL_FEISHU_INBOUND_MARKER'), + ]; + const feishuConfigured = Boolean(effectiveEnv.CLAWX_REAL_FEISHU_APP_ID?.trim()) + && Boolean(effectiveEnv.CLAWX_REAL_FEISHU_APP_SECRET?.trim()) + && Boolean(effectiveEnv.CLAWX_REAL_FEISHU_ADMIN_FROM?.trim()); + const feishuRequested = effectiveEnv.CLAWX_REAL_FEISHU_E2E === '1' || args.includeFeishu; + const feishuInboundRequested = effectiveEnv.CLAWX_REAL_FEISHU_INBOUND_E2E === '1' || args.includeFeishuInbound; + const feishuInboundConfigured = feishuConfigured && authImportAllowed && effectiveEnv.CLAWX_REAL_FEISHU_INBOUND_E2E === '1'; + const residualRuntimeProcesses = process.platform === 'win32' + ? [] + : [ + ...await listProcessCommandsContaining('/runtimes/cc-connect/'), + ...await listProcessCommandsContaining('plugins-clone'), + ].filter((value, index, array) => array.indexOf(value) === index); + + const unsafeRepoEnvFiles = envFileSummaries.filter((item) => + item.exists && item.safety?.location === 'repo' && item.safety?.safe !== true + ); + + const checks = [ + createCheck( + 'local-env-files', + unsafeRepoEnvFiles.length === 0 ? 'pass' : 'fail', + unsafeRepoEnvFiles.length === 0 + ? envFileSummaries.some((item) => item.loaded) + ? 'Local verifier env files were loaded with secret values redacted and untracked/gitignore safety checked.' + : 'No local verifier env files were found.' + : 'Repo-local verifier env files must be untracked and gitignored before they can be loaded.', + { files: envFileSummaries, unsafeLoadedFileNames: unsafeRepoEnvFiles.map((item) => item.name) }, + ), + createCheck( + 'current-runtime-bundles', + missingBundles.length === 0 ? 'pass' : 'fail', + missingBundles.length === 0 + ? 'Current platform cc-connect and Codex bundles are present.' + : 'Current platform runtime bundles are missing.', + { + target: currentTarget(), + ccConnectPath: bundles.ccConnect, + codexPath: bundles.codex, + missing: missingBundles, + }, + ), + createCheck( + 'cc-connect-upstream-cli-surface', + ccConnectCliSurface ? 'pass' : 'skipped', + ccConnectCliSurface + ? 'cc-connect CLI surface was probed from the bundled binary; unsupported upstream primitives are recorded as validation gaps.' + : 'cc-connect bundled binary is unavailable; upstream CLI surface could not be probed.', + { + target: currentTarget(), + binaryPath: bundles.ccConnect, + surface: ccConnectCliSurface, + }, + ), + createCheck( + 'codex-oauth-auth-json', + authImportAllowed ? 'pass' : 'skipped', + authImportAllowed + ? authSummary.expired + ? 'Explicit Codex auth.json has a complete refresh-token set; real OAuth validation must prove Codex can refresh it inside the isolated managed CODEX_HOME.' + : 'Explicit Codex auth.json is available for real OAuth validation.' + : 'CLAWX_REAL_CODEX_AUTH_JSON is missing or incomplete; real OAuth validation will not copy user-global Codex auth implicitly.', + { + source: explicitAuthPath ? 'CLAWX_REAL_CODEX_AUTH_JSON' : 'default-codex-auth-json-observed-only', + explicitImport: authImportExplicit, + defaultAuthJson: { + exists: defaultAuthSummary.exists, + hasTokens: defaultAuthSummary.hasTokens, + completeTokens: defaultAuthSummary.completeTokens, + missingTokenKeys: defaultAuthSummary.missingTokenKeys, + expiryStatus: defaultAuthSummary.expiryStatus, + expiresAt: defaultAuthSummary.expiresAt, + openAiApiKey: defaultAuthSummary.openAiApiKey, + }, + tokenKeys: authSummary.tokenKeys, + completeTokens: authSummary.completeTokens, + missingTokenKeys: authSummary.missingTokenKeys, + expiryStatus: authSummary.expiryStatus, + expiresAt: authSummary.expiresAt, + openAiApiKey: authSummary.openAiApiKey, + }, + ), + createCheck( + 'openai-api-key-env', + openAiConfigured ? 'pass' : (args.strictReal ? 'fail' : 'skipped'), + openAiApiKeyPreconditionMessage(openAiConfigured, openAiRequested, authSummary.openAiApiKey), + { + env: openAiEnv, + requested: openAiRequested, + credentialSource: openAiApiKey.source || null, + codexAuthOpenAiApiKey: authSummary.openAiApiKey, + }, + ), + createCheck( + 'feishu-env', + feishuConfigured ? 'pass' : (args.strictReal ? 'fail' : 'skipped'), + feishuConfigured + ? feishuRequested + ? 'Feishu/Lark real E2E environment is configured and selected for this run.' + : 'Feishu/Lark real E2E environment is configured; the real Feishu/Lark E2E was not requested in this run.' + : 'Feishu/Lark real E2E environment is not configured.', + { env: feishuEnv, requested: feishuRequested }, + ), + createCheck( + 'feishu-inbound-fixture', + feishuInboundConfigured ? 'pass' : (args.strictReal ? 'fail' : 'skipped'), + feishuInboundConfigured + ? feishuInboundRequested + ? 'Feishu/Lark inbound tenant-message fixture is configured and selected for this run.' + : 'Feishu/Lark inbound tenant-message fixture is configured; the inbound E2E was not requested in this run.' + : 'Feishu/Lark inbound tenant-message fixture is not configured.', + { + env: feishuEnv, + requested: feishuInboundRequested, + note: 'Set CLAWX_REAL_FEISHU_INBOUND_E2E=1 only when a sandbox tenant chat can send the verifier marker to the configured bot during the test timeout.', + }, + ), + createCheck( + 'packaged-native-app', + appPath && await pathExists(appPath) ? 'pass' : 'skipped', + appPath && await pathExists(appPath) + ? 'Native packaged application is available for optional packaged smoke.' + : 'Native packaged application is unavailable.', + { appPath }, + ), + createCheck( + 'residual-runtime-processes', + residualRuntimeProcesses.length === 0 ? 'pass' : 'fail', + residualRuntimeProcesses.length === 0 + ? 'No residual local cc-connect/Codex runtime processes were found.' + : 'Residual cc-connect/Codex runtime processes were found.', + { processCount: residualRuntimeProcesses.length, processes: residualRuntimeProcesses }, + ), + ]; + + const commands = []; + if (args.run && !args.externalGatesOnly) { + commands.push(await runCommand('pnpm', ['run', 'verify:runtime-bundles'], { baseEnv: effectiveEnv })); + commands.push(await runCommand('pnpm', [ + 'exec', + 'vitest', + 'run', + 'tests/unit/cc-connect-local-real-verifier.test.ts', + 'tests/unit/e2e-local-real-env.test.ts', + ], { baseEnv: effectiveEnv })); + commands.push(await runCommand('pnpm', [ + 'exec', + 'vitest', + 'run', + 'tests/unit/cc-connect-provider-profile.test.ts', + ], { baseEnv: effectiveEnv })); + commands.push(await runCommand('pnpm', [ + 'exec', + 'vitest', + 'run', + 'tests/unit/cc-connect-runtime-provider.test.ts', + ], { baseEnv: effectiveEnv })); + commands.push(await runCommand('pnpm', [ + 'exec', + 'vitest', + 'run', + 'tests/unit/runtime-rpc-contract.test.ts', + 'tests/unit/runtime-operation-capabilities.test.ts', + 'tests/unit/channel-store-operation-capabilities.test.ts', + ], { baseEnv: effectiveEnv })); + commands.push(await runCommand('pnpm', [ + 'exec', + 'vitest', + 'run', + 'tests/unit/cc-connect-bridge-adapter.test.ts', + ], { baseEnv: effectiveEnv })); + commands.push(await runCommand('pnpm', [ + 'run', + 'test:e2e', + '--', + 'tests/e2e/cc-connect-codex-runtime.spec.ts', + ], { baseEnv: effectiveEnv })); + commands.push(await runCommand('pnpm', [ + 'run', + 'test:e2e:cc-connect:codex-oauth-lifecycle', + ], { baseEnv: effectiveEnv })); + commands.push(await runCommand('pnpm', [ + 'exec', + 'vitest', + 'run', + 'tests/unit/token-usage.test.ts', + 'tests/unit/token-usage-scan.test.ts', + 'tests/unit/runtime-usage.test.ts', + 'tests/unit/usage-api.test.ts', + 'tests/unit/openclaw-runtime-provider.test.ts', + 'tests/unit/cc-connect-runtime-provider.test.ts', + ], { baseEnv: effectiveEnv })); + commands.push(await runCommand('pnpm', [ + 'run', + 'test:e2e', + '--', + 'tests/e2e/token-usage.spec.ts', + ], { baseEnv: effectiveEnv })); + commands.push(await runCommand('pnpm', [ + 'run', + 'test:e2e', + '--', + 'tests/e2e/cc-connect-real-bundle-smoke.spec.ts', + ], { baseEnv: effectiveEnv })); + commands.push(await runCommand('pnpm', [ + 'run', + 'test:e2e', + '--', + 'tests/e2e/cc-connect-real-openai-api-key.spec.ts', + 'tests/e2e/cc-connect-real-feishu-channel.spec.ts', + ], { baseEnv: effectiveEnv })); + } + if (args.run && args.includeOAuth) { + if (authImportAllowed) { + commands.push(await runCommandWithRetry( + 'pnpm', + ['run', 'test:e2e:cc-connect:real-oauth'], + ({ attemptNumber }) => { + const oauthRunId = `${Date.now()}-${process.pid}-tool-${attemptNumber}`; + const oauthHomeDir = join(tmpdir(), `clawx-local-real-oauth-tool-home-${oauthRunId}`); + const oauthUserDataDir = join(tmpdir(), `clawx-local-real-oauth-tool-user-data-${oauthRunId}`); + return { + baseEnv: effectiveEnv, + env: { + CLAWX_REAL_OAUTH_E2E: '1', + CLAWX_E2E_HOME_DIR: oauthHomeDir, + CLAWX_E2E_USER_DATA_DIR: oauthUserDataDir, + }, + cleanup: () => Promise.all([ + rm(oauthHomeDir, { recursive: true, force: true }), + rm(oauthUserDataDir, { recursive: true, force: true }), + ]), + }; + }, + { retries: 1 }, + )); + commands.push(await runCommandWithRetry( + 'pnpm', + ['run', 'test:e2e:cc-connect:real-comprehensive'], + ({ attemptNumber }) => { + const oauthRunId = `${Date.now()}-${process.pid}-${attemptNumber}`; + const oauthHomeDir = join(tmpdir(), `clawx-local-real-oauth-home-${oauthRunId}`); + const oauthUserDataDir = join(tmpdir(), `clawx-local-real-oauth-user-data-${oauthRunId}`); + return { + baseEnv: effectiveEnv, + env: { + CLAWX_REAL_OAUTH_E2E: '1', + CLAWX_E2E_HOME_DIR: oauthHomeDir, + CLAWX_E2E_USER_DATA_DIR: oauthUserDataDir, + }, + cleanup: () => Promise.all([ + rm(oauthHomeDir, { recursive: true, force: true }), + rm(oauthUserDataDir, { recursive: true, force: true }), + ]), + }; + }, + { retries: 1 }, + )); + } else { + commands.push(skippedCommand( + 'pnpm', + ['run', 'test:e2e:cc-connect:real-oauth'], + 'CLAWX_REAL_CODEX_AUTH_JSON is missing or incomplete; user-global ~/.codex/auth.json is not copied implicitly.', + )); + commands.push(skippedCommand( + 'pnpm', + ['run', 'test:e2e:cc-connect:real-comprehensive'], + 'CLAWX_REAL_CODEX_AUTH_JSON is missing or incomplete; user-global ~/.codex/auth.json is not copied implicitly.', + )); + } + } + if (args.run && args.includeOpenAiApiKey) { + if (openAiApiKey.value) { + commands.push(await runCommand('pnpm', ['run', 'test:e2e:cc-connect:real-openai-api-key'], { + baseEnv: effectiveEnv, + env: { + CLAWX_REAL_OPENAI_API_KEY_E2E: '1', + ...openAiApiKey.childEnv, + }, + })); + } else { + commands.push(skippedCommand( + 'pnpm', + ['run', 'test:e2e:cc-connect:real-openai-api-key'], + 'CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY is not configured.', + )); + } + } + if (args.run && args.includeFeishu) { + if ( + feishuConfigured && authImportAllowed + ) { + commands.push(await runCommand('pnpm', ['run', 'test:e2e:cc-connect:real-feishu'], { + baseEnv: effectiveEnv, + env: { CLAWX_REAL_FEISHU_E2E: '1' }, + })); + } else { + commands.push(skippedCommand( + 'pnpm', + ['run', 'test:e2e:cc-connect:real-feishu'], + 'Feishu/Lark app credentials or CLAWX_REAL_CODEX_AUTH_JSON are not configured.', + )); + } + } + if (args.run && args.includeFeishuInbound) { + if ( + feishuConfigured + && authImportAllowed + && effectiveEnv.CLAWX_REAL_FEISHU_INBOUND_E2E === '1' + ) { + commands.push(await runCommand('pnpm', ['run', 'test:e2e:cc-connect:real-feishu-inbound'], { + baseEnv: effectiveEnv, + env: { CLAWX_REAL_FEISHU_INBOUND_E2E: '1' }, + })); + } else { + commands.push(skippedCommand( + 'pnpm', + ['run', 'test:e2e:cc-connect:real-feishu-inbound'], + 'Feishu/Lark app credentials, CLAWX_REAL_CODEX_AUTH_JSON, or CLAWX_REAL_FEISHU_INBOUND_E2E=1 tenant-message fixture are not configured.', + )); + } + } + if (args.run && args.includeScheduledCron) { + const scheduledCronEnv = { CLAWX_REAL_SCHEDULED_CRON_E2E: '1' }; + const scheduledCronCoverageAliases = []; + if (authImportAllowed) { + scheduledCronEnv.CLAWX_REAL_SCHEDULED_PROMPT_CRON_E2E = '1'; + scheduledCronCoverageAliases.push('test:e2e:cc-connect:real-scheduled-prompt-cron'); + } + commands.push(await runCommand('pnpm', ['run', 'test:e2e:cc-connect:real-scheduled-cron'], { + baseEnv: effectiveEnv, + env: scheduledCronEnv, + coverageAliases: scheduledCronCoverageAliases, + })); + if (!authImportAllowed) { + commands.push(skippedCommand( + 'pnpm', + ['run', 'test:e2e:cc-connect:real-scheduled-prompt-cron'], + 'CLAWX_REAL_CODEX_AUTH_JSON is missing or incomplete; scheduled prompt cron bridge fallback delivery requires managed Codex OAuth.', + )); + } + } + const packagedExecutableExists = appPath + ? await executableExists(packagedExecutablePath(appPath)) + : false; + if (args.run && args.includePackaged) { + if (appPath && packagedExecutableExists) { + commands.push(await runCommand('pnpm', [ + 'run', + 'smoke:cc-connect:packaged', + '--', + `--app=${appPath}`, + ], { baseEnv: effectiveEnv })); + } else { + commands.push(skippedCommand( + 'pnpm', + ['run', 'smoke:cc-connect:packaged', '--', `--app=${appPath ?? ''}`], + 'Native packaged application executable is unavailable.', + )); + } + } + if (args.run && args.includePackagedOAuth) { + if (appPath && authImportAllowed && packagedExecutableExists) { + commands.push(await runCommand('pnpm', [ + 'run', + 'smoke:cc-connect:packaged', + '--', + `--app=${appPath}`, + '--real-oauth=1', + ], { baseEnv: effectiveEnv })); + } else { + commands.push(skippedCommand( + 'pnpm', + ['run', 'smoke:cc-connect:packaged', '--', `--app=${appPath ?? ''}`, '--real-oauth=1'], + 'Native packaged application executable or CLAWX_REAL_CODEX_AUTH_JSON is unavailable.', + )); + } + } + + const commandFailures = commands.filter((item) => item.status === 'fail'); + const commandSkips = commands.filter((item) => item.status === 'skipped'); + if (commandFailures.length > 0) { + checks.push(createCheck('local-validation-commands', 'fail', 'One or more requested local validation commands failed.', { commands })); + } else if (commandSkips.length > 0) { + checks.push(createCheck('local-validation-commands', 'pass', 'Requested local validation commands passed; unavailable real-credential paths were recorded as skipped.', { commands })); + } else if (commands.length > 0) { + checks.push(createCheck('local-validation-commands', 'pass', 'Requested local validation commands passed.', { commands })); + } + + const missing = missingPreconditions({ + authSummary, + authImportExplicit, + openAiConfigured, + feishuConfigured, + feishuInboundConfigured, + appPath, + packagedExecutableExists, + }); + const coverage = buildCoverage({ + args, + missingPreconditions: missing, + checks, + }); + const coverageCheck = requiredCoverageCheck(coverage, args.requireCoverage); + if (coverageCheck) checks.push(coverageCheck); + const replacementReadiness = buildReplacementReadiness(coverage, missing); + const validationGaps = buildValidationGaps(replacementReadiness, missing, ccConnectCliSurface, coverage); + const replacementContract = buildReplacementContract(coverage, replacementReadiness, validationGaps); + const nextActions = buildNextActions(replacementReadiness, missing, ccConnectCliSurface); + checks.push(replacementReadinessCheck(replacementReadiness, { hardGate: args.requireReplacementReady })); + const failed = checks.filter((check) => check.status === 'fail'); + const skipped = checks.filter((check) => check.status === 'skipped'); + const status = failed.length > 0 + ? 'fail' + : skipped.length > 0 || !replacementReadiness.replacementReady + ? 'partial' + : 'pass'; + const matrixStatus = runtimeMatrixStatus(coverage, replacementReadiness); + + return sanitizeReportPaths({ + generatedAt: new Date().toISOString(), + status, + runtimeMatrixStatus: matrixStatus, + args, + missingPreconditions: missing, + replacementReadiness, + replacementContract, + validationGaps, + nextActions, + ccConnectCliSurface, + coverage, + checks, + }); +} + +function markdownStatus(status) { + if (status === 'pass') return 'PASS'; + if (status === 'fail') return 'FAIL'; + if (status === 'partial') return 'PARTIAL'; + return 'SKIPPED'; +} + +function markdownCell(value) { + return String(value ?? '') + .replaceAll('\\', '\\\\') + .replaceAll('|', '\\|') + .replaceAll('\n', ' '); +} + +function markdownList(values) { + return markdownCell((values ?? []).join(', ')); +} + +function commandRecords(report) { + return report.checks.find((check) => check.id === 'local-validation-commands')?.details?.commands ?? []; +} + +function preconditionRecords(report) { + return report.missingPreconditions ?? []; +} + +function findCommand(commands, needle) { + return commands.find((command) => + command.command.includes(needle) + || (command.coverageAliases ?? []).some((alias) => alias.includes(needle)) + ); +} + +function commandCoverage(commands, needle, fallbackStatus = 'not-run') { + const command = findCommand(commands, needle); + if (!command) { + return { + status: fallbackStatus, + command: '', + reason: 'Command was not requested.', + }; + } + return { + status: command.status, + command: command.command, + reason: command.reason || command.error || '', + }; +} + +function commandCoverageWithPreconditions(report, commands, needle, preconditionIds) { + const coverage = commandCoverage(commands, needle); + if (coverage.status !== 'not-run') return coverage; + const missing = preconditionIds + .map((id) => (report.missingPreconditions ?? []).find((item) => item.id === id)) + .find(Boolean); + if (!missing) return coverage; + return { + status: 'skipped', + command: '', + reason: missing.note || `Missing precondition: ${missing.id}`, + }; +} + +function buildCoverage(report) { + const commands = commandRecords(report); + const bundles = report.checks.find((check) => check.id === 'current-runtime-bundles'); + const compile = commandCoverage(commands, 'tests/e2e/cc-connect-real-openai-api-key.spec.ts'); + const providerProfile = commandCoverage(commands, 'tests/unit/cc-connect-provider-profile.test.ts'); + const runtimeProviderUnit = commandCoverage(commands, 'tests/unit/cc-connect-runtime-provider.test.ts'); + const operationCapabilitiesUnit = commandCoverage(commands, 'tests/unit/runtime-rpc-contract.test.ts'); + const oauthHostApiLifecycle = commandCoverage(commands, 'test:e2e:cc-connect:codex-oauth-lifecycle'); + const verifierUnit = commandCoverage(commands, 'tests/unit/cc-connect-local-real-verifier.test.ts'); + const tokenUsageUnit = commandCoverage(commands, 'tests/unit/token-usage-scan.test.ts'); + const tokenUsageE2e = commandCoverage(commands, 'tests/e2e/token-usage.spec.ts'); + const bridgeAdapterUnit = commandCoverage(commands, 'tests/unit/cc-connect-bridge-adapter.test.ts'); + const mockBridgeE2e = commandCoverage(commands, 'tests/e2e/cc-connect-codex-runtime.spec.ts'); + const runtimeManagementBundle = commandCoverage(commands, 'tests/e2e/cc-connect-real-bundle-smoke.spec.ts'); + const oauth = commandCoverageWithPreconditions(report, commands, 'test:e2e:cc-connect:real-comprehensive', ['codex-oauth-auth-json']); + const oauthTool = commandCoverageWithPreconditions(report, commands, 'test:e2e:cc-connect:real-oauth', ['codex-oauth-auth-json']); + const generatedFileOAuth = oauthTool.status === 'not-run' ? oauth : oauthTool; + const apiKey = commandCoverageWithPreconditions(report, commands, 'test:e2e:cc-connect:real-openai-api-key', ['openai-api-key-env']); + const feishu = commandCoverageWithPreconditions(report, commands, 'test:e2e:cc-connect:real-feishu', ['feishu-env', 'codex-oauth-auth-json']); + const feishuInbound = commandCoverageWithPreconditions( + report, + commands, + 'test:e2e:cc-connect:real-feishu-inbound', + ['feishu-env', 'codex-oauth-auth-json', 'feishu-inbound-fixture'], + ); + const scheduledCron = commandCoverage(commands, 'test:e2e:cc-connect:real-scheduled-cron'); + const scheduledPromptDelivery = commandCoverageWithPreconditions( + report, + commands, + 'test:e2e:cc-connect:real-scheduled-prompt-cron', + ['codex-oauth-auth-json'], + ); + const packaged = commandCoverageWithPreconditions(report, commands, 'smoke:cc-connect:packaged', ['packaged-native-app', 'codex-oauth-auth-json']); + const tokenUsageChecks = [tokenUsageUnit, tokenUsageE2e, runtimeManagementBundle]; + const tokenUsageStatus = tokenUsageChecks.every((check) => check.status === 'pass') + ? 'partial' + : tokenUsageChecks.some((check) => check.status === 'fail') + ? 'fail' + : tokenUsageChecks.every((check) => check.status === 'not-run') + ? 'not-run' + : 'skipped'; + + return [ + { + id: 'runtime-bundles-current-platform', + status: bundles?.status || 'not-run', + covers: ['cc-connect binary', 'Codex binary', 'current platform resolver'], + evidence: bundles?.message || 'Runtime bundle preflight was not evaluated.', + }, + { + id: 'runtime-boundary-bridgeplatform-only', + status: runtimeProviderUnit.status === 'pass' && bridgeAdapterUnit.status === 'pass' && mockBridgeE2e.status === 'pass' + ? 'pass' + : runtimeProviderUnit.status === 'fail' || bridgeAdapterUnit.status === 'fail' || mockBridgeE2e.status === 'fail' + ? 'fail' + : runtimeProviderUnit.status === 'not-run' && bridgeAdapterUnit.status === 'not-run' && mockBridgeE2e.status === 'not-run' + ? 'not-run' + : 'skipped', + covers: [ + 'CcConnectRuntimeProvider chat.send delegates to the BridgePlatform adapter', + 'BridgePlatform WebSocket message delivery carries the GUI chat payload into cc-connect', + 'Codex is configured only as the cc-connect project agent command and managed CODEX_HOME', + 'Mock Electron E2E proves chat box delivery through cc-connect BridgePlatform', + 'Electron Host API provider sync replaces stale same-account managed OAuth after browser re-login', + 'ordinary runtime start preserves Codex-refreshed managed OAuth over an older vault snapshot', + ], + evidence: [runtimeProviderUnit.command, bridgeAdapterUnit.command, mockBridgeE2e.command].filter(Boolean).join(' && ') + || runtimeProviderUnit.reason + || bridgeAdapterUnit.reason + || mockBridgeE2e.reason, + reason: [runtimeProviderUnit.reason, bridgeAdapterUnit.reason, mockBridgeE2e.reason].filter(Boolean).join('; '), + }, + { + id: 'session-history-parity-local-diagnostics', + status: runtimeProviderUnit.status === 'pass' && bridgeAdapterUnit.status === 'pass' && mockBridgeE2e.status === 'pass' + ? 'pass' + : runtimeProviderUnit.status === 'fail' || bridgeAdapterUnit.status === 'fail' || mockBridgeE2e.status === 'fail' + ? 'fail' + : runtimeProviderUnit.status === 'not-run' && bridgeAdapterUnit.status === 'not-run' && mockBridgeE2e.status === 'not-run' + ? 'not-run' + : 'skipped', + covers: [ + 'cross-agent session keys stay runtime-routed through cc-connect stores', + 'named and active session keys load from cc-connect-owned session stores', + 'Host API sessions.rename updates cc-connect-owned session labels and titles', + 'Host API sessions.delete removes cc-connect-owned session state and history', + 'channel session display names preserve cc-connect chat/user metadata', + ], + evidence: [runtimeProviderUnit.command, bridgeAdapterUnit.command, mockBridgeE2e.command].filter(Boolean).join(' && ') + || runtimeProviderUnit.reason + || bridgeAdapterUnit.reason + || mockBridgeE2e.reason, + reason: [runtimeProviderUnit.reason, bridgeAdapterUnit.reason, mockBridgeE2e.reason].filter(Boolean).join('; '), + }, + { + id: 'real-spec-compile-and-skip-paths', + status: compile.status, + covers: ['OpenAI API key E2E spec compiles', 'Feishu lifecycle/inbound E2E spec compiles', 'credential-gated skip semantics'], + evidence: compile.command || compile.reason, + reason: compile.reason, + }, + { + id: 'codex-oauth-lifecycle-local-diagnostics', + status: verifierUnit.status, + covers: [ + 'explicit auth import requirement', + 'complete Codex token field requirement', + 'expired access-token refresh through isolated real execution', + 'sanitized expiry metadata', + 'missing token key reporting without token values', + ], + evidence: verifierUnit.command || verifierUnit.reason, + reason: verifierUnit.reason, + }, + { + id: 'codex-oauth-host-api-lifecycle-local', + status: oauthHostApiLifecycle.status, + covers: [ + 'Electron Host API providers.codexOAuthStatus', + 'Electron Host API providers.importCodexOAuth', + 'Electron Host API providers.logoutCodexOAuth', + 'managed Codex auth file lifecycle', + 'provider OAuth secret cleanup', + 'token redaction in Host API responses and public provider profile', + ], + evidence: oauthHostApiLifecycle.command || oauthHostApiLifecycle.reason, + reason: oauthHostApiLifecycle.reason, + }, + { + id: 'provider-model-profile-local-diagnostics', + status: providerProfile.status, + covers: [ + 'OpenAI API-key profile materialization', + 'OpenAI OAuth profile materialization', + 'custom Responses profile materialization', + 'unsupported provider diagnostics', + 'secret redaction', + 'running runtime provider/model sync restart', + 'browser OAuth re-login secret precedence over stale same-account managed auth', + 'Codex-refreshed managed auth precedence during ordinary runtime start', + ], + evidence: providerProfile.command || providerProfile.reason, + reason: providerProfile.reason, + }, + { + id: 'operation-capabilities-local-diagnostics', + status: operationCapabilitiesUnit.status === 'pass' && runtimeManagementBundle.status === 'pass' + ? 'pass' + : operationCapabilitiesUnit.status === 'fail' || runtimeManagementBundle.status === 'fail' + ? 'fail' + : operationCapabilitiesUnit.status === 'not-run' && runtimeManagementBundle.status === 'not-run' + ? 'not-run' + : 'skipped', + covers: [ + 'OpenClaw proxy and cc-connect native/unsupported operation declarations', + 'cc-connect compatibility alias declarations', + 'renderer fail-closed behavior for undeclared operations after status publication', + 'channel add and QR entry points stop before runtime RPC when explicitly unsupported', + 'real bundled cc-connect operation capabilities exposed through runtime status', + ], + evidence: [operationCapabilitiesUnit.command, runtimeManagementBundle.command].filter(Boolean).join(' && ') + || operationCapabilitiesUnit.reason + || runtimeManagementBundle.reason, + reason: [operationCapabilitiesUnit.reason, runtimeManagementBundle.reason].filter(Boolean).join('; '), + }, + { + id: 'token-usage-contract-local-diagnostics', + status: tokenUsageStatus, + covers: [ + 'RuntimeProvider.listUsage ownership for OpenClaw and cc-connect', + 'cache tokens remain an input subset and are not added twice to inferred totals', + 'reasoning-token mapping without double-counting output', + 'cc-connect private session-store exclusion', + 'managed and user-global Codex transcript exclusion', + 'explicit missing usage rows for public cc-connect assistant history without counters', + 'real bundled cc-connect Host API and GUI missing-usage evidence', + 'runtimeKind filtering without OpenClaw data leakage', + 'OpenClaw transcript usage remains unaffected', + 'Electron IPC unavailable-usage shape', + ], + evidence: [tokenUsageUnit.command, tokenUsageE2e.command, runtimeManagementBundle.command].filter(Boolean).join(' && ') + || tokenUsageUnit.reason + || tokenUsageE2e.reason + || runtimeManagementBundle.reason, + reason: tokenUsageChecks.every((check) => check.status === 'pass') + ? 'Boundary diagnostics pass, but published cc-connect releases have no versioned, attributable, replayable per-turn usage API or event; unmerged upstream PR #1428 is insufficient for production parity.' + : tokenUsageChecks.map((check) => check.reason).filter(Boolean).join('; '), + }, + { + id: 'runtime-management-bundle-local-diagnostics', + status: runtimeManagementBundle.status, + covers: [ + 'real bundled cc-connect startup', + 'runtime diagnostics redaction', + 'fallback port selection', + 'Management API sessions/providers/models across managed projects', + 'read-only Host API provider/model profiles without runtime restart', + 'provider/model Management response field allowlist without secret pass-through', + 'Management API channel reload/status', + 'Management API cron lifecycle', + 'managed cc-connect user-isolation plus Codex doctor JSON audit', + 'quit cleanup', + 'rollback cleanup', + ], + evidence: runtimeManagementBundle.command || runtimeManagementBundle.reason, + reason: runtimeManagementBundle.reason, + }, + { + id: 'bridge-media-packets-local-diagnostics', + status: bridgeAdapterUnit.status, + covers: [ + 'BridgePlatform image packet to renderer attached file', + 'BridgePlatform file packet to renderer attached file', + 'BridgePlatform audio packet to renderer attached file', + 'BridgePlatform video packet to renderer attached file', + 'cc-connect managed media directory writes', + 'image preview data URL preservation', + 'file/audio/video preview suppression', + ], + evidence: bridgeAdapterUnit.command || bridgeAdapterUnit.reason, + reason: bridgeAdapterUnit.reason, + }, + { + id: 'bridge-media-send-real-bundle', + status: compile.status, + covers: [ + 'real bundled cc-connect send CLI against an active managed session', + 'public Bridge image/file/audio/video packets', + 'ClawX-managed media copies with exact byte preservation', + 'Host API session history attachment merge', + 'renderer final-event message-id deduplication and session routing', + 'GUI image preview plus PDF/audio/video file cards', + 'sanitized screenshot and structured evidence artifacts', + ], + evidence: compile.command || compile.reason, + reason: compile.reason, + }, + { + id: 'bridge-rich-packets-local-diagnostics', + status: bridgeAdapterUnit.status, + covers: [ + 'BridgePlatform card packet to shared assistant message', + 'BridgePlatform buttons packet to shared assistant message', + 'BridgePlatform preview_start acknowledgement', + 'BridgePlatform update_message assistant delta', + 'BridgePlatform text preview delete clears transient assistant content', + 'structured progress delete preserves semantic tool lifecycle', + 'typing packet no-op stability', + ], + evidence: bridgeAdapterUnit.command || bridgeAdapterUnit.reason, + reason: bridgeAdapterUnit.reason, + }, + { + id: 'bridge-rich-progress-real-bundle', + status: runtimeManagementBundle.status, + covers: [ + 'real bundled cc-connect v1.4.1 engine process', + 'deterministic Codex app-server protocol boundary without direct ClawX-to-Codex chat', + 'public Bridge preview_start and update_message packets observed in runtime diagnostics', + 'thinking and tool lifecycle projected into the shared runtime graph', + 'GUI execution graph and final assistant reply', + 'sanitized screenshot and structured evidence artifacts', + ], + evidence: runtimeManagementBundle.command || runtimeManagementBundle.reason, + reason: runtimeManagementBundle.reason, + }, + { + id: 'bridge-rich-card-action-real-bundle', + status: runtimeManagementBundle.status, + covers: [ + 'real bundled cc-connect Bridge card packet from /cron list', + 'real card action values emitted by cc-connect', + 'card_action disable callback observed through Host API', + 'card_action enable callback observed through Host API', + 'card_action delete callback observed through Host API', + 'usable text reply fallback for /cron add acknowledgement', + ], + evidence: runtimeManagementBundle.command || runtimeManagementBundle.reason, + reason: runtimeManagementBundle.reason, + }, + { + id: 'bridge-runtime-choice-real-bundle', + status: runtimeManagementBundle.status, + covers: [ + 'real bundled cc-connect /lang card rendered as a shared runtime choice', + 'select option action validated against the public Bridge card payload', + 'GUI action returned through the public card_action packet', + 'updated card closes the Chat run without restarting cc-connect', + 'live language state verified through the public Management project API', + 'sanitized before/after screenshots and structured evidence artifacts', + ], + evidence: runtimeManagementBundle.command || runtimeManagementBundle.reason, + reason: runtimeManagementBundle.reason, + }, + { + id: 'channel-lifecycle-local-bundle', + status: runtimeManagementBundle.status, + covers: [ + 'Host API channels.connect through cc-connect runtime', + 'managed config reload without cc-connect restart', + 'channel account status from runtime-owned state', + 'Feishu/Lark local config projection', + 'Feishu/Lark agent binding and workspace projection', + 'Host API channels.disconnect through cc-connect runtime', + 'real user channel credential removal', + 'placeholder platform preservation for cc-connect startup', + ], + evidence: runtimeManagementBundle.command || runtimeManagementBundle.reason, + reason: runtimeManagementBundle.reason, + }, + { + id: 'channel-cron-command-local-diagnostics', + status: runtimeManagementBundle.status, + covers: [ + 'public Bridge channel adapter registration against the real bundled cc-connect binary', + 'managed admin identity for Channel Cron mutation commands', + 'Channel /cron add observed through Host API Cron list', + 'GUI announce Cron observed through Channel /cron list for the same Feishu target', + 'Channel /cron disable and enable observed through Host API', + 'Channel /cron delete observed through Host API', + 'single native cc-connect scheduler and unchanged runtime PID', + 'real cc-connect /cron card packet and actionable disable/enable/delete callbacks', + 'usable text fallback for the /cron add acknowledgement', + 'sanitized channel Cron evidence without tenant credentials', + ], + evidence: runtimeManagementBundle.command || runtimeManagementBundle.reason, + reason: runtimeManagementBundle.reason, + }, + { + id: 'cron-lifecycle-local-bundle', + status: runtimeManagementBundle.status, + covers: [ + 'Management API cron create/list/update/toggle/delete', + 'non-main agent project routing', + 'prompt cron delivery field mapping', + 'exec cron field mapping', + 'work_dir preservation', + 'session_mode preservation', + 'timeout_mins preservation', + 'mute/silent persistence', + 'non-cron schedule unsupported/error semantics', + 'manual exec run unsupported/error semantics', + ], + evidence: runtimeManagementBundle.command || runtimeManagementBundle.reason, + reason: runtimeManagementBundle.reason, + }, + { + id: 'scheduled-cron-delivery-local-bundle', + status: scheduledCron.status, + covers: [ + 'real cc-connect scheduler tick', + 'enabled exec cron delivery', + 'work_dir execution context', + 'runtime PID preservation', + 'Cron page job card and sanitized machine/visual evidence', + 'scheduled job cleanup', + ], + evidence: scheduledCron.command || scheduledCron.reason, + reason: scheduledCron.reason, + }, + { + id: 'scheduled-prompt-cron-delivery-local-bundle', + status: scheduledPromptDelivery.status, + covers: [ + 'real cc-connect prompt cron creation', + 'real scheduler tick wait', + 'ClawX fallback delivery through cc-connect BridgePlatform', + 'cc-connect session history after scheduled prompt delivery', + 'runtime PID preservation', + 'Cron page job card and sanitized machine/visual evidence', + 'scheduled job cleanup', + ], + evidence: scheduledPromptDelivery.command || scheduledPromptDelivery.reason, + reason: scheduledPromptDelivery.reason, + }, + { + id: 'oauth-core-runtime-parity', + status: oauth.status, + covers: [ + 'chat', + 'sessions/history', + 'runtime-routed session rename', + 'restart reload', + 'cross-agent sessions', + 'named sessions', + 'workspace isolation', + 'tool events', + 'apply_patch generated file card', + 'skills sync', + 'cron create/list/trigger/toggle/delete', + ], + evidence: oauth.command || oauth.reason, + reason: oauth.reason, + }, + { + id: 'generated-file-card-real-oauth', + status: generatedFileOAuth.status, + covers: [ + 'real Codex apply_patch tool turn', + 'run-correlated cc-connect Bridge tool lifecycle', + 'generated-file card rendered in GUI chat', + ], + evidence: generatedFileOAuth.command || generatedFileOAuth.reason, + reason: generatedFileOAuth.reason, + }, + { + id: 'local-openai-compatible-api-key-chat', + status: compile.status, + covers: [ + 'OpenAI API-key provider with custom baseUrl', + 'local OpenAI-compatible Responses server', + 'model propagation', + 'Authorization bearer header', + 'secret redaction', + 'chat through real cc-connect and bundled Codex', + ], + evidence: compile.command || compile.reason, + reason: compile.reason, + }, + { + id: 'chat-abort-local-openai-compatible', + status: compile.status, + covers: [ + 'long-running local OpenAI-compatible Responses stream', + 'GUI Stop button through Host API chat.abort', + 'session-scoped cc-connect BridgePlatform /stop cancellation', + 'upstream stream closure before completion release', + 'late assistant output suppression', + 'unchanged cc-connect PID and runtime recovery to running state', + ], + evidence: compile.command || compile.reason, + reason: compile.reason, + }, + { + id: 'openai-api-key-provider-model-chat', + status: apiKey.status, + covers: ['OpenAI API key provider profile', 'model propagation', 'secret redaction', 'chat through cc-connect'], + evidence: apiKey.command || apiKey.reason, + reason: apiKey.reason, + }, + { + id: 'feishu-live-channel-lifecycle', + status: feishu.status, + covers: ['Feishu/Lark config mapping', 'agent binding', 'runtime channel status', 'connect/disconnect', 'delete config refresh'], + evidence: feishu.command || feishu.reason, + reason: feishu.reason, + }, + { + id: 'feishu-live-inbound-delivery', + status: feishuInbound.status, + covers: [ + 'sanitized Feishu/Lark inbound marker handoff artifact', + 'real Feishu/Lark tenant message sent by a sandbox chat', + 'cc-connect platform receives the tenant event', + 'managed cc-connect session store records the inbound marker', + 'managed runtime process cleanup after inbound smoke', + ], + evidence: feishuInbound.command || feishuInbound.reason, + reason: feishuInbound.reason, + }, + { + id: 'packaged-oauth-runtime-smoke', + status: packaged.status, + covers: [ + 'packaged resources path', + 'packaged cc-connect manifest and source sha256 integrity', + 'packaged Codex manifest and source sha256 integrity', + 'packaged signed executable version checks', + 'packaged Codex ripgrep helper executable', + 'packaged cc-connect startup', + 'packaged GUI Chat through cc-connect and the managed Codex OAuth launcher', + ], + evidence: packaged.command || packaged.reason, + reason: packaged.reason, + }, + ]; +} + +function coverageRecords(report) { + const inferred = buildCoverage(report); + if (!Array.isArray(report.coverage)) return inferred; + const rows = [...report.coverage]; + const existing = new Set(rows.map((row) => row.id)); + for (const row of inferred) { + if (!existing.has(row.id)) rows.push(row); + } + return rows; +} + +function coverageStatus(status) { + if (status === 'pass') return 'PASS'; + if (status === 'fail') return 'FAIL'; + if (status === 'partial') return 'PARTIAL'; + if (status === 'skipped') return 'SKIPPED'; + return 'NOT RUN'; +} + +function toMarkdown(report) { + const lines = [ + '# cc-connect Local Real Validation Report', + '', + `- Generated at: ${report.generatedAt}`, + `- Overall status: ${report.status.toUpperCase()}`, + `- Runtime matrix status: ${(report.runtimeMatrixStatus ?? report.status).toUpperCase()}`, + '', + '| Check | Status | Message |', + '|---|---|---|', + ...report.checks.map((check) => `| ${markdownCell(check.id)} | ${markdownStatus(check.status)} | ${markdownCell(check.message)} |`), + '', + ]; + + const commands = commandRecords(report); + if (commands.length > 0) { + lines.push( + '## Command Results', + '', + '| Command | Status | Exit | Duration | Reason |', + '|---|---|---:|---:|---|', + ...commands.map((command) => [ + `| ${markdownCell(command.command)}`, + markdownStatus(command.status), + command.exitCode ?? '', + `${command.durationMs ?? 0}ms`, + `${markdownCell(command.reason ?? command.error ?? '')} |`, + ].join(' | ')), + '', + ); + } + + const coverage = coverageRecords(report); + if (coverage.length > 0) { + lines.push( + '## Runtime Parity Coverage', + '', + '| Area | Status | Covers | Evidence | Reason |', + '|---|---|---|---|---|', + ...coverage.map((item) => [ + `| ${markdownCell(item.id)}`, + coverageStatus(item.status), + markdownCell((item.covers ?? []).join(', ')), + markdownCell(item.evidence), + `${markdownCell(item.reason ?? '')} |`, + ].join(' | ')), + '', + ); + } + + const surface = report.ccConnectCliSurface; + if (surface) { + lines.push( + '## cc-connect Upstream CLI Surface', + '', + `- Missing upstream primitives: ${surface.missingPrimitives.length > 0 ? markdownCell(surface.missingPrimitives.join(', ')) : 'none recorded'}`, + '', + '| Area | Evidence |', + '|---|---|', + `| Commands | ${markdownCell(Object.entries(surface.commands).filter(([, value]) => value).map(([key]) => key).join(', '))} |`, + `| Cron | ${markdownCell(Object.entries(surface.cron).filter(([, value]) => value).map(([key]) => key).join(', '))} |`, + `| Sessions | ${markdownCell(Object.entries(surface.sessions).filter(([, value]) => value).map(([key]) => key).join(', '))} |`, + `| Providers | ${markdownCell(Object.entries(surface.providers).filter(([, value]) => value).map(([key]) => key).join(', '))} |`, + `| Feishu/Lark | ${markdownCell(Object.entries(surface.feishu).filter(([, value]) => value).map(([key]) => key).join(', '))} |`, + `| Channel lifecycle | ${markdownCell(Object.entries(surface.channelLifecycle).filter(([, value]) => value).map(([key]) => key).join(', '))} |`, + '', + ); + } + + const readiness = report.replacementReadiness; + if (readiness) { + lines.push( + '## Replacement Readiness', + '', + `- Status: ${readiness.status.toUpperCase()}`, + `- Replacement ready: ${readiness.replacementReady ? 'yes' : 'no'}`, + `- Note: ${markdownCell(readiness.note)}`, + '', + ); + if ((readiness.missingCoverage ?? []).length > 0) { + lines.push( + '| Missing Coverage | Status | Reason | Next Command |', + '|---|---|---|---|', + ...readiness.missingCoverage.map((item) => [ + `| ${markdownCell(item.id)}`, + coverageStatus(item.status), + markdownCell(item.reason || item.evidence || ''), + `${markdownCell(item.nextCommand)} |`, + ].join(' | ')), + '', + ); + } + } + + if ((report.replacementContract ?? []).length > 0) { + lines.push( + '## Replacement Contract Checklist', + '', + '| ID | Area | Status | Required For Local Gate | Expected State | Requirement | Evidence | Next Action |', + '|---|---|---|---|---|---|---|---|', + ...report.replacementContract.map((item) => [ + `| ${markdownCell(item.id)}`, + markdownCell(item.area), + coverageStatus(item.status), + item.requiredForLocalReplacementGate ? 'yes' : 'no', + markdownCell(item.expectedState), + markdownCell(item.requirement), + markdownCell(item.evidence), + `${markdownCell(item.nextAction ?? '')} |`, + ].join(' | ')), + '', + ); + } + + const missing = preconditionRecords(report); + if (missing.length > 0) { + lines.push( + '## Missing Preconditions', + '', + '| ID | Required | Optional | Next Command | Note |', + '|---|---|---|---|---|', + ...missing.map((item) => [ + `| ${markdownCell(item.id)}`, + markdownList(item.required), + markdownList(item.optional), + markdownCell(item.nextCommand), + `${markdownCell(item.note)} |`, + ].join(' | ')), + '', + ); + } + + if ((report.nextActions ?? []).length > 0) { + lines.push( + '## Next Actions', + '', + '| ID | Type | Priority | Required | Optional | Command or Action | Reason |', + '|---|---|---|---|---|---|---|', + ...report.nextActions.map((item) => [ + `| ${markdownCell(item.id)}`, + markdownCell(item.type), + markdownCell(item.priority), + markdownList(item.required), + markdownList(item.optional), + markdownCell(item.command), + `${markdownCell(item.reason)} |`, + ].join(' | ')), + '', + ); + } + + if ((report.validationGaps ?? []).length > 0) { + lines.push( + '## Validation Gaps', + '', + '| ID | Area | Priority | Status | Blocks Local Gate | Required | Optional | Next Command or Action | Reason |', + '|---|---|---|---|---|---|---|---|---|', + ...report.validationGaps.map((item) => [ + `| ${markdownCell(item.id)}`, + markdownCell(item.area), + markdownCell(item.priority), + markdownCell(item.status), + item.requiredForLocalReplacementGate ? 'yes' : 'no', + markdownList(item.required), + markdownList(item.optional), + markdownCell(item.nextCommand), + `${markdownCell(item.reason)} |`, + ].join(' | ')), + '', + ); + } + + lines.push( + '## Notes', + '', + '- Secret values are not written to this report; environment entries record only presence and length.', + '- Untracked and gitignored local env files may be loaded for child commands, but the report records only file names, variable names, tracked state, and gitignore safety.', + '- Explicit env files outside the repository may be loaded, but reports do not include their absolute paths.', + '- Real OAuth/API-key/Feishu checks remain explicit opt-in validation paths and are not default CI gates.', + '- Packaged real OAuth is also opt-in because it uses the caller-selected Codex auth source.', + '- Use `--strict-real` when a release candidate requires every real credential precondition to be configured.', + '', + ); + + return lines.join('\n'); +} + +function toConsoleSummaryLines(report, options = {}) { + const focusCoverageIds = new Set(options.focusCoverageIds ?? []); + const shouldIncludeCoverage = (id) => focusCoverageIds.size === 0 || focusCoverageIds.has(id); + const lines = []; + const missing = preconditionRecords(report); + if (missing.length > 0) { + lines.push('Missing preconditions:'); + for (const item of missing) { + const required = (item.required ?? []).join(', ') || 'none'; + const optional = (item.optional ?? []).join(', ') || 'none'; + lines.push(`- ${item.id}: required=${required}; optional=${optional}; next=${item.nextCommand || 'n/a'}`); + } + } + + const missingCoverage = (report.replacementReadiness?.missingCoverage ?? []) + .filter((item) => shouldIncludeCoverage(item.id)); + if (missingCoverage.length > 0) { + lines.push('Missing replacement coverage:'); + for (const item of missingCoverage) { + const reason = item.reason || item.evidence || 'Required replacement-readiness coverage is not PASS.'; + lines.push(`- ${item.id}: ${item.status}; next=${item.nextCommand || 'n/a'}; reason=${reason}`); + } + } + + const nextActions = (report.nextActions ?? []) + .filter((item) => { + if (focusCoverageIds.size === 0) return true; + if (item.type !== 'coverage') return item.type === 'precondition'; + return focusCoverageIds.has(item.id.replace(/^verify-/, '')); + }); + if (nextActions.length > 0) { + lines.push('Next actions:'); + for (const item of nextActions) { + lines.push(`- ${item.id}: ${item.command || item.reason || 'n/a'}`); + } + } + + return lines; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log(usage()); + return; + } + const { env: loadedEnv, summaries: envFileSummaries } = await loadLocalEnvFiles(args); + const effectiveEnv = { ...process.env, ...loadedEnv }; + const report = await buildReport(args, effectiveEnv, envFileSummaries); + + console.log(`cc-connect local real validation status: ${report.status.toUpperCase()}`); + console.log(`cc-connect runtime matrix status: ${(report.runtimeMatrixStatus ?? report.status).toUpperCase()}`); + const focusCoverageIds = args.externalGatesOnly ? requiredCoverageIds(args.requireCoverage) : []; + for (const line of toConsoleSummaryLines(report, { focusCoverageIds })) console.log(line); + if (shouldWriteReport(args)) { + await mkdir(reportDir, { recursive: true }); + await writeFile(jsonReportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + await writeFile(markdownReportPath, toMarkdown(report), 'utf8'); + console.log(`Wrote ${jsonReportPath}`); + console.log(`Wrote ${markdownReportPath}`); + if (shouldWriteHandoff(args)) { + await writeFile(externalGateHandoffPath, toExternalGateHandoffMarkdown(report), 'utf8'); + await writeFile(externalGateHandoffJsonPath, toExternalGateHandoffJson(report), 'utf8'); + console.log(`Wrote ${externalGateHandoffPath}`); + console.log(`Wrote ${externalGateHandoffJsonPath}`); + } + } else { + console.log('No report artifacts were written (--no-write).'); + } + if (report.status === 'fail') process.exit(1); +} + +function isCliEntryPoint() { + return process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false; +} + +export { + COVERAGE_IDS, + REPLACEMENT_REQUIRED_COVERAGE_IDS, + RESIDUAL_VALIDATION_GAPS, + REPLACEMENT_CONTRACT_ITEMS, + buildCoverage, + coverageRecords, + buildNextActions, + buildReplacementContract, + buildReplacementReadiness, + buildValidationGaps, + analyzeCcConnectCliSurface, + classifyCommandExit, + codexAuthExpirySummary, + extraLocalRealEnvFiles, + isPathInsideRoot, + localEnvFileSafety, + loadLocalEnvFiles, + missingPreconditions, + openAiApiKeyCandidateSummary, + openAiApiKeyPreconditionMessage, + parseArgs, + replacementReadinessCheck, + requiredCoverageCheck, + requiredCoverageIds, + resolveOpenAiApiKeyEnv, + runtimeMatrixStatus, + sanitizeReportPaths, + shouldWriteHandoff, + shouldWriteReport, + toConsoleSummaryLines, + summarizeCommandAttempts, + toMarkdown, +}; + +if (isCliEntryPoint()) { + main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); + }); +} diff --git a/scripts/verify-packaged-runtime-resources.mjs b/scripts/verify-packaged-runtime-resources.mjs new file mode 100644 index 000000000..b07d6a873 --- /dev/null +++ b/scripts/verify-packaged-runtime-resources.mjs @@ -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= --platform= --arch='); + } + 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); + } +} diff --git a/scripts/verify-runtime-bundles.mjs b/scripts/verify-runtime-bundles.mjs new file mode 100644 index 000000000..50e3bef02 --- /dev/null +++ b/scripts/verify-runtime-bundles.mjs @@ -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.'); +} diff --git a/shared/chat-runtime-events.ts b/shared/chat-runtime-events.ts index 18694c593..408d12670 100644 --- a/shared/chat-runtime-events.ts +++ b/shared/chat-runtime-events.ts @@ -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; + }>; }); diff --git a/shared/chat/types.ts b/shared/chat/types.ts index dec4a62c7..299c6db4d 100644 --- a/shared/chat/types.ts +++ b/shared/chat/types.ts @@ -75,6 +75,7 @@ export interface ChatSession { key: string; label?: string; displayName?: string; + agentId?: string; derivedTitle?: string; lastMessagePreview?: string; thinkingLevel?: string; @@ -143,7 +144,7 @@ export interface ChatState { thinkingLevel: string | null; // Actions - loadSessions: () => Promise; + loadSessions: (force?: boolean) => Promise; switchSession: (key: string) => void; newSession: () => void; deleteSession: (key: string) => Promise; diff --git a/shared/host-api/contract.ts b/shared/host-api/contract.ts index d24012194..ec93757a5 100644 --- a/shared/host-api/contract.ts +++ b/shared/host-api/contract.ts @@ -1,7 +1,7 @@ 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'; export type JsonRecord = Record; @@ -18,6 +18,8 @@ export type OpenClawDoctorResult = HostSuccess & { cwd: string; durationMs: number; timedOut?: boolean; + auditPath?: string; + audit?: JsonRecord; }; export type OpenClawDoctorPayload = { mode: OpenClawDoctorMode }; @@ -103,6 +105,7 @@ export type SettingsSnapshot = Partial<{ launchAtStartup: boolean; telemetryEnabled: boolean; gatewayAutoStart: boolean; + runtimeKind: RuntimeKind; gatewayPort: number; proxyEnabled: boolean; proxyServer: string; @@ -240,7 +243,12 @@ export type ChannelConfiguredResult = HostSuccess & { channels?: Array }>; export type SkillKeyPayload = { skillKey: string }; export type SkillUpdateConfigPayload = SkillKeyPayload & { @@ -639,9 +683,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; @@ -650,10 +698,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; @@ -785,6 +834,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[]; @@ -818,13 +870,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; diff --git a/shared/i18n/locales/en/agents.json b/shared/i18n/locales/en/agents.json index b4401cf99..ecd90936e 100644 --- a/shared/i18n/locales/en/agents.json +++ b/shared/i18n/locales/en/agents.json @@ -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}}" } -} \ No newline at end of file +} diff --git a/shared/i18n/locales/en/channels.json b/shared/i18n/locales/en/channels.json index 6f603e63c..6f4ca6c7b 100644 --- a/shared/i18n/locales/en/channels.json +++ b/shared/i18n/locales/en/channels.json @@ -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": [ diff --git a/shared/i18n/locales/en/chat.json b/shared/i18n/locales/en/chat.json index 355cf9ffc..ec14abfc6 100644 --- a/shared/i18n/locales/en/chat.json +++ b/shared/i18n/locales/en/chat.json @@ -157,11 +157,19 @@ "title": "Execution Graph", "branchLabel": "branch", "thinkingLabel": "Thinking", + "interactionLabel": "Choose an action", "errorLabel": "Error", "imageGenerateLabel": "Image generation", "agentRun": "{{agent}} execution", "collapsedSummary": "{{toolCount}} tool calls · {{processCount}} process messages", - "collapseAction": "Collapse execution graph" + "collapseAction": "Collapse execution graph", + "approval": { + "allow": "Allow once", + "allowAll": "Allow for this run", + "deny": "Deny", + "answer": "Answer", + "failed": "Could not send the approval response" + } }, "imageGeneration": { "generating": "Generating image, please wait…", @@ -205,4 +213,4 @@ "fallback": "Question {{number}}", "moreHint": "{{count}} more questions not shown" } -} \ No newline at end of file +} diff --git a/shared/i18n/locales/en/common.json b/shared/i18n/locales/en/common.json index 6ffaf583a..f6f1d5f4b 100644 --- a/shared/i18n/locales/en/common.json +++ b/shared/i18n/locales/en/common.json @@ -12,6 +12,7 @@ "models": "Models", "deleteSessionConfirm": "Delete \"{{label}}\"?", "openClawPage": "OpenClaw Page", + "ccConnectPage": "CC Connect Page", "openClawDreams": "Dreams", "renameSession": "Rename", "deleteSession": "Delete", diff --git a/shared/i18n/locales/en/settings.json b/shared/i18n/locales/en/settings.json index 6359c6cf1..2f8c1b60e 100644 --- a/shared/i18n/locales/en/settings.json +++ b/shared/i18n/locales/en/settings.json @@ -125,6 +125,33 @@ "step2": "Open the login page in your browser.", "step3": "Paste the code to approve access.", "requestingCode": "Requesting secure login code..." + }, + "codexOAuth": { + "title": "cc-connect Codex OAuth", + "description": "Managed Codex auth used by the cc-connect Codex backend.", + "managedAuth": "Managed auth", + "providerSecret": "Provider secret", + "localAuth": "Local Codex auth", + "ok": "OK", + "missing": "Missing", + "refresh": "Refresh", + "copyPath": "Copy path", + "openFolder": "Open folder", + "signInAgain": "Sign in again", + "importLocal": "Import local Codex auth", + "logout": "Logout", + "manualPrompt": "Complete sign-in in the browser, then paste the callback URL or code.", + "openAuthorization": "Open authorization page", + "manualCodePlaceholder": "Paste callback URL or code", + "submitCode": "Submit code", + "statusFailed": "Failed to read Codex OAuth status", + "importFailed": "Failed to import Codex OAuth", + "logoutFailed": "Failed to logout Codex OAuth", + "signInFailed": "Failed to start Codex OAuth sign-in", + "imported": "Local Codex auth imported", + "loggedOut": "Codex OAuth logged out", + "signInSuccess": "Codex OAuth sign-in completed", + "pathCopied": "Path copied" } }, "gateway": { @@ -155,6 +182,34 @@ "proxySaved": "Proxy settings saved", "proxySaveFailed": "Failed to save proxy settings" }, + "runtime": { + "title": "Runtime", + "description": "Choose the managed agent runtime. OpenClaw remains the default fallback.", + "changed": "Runtime selection saved. Restart the runtime to apply it.", + "restart": "Restart Runtime", + "restartHint": "Switching runtime stops the previous process; restart applies the selected runtime.", + "configDir": "Config directory", + "supported": "supported", + "unsupported": "unavailable", + "operationGapsTitle": "Limited or unavailable runtime operations", + "degraded": "Limited", + "openclawOnly": "This OpenClaw-specific tool is unavailable for the selected runtime.", + "kinds": { + "openclaw": "OpenClaw", + "cc-connect": "cc-connect" + }, + "capabilities": { + "chat": "Chat", + "sessions": "Sessions", + "history": "History", + "providers": "Providers", + "channels": "Channels", + "cron": "Cron", + "logs": "Logs", + "skills": "Skills", + "doctor": "Doctor" + } + }, "updates": { "title": "Updates", "description": "Keep ClawX up to date", @@ -218,8 +273,8 @@ "cliPowershell": "PowerShell command.", "cmdUnavailable": "Command unavailable", "cmdCopied": "CLI command copied", - "doctor": "OpenClaw Doctor", - "doctorDesc": "Run `openclaw doctor` and inspect the raw diagnostic output.", + "doctor": "Runtime Doctor", + "doctorDesc": "Run the selected runtime's doctor command and inspect the raw diagnostic output.", "runDoctor": "Run Doctor", "runDoctorFix": "Run Doctor Fix", "doctorSucceeded": "OpenClaw doctor completed", @@ -241,6 +296,10 @@ "doctorStdout": "Stdout", "doctorStderr": "Stderr", "doctorOutputEmpty": "(empty)", + "runtimeDiagnostics": "Runtime Diagnostics", + "runtimeDiagnosticsDesc": "Copy a redacted snapshot of runtime status, capabilities, channel state, logs, and cc-connect managed paths.", + "runtimeDiagnosticsCopied": "Runtime diagnostics copied", + "runtimeDiagnosticsCopyFailed": "Failed to copy runtime diagnostics: {{error}}", "telemetryViewer": "Telemetry Viewer", "telemetryViewerDesc": "Local-only UX/performance telemetry, latest 200 entries.", "telemetryAggregated": "Top Events", diff --git a/shared/i18n/locales/en/skills.json b/shared/i18n/locales/en/skills.json index d60be4406..421e97441 100644 --- a/shared/i18n/locales/en/skills.json +++ b/shared/i18n/locales/en/skills.json @@ -3,6 +3,10 @@ "subtitle": "Browse and manage AI capabilities", "refresh": "Refresh", "openFolder": "Open Skills Folder", + "openRuntimeFolder": "Open Runtime Skills Folder", + "runtimeTarget": { + "mirror": "Runtime mirror: {{path}}" + }, "gatewayWarning": "Gateway is not running. Local skills are still available, but runtime status is unavailable.", "gatewayStarting": "Gateway is starting. Local skills are available now; runtime status will appear once it is ready.", "tabs": { diff --git a/shared/i18n/locales/ja/agents.json b/shared/i18n/locales/ja/agents.json index 0159dbcd7..6641e5afb 100644 --- a/shared/i18n/locales/ja/agents.json +++ b/shared/i18n/locales/ja/agents.json @@ -44,6 +44,15 @@ "closeWithoutSaving": "保存せずに閉じる", "saveModelOverride": "モデルを保存", "useDefaultModel": "デフォルトモデルを使用", + "permissionModeLabel": "ツール権限", + "permissionModes": { + "full-auto": "フルオート", + "suggest": "承認を求める" + }, + "permissionModeDescriptions": { + "full-auto": "Codex は管理 workspace 内で確認なしにツールを使用できます。", + "suggest": "Codex はツール使用前に承認を求め、承認までは読み取り専用サンドボックスで実行します。" + }, "channelsTitle": "Channels", "channelsDescription": "この一覧は読み取り専用です。チャンネルアカウントと紐付けは Channels ページで管理してください。", "mainAccount": "メインアカウント", @@ -69,9 +78,10 @@ "agentModelUpdateFailed": "Agent モデルの更新に失敗しました: {{error}}", "agentModelReset": "Agent モデルをデフォルトに戻しました", "agentModelResetFailed": "Agent モデルのリセットに失敗しました: {{error}}", + "agentRuntimeUpdated": "Agent runtime 設定を更新しました", "channelAssigned": "{{channel}} を Agent に割り当てました", "channelAssignFailed": "Channel の割り当てに失敗しました: {{error}}", "channelRemoved": "{{channel}} を削除しました", "channelRemoveFailed": "Channel の削除に失敗しました: {{error}}" } -} \ No newline at end of file +} diff --git a/shared/i18n/locales/ja/channels.json b/shared/i18n/locales/ja/channels.json index 3cb80ca5a..181d619dd 100644 --- a/shared/i18n/locales/ja/channels.json +++ b/shared/i18n/locales/ja/channels.json @@ -240,6 +240,11 @@ "appSecret": { "label": "App Secret", "placeholder": "アプリのシークレット" + }, + "adminUsers": { + "label": "Cron 管理者 ID", + "placeholder": "ou_xxx, ou_yyy", + "description": "Feishu/Lark から cc-connect の Cron ジョブを作成、編集、実行、削除できるユーザー ID をカンマ区切りで指定します。" } }, "instructions": [ diff --git a/shared/i18n/locales/ja/chat.json b/shared/i18n/locales/ja/chat.json index 68dcb2068..976dd591a 100644 --- a/shared/i18n/locales/ja/chat.json +++ b/shared/i18n/locales/ja/chat.json @@ -157,11 +157,19 @@ "title": "実行グラフ", "branchLabel": "branch", "thinkingLabel": "考え中", + "interactionLabel": "操作を選択", "errorLabel": "エラー", "imageGenerateLabel": "画像生成", "agentRun": "{{agent}} の実行", "collapsedSummary": "ツール呼び出し {{toolCount}} 件 · プロセスメッセージ {{processCount}} 件", - "collapseAction": "実行グラフを折りたたむ" + "collapseAction": "実行グラフを折りたたむ", + "approval": { + "allow": "今回のみ許可", + "allowAll": "この実行中はすべて許可", + "deny": "拒否", + "answer": "回答", + "failed": "承認応答を送信できませんでした" + } }, "imageGeneration": { "generating": "画像を生成しています。少々お待ちください…", @@ -205,4 +213,4 @@ "fallback": "質問 {{number}}", "moreHint": "さらに {{count}} 件の質問は表示されていません" } -} \ No newline at end of file +} diff --git a/shared/i18n/locales/ja/common.json b/shared/i18n/locales/ja/common.json index f92025499..9cd6880c6 100644 --- a/shared/i18n/locales/ja/common.json +++ b/shared/i18n/locales/ja/common.json @@ -12,6 +12,7 @@ "models": "モデル", "deleteSessionConfirm": "「{{label}}」を削除しますか?", "openClawPage": "OpenClaw ページ", + "ccConnectPage": "CC Connect ページ", "openClawDreams": "夢", "renameSession": "名前を変更", "deleteSession": "削除", diff --git a/shared/i18n/locales/ja/settings.json b/shared/i18n/locales/ja/settings.json index 6fb8e90d5..3ccd31612 100644 --- a/shared/i18n/locales/ja/settings.json +++ b/shared/i18n/locales/ja/settings.json @@ -125,6 +125,33 @@ "step2": "ブラウザでログインページを開いてください。", "step3": "コードを貼り付けてアクセスを承認してください。", "requestingCode": "セキュアログインコードを取得中..." + }, + "codexOAuth": { + "title": "cc-connect Codex OAuth", + "description": "cc-connect の Codex backend が使用する管理済み Codex 認証です。", + "managedAuth": "管理済み認証", + "providerSecret": "プロバイダーシークレット", + "localAuth": "ローカル Codex 認証", + "ok": "OK", + "missing": "未設定", + "refresh": "更新", + "copyPath": "パスをコピー", + "openFolder": "フォルダーを開く", + "signInAgain": "再サインイン", + "importLocal": "ローカル Codex 認証をインポート", + "logout": "ログアウト", + "manualPrompt": "ブラウザでサインインを完了し、コールバック URL またはコードを貼り付けてください。", + "openAuthorization": "認可ページを開く", + "manualCodePlaceholder": "コールバック URL またはコードを貼り付け", + "submitCode": "コードを送信", + "statusFailed": "Codex OAuth 状態の読み取りに失敗しました", + "importFailed": "Codex OAuth のインポートに失敗しました", + "logoutFailed": "Codex OAuth のログアウトに失敗しました", + "signInFailed": "Codex OAuth サインインの開始に失敗しました", + "imported": "ローカル Codex 認証をインポートしました", + "loggedOut": "Codex OAuth からログアウトしました", + "signInSuccess": "Codex OAuth サインインが完了しました", + "pathCopied": "パスをコピーしました" } }, "gateway": { @@ -155,6 +182,34 @@ "proxySaved": "プロキシ設定を保存しました", "proxySaveFailed": "プロキシ設定の保存に失敗しました" }, + "runtime": { + "title": "Runtime", + "description": "ClawX が管理する agent runtime を選択します。OpenClaw は既定のフォールバックです。", + "changed": "Runtime の選択を保存しました。適用するには runtime を再起動してください。", + "restart": "Runtime を再起動", + "restartHint": "runtime を切り替えると前のプロセスは停止します。再起動後に選択した runtime が使われます。", + "configDir": "設定ディレクトリ", + "supported": "対応", + "unsupported": "利用不可", + "operationGapsTitle": "制限中または利用できない runtime 操作", + "degraded": "制限あり", + "openclawOnly": "この OpenClaw 専用ツールは選択中の runtime では利用できません。", + "kinds": { + "openclaw": "OpenClaw", + "cc-connect": "cc-connect" + }, + "capabilities": { + "chat": "チャット", + "sessions": "セッション", + "history": "履歴", + "providers": "プロバイダー", + "channels": "チャンネル", + "cron": "Cron", + "logs": "ログ", + "skills": "Skills", + "doctor": "Doctor" + } + }, "updates": { "title": "アップデート", "description": "ClawX を最新に保つ", @@ -218,8 +273,8 @@ "cliPowershell": "PowerShell コマンド。", "cmdUnavailable": "コマンドが利用できません", "cmdCopied": "CLI コマンドをコピーしました", - "doctor": "OpenClaw Doctor", - "doctorDesc": "`openclaw doctor` を実行して診断の生出力を確認します。", + "doctor": "Runtime Doctor", + "doctorDesc": "選択中 runtime の doctor コマンドを実行して診断の生出力を確認します。", "runDoctor": "Doctor を実行", "runDoctorFix": "Doctor 修復を実行", "doctorSucceeded": "OpenClaw doctor が完了しました", @@ -241,6 +296,10 @@ "doctorStdout": "標準出力", "doctorStderr": "標準エラー", "doctorOutputEmpty": "(空)", + "runtimeDiagnostics": "Runtime 診断", + "runtimeDiagnosticsDesc": "Runtime 状態、機能、チャンネル状態、ログ、cc-connect 管理パスを含むマスク済みスナップショットをコピーします。", + "runtimeDiagnosticsCopied": "Runtime 診断をコピーしました", + "runtimeDiagnosticsCopyFailed": "Runtime 診断のコピーに失敗しました: {{error}}", "telemetryViewer": "テレメトリビューア", "telemetryViewerDesc": "ローカル専用の UX/性能テレメトリ(最新 200 件)。", "telemetryAggregated": "イベント集計", diff --git a/shared/i18n/locales/ja/skills.json b/shared/i18n/locales/ja/skills.json index 16d42907b..47b22ae7e 100644 --- a/shared/i18n/locales/ja/skills.json +++ b/shared/i18n/locales/ja/skills.json @@ -3,6 +3,10 @@ "subtitle": "AI機能の閲覧と管理", "refresh": "更新", "openFolder": "スキルフォルダを開く", + "openRuntimeFolder": "ランタイムのスキルフォルダを開く", + "runtimeTarget": { + "mirror": "ランタイムミラー: {{path}}" + }, "gatewayWarning": "ゲートウェイは停止しています。ローカルスキルは利用できますが、ランタイム状態は取得できません。", "gatewayStarting": "ゲートウェイを起動しています。ローカルスキルはすでに利用でき、ランタイム状態は準備完了後に反映されます。", "tabs": { diff --git a/shared/i18n/locales/ru/agents.json b/shared/i18n/locales/ru/agents.json index 36961178a..250d54fa0 100644 --- a/shared/i18n/locales/ru/agents.json +++ b/shared/i18n/locales/ru/agents.json @@ -44,6 +44,15 @@ "closeWithoutSaving": "Закрыть без сохранения", "saveModelOverride": "Сохранить модель", "useDefaultModel": "Использовать модель по умолчанию", + "permissionModeLabel": "Разрешения инструментов", + "permissionModes": { + "full-auto": "Полный автоматический режим", + "suggest": "Запрашивать разрешение" + }, + "permissionModeDescriptions": { + "full-auto": "Codex может использовать инструменты в управляемой рабочей папке без запросов.", + "suggest": "Codex запрашивает разрешение перед использованием инструментов и до одобрения работает в песочнице только для чтения." + }, "channelsTitle": "Каналы", "channelsDescription": "Этот список доступен только для чтения. Управляйте учётными записями каналов и привязками на странице Каналов.", "mainAccount": "Основная учётная запись", @@ -69,9 +78,10 @@ "agentModelUpdateFailed": "Не удалось обновить модель агента: {{error}}", "agentModelReset": "Модель агента сброшена до значения по умолчанию", "agentModelResetFailed": "Не удалось сбросить модель агента: {{error}}", + "agentRuntimeUpdated": "Настройки среды агента обновлены", "channelAssigned": "{{channel}} назначен агенту", "channelAssignFailed": "Не удалось назначить канал: {{error}}", "channelRemoved": "{{channel}} удалён", "channelRemoveFailed": "Не удалось удалить канал: {{error}}" } -} \ No newline at end of file +} diff --git a/shared/i18n/locales/ru/channels.json b/shared/i18n/locales/ru/channels.json index 21c17b016..c89c6115e 100644 --- a/shared/i18n/locales/ru/channels.json +++ b/shared/i18n/locales/ru/channels.json @@ -240,6 +240,11 @@ "appSecret": { "label": "App Secret", "placeholder": "Секрет вашего приложения" + }, + "adminUsers": { + "label": "ID администраторов Cron", + "placeholder": "ou_xxx, ou_yyy", + "description": "ID пользователей через запятую, которым разрешено создавать, изменять, запускать и удалять задания cc-connect Cron из Feishu/Lark." } }, "instructions": [ diff --git a/shared/i18n/locales/ru/chat.json b/shared/i18n/locales/ru/chat.json index 2056ad0ae..99682444c 100644 --- a/shared/i18n/locales/ru/chat.json +++ b/shared/i18n/locales/ru/chat.json @@ -157,11 +157,19 @@ "title": "Граф выполнения", "branchLabel": "ветвь", "thinkingLabel": "Думаю", + "interactionLabel": "Выберите действие", "errorLabel": "Ошибка модели", "imageGenerateLabel": "Генерация изображения", "agentRun": "Выполнение {{agent}}", "collapsedSummary": "Вызовов инструментов: {{toolCount}} · Промежуточных сообщений: {{processCount}}", - "collapseAction": "Свернуть граф выполнения" + "collapseAction": "Свернуть граф выполнения", + "approval": { + "allow": "Разрешить один раз", + "allowAll": "Разрешить для этого запуска", + "deny": "Запретить", + "answer": "Ответить", + "failed": "Не удалось отправить ответ на запрос разрешения" + } }, "imageGeneration": { "generating": "Изображение генерируется, подождите…", @@ -205,4 +213,4 @@ "fallback": "Вопрос {{number}}", "moreHint": "Ещё {{count}} вопросов не показано" } -} \ No newline at end of file +} diff --git a/shared/i18n/locales/ru/common.json b/shared/i18n/locales/ru/common.json index ed1128467..8506558fd 100644 --- a/shared/i18n/locales/ru/common.json +++ b/shared/i18n/locales/ru/common.json @@ -12,6 +12,7 @@ "models": "Модели", "deleteSessionConfirm": "Удалить \"{{label}}\"?", "openClawPage": "Страница OpenClaw", + "ccConnectPage": "Страница CC Connect", "openClawDreams": "Dreams", "renameSession": "Переименовать", "deleteSession": "Удалить", diff --git a/shared/i18n/locales/ru/settings.json b/shared/i18n/locales/ru/settings.json index ea8e93bed..ce608833d 100644 --- a/shared/i18n/locales/ru/settings.json +++ b/shared/i18n/locales/ru/settings.json @@ -125,6 +125,33 @@ "step2": "Откройте страницу входа в браузере.", "step3": "Вставьте код для подтверждения доступа.", "requestingCode": "Запрос кода безопасного входа..." + }, + "codexOAuth": { + "title": "cc-connect Codex OAuth", + "description": "Управляемая авторизация Codex для backend Codex в cc-connect.", + "managedAuth": "Управляемая авторизация", + "providerSecret": "Секрет провайдера", + "localAuth": "Локальная авторизация Codex", + "ok": "OK", + "missing": "Нет", + "refresh": "Обновить", + "copyPath": "Копировать путь", + "openFolder": "Открыть папку", + "signInAgain": "Войти снова", + "importLocal": "Импортировать локальную авторизацию Codex", + "logout": "Выйти", + "manualPrompt": "Завершите вход в браузере, затем вставьте callback URL или код.", + "openAuthorization": "Открыть страницу авторизации", + "manualCodePlaceholder": "Вставьте callback URL или код", + "submitCode": "Отправить код", + "statusFailed": "Не удалось прочитать статус Codex OAuth", + "importFailed": "Не удалось импортировать Codex OAuth", + "logoutFailed": "Не удалось выйти из Codex OAuth", + "signInFailed": "Не удалось начать вход Codex OAuth", + "imported": "Локальная авторизация Codex импортирована", + "loggedOut": "Выполнен выход из Codex OAuth", + "signInSuccess": "Вход Codex OAuth завершён", + "pathCopied": "Путь скопирован" } }, "gateway": { @@ -155,6 +182,34 @@ "proxySaved": "Настройки прокси сохранены", "proxySaveFailed": "Не удалось сохранить настройки прокси" }, + "runtime": { + "title": "Runtime", + "description": "Выберите управляемую ClawX среду agent runtime. OpenClaw остаётся резервным вариантом по умолчанию.", + "changed": "Выбор runtime сохранён. Перезапустите runtime, чтобы применить его.", + "restart": "Перезапустить Runtime", + "restartHint": "При смене runtime предыдущий процесс останавливается; перезапуск применит выбранный runtime.", + "configDir": "Каталог конфигурации", + "supported": "доступно", + "unsupported": "недоступно", + "operationGapsTitle": "Ограниченные или недоступные операции runtime", + "degraded": "Ограничено", + "openclawOnly": "Этот инструмент OpenClaw недоступен для выбранного runtime.", + "kinds": { + "openclaw": "OpenClaw", + "cc-connect": "cc-connect" + }, + "capabilities": { + "chat": "Чат", + "sessions": "Сессии", + "history": "История", + "providers": "Провайдеры", + "channels": "Каналы", + "cron": "Cron", + "logs": "Логи", + "skills": "Skills", + "doctor": "Doctor" + } + }, "updates": { "title": "Обновления", "description": "Поддерживайте ClawX в актуальном состоянии", @@ -218,8 +273,8 @@ "cliPowershell": "Команда PowerShell.", "cmdUnavailable": "Команда недоступна", "cmdCopied": "CLI-команда скопирована", - "doctor": "OpenClaw Doctor", - "doctorDesc": "Запустите `openclaw doctor` и просмотрите вывод диагностики.", + "doctor": "Runtime Doctor", + "doctorDesc": "Запустите doctor выбранного runtime и просмотрите вывод диагностики.", "runDoctor": "Запустить Doctor", "runDoctorFix": "Запустить исправление Doctor", "doctorSucceeded": "OpenClaw doctor завершён", @@ -241,6 +296,10 @@ "doctorStdout": "Стандартный вывод", "doctorStderr": "Стандартная ошибка", "doctorOutputEmpty": "(пусто)", + "runtimeDiagnostics": "Диагностика runtime", + "runtimeDiagnosticsDesc": "Копирует отредактированный снимок статуса runtime, возможностей, каналов, журналов и управляемых путей cc-connect.", + "runtimeDiagnosticsCopied": "Диагностика runtime скопирована", + "runtimeDiagnosticsCopyFailed": "Не удалось скопировать диагностику runtime: {{error}}", "telemetryViewer": "Просмотр телеметрии", "telemetryViewerDesc": "Локальная телеметрия UX/производительности, последние 200 записей.", "telemetryAggregated": "Топ событий", diff --git a/shared/i18n/locales/ru/skills.json b/shared/i18n/locales/ru/skills.json index a1b9a7feb..4ad09e1cf 100644 --- a/shared/i18n/locales/ru/skills.json +++ b/shared/i18n/locales/ru/skills.json @@ -3,6 +3,10 @@ "subtitle": "Просмотр и управление AI-возможностями", "refresh": "Обновить", "openFolder": "Открыть папку навыков", + "openRuntimeFolder": "Открыть папку навыков рантайма", + "runtimeTarget": { + "mirror": "Зеркало рантайма: {{path}}" + }, "gatewayWarning": "Шлюз не запущен. Локальные навыки по-прежнему доступны, но статус рантайма недоступен.", "gatewayStarting": "Шлюз запускается. Локальные навыки уже доступны, а статус рантайма появится после готовности шлюза.", "tabs": { diff --git a/shared/i18n/locales/zh/agents.json b/shared/i18n/locales/zh/agents.json index 4843f9a93..00b6875b8 100644 --- a/shared/i18n/locales/zh/agents.json +++ b/shared/i18n/locales/zh/agents.json @@ -44,6 +44,15 @@ "closeWithoutSaving": "不保存并关闭", "saveModelOverride": "保存模型", "useDefaultModel": "使用默认模型", + "permissionModeLabel": "工具权限", + "permissionModes": { + "full-auto": "全自动", + "suggest": "需要审批" + }, + "permissionModeDescriptions": { + "full-auto": "Codex 可在托管 workspace 内直接使用工具,无需逐次确认。", + "suggest": "Codex 使用工具前会请求审批,批准前运行在只读沙箱中。" + }, "channelsTitle": "频道", "channelsDescription": "该列表为只读。频道账号与绑定关系请在 Channels 页面管理。", "mainAccount": "主账号", @@ -69,9 +78,10 @@ "agentModelUpdateFailed": "更新 Agent 模型失败:{{error}}", "agentModelReset": "Agent 模型已恢复为默认", "agentModelResetFailed": "恢复 Agent 默认模型失败:{{error}}", + "agentRuntimeUpdated": "Agent runtime 设置已更新", "channelAssigned": "{{channel}} 已分配给 Agent", "channelAssignFailed": "分配频道失败:{{error}}", "channelRemoved": "{{channel}} 已移除", "channelRemoveFailed": "移除频道失败:{{error}}" } -} \ No newline at end of file +} diff --git a/shared/i18n/locales/zh/channels.json b/shared/i18n/locales/zh/channels.json index 36903393e..9fe87e13a 100644 --- a/shared/i18n/locales/zh/channels.json +++ b/shared/i18n/locales/zh/channels.json @@ -240,6 +240,11 @@ "appSecret": { "label": "应用密钥 (App Secret)", "placeholder": "您的应用密钥" + }, + "adminUsers": { + "label": "Cron 管理员 ID", + "placeholder": "ou_xxx, ou_yyy", + "description": "允许在飞书/Lark 中创建、修改、执行或删除 cc-connect 定时任务的用户 ID,多个 ID 用逗号分隔。" } }, "instructions": [ diff --git a/shared/i18n/locales/zh/chat.json b/shared/i18n/locales/zh/chat.json index b0e167881..2a290f16f 100644 --- a/shared/i18n/locales/zh/chat.json +++ b/shared/i18n/locales/zh/chat.json @@ -157,11 +157,19 @@ "title": "执行关系图", "branchLabel": "分支", "thinkingLabel": "思考中", + "interactionLabel": "选择操作", "errorLabel": "模型调用失败", "imageGenerateLabel": "图片生成", "agentRun": "{{agent}} 执行", "collapsedSummary": "{{toolCount}} 个工具调用,{{processCount}} 条过程消息", - "collapseAction": "收起执行关系图" + "collapseAction": "收起执行关系图", + "approval": { + "allow": "允许一次", + "allowAll": "本次运行全部允许", + "deny": "拒绝", + "answer": "回答", + "failed": "无法发送审批响应" + } }, "imageGeneration": { "generating": "图片生成中,请稍候…", diff --git a/shared/i18n/locales/zh/common.json b/shared/i18n/locales/zh/common.json index c55e5ab85..4168fe9c5 100644 --- a/shared/i18n/locales/zh/common.json +++ b/shared/i18n/locales/zh/common.json @@ -12,6 +12,7 @@ "models": "模型", "deleteSessionConfirm": "删除「{{label}}」?", "openClawPage": "OpenClaw 页面", + "ccConnectPage": "CC Connect 页面", "openClawDreams": "梦境", "renameSession": "重命名", "deleteSession": "删除", diff --git a/shared/i18n/locales/zh/settings.json b/shared/i18n/locales/zh/settings.json index fbcb10647..dfc6eb33c 100644 --- a/shared/i18n/locales/zh/settings.json +++ b/shared/i18n/locales/zh/settings.json @@ -125,6 +125,33 @@ "step2": "在浏览器中打开登录页面。", "step3": "粘贴授权码以批准访问。", "requestingCode": "正在获取安全登录码..." + }, + "codexOAuth": { + "title": "cc-connect Codex OAuth", + "description": "cc-connect Codex backend 使用的托管 Codex 登录状态。", + "managedAuth": "托管登录", + "providerSecret": "Provider 密钥", + "localAuth": "本机 Codex 登录", + "ok": "正常", + "missing": "缺失", + "refresh": "刷新", + "copyPath": "复制路径", + "openFolder": "打开文件夹", + "signInAgain": "重新登录", + "importLocal": "导入本机 Codex 登录", + "logout": "登出", + "manualPrompt": "请先在浏览器完成登录,然后粘贴回调 URL 或授权码。", + "openAuthorization": "打开授权页面", + "manualCodePlaceholder": "粘贴回调 URL 或授权码", + "submitCode": "提交授权码", + "statusFailed": "读取 Codex OAuth 状态失败", + "importFailed": "导入 Codex OAuth 失败", + "logoutFailed": "登出 Codex OAuth 失败", + "signInFailed": "启动 Codex OAuth 登录失败", + "imported": "已导入本机 Codex 登录", + "loggedOut": "已登出 Codex OAuth", + "signInSuccess": "Codex OAuth 登录完成", + "pathCopied": "路径已复制" } }, "gateway": { @@ -155,6 +182,34 @@ "proxySaved": "代理设置已保存", "proxySaveFailed": "保存代理设置失败" }, + "runtime": { + "title": "Runtime", + "description": "选择由 ClawX 托管的 agent runtime。OpenClaw 仍是默认和回滚路径。", + "changed": "Runtime 选择已保存。请重启 runtime 以应用。", + "restart": "重启 Runtime", + "restartHint": "切换 runtime 会停止旧进程;重启后使用当前选择的 runtime。", + "configDir": "配置目录", + "supported": "支持", + "unsupported": "不可用", + "operationGapsTitle": "当前受限或不可用的 runtime 子操作", + "degraded": "受限", + "openclawOnly": "当前 runtime 不支持此 OpenClaw 专属工具。", + "kinds": { + "openclaw": "OpenClaw", + "cc-connect": "cc-connect" + }, + "capabilities": { + "chat": "聊天", + "sessions": "会话", + "history": "历史", + "providers": "提供商", + "channels": "频道", + "cron": "定时任务", + "logs": "日志", + "skills": "Skills", + "doctor": "Doctor" + } + }, "updates": { "title": "更新", "description": "保持 ClawX 最新", @@ -218,8 +273,8 @@ "cliPowershell": "PowerShell 命令。", "cmdUnavailable": "命令不可用", "cmdCopied": "CLI 命令已复制", - "doctor": "OpenClaw Doctor 诊断", - "doctorDesc": "运行 `openclaw doctor` 并查看原始诊断输出。", + "doctor": "Runtime Doctor 诊断", + "doctorDesc": "运行当前 runtime 的 doctor 命令并查看原始诊断输出。", "runDoctor": "运行 Doctor", "runDoctorFix": "运行 Doctor 并修复", "doctorSucceeded": "OpenClaw doctor 已完成", @@ -241,6 +296,10 @@ "doctorStdout": "标准输出", "doctorStderr": "标准错误", "doctorOutputEmpty": "(空)", + "runtimeDiagnostics": "Runtime 诊断", + "runtimeDiagnosticsDesc": "复制已脱敏的 runtime 状态、能力、频道状态、日志和 cc-connect 托管路径快照。", + "runtimeDiagnosticsCopied": "Runtime 诊断已复制", + "runtimeDiagnosticsCopyFailed": "复制 Runtime 诊断失败:{{error}}", "telemetryViewer": "埋点查看器", "telemetryViewerDesc": "仅本地 UX/性能埋点,显示最近 200 条。", "telemetryAggregated": "事件聚合", diff --git a/shared/i18n/locales/zh/skills.json b/shared/i18n/locales/zh/skills.json index c8efcd955..509433a8b 100644 --- a/shared/i18n/locales/zh/skills.json +++ b/shared/i18n/locales/zh/skills.json @@ -3,6 +3,10 @@ "subtitle": "浏览和管理 AI 能力", "refresh": "刷新", "openFolder": "打开技能文件夹", + "openRuntimeFolder": "打开运行时技能目录", + "runtimeTarget": { + "mirror": "运行时镜像目录:{{path}}" + }, "gatewayWarning": "网关未运行。你仍可查看本地技能,但运行时状态暂不可用。", "gatewayStarting": "网关正在启动。本地技能已可用,运行时状态会在就绪后自动补充。", "tabs": { diff --git a/shared/types/agent.ts b/shared/types/agent.ts index b286c7999..7a257003f 100644 --- a/shared/types/agent.ts +++ b/shared/types/agent.ts @@ -5,6 +5,8 @@ export interface AgentSummary { modelDisplay: string; modelRef?: string | null; overrideModelRef?: string | null; + providerAccountId?: string | null; + permissionMode?: 'suggest' | 'full-auto'; inheritedModel: boolean; workspace: string; agentDir: string; diff --git a/shared/types/channel.ts b/shared/types/channel.ts index aff732259..66427649e 100644 --- a/shared/types/channel.ts +++ b/shared/types/channel.ts @@ -365,6 +365,13 @@ export const CHANNEL_META: Record = { required: true, envVar: 'FEISHU_APP_SECRET', }, + { + key: 'adminUsers', + label: 'channels:meta.feishu.fields.adminUsers.label', + type: 'text', + placeholder: 'channels:meta.feishu.fields.adminUsers.placeholder', + description: 'channels:meta.feishu.fields.adminUsers.description', + }, ], instructions: [ 'channels:meta.feishu.instructions.0', diff --git a/shared/types/cron.ts b/shared/types/cron.ts index 64b6674e7..bd1d02e23 100644 --- a/shared/types/cron.ts +++ b/shared/types/cron.ts @@ -59,6 +59,11 @@ export interface CronJob { lastRun?: CronJobLastRun; nextRun?: string; agentId: string; + exec?: string; + workDir?: string; + sessionMode?: string; + timeoutMins?: number; + mute?: boolean; } /** @@ -70,7 +75,12 @@ export interface CronJob { */ export interface CronJobCreateInput { name: string; - message: string; + message?: string; + exec?: string; + workDir?: string; + sessionMode?: string; + timeoutMins?: number; + mute?: boolean; schedule: string | CronSchedule; delivery?: CronJobDelivery; enabled?: boolean; @@ -83,6 +93,11 @@ export interface CronJobCreateInput { export interface CronJobUpdateInput { name?: string; message?: string; + exec?: string; + workDir?: string; + sessionMode?: string; + timeoutMins?: number; + mute?: boolean; schedule?: string | CronSchedule; delivery?: CronJobDelivery; enabled?: boolean; diff --git a/shared/types/gateway.ts b/shared/types/gateway.ts index 2b166f931..22222b1db 100644 --- a/shared/types/gateway.ts +++ b/shared/types/gateway.ts @@ -14,6 +14,33 @@ export type GatewayRuntimeJsonValue = export type GatewayRuntimePayload = GatewayRuntimeJsonValue | undefined; export type GatewayRuntimeRecord = { [key: string]: GatewayRuntimeJsonValue | undefined }; +export type RuntimeKind = 'openclaw' | 'cc-connect'; + +export type RuntimeCapabilityName = + | 'chat' + | 'sessions' + | 'history' + | 'providers' + | 'models' + | 'channels' + | 'cron' + | 'logs' + | 'skills' + | 'doctor' + | 'controlUi'; + +export type RuntimeCapabilities = Record; + +export type RuntimeOperationSupport = 'native' | 'proxy' | 'degraded' | 'unsupported'; + +export type RuntimeOperationCapability = { + capability: RuntimeCapabilityName; + support: RuntimeOperationSupport; + notes: string; +}; + +export type RuntimeOperationCapabilities = Record; + /** * Gateway connection status */ @@ -28,6 +55,10 @@ export interface GatewayStatus { reconnectAttempts?: number; /** True once the gateway's internal subsystems (skills, plugins) are ready for RPC calls. */ gatewayReady?: boolean; + runtimeKind?: RuntimeKind; + capabilities?: RuntimeCapabilities; + operationCapabilities?: RuntimeOperationCapabilities; + configDir?: string; } /** diff --git a/src/App.tsx b/src/App.tsx index ce14be358..22fde30e8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -104,10 +104,14 @@ function App() { const language = useSettingsStore((state) => state.language); const setupComplete = useSettingsStore((state) => state.setupComplete); const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked); + const runtimeKind = useSettingsStore((state) => state.runtimeKind); + const gatewayStatus = useGatewayStore((state) => state.status); const initGateway = useGatewayStore((state) => state.init); const initUpdate = useUpdateStore((state) => state.init); const initProviders = useProviderStore((state) => state.init); const handleNewChat = useNewChatAction(); + const activeRuntimeKind = gatewayStatus.runtimeKind ?? runtimeKind ?? 'openclaw'; + const dreamsEnabled = devModeUnlocked && activeRuntimeKind === 'openclaw'; useEffect(() => { let cancelled = false; @@ -211,7 +215,7 @@ function App() { } /> } /> : } /> - : } /> + : } /> } /> {extraRoutes.map((r) => ( } /> diff --git a/src/components/file-preview/GeneratedFilesPanel.tsx b/src/components/file-preview/GeneratedFilesPanel.tsx index fc4f73d58..9e5596881 100644 --- a/src/components/file-preview/GeneratedFilesPanel.tsx +++ b/src/components/file-preview/GeneratedFilesPanel.tsx @@ -42,7 +42,7 @@ export function GeneratedFilesPanel({ }; return ( -
+

{t('generatedFiles.title', { count: files.length, defaultValue: 'File changes ({{count}})' })} @@ -95,6 +95,7 @@ export function GeneratedFilesPanel({ 'dark:border-white/10 dark:bg-white/[0.04]', )} title={file.filePath} + data-testid={`generated-file-card-${file.fileName}`} >

{file.fileName} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 3ec30d8f2..544cc82ac 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -34,7 +34,6 @@ import { useChatStore } from '@/stores/chat'; import { useGatewayStore } from '@/stores/gateway'; import { useAgentsStore } from '@/stores/agents'; import { getSessionActivityMs, getSessionBucket, type SessionBucketKey } from './session-buckets'; -import { CHANNEL_NAMES } from '@shared/types/channel'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; @@ -44,6 +43,7 @@ import { SIDEBAR_COLLAPSED_WIDTH, MAC_SIDEBAR_CHROME_HEIGHT } from '@shared/side import { useTranslation } from 'react-i18next'; import logoSvg from '@/assets/logo.svg'; import { useNewChatAction } from './use-new-chat-action'; +import { CHANNEL_NAMES, type ChannelType } from '@/types/channel'; interface NavItemProps { to: string; @@ -105,6 +105,12 @@ function getAgentIdFromSessionKey(sessionKey: string): string { return agentId || 'main'; } +function getChannelNameFromSessionKey(sessionKey: string): string | null { + const [channelType] = sessionKey.split(':'); + if (!channelType || channelType === 'agent' || channelType === 'clawx') return null; + return CHANNEL_NAMES[channelType as ChannelType] || channelType; +} + export function Sidebar() { const isMac = window.electron?.platform === 'darwin'; const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); @@ -112,6 +118,7 @@ export function Sidebar() { const sidebarWidth = useSettingsStore((state) => state.sidebarWidth); const setSidebarWidth = useSettingsStore((state) => state.setSidebarWidth); const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked); + const runtimeKind = useSettingsStore((state) => state.runtimeKind); const [isResizing, setIsResizing] = useState(false); const stopResizeRef = useRef<(() => void) | null>(null); @@ -162,11 +169,26 @@ export function Sidebar() { const navigate = useNavigate(); const isOnChat = useLocation().pathname === '/'; + const { t } = useTranslation(['common', 'chat']); - const getSessionLabel = (key: string, displayName?: string, label?: string) => - sessionLabels[key] ?? label ?? displayName ?? key; + const getSessionLabel = ( + key: string, + displayName?: string, + label?: string, + derivedTitle?: string, + lastMessagePreview?: string, + ) => { + const baseLabel = sessionLabels[key] ?? label ?? derivedTitle ?? lastMessagePreview ?? displayName ?? key; + const channelName = getChannelNameFromSessionKey(key); + return channelName ? `${channelName}: ${baseLabel}` : baseLabel; + }; - const openControlUi = async (view?: 'dreams', label = 'OpenClaw Page') => { + const controlUiLabel = gatewayStatus.runtimeKind === 'cc-connect' + ? t('common:sidebar.ccConnectPage') + : t('common:sidebar.openClawPage'); + const dreamsNavEnabled = devModeUnlocked && (gatewayStatus.runtimeKind ?? runtimeKind ?? 'openclaw') === 'openclaw'; + + const openControlUi = async (view?: 'dreams', label = controlUiLabel) => { try { const result = await hostApi.gateway.controlUi(view); if (result.success && result.url) { @@ -180,10 +202,9 @@ export function Sidebar() { }; const openDevConsole = async () => { - await openControlUi(undefined, 'OpenClaw Page'); + await openControlUi(undefined, controlUiLabel); }; - const { t } = useTranslation(['common', 'chat']); const [sessionToDelete, setSessionToDelete] = useState<{ key: string; label: string } | null>(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [editingSessionKey, setEditingSessionKey] = useState(null); @@ -324,7 +345,9 @@ export function Sidebar() { ...(devModeUnlocked ? [ { to: '/image-generation', icon: , label: t('common:sidebar.imageGeneration'), testId: 'sidebar-nav-image-generation' }, - { to: '/dreams', icon: , label: t('common:sidebar.openClawDreams'), testId: 'sidebar-nav-dreams' }, + ...(dreamsNavEnabled + ? [{ to: '/dreams', icon: , label: t('common:sidebar.openClawDreams'), testId: 'sidebar-nav-dreams' }] + : []), ] : []), ]; @@ -444,10 +467,16 @@ export function Sidebar() { {bucket.label} {isBucketExpanded && bucket.sessions.map((s) => { - const agentId = getAgentIdFromSessionKey(s.key); + const agentId = s.agentId || getAgentIdFromSessionKey(s.key); const agentName = agentNameById[agentId] || agentId; const isEditing = editingSessionKey === s.key; - const sessionLabel = getSessionLabel(s.key, s.displayName, s.label); + const sessionLabel = getSessionLabel( + s.key, + s.displayName, + s.label, + s.derivedTitle, + s.lastMessagePreview, + ); const channelType = s.channel && s.channel !== 'webchat' ? s.channel : null; const channelName = channelType ? CHANNEL_NAMES[channelType as keyof typeof CHANNEL_NAMES] ?? channelType : null; return ( @@ -622,7 +651,7 @@ export function Sidebar() {
{!sidebarCollapsed && ( <> - {t('common:sidebar.openClawPage')} + {controlUiLabel} )} diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index a80e9b7a0..770b37752 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -2,7 +2,7 @@ * Providers Settings Component * Manage AI provider configurations and API keys */ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Plus, Trash2, @@ -17,6 +17,10 @@ import { Copy, XCircle, ChevronDown, + RefreshCw, + Import, + LogOut, + FolderOpen, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -54,6 +58,7 @@ import { useSettingsStore } from '@/stores/settings'; import { hostApi } from '@/lib/host-api'; import { hostEvents } from '@/lib/host-events'; import type { OAuthCodeEvent, OAuthErrorEvent, OAuthSuccessEvent } from '@shared/host-events/contract'; +import type { ProviderCodexOAuthStatusResult } from '@shared/host-api/contract'; const inputClasses = 'h-[44px] rounded-xl font-mono text-meta bg-transparent border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40'; const labelClasses = 'text-sm text-foreground/80 font-bold'; @@ -131,6 +136,10 @@ function shouldShowUserAgentFieldForNewProvider(providerType: ProviderType | nul return providerType === 'custom'; } +function shouldShowCodexOAuthRuntimePanel(account: ProviderAccount): boolean { + return account.vendorId === 'openai' && account.authMode === 'oauth_browser'; +} + function getAuthModeLabel( authMode: ProviderAccount['authMode'], t: (key: string) => string @@ -793,6 +802,12 @@ function ProviderCard({
)}
+ {shouldShowCodexOAuthRuntimePanel(account) && ( + + )}
@@ -911,6 +926,380 @@ function ProviderCard({ ); } +interface CodexOAuthRuntimePanelProps { + account: ProviderAccount; + currentSectionLabelClasses: string; +} + +function CodexOAuthRuntimePanel({ + account, + currentSectionLabelClasses, +}: CodexOAuthRuntimePanelProps) { + const { t } = useTranslation('settings'); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [action, setAction] = useState<'import' | 'logout' | 'signin' | null>(null); + const [error, setError] = useState(null); + const [manualOAuth, setManualOAuth] = useState<{ + authorizationUrl: string; + message?: string; + } | null>(null); + const [manualCodeInput, setManualCodeInput] = useState(''); + + const refreshStatus = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await hostApi.providers.codexOAuthStatus({ accountId: account.id }); + if (!result.success) { + throw new Error(result.error || t('aiProviders.codexOAuth.statusFailed')); + } + setStatus(result); + } catch (err) { + setError(String(err)); + } finally { + setLoading(false); + } + }, [account.id, t]); + + useEffect(() => { + void refreshStatus(); + }, [refreshStatus]); + + useEffect(() => { + const handleCode = (payload: OAuthCodeEvent) => { + if (payload.provider !== 'openai') return; + if ('mode' in payload && payload.mode === 'manual') { + setManualOAuth({ + authorizationUrl: payload.authorizationUrl, + message: payload.message, + }); + setError(null); + } + }; + const handleSuccess = async (payload: OAuthSuccessEvent) => { + if (payload.provider !== 'openai' || payload.accountId !== account.id) return; + setAction(null); + setManualOAuth(null); + setManualCodeInput(''); + setError(null); + await useProviderStore.getState().refreshProviderSnapshot(); + await refreshStatus(); + toast.success(t('aiProviders.codexOAuth.signInSuccess')); + }; + const handleError = (payload: OAuthErrorEvent) => { + setAction(null); + setManualOAuth(null); + setError(payload.message); + }; + + const offCode = hostEvents.onOAuthCode(handleCode); + const offSuccess = hostEvents.onOAuthSuccess(handleSuccess); + const offError = hostEvents.onOAuthError(handleError); + + return () => { + offCode(); + offSuccess(); + offError(); + }; + }, [account.id, refreshStatus, t]); + + const runImport = async () => { + setAction('import'); + setError(null); + try { + const result = await hostApi.providers.importCodexOAuth({ accountId: account.id }); + if (!result.success) { + throw new Error(result.error || t('aiProviders.codexOAuth.importFailed')); + } + setStatus(result); + toast.success(t('aiProviders.codexOAuth.imported')); + } catch (err) { + setError(String(err)); + } finally { + setAction(null); + } + }; + + const runLogout = async () => { + setAction('logout'); + setError(null); + try { + const result = await hostApi.providers.logoutCodexOAuth({ accountId: account.id }); + if (!result.success) { + throw new Error(result.error || t('aiProviders.codexOAuth.logoutFailed')); + } + setStatus(result); + await useProviderStore.getState().refreshProviderSnapshot(); + toast.success(t('aiProviders.codexOAuth.loggedOut')); + } catch (err) { + setError(String(err)); + } finally { + setAction(null); + } + }; + + const startSignIn = async () => { + setAction('signin'); + setManualOAuth(null); + setManualCodeInput(''); + setError(null); + try { + const result = await hostApi.providers.requestOAuth({ + provider: 'openai', + accountId: account.id, + label: account.label, + }); + if (!result.success) { + throw new Error(result.error || t('aiProviders.codexOAuth.signInFailed')); + } + } catch (err) { + setAction(null); + setError(String(err)); + } + }; + + const cancelSignIn = async () => { + setAction(null); + setManualOAuth(null); + setManualCodeInput(''); + setError(null); + await hostApi.providers.cancelOAuth(); + }; + + const submitManualCode = async () => { + const value = manualCodeInput.trim(); + if (!value) return; + try { + const result = await hostApi.providers.submitOAuth({ code: value }); + if (!result.success) { + throw new Error(result.error || t('aiProviders.codexOAuth.signInFailed')); + } + setError(null); + } catch (err) { + setError(String(err)); + } + }; + + const copyAuthPath = async () => { + const path = status?.authPath; + if (!path) return; + await navigator.clipboard.writeText(path); + toast.success(t('aiProviders.codexOAuth.pathCopied')); + }; + + const openManagedFolder = async () => { + const path = status?.managedCodexHome; + if (!path) return; + await hostApi.shell.openPath(path); + }; + + const managedComplete = status?.managed?.complete === true; + const userComplete = status?.user?.complete === true; + const providerSecret = status?.provider?.hasOAuthSecret === true; + const busy = loading || action !== null; + + return ( +
+
+
+

{t('aiProviders.codexOAuth.title')}

+

+ {t('aiProviders.codexOAuth.description')} +

+
+ +
+ +
+ + + +
+ + {status?.managedCodexHome && ( +
+ + {status.authPath || status.managedCodexHome} + + + +
+ )} + +
+ + + +
+ + {manualOAuth && ( +
+

+ {manualOAuth.message || t('aiProviders.codexOAuth.manualPrompt')} +

+ + setManualCodeInput(event.target.value)} + placeholder={t('aiProviders.codexOAuth.manualCodePlaceholder')} + className={inputClasses} + /> +
+ + +
+
+ )} + + {error && ( +

+ + {error} +

+ )} +
+ ); +} + +function CodexOAuthStatusPill({ + testId, + label, + ok, + statusLabel, +}: { + testId: string; + label: string; + ok: boolean; + statusLabel: string; +}) { + return ( +
+ {label} + + + {statusLabel} + +
+ ); +} + interface AddProviderDialogProps { open: boolean; existingVendorIds: Set; diff --git a/src/lib/generated-files.ts b/src/lib/generated-files.ts index 5d08a2f20..955212cc1 100644 --- a/src/lib/generated-files.ts +++ b/src/lib/generated-files.ts @@ -103,6 +103,14 @@ const EDIT_TOOLS = new Set([ 'multiEdit', ]); +const APPLY_PATCH_TOOLS = new Set([ + 'apply_patch', + 'ApplyPatch', + 'applyPatch', +]); + +const STRUCTURED_PATCH_TOOLS = new Set(['Patch', 'patch']); + const FILE_PATH_KEYS = ['file_path', 'filepath', 'path', 'fileName', 'file_name', 'target_path']; /** Best-effort detector that mirrors the buckets WorkBuddy uses internally. */ @@ -316,6 +324,113 @@ function pickEditOps(input: unknown): FileEditOp[] { return ops; } +type ApplyPatchFileChange = { + filePath: string; + action: 'created' | 'modified'; + fullContent?: string; + edits?: FileEditOp[]; +}; + +function readApplyPatchText(input: unknown): string | null { + if (typeof input === 'string') return input; + if (Array.isArray(input) && input.every((item) => typeof item === 'string')) { + return input.join('\n'); + } + const rec = asRecord(input); + if (!rec) return null; + for (const key of ['patch', 'input', 'text', 'content']) { + const value = rec[key]; + if (typeof value === 'string' && value.includes('*** ')) return value; + } + return null; +} + +function parseApplyPatchFiles(input: unknown): ApplyPatchFileChange[] { + const text = readApplyPatchText(input); + if (!text) return []; + + const changes: ApplyPatchFileChange[] = []; + let current: ApplyPatchFileChange | null = null; + let addLines: string[] = []; + let oldLines: string[] = []; + let newLines: string[] = []; + + const flush = () => { + if (!current) return; + if (current.action === 'created') { + current.fullContent = addLines.join('\n'); + if (addLines.length > 0) current.fullContent += '\n'; + } else { + const oldText = oldLines.length > 0 ? `${oldLines.join('\n')}\n` : ''; + const newText = newLines.length > 0 ? `${newLines.join('\n')}\n` : ''; + current.edits = oldText || newText ? [{ old: oldText, new: newText }] : []; + } + changes.push(current); + current = null; + addLines = []; + oldLines = []; + newLines = []; + }; + + for (const rawLine of text.split(/\r?\n/)) { + const addMatch = rawLine.match(/^\*\*\* Add File:\s*(.+)$/); + const updateMatch = rawLine.match(/^\*\*\* Update File:\s*(.+)$/); + const deleteMatch = rawLine.match(/^\*\*\* Delete File:\s*(.+)$/); + if (addMatch || updateMatch || deleteMatch || rawLine.startsWith('*** End Patch')) { + flush(); + if (addMatch?.[1]) { + current = { filePath: addMatch[1].trim(), action: 'created' }; + } else if (updateMatch?.[1]) { + current = { filePath: updateMatch[1].trim(), action: 'modified' }; + } + continue; + } + if (!current) continue; + if (current.action === 'created') { + if (rawLine.startsWith('+') && !rawLine.startsWith('+++')) { + addLines.push(rawLine.slice(1)); + } + continue; + } + if (rawLine.startsWith('+') && !rawLine.startsWith('+++')) { + newLines.push(rawLine.slice(1)); + } else if (rawLine.startsWith('-') && !rawLine.startsWith('---')) { + oldLines.push(rawLine.slice(1)); + } + } + flush(); + + return changes.filter((change) => change.filePath); +} + +function parseStructuredPatchFiles(input: unknown): ApplyPatchFileChange[] { + const parsed = typeof input === 'string' + ? (() => { + try { + return JSON.parse(input) as unknown; + } catch { + return input; + } + })() + : input; + const entries = Array.isArray(parsed) ? parsed : [parsed]; + + return entries.flatMap((entry): ApplyPatchFileChange[] => { + const record = asRecord(entry); + if (!record) return []; + const filePath = pickStringByKeys(record, FILE_PATH_KEYS); + const diff = typeof record.diff === 'string' ? record.diff : undefined; + if (!filePath || diff === undefined) return []; + const kind = asRecord(record.kind); + const patchType = (typeof kind?.type === 'string' ? kind.type : typeof record.kind === 'string' ? record.kind : '') + .toLowerCase(); + if (patchType === 'add' || patchType === 'create') { + return [{ filePath, action: 'created', fullContent: diff }]; + } + return [{ filePath, action: 'modified', edits: [{ old: '', new: diff }] }]; + }); +} + function determineWriteAction( existing: GeneratedFile | undefined, baseline: GeneratedFileBaseline | undefined, @@ -430,6 +545,26 @@ export function extractGeneratedFiles( const name = typeof block.name === 'string' ? block.name : ''; if (!name) continue; const input = block.input ?? block.arguments; + const isApplyPatch = APPLY_PATCH_TOOLS.has(name); + const isStructuredPatch = STRUCTURED_PATCH_TOOLS.has(name); + if (isApplyPatch || isStructuredPatch) { + const patchFiles = isStructuredPatch ? parseStructuredPatchFiles(input) : parseApplyPatchFiles(input); + for (const patchFile of patchFiles) { + const existing = map.get(patchFile.filePath); + map.set(patchFile.filePath, buildGeneratedFile( + patchFile.filePath, + patchFile.action === 'created' ? determineWriteAction(existing, { status: 'missing' }) : 'modified', + { + fullContent: patchFile.fullContent, + edits: patchFile.edits, + baseline: patchFile.action === 'created' ? { status: 'missing' } : existing?.baseline, + }, + i, + )); + } + continue; + } + const filePath = pickFilePath(input); if (!filePath) continue; diff --git a/src/lib/host-api.ts b/src/lib/host-api.ts index c4837de13..7307594b2 100644 --- a/src/lib/host-api.ts +++ b/src/lib/host-api.ts @@ -16,6 +16,8 @@ import type { OpenClawDoctorMode, OpenClawDoctorResult, ProviderAccount, + ProviderCodexOAuthLogoutPayload, + ProviderCodexOAuthPayload, ProviderConfig, ProviderOAuthRequestPayload, ProviderUpdateWithKeyPayload, @@ -30,6 +32,7 @@ import type { SkillUpdateConfigPayload, SkillUpdatePayload, UpdateChannel, + UsageHistoryPayload, } from '@shared/host-api/contract'; import type { CronJobCreateInput, CronJobUpdateInput } from '@shared/types/cron'; import { invokeHost } from './host-api-client'; @@ -68,6 +71,7 @@ export type { SettingsResetResult, SettingsSnapshot, SkillConfigsResult, + SkillsRuntimeTargetResult, SkillsStatusResult, StagedFileResult, UsageHistoryEntry, @@ -184,9 +188,17 @@ export const hostApi = { ...input, }) ), - updateModel: (id: string, modelRef: string | null) => ( - invokeHost('agents', 'updateModel', { id, modelRef }) - ), + updateModel: ( + id: string, + modelRef: string | null, + providerAccountId?: string | null, + permissionMode?: 'suggest' | 'full-auto', + ) => invokeHost('agents', 'updateModel', { + id, + modelRef, + ...(providerAccountId !== undefined ? { providerAccountId } : {}), + ...(permissionMode ? { permissionMode } : {}), + }), delete: (id: string) => invokeHost('agents', 'delete', { id }), assignChannel: (id: string, channelType: string) => ( invokeHost('agents', 'assignChannel', { id, channelType }) @@ -252,6 +264,15 @@ export const hostApi = { requestOAuth: (input: ProviderOAuthRequestPayload) => invokeHost('providers', 'requestOAuth', input), cancelOAuth: () => invokeHost('providers', 'cancelOAuth'), submitOAuth: (input: { code: string }) => invokeHost('providers', 'submitOAuth', input), + codexOAuthStatus: (input?: ProviderCodexOAuthPayload) => ( + invokeHost('providers', 'codexOAuthStatus', input) + ), + importCodexOAuth: (input?: ProviderCodexOAuthPayload) => ( + invokeHost('providers', 'importCodexOAuth', input) + ), + logoutCodexOAuth: (input?: ProviderCodexOAuthLogoutPayload) => ( + invokeHost('providers', 'logoutCodexOAuth', input) + ), }, files: { stagePaths: (input: { filePaths: string[] }) => invokeHost('files', 'stagePaths', input), @@ -308,6 +329,7 @@ export const hostApi = { }, skills: { local: () => invokeHost('skills', 'local'), + target: () => invokeHost('skills', 'target'), configs: () => invokeHost('skills', 'configs'), allConfigs: () => invokeHost('skills', 'allConfigs'), getConfig: (skillKey: string) => invokeHost('skills', 'getConfig', { skillKey }), @@ -329,8 +351,8 @@ export const hostApi = { ), }, usage: { - recentTokenHistory: (limit?: number) => ( - invokeHost('usage', 'recentTokenHistory', { limit }) + recentTokenHistory: (input?: number | UsageHistoryPayload) => ( + invokeHost('usage', 'recentTokenHistory', typeof input === 'number' ? { limit: input } : input) ), }, }; diff --git a/src/lib/runtime-operation-capabilities.ts b/src/lib/runtime-operation-capabilities.ts new file mode 100644 index 000000000..b9a51d2fe --- /dev/null +++ b/src/lib/runtime-operation-capabilities.ts @@ -0,0 +1,38 @@ +import type { GatewayStatus, RuntimeOperationCapability } from '@/types/gateway'; + +export function getRuntimeOperationCapability( + status: Pick | null | undefined, + method: string, +): RuntimeOperationCapability | undefined { + return status?.operationCapabilities?.[method]; +} + +export function isRuntimeOperationSupported( + status: Pick | null | undefined, + method: string, +): boolean { + const operations = status?.operationCapabilities; + if (!operations) return true; + return method in operations && operations[method]?.support !== 'unsupported'; +} + +export function getUnsupportedRuntimeOperation( + status: Pick | null | undefined, + method: string, +): RuntimeOperationCapability | undefined { + const capability = getRuntimeOperationCapability(status, method); + return capability?.support === 'unsupported' ? capability : undefined; +} + +export function assertRuntimeOperationSupported( + status: Pick | null | undefined, + method: string, +): void { + const unsupported = getUnsupportedRuntimeOperation(status, method); + if (!unsupported) { + if (!status?.operationCapabilities || method in status.operationCapabilities) return; + throw new Error(`Runtime operation ${method} is not declared by the selected runtime.`); + } + const detail = unsupported.notes ? ` ${unsupported.notes}` : ''; + throw new Error(`Runtime operation ${method} is unavailable for the selected runtime.${detail}`); +} diff --git a/src/pages/Agents/index.tsx b/src/pages/Agents/index.tsx index 1bd30b1a1..d08abff02 100644 --- a/src/pages/Agents/index.tsx +++ b/src/pages/Agents/index.tsx @@ -250,6 +250,7 @@ function AgentCard({ return (
)}
+ ))} +
+

+ {t(`settingsDialog.permissionModeDescriptions.${permissionMode}`)} +

+
+ )}
+ ))} +
+ )}
); } @@ -176,6 +227,7 @@ export function ExecutionGraphCard({ suppressThinking = false, expanded: controlledExpanded, onExpandedChange, + onApprovalAction, }: ExecutionGraphCardProps) { const { t } = useTranslation('chat'); @@ -291,7 +343,7 @@ export function ExecutionGraphCard({
- + )})} @@ -305,7 +357,14 @@ export function ExecutionGraphCard({ data-testid="chat-execution-step-thinking-trailing" style={{ marginLeft: `${TOOL_ROW_EXTRA_INDENT_PX}px` }} > -
+
+
+ +
+
{t('executionGraph.thinkingLabel')} diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx index 765370627..6b99f18a5 100644 --- a/src/pages/Chat/index.tsx +++ b/src/pages/Chat/index.tsx @@ -122,8 +122,10 @@ function isRealUserMessage(msg: RawMessage): boolean { return blocks.length === 0 || !blocks.every((b) => b.type === 'tool_result' || b.type === 'toolResult'); } -function hasUserFacingImageAttachments(msg: RawMessage): boolean { - return (msg._attachedFiles ?? []).some((file) => file.mimeType.startsWith('image/')); +function hasUserFacingRuntimeAttachments(msg: RawMessage): boolean { + return (msg._attachedFiles ?? []).some((file) => ( + file.source === 'gateway-media' || file.mimeType.startsWith('image/') + )); } function generatedFileToTarget(file: GeneratedFile): FilePreviewTarget { @@ -958,9 +960,9 @@ export function Chat() {
)} {messages.map((msg, idx) => { - if (isInternalMessage(msg) && !hasUserFacingImageAttachments(msg)) return null; + if (isInternalMessage(msg) && !hasUserFacingRuntimeAttachments(msg)) return null; const isFoldedNarration = foldedNarrationIndices.has(idx); - if (isFoldedNarration && !hasUserFacingImageAttachments(msg)) return null; + if (isFoldedNarration && !hasUserFacingRuntimeAttachments(msg)) return null; const suppressToolCards = runSegmentMessageIndices.has(idx); const isToolOnlyAssistant = normalizeMessageRole(msg.role) === 'assistant' && extractToolUse(msg).length > 0 @@ -1014,6 +1016,15 @@ export function Chat() { onExpandedChange={(next) => setGraphExpandedOverrides((prev) => ({ ...prev, [runKey]: next })) } + onApprovalAction={async (runId, action) => { + try { + await hostApi.gateway.rpc('chat.approval.respond', { runId, action }); + } catch (error) { + toast.error(error instanceof Error + ? error.message + : t('executionGraph.approval.failed')); + } + }} /> {generatedFiles.length > 0 && ( ; + }; } /** @@ -454,6 +463,14 @@ export function deriveRuntimeTaskSteps(runState: ChatRuntimeRunState | null | un kind: 'system', detail: runtimeDetail(event.message), depth: 1, + approval: event.actions?.length + ? { + runId: event.runId, + kind: event.kind, + status: event.status, + actions: event.actions, + } + : undefined, }); break; } @@ -492,6 +509,9 @@ export function deriveTaskSteps({ const streamMessage = streamingMessage && typeof streamingMessage === 'object' ? streamingMessage as RawMessage : null; + const formatToolInput = (input: unknown): string => ( + typeof input === 'string' ? input : JSON.stringify(input, null, 2) + ); // The final answer the user sees as a chat bubble. We avoid folding it into // the graph to prevent duplication. When a run is still streaming, the @@ -532,7 +552,7 @@ export function deriveTaskSteps({ label: tool.name, status: 'completed', kind: 'tool', - detail: normalizeText(JSON.stringify(tool.input, null, 2)), + detail: normalizeText(formatToolInput(tool.input)), depth: 1, url, }); @@ -585,7 +605,7 @@ export function deriveTaskSteps({ label: tool.name, status: 'running', kind: 'tool', - detail: normalizeText(JSON.stringify(tool.input, null, 2)), + detail: normalizeText(formatToolInput(tool.input)), depth: 1, url, }); diff --git a/src/pages/Cron/index.tsx b/src/pages/Cron/index.tsx index 42ea3588b..2b438bdc0 100644 --- a/src/pages/Cron/index.tsx +++ b/src/pages/Cron/index.tsx @@ -45,6 +45,7 @@ import { useChatStore } from '@/stores/chat'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { formatRelativeTime, cn } from '@/lib/utils'; import { fetchQuickAccessSkills } from '@/lib/quick-access-skills'; +import { isRuntimeOperationSupported } from '@/lib/runtime-operation-capabilities'; import { toast } from 'sonner'; import type { CronJob, CronJobCreateInput, CronSchedule, ScheduleType } from '@/types/cron'; import type { QuickAccessSkill } from '@/types/skill'; @@ -1390,13 +1391,28 @@ function TaskDialog({ open, job, configuredChannels, onClose, onSave }: TaskDial interface CronJobCardProps { job: CronJob; deliveryAccountName?: string; + canUpdate: boolean; + canDelete: boolean; + canToggle: boolean; + canTrigger: boolean; onToggle: (enabled: boolean) => void; onEdit: () => void; onDelete: () => void; onTrigger: () => Promise; } -function CronJobCard({ job, deliveryAccountName, onToggle, onEdit, onDelete, onTrigger }: CronJobCardProps) { +function CronJobCard({ + job, + deliveryAccountName, + canUpdate, + canDelete, + canToggle, + canTrigger, + onToggle, + onEdit, + onDelete, + onTrigger, +}: CronJobCardProps) { const { t } = useTranslation('cron'); const [triggering, setTriggering] = useState(false); const agents = useAgentsStore((s) => s.agents); @@ -1430,8 +1446,11 @@ function CronJobCard({ job, deliveryAccountName, onToggle, onEdit, onDelete, onT return (
@@ -1459,6 +1478,7 @@ function CronJobCard({ job, deliveryAccountName, onToggle, onEdit, onDelete, onT
e.stopPropagation()}>
@@ -1487,7 +1507,11 @@ function CronJobCard({ job, deliveryAccountName, onToggle, onEdit, onDelete, onT )} {job.lastRun && ( - + {t('card.last')}: {formatRelativeTime(job.lastRun.time)} {job.lastRun.success ? ( @@ -1525,7 +1549,7 @@ function CronJobCard({ job, deliveryAccountName, onToggle, onEdit, onDelete, onT variant="ghost" size="sm" onClick={handleTrigger} - disabled={triggering} + disabled={triggering || !canTrigger} className="h-8 px-3 text-foreground/70 hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 rounded-lg text-meta font-medium transition-colors" > {triggering ? ( @@ -1539,6 +1563,7 @@ function CronJobCard({ job, deliveryAccountName, onToggle, onEdit, onDelete, onT variant="ghost" size="sm" onClick={handleDelete} + disabled={!canDelete} className="h-8 px-3 text-destructive/70 hover:text-destructive hover:bg-destructive/10 rounded-lg text-meta font-medium transition-colors" > @@ -1561,6 +1586,12 @@ export function Cron() { const isGatewayRunning = gatewayStatus.state === 'running'; const showGatewayUnavailableWarning = isGatewayStopped(gatewayStatus); + const canListCron = isGatewayRunning && isRuntimeOperationSupported(gatewayStatus, 'cron.list'); + const canCreateCron = isGatewayRunning && isRuntimeOperationSupported(gatewayStatus, 'cron.create'); + const canUpdateCron = isGatewayRunning && isRuntimeOperationSupported(gatewayStatus, 'cron.update'); + const canDeleteCron = isGatewayRunning && isRuntimeOperationSupported(gatewayStatus, 'cron.delete'); + const canToggleCron = isGatewayRunning && isRuntimeOperationSupported(gatewayStatus, 'cron.toggle'); + const canRunCron = isGatewayRunning && isRuntimeOperationSupported(gatewayStatus, 'cron.run'); const fetchConfiguredChannels = useCallback(async () => { try { @@ -1577,10 +1608,10 @@ export function Cron() { // Fetch jobs on mount useEffect(() => { - if (isGatewayRunning) { + if (canListCron) { fetchJobs(); } - }, [fetchJobs, isGatewayRunning]); + }, [fetchJobs, canListCron]); useEffect(() => { void fetchConfiguredChannels(); @@ -1639,7 +1670,7 @@ export function Cron() { void fetchJobs(); void fetchConfiguredChannels(); }} - disabled={!isGatewayRunning} + disabled={!canListCron} className="h-9 text-meta font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground transition-colors" > @@ -1651,7 +1682,7 @@ export function Cron() { setEditingJob(undefined); setShowDialog(true); }} - disabled={!isGatewayRunning} + disabled={!canCreateCron} className="h-9 text-meta font-medium rounded-full px-4 shadow-none" > @@ -1746,7 +1777,7 @@ export function Cron() { setEditingJob(undefined); setShowDialog(true); }} - disabled={!isGatewayRunning} + disabled={!canCreateCron} className="rounded-full px-6 h-10" > @@ -1764,6 +1795,10 @@ export function Cron() { key={job.id} job={job} deliveryAccountName={deliveryAccountName} + canUpdate={canUpdateCron} + canDelete={canDeleteCron} + canToggle={canToggleCron} + canTrigger={canRunCron} onToggle={(enabled) => handleToggle(job.id, enabled)} onEdit={() => { setEditingJob(job); diff --git a/src/pages/Models/index.tsx b/src/pages/Models/index.tsx index 8b3dc17d3..2326c80be 100644 --- a/src/pages/Models/index.tsx +++ b/src/pages/Models/index.tsx @@ -39,6 +39,7 @@ export function Models() { const gatewayStatus = useGatewayStore((state) => state.status); const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked); const isGatewayRunning = gatewayStatus.state === 'running'; + const activeRuntimeKind = gatewayStatus.runtimeKind; const usageFetchMaxAttempts = window.electron.platform === 'win32' ? WINDOWS_USAGE_FETCH_MAX_ATTEMPTS : DEFAULT_USAGE_FETCH_MAX_ATTEMPTS; @@ -179,7 +180,7 @@ export function Models() { restartMarker, }); try { - const entries = await hostApi.usage.recentTokenHistory(); + const entries = await hostApi.usage.recentTokenHistory({ runtimeKind: activeRuntimeKind }); if (usageFetchGenerationRef.current !== generation) return; const normalized = Array.isArray(entries) ? entries : []; @@ -251,7 +252,7 @@ export function Models() { usageFetchTimerRef.current = null; } }; - }, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts, usageRefreshNonce]); + }, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, activeRuntimeKind, usageFetchMaxAttempts, usageRefreshNonce]); const usageHistory = isGatewayRunning ? fetchState.data.filter((entry) => !shouldHideUsageEntry(entry)) @@ -394,7 +395,7 @@ export function Models() {
{pagedUsageHistory.map((entry) => (
diff --git a/src/pages/Models/usage-history.ts b/src/pages/Models/usage-history.ts index 13cacdf03..5e4731532 100644 --- a/src/pages/Models/usage-history.ts +++ b/src/pages/Models/usage-history.ts @@ -1,7 +1,11 @@ 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; @@ -10,6 +14,7 @@ export type UsageHistoryEntry = { outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; + reasoningTokens?: number; totalTokens: number; costUsd?: number; }; diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index 43b514698..060ef1189 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -11,6 +11,7 @@ import { ExternalLink, Copy, FileText, + ServerCog, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; @@ -24,6 +25,7 @@ import { useGatewayStore } from '@/stores/gateway'; import { useUpdateStore } from '@/stores/update'; import { UpdateSettings } from '@/components/settings/UpdateSettings'; import { toUserMessage } from '@/lib/error-message'; +import { isRuntimeOperationSupported } from '@/lib/runtime-operation-capabilities'; import { clearUiTelemetry, getUiTelemetrySnapshot, @@ -53,6 +55,8 @@ export function Settings() { setLaunchAtStartup, gatewayAutoStart, setGatewayAutoStart, + runtimeKind, + setRuntimeKind, proxyEnabled, proxyServer, proxyHttpServer, @@ -73,7 +77,11 @@ export function Settings() { setTelemetryEnabled, } = useSettingsStore(); - const { status: gatewayStatus, restart: restartGateway } = useGatewayStore(); + const { + status: gatewayStatus, + restart: restartGateway, + setStatus: setGatewayStatus, + } = useGatewayStore(); const currentVersion = useUpdateStore((state) => state.currentVersion); const [controlUiInfo, setControlUiInfo] = useState(null); const [openclawCliCommand, setOpenclawCliCommand] = useState(''); @@ -94,6 +102,30 @@ export function Settings() { const [logContent, setLogContent] = useState(''); const [doctorRunningMode, setDoctorRunningMode] = useState<'diagnose' | 'fix' | null>(null); const [doctorResult, setDoctorResult] = useState(null); + const [runtimeDiagnosticsLoading, setRuntimeDiagnosticsLoading] = useState(false); + const activeRuntimeKind = gatewayStatus.runtimeKind ?? runtimeKind ?? 'openclaw'; + const runtimeCapabilities = gatewayStatus.capabilities; + const runtimeOperationCapabilities = gatewayStatus.operationCapabilities; + const supportsDoctor = runtimeCapabilities?.doctor ?? true; + const supportsDoctorFix = isRuntimeOperationSupported(gatewayStatus, 'doctor.fix'); + const runtimeCapabilityEntries = [ + ['chat', t('runtime.capabilities.chat')], + ['sessions', t('runtime.capabilities.sessions')], + ['history', t('runtime.capabilities.history')], + ['providers', t('runtime.capabilities.providers')], + ['channels', t('runtime.capabilities.channels')], + ['cron', t('runtime.capabilities.cron')], + ['logs', t('runtime.capabilities.logs')], + ['skills', t('runtime.capabilities.skills')], + ['doctor', t('runtime.capabilities.doctor')], + ] as const; + const unsupportedRuntimeOperations = useMemo(() => { + if (!runtimeOperationCapabilities) return []; + return Object.entries(runtimeOperationCapabilities) + .filter(([, operation]) => operation.support === 'unsupported' || operation.support === 'degraded') + .sort(([left], [right]) => left.localeCompare(right)) + .slice(0, 8); + }, [runtimeOperationCapabilities]); const handleShowLogs = async () => { try { @@ -118,6 +150,10 @@ export function Settings() { }; const handleRunOpenClawDoctor = async (mode: 'diagnose' | 'fix') => { + if (mode === 'fix' && !supportsDoctorFix) { + toast.error(t('runtime.openclawOnly')); + return; + } setDoctorRunningMode(mode); try { const result = await hostApi.app.openClawDoctor(mode); @@ -169,6 +205,19 @@ export function Settings() { } }; + const handleCopyRuntimeDiagnostics = async () => { + setRuntimeDiagnosticsLoading(true); + try { + const snapshot = await hostApi.diagnostics.gatewaySnapshot(); + await navigator.clipboard.writeText(JSON.stringify(snapshot, null, 2)); + toast.success(t('developer.runtimeDiagnosticsCopied')); + } catch (error) { + toast.error(t('developer.runtimeDiagnosticsCopyFailed', { error: String(error) })); + } finally { + setRuntimeDiagnosticsLoading(false); + } + }; + const refreshControlUiInfo = async () => { @@ -234,6 +283,19 @@ export function Settings() { return () => { unsubscribe?.(); }; }, []); + useEffect(() => { + if (!devModeUnlocked) return; + let cancelled = false; + void hostApi.gateway.status() + .then((status) => { + if (!cancelled) setGatewayStatus(status); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [devModeUnlocked, runtimeKind, setGatewayStatus]); + useEffect(() => { if (!devModeUnlocked) return; setTelemetryEntries(getUiTelemetrySnapshot(200)); @@ -418,6 +480,19 @@ export function Settings() { toast.success(translateNext('appearance.menuLanguageUpdated')); }; + const handleRuntimeChange = (nextRuntimeKind: 'openclaw' | 'cc-connect') => { + if (!devModeUnlocked) return; + if (nextRuntimeKind === runtimeKind) return; + setRuntimeKind(nextRuntimeKind); + setDoctorResult(null); + window.setTimeout(() => { + void hostApi.gateway.status() + .then(setGatewayStatus) + .catch(() => {}); + }, 50); + toast.success(t('runtime.changed')); + }; + return (
@@ -576,6 +651,103 @@ export function Settings() { />
+ {devModeUnlocked && ( +
+
+
+ +

+ {t('runtime.description')} +

+
+ + {t(`runtime.kinds.${activeRuntimeKind}`)} + +
+
+ + + +
+

+ {t('runtime.restartHint')} +

+ {gatewayStatus.configDir && ( +

+ {t('runtime.configDir')}: {gatewayStatus.configDir} +

+ )} +
+ {runtimeCapabilityEntries.map(([key, label]) => { + const supported = runtimeCapabilities?.[key] ?? (activeRuntimeKind === 'openclaw'); + return ( + + {label}: {supported ? t('runtime.supported') : t('runtime.unsupported')} + + ); + })} +
+ {unsupportedRuntimeOperations.length > 0 && ( +
+

+ {t('runtime.operationGapsTitle')} +

+
+ {unsupportedRuntimeOperations.map(([method, operation]) => ( + + {method}: {t(operation.support === 'degraded' ? 'runtime.degraded' : 'runtime.unsupported')} + + ))} +
+
+ )} +
+ )} +
@@ -762,7 +934,7 @@ export function Settings() {
- {showCliTools && ( + {showCliTools && activeRuntimeKind === 'openclaw' && (

@@ -794,6 +966,33 @@ export function Settings() {

)} +
+
+
+ +

+ {t('developer.runtimeDiagnosticsDesc')} +

+
+ +
+
+ + {supportsDoctor ? (
@@ -806,6 +1005,7 @@ export function Settings() {
)}
+ ) : ( +
+ +

+ {t('runtime.openclawOnly')} +

+
+ )}
diff --git a/src/pages/Skills/index.tsx b/src/pages/Skills/index.tsx index 9e74ad3ef..af0afc7d8 100644 --- a/src/pages/Skills/index.tsx +++ b/src/pages/Skills/index.tsx @@ -24,6 +24,7 @@ import { useGatewayStore } from '@/stores/gateway'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { cn } from '@/lib/utils'; import { hostApi } from '@/lib/host-api'; +import type { SkillsRuntimeTargetResult } from '@/lib/host-api'; import { isGatewayStopped } from '@/lib/gateway-status'; import { toast } from 'sonner'; import type { Skill } from '@/types/skill'; @@ -262,6 +263,8 @@ export function Skills() { const [selectedSkill, setSelectedSkill] = useState(null); const [statusFilter, setStatusFilter] = useState<'all' | 'enabled' | 'disabled'>('all'); const [marketplaceAvailable, setMarketplaceAvailable] = useState(false); + const [skillsTarget, setSkillsTarget] = useState(null); + const [skillsDirPath, setSkillsDirPath] = useState('~/.openclaw/skills'); const gatewayRunning = gatewayStatus.state === 'running'; const gatewayReportedReady = gatewayStatus.gatewayReady !== false; @@ -372,7 +375,7 @@ export function Skills() { const handleOpenSkillsFolder = useCallback(async () => { try { - const skillsDir = await hostApi.openclaw.getSkillsDir(); + const skillsDir = skillsTarget?.openDir || await hostApi.openclaw.getSkillsDir(); if (!skillsDir) { throw new Error('Skills directory not available'); } @@ -387,7 +390,7 @@ export function Skills() { } catch (err) { toast.error(t('toast.failedOpenFolder') + ': ' + String(err)); } - }, [t]); + }, [skillsTarget?.openDir, t]); const handleOpenSkillFolder = useCallback(async (skill: Skill) => { try { @@ -404,12 +407,26 @@ export function Skills() { } }, [t]); - const [skillsDirPath, setSkillsDirPath] = useState('~/.openclaw/skills'); - useEffect(() => { - hostApi.openclaw.getSkillsDir() - .then((dir) => setSkillsDirPath(dir)) - .catch(console.error); + let cancelled = false; + hostApi.skills.target() + .then((target) => { + if (cancelled) return; + setSkillsTarget(target); + setSkillsDirPath(target.sourceDir || target.openDir); + }) + .catch(async (error) => { + console.error(error); + try { + const dir = await hostApi.openclaw.getSkillsDir(); + if (!cancelled) setSkillsDirPath(dir); + } catch (fallbackError) { + console.error(fallbackError); + } + }); + return () => { + cancelled = true; + }; }, []); useEffect(() => { @@ -472,6 +489,14 @@ export function Skills() {

{t('subtitle')}

+ {skillsTarget?.mirrorMode === 'runtime-mirror' && ( +

+ {t('runtimeTarget.mirror', { path: skillsTarget.openDir })} +

+ )}
@@ -481,7 +506,7 @@ export function Skills() { className="hover:bg-black/5 dark:hover:bg-white/5 transition-colors shrink-0 text-meta font-medium px-4 h-8 rounded-full border border-black/10 dark:border-white/10 flex items-center justify-center text-foreground/80 hover:text-foreground" > - {t('openFolder')} + {t(skillsTarget?.mirrorMode === 'runtime-mirror' ? 'openRuntimeFolder' : 'openFolder')} )}
diff --git a/src/stores/agents.ts b/src/stores/agents.ts index 57ad04145..b154660c3 100644 --- a/src/stores/agents.ts +++ b/src/stores/agents.ts @@ -15,7 +15,7 @@ interface AgentsState { fetchAgents: () => Promise; createAgent: (name: string, options?: { inheritWorkspace?: boolean }) => Promise; updateAgent: (agentId: string, name: string) => Promise; - updateAgentModel: (agentId: string, modelRef: string | null) => Promise; + updateAgentModel: (agentId: string, modelRef: string | null, providerAccountId?: string | null, permissionMode?: 'suggest' | 'full-auto') => Promise; deleteAgent: (agentId: string) => Promise; assignChannel: (agentId: string, channelType: ChannelType) => Promise; removeChannel: (agentId: string, channelType: ChannelType) => Promise; @@ -81,10 +81,10 @@ export const useAgentsStore = create((set) => ({ } }, - updateAgentModel: async (agentId: string, modelRef: string | null) => { + updateAgentModel: async (agentId: string, modelRef: string | null, providerAccountId?: string | null, permissionMode?: 'suggest' | 'full-auto') => { set({ error: null }); try { - const snapshot = await hostApi.agents.updateModel(agentId, modelRef); + const snapshot = await hostApi.agents.updateModel(agentId, modelRef, providerAccountId, permissionMode); set(applySnapshot(snapshot)); } catch (error) { set({ error: String(error) }); diff --git a/src/stores/channels.ts b/src/stores/channels.ts index 09712b5f7..a79c75328 100644 --- a/src/stores/channels.ts +++ b/src/stores/channels.ts @@ -4,6 +4,7 @@ */ import { create } from 'zustand'; import { hostApi } from '@/lib/host-api'; +import { assertRuntimeOperationSupported, isRuntimeOperationSupported } from '@/lib/runtime-operation-capabilities'; import { isChannelRuntimeConnected, pickChannelRuntimeStatus, @@ -141,6 +142,7 @@ export const useChannelsStore = create((set, get) => ({ }, addChannel: async (params) => { + assertRuntimeOperationSupported(useGatewayStore.getState().status, 'channels.add'); try { const result = await useGatewayStore.getState().rpc('channels.add', params); @@ -190,7 +192,10 @@ export const useChannelsStore = create((set, get) => ({ } try { - await useGatewayStore.getState().rpc('channels.delete', { channelId: gatewayChannelType }); + const gatewayState = useGatewayStore.getState(); + if (isRuntimeOperationSupported(gatewayState.status, 'channels.delete')) { + await gatewayState.rpc('channels.delete', { channelId: gatewayChannelType }); + } } catch (error) { // Continue with local deletion even if gateway fails console.error('Failed to delete channel from gateway:', error); @@ -207,6 +212,7 @@ export const useChannelsStore = create((set, get) => ({ updateChannel(channelId, { status: 'connecting', error: undefined }); try { + assertRuntimeOperationSupported(useGatewayStore.getState().status, 'channels.connect'); const { channelType, accountId } = splitChannelId(channelId); await useGatewayStore.getState().rpc('channels.connect', { channelId: `${toOpenClawChannelType(channelType)}${accountId ? `-${accountId}` : ''}`, @@ -222,6 +228,7 @@ export const useChannelsStore = create((set, get) => ({ clearAutoReconnect(channelId); try { + assertRuntimeOperationSupported(useGatewayStore.getState().status, 'channels.disconnect'); const { channelType, accountId } = splitChannelId(channelId); await useGatewayStore.getState().rpc('channels.disconnect', { channelId: `${toOpenClawChannelType(channelType)}${accountId ? `-${accountId}` : ''}`, @@ -234,6 +241,7 @@ export const useChannelsStore = create((set, get) => ({ }, requestQrCode: async (channelType) => { + assertRuntimeOperationSupported(useGatewayStore.getState().status, 'channels.requestQr'); return await useGatewayStore.getState().rpc<{ qrCode: string; sessionId: string }>( 'channels.requestQr', { type: toOpenClawChannelType(channelType) }, diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 104bc2121..085884879 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -1732,6 +1732,10 @@ function getAgentIdFromSessionKey(sessionKey: string): string { return parts[1] || 'main'; } +function getAgentIdForSessionKey(sessionKey: string, sessions: ChatSession[]): string { + return sessions.find((session) => session.key === sessionKey)?.agentId || getAgentIdFromSessionKey(sessionKey); +} + function parseSessionUpdatedAtMs(value: unknown): number | undefined { if (typeof value === 'number' && Number.isFinite(value)) { return toMs(value); @@ -1854,6 +1858,13 @@ function resolveMainSessionKeyForAgent(agentId: string | undefined | null): stri return summary?.mainSessionKey || buildFallbackMainSessionKey(normalizedAgentId); } +function resolveTargetSessionKey(currentSessionKey: string, targetAgentId: string | undefined | null): string { + if (!targetAgentId || !currentSessionKey.startsWith('agent:')) { + return currentSessionKey; + } + return resolveMainSessionKeyForAgent(targetAgentId) ?? currentSessionKey; +} + function ensureSessionEntry(sessions: ChatSession[], sessionKey: string): ChatSession[] { if (sessions.some((session) => session.key === sessionKey)) { return sessions; @@ -1910,7 +1921,7 @@ function buildSessionSwitchPatch( return { currentSessionKey: nextSessionKey, - currentAgentId: getAgentIdFromSessionKey(nextSessionKey), + currentAgentId: getAgentIdForSessionKey(nextSessionKey, nextSessions), sessions: ensureSessionEntry(nextSessions, nextSessionKey), sessionLabels: leavingEmpty ? clearSessionEntryFromMap(state.sessionLabels, state.currentSessionKey) @@ -2616,13 +2627,13 @@ export const useChatStore = create((set, get) => ({ // ── Load sessions via sessions.list ── - loadSessions: async () => { + loadSessions: async (force = false) => { const now = Date.now(); if (_loadSessionsInFlight) { await _loadSessionsInFlight; return; } - if (now - _lastLoadSessionsAt < SESSION_LOAD_MIN_INTERVAL_MS) { + if (!force && now - _lastLoadSessionsAt < SESSION_LOAD_MIN_INTERVAL_MS) { return; } @@ -2637,6 +2648,7 @@ export const useChatStore = create((set, get) => ({ displayName: s.displayName ? String(s.displayName) : undefined, derivedTitle: s.derivedTitle ? String(s.derivedTitle) : undefined, lastMessagePreview: s.lastMessagePreview ? String(s.lastMessagePreview) : undefined, + agentId: s.agentId ? String(s.agentId) : undefined, thinkingLevel: s.thinkingLevel ? String(s.thinkingLevel) : undefined, model: s.model ? String(s.model) : undefined, updatedAt: parseSessionUpdatedAtMs(s.updatedAt), @@ -2719,7 +2731,7 @@ export const useChatStore = create((set, get) => ({ set((state) => ({ sessions: sessionsWithCurrent, currentSessionKey: nextSessionKey, - currentAgentId: getAgentIdFromSessionKey(nextSessionKey), + currentAgentId: getAgentIdForSessionKey(nextSessionKey, sessionsWithCurrent), sessionLastActivity: { ...state.sessionLastActivity, ...discoveredActivity, @@ -2867,7 +2879,7 @@ export const useChatStore = create((set, get) => ({ lastUserMessageAt: null, pendingToolImages: [], currentSessionKey: next?.key ?? DEFAULT_SESSION_KEY, - currentAgentId: getAgentIdFromSessionKey(next?.key ?? DEFAULT_SESSION_KEY), + currentAgentId: getAgentIdForSessionKey(next?.key ?? DEFAULT_SESSION_KEY, remaining), })); if (next) { get().loadHistory(); @@ -3635,7 +3647,7 @@ export const useChatStore = create((set, get) => ({ const trimmed = text.trim(); if (!trimmed && (!attachments || attachments.length === 0)) return; - const targetSessionKey = resolveMainSessionKeyForAgent(targetAgentId) ?? get().currentSessionKey; + const targetSessionKey = resolveTargetSessionKey(get().currentSessionKey, targetAgentId); // Guard against double-submit before React re-renders with sending=true. if (get().sending && targetSessionKey === get().currentSessionKey) { diff --git a/src/stores/chat/helpers.ts b/src/stores/chat/helpers.ts index 2bfff6d89..c6cd5be75 100644 --- a/src/stores/chat/helpers.ts +++ b/src/stores/chat/helpers.ts @@ -1201,7 +1201,7 @@ function enrichWithCachedImages(messages: RawMessage[]): RawMessage[] { // The renderer cannot fetch the URL directly, so we surface it as an // `_attachedFiles` entry whose preview is filled in later by // `loadMissingPreviews` -> Main `media:getThumbnails` (which dereferences - // the URL to the original file in `~/.openclaw/media/outgoing/`). + // the URL through active runtime media records). const gatewayMediaFiles: AttachedFileMeta[] = msg.role === 'assistant' ? extractImagesAsAttachedFiles(msg.content).filter(file => file.gatewayUrl) : []; @@ -1308,8 +1308,8 @@ function waitForPreviewRetry(ms: number): Promise { // Collect all image refs that need previews. The IPC handler accepts: // - { filePath, mimeType } — local on-disk files // - { gatewayUrl, mimeType } — Gateway-injected outgoing media; the -// handler resolves the URL to a local file -// via `~/.openclaw/media/outgoing/records/`. +// handler resolves the URL through runtime +// media records. // We use `filePath || gatewayUrl` as the dedupe / lookup key on the way // back; a file always carries at most one of the two. function collectMissingPreviewRefs(messages: RawMessage[]): PreviewRef[] { diff --git a/src/stores/chat/runtime-graph.ts b/src/stores/chat/runtime-graph.ts index 422b92ea5..a0d09ed30 100644 --- a/src/stores/chat/runtime-graph.ts +++ b/src/stores/chat/runtime-graph.ts @@ -71,7 +71,8 @@ function sameRuntimeEvent(left: ChatRuntimeEvent | undefined, right: ChatRuntime && right.toolCallId === left.toolCallId && right.status === left.status && right.phase === left.phase - && right.message === left.message; + && right.message === left.message + && stableRuntimeFingerprint(right.actions) === stableRuntimeFingerprint(left.actions); } if (left.type === 'assistant.delta') { return right.type === left.type && right.text === left.text && right.delta === left.delta; @@ -109,10 +110,10 @@ export function applyRuntimeEventToRuns( break; case 'assistant.delta': { const incoming = event.text ?? event.delta ?? ''; - if (incoming) { - if (event.replace) { - nextRun.assistantText = incoming; - } else if (event.text) { + if (event.replace) { + nextRun.assistantText = incoming; + } else if (incoming) { + if (event.text) { nextRun.assistantText = event.text.startsWith(nextRun.assistantText) ? event.text : event.text; diff --git a/src/stores/chat/runtime-send-actions.ts b/src/stores/chat/runtime-send-actions.ts index 5701545cb..62fab2692 100644 --- a/src/stores/chat/runtime-send-actions.ts +++ b/src/stores/chat/runtime-send-actions.ts @@ -38,6 +38,13 @@ function resolveMainSessionKeyForAgent(agentId: string | undefined | null): stri return summary?.mainSessionKey || buildFallbackMainSessionKey(normalizedAgentId); } +function resolveTargetSessionKey(currentSessionKey: string, targetAgentId: string | undefined | null): string { + if (!targetAgentId || !currentSessionKey.startsWith('agent:')) { + return currentSessionKey; + } + return resolveMainSessionKeyForAgent(targetAgentId) ?? currentSessionKey; +} + function ensureSessionEntry(sessions: ChatSession[], sessionKey: string): ChatSession[] { if (sessions.some((session) => session.key === sessionKey)) { return sessions; @@ -58,7 +65,7 @@ export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick session.key === sessionKey)?.agentId; + return sessionAgentId || getAgentIdFromSessionKey(sessionKey); +} + function toSessionLabel(text: string, maxLength = 50): string { const trimmed = text.trim(); if (!trimmed) return ''; @@ -125,6 +130,7 @@ export function createSessionActions( key: String(s.key || ''), label: s.label ? String(s.label) : undefined, displayName: s.displayName ? String(s.displayName) : undefined, + agentId: s.agentId ? String(s.agentId) : undefined, derivedTitle: s.derivedTitle ? String(s.derivedTitle) : undefined, lastMessagePreview: s.lastMessagePreview ? String(s.lastMessagePreview) : undefined, thinkingLevel: s.thinkingLevel ? String(s.thinkingLevel) : undefined, @@ -190,7 +196,7 @@ export function createSessionActions( set((state) => ({ sessions: sessionsWithCurrent, currentSessionKey: nextSessionKey, - currentAgentId: getAgentIdFromSessionKey(nextSessionKey), + currentAgentId: getAgentIdForSessionKey(nextSessionKey, sessionsWithCurrent), sessionLastActivity: { ...state.sessionLastActivity, ...discoveredActivity, @@ -279,7 +285,7 @@ export function createSessionActions( && !sessionLabels[currentSessionKey]; set((s) => ({ currentSessionKey: key, - currentAgentId: getAgentIdFromSessionKey(key), + currentAgentId: getAgentIdForSessionKey(key, s.sessions), messages: [], streamingText: '', streamingMessage: null, @@ -347,7 +353,7 @@ export function createSessionActions( lastUserMessageAt: null, pendingToolImages: [], currentSessionKey: next?.key ?? DEFAULT_SESSION_KEY, - currentAgentId: getAgentIdFromSessionKey(next?.key ?? DEFAULT_SESSION_KEY), + currentAgentId: getAgentIdForSessionKey(next?.key ?? DEFAULT_SESSION_KEY, remaining), })); if (next) { get().loadHistory(); diff --git a/src/stores/cron.ts b/src/stores/cron.ts index 2378a6d27..e8af75ddc 100644 --- a/src/stores/cron.ts +++ b/src/stores/cron.ts @@ -4,10 +4,14 @@ */ import { create } from 'zustand'; import { hostApi } from '@/lib/host-api'; +import { assertRuntimeOperationSupported } from '@/lib/runtime-operation-capabilities'; import { useChatStore } from './chat'; +import { useGatewayStore } from './gateway'; import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron'; let _fetchJobsInFlight: Promise | null = null; +let _runObservationSequence = 0; +const _runObservations = new Map(); /** * How long an optimistically-created job is kept in the list even when the @@ -15,6 +19,49 @@ let _fetchJobsInFlight: Promise | null = null; * window a job missing from the Gateway is treated as deleted/auto-removed. */ const OPTIMISTIC_CREATE_GRACE_MS = 15_000; +const RUN_OBSERVATION_INITIAL_DELAY_MS = 1_000; +const RUN_OBSERVATION_MAX_DELAY_MS = 15_000; +const RUN_OBSERVATION_TIMEOUT_GRACE_MS = 15_000; + +function cancelRunObservation(id: string): void { + _runObservations.delete(id); +} + +function runObservationTimeoutMs(job: CronJob): number { + const configuredTimeoutMins = typeof job.timeoutMins === 'number' && Number.isFinite(job.timeoutMins) + ? job.timeoutMins + : 0; + const timeoutMins = configuredTimeoutMins > 0 ? configuredTimeoutMins : 30; + return timeoutMins * 60_000 + RUN_OBSERVATION_TIMEOUT_GRACE_MS; +} + +function runCompletedSince(job: CronJob | undefined, previousLastRunTime: string | undefined): boolean { + return Boolean(job?.lastRun?.time && job.lastRun.time !== previousLastRunTime); +} + +async function observeTriggeredRun( + id: string, + observationId: number, + previousLastRunTime: string | undefined, + runtimeKind: string | undefined, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + let delayMs = RUN_OBSERVATION_INITIAL_DELAY_MS; + while (Date.now() < deadline && _runObservations.get(id) === observationId) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + if (_runObservations.get(id) !== observationId) return; + if (useGatewayStore.getState().status.runtimeKind !== runtimeKind) break; + + await useCronStore.getState().fetchJobs(); + const job = useCronStore.getState().jobs.find((candidate) => candidate.id === id); + if (!job || runCompletedSince(job, previousLastRunTime)) break; + delayMs = Math.min(delayMs * 2, RUN_OBSERVATION_MAX_DELAY_MS); + } + if (_runObservations.get(id) === observationId) { + _runObservations.delete(id); + } +} interface CronState { jobs: CronJob[]; @@ -52,6 +99,7 @@ export const useCronStore = create((set) => ({ } try { + assertRuntimeOperationSupported(useGatewayStore.getState().status, 'cron.list'); const result = await hostApi.cron.list(); // The Gateway list is authoritative. A job missing from it has either been @@ -86,6 +134,7 @@ export const useCronStore = create((set) => ({ createJob: async (input) => { try { + assertRuntimeOperationSupported(useGatewayStore.getState().status, 'cron.create'); // Auto-capture currentAgentId if not provided const agentId = input.agentId ?? useChatStore.getState().currentAgentId; const job = await hostApi.cron.create({ ...input, agentId }); @@ -99,6 +148,7 @@ export const useCronStore = create((set) => ({ updateJob: async (id, input) => { try { + assertRuntimeOperationSupported(useGatewayStore.getState().status, 'cron.update'); const updatedJob = await hostApi.cron.update(id, input); set((state) => ({ jobs: state.jobs.map((job) => @@ -113,7 +163,9 @@ export const useCronStore = create((set) => ({ deleteJob: async (id) => { try { + assertRuntimeOperationSupported(useGatewayStore.getState().status, 'cron.delete'); await hostApi.cron.delete(id); + cancelRunObservation(id); set((state) => ({ jobs: state.jobs.filter((job) => job.id !== id), })); @@ -125,6 +177,7 @@ export const useCronStore = create((set) => ({ toggleJob: async (id, enabled) => { try { + assertRuntimeOperationSupported(useGatewayStore.getState().status, 'cron.toggle'); await hostApi.cron.toggle(id, enabled); set((state) => ({ jobs: state.jobs.map((job) => @@ -139,14 +192,27 @@ export const useCronStore = create((set) => ({ triggerJob: async (id) => { try { + assertRuntimeOperationSupported(useGatewayStore.getState().status, 'cron.run'); + const jobBeforeTrigger = useCronStore.getState().jobs.find((job) => job.id === id); + const previousLastRunTime = jobBeforeTrigger?.lastRun?.time; + const runtimeKind = useGatewayStore.getState().status.runtimeKind; await hostApi.cron.trigger(id); - // Refresh jobs after trigger to update lastRun/nextRun state - try { - const result = await hostApi.cron.list(); - set({ jobs: result }); - } catch { - // Ignore refresh error + await useCronStore.getState().fetchJobs(); + const refreshedJob = useCronStore.getState().jobs.find((job) => job.id === id); + if (!refreshedJob || runCompletedSince(refreshedJob, previousLastRunTime)) { + cancelRunObservation(id); + return; } + + const observationId = ++_runObservationSequence; + _runObservations.set(id, observationId); + void observeTriggeredRun( + id, + observationId, + previousLastRunTime, + runtimeKind, + runObservationTimeoutMs(refreshedJob), + ); } catch (error) { console.error('Failed to trigger cron job:', error); throw error; diff --git a/src/stores/gateway.ts b/src/stores/gateway.ts index 1511c8e87..f98844b35 100644 --- a/src/stores/gateway.ts +++ b/src/stores/gateway.ts @@ -88,16 +88,21 @@ function buildGatewayEventDedupeKey(event: Record): string | nu const sessionKey = event.sessionKey != null ? String(event.sessionKey) : ''; const seq = event.seq != null ? String(event.seq) : ''; const state = event.state != null ? String(event.state) : ''; + const message = event.message; + const messageId = message && typeof message === 'object' && (message as Record).id != null + ? String((message as Record).id) + : ''; if (state === 'delta' && !seq) { return ['delta-nosq', runId, sessionKey, stableGatewayEventFingerprint(event.message ?? event)].join('|'); } + if (state === 'final' && messageId) { + return [runId, sessionKey, seq, state, messageId].join('|'); + } if (runId || sessionKey || seq || state) { return [runId, sessionKey, seq, state].join('|'); } - const message = event.message; if (message && typeof message === 'object') { const msg = message as Record; - const messageId = msg.id != null ? String(msg.id) : ''; const stopReason = msg.stopReason ?? msg.stop_reason; if (messageId || stopReason) { return `msg|${messageId}|${String(stopReason ?? '')}`; @@ -132,7 +137,7 @@ function shouldProcessGatewayEvent(event: Record): boolean { } function maybeLoadSessions( - state: { loadSessions: () => Promise }, + state: { loadSessions: (force?: boolean) => Promise }, force = false, ): void { const { status } = useGatewayStore.getState(); @@ -141,7 +146,7 @@ function maybeLoadSessions( const now = Date.now(); if (!force && now - lastLoadSessionsAt < LOAD_SESSIONS_MIN_INTERVAL_MS) return; lastLoadSessionsAt = now; - void state.loadSessions(); + void state.loadSessions(force); } function maybeLoadHistory( @@ -154,6 +159,34 @@ function maybeLoadHistory( void state.loadHistory(true); } +function runtimeStatusFingerprint(status: GatewayStatus): string { + return stableGatewayEventFingerprint({ + state: status.state, + gatewayReady: status.gatewayReady, + runtimeKind: status.runtimeKind, + capabilities: status.capabilities, + operationCapabilities: status.operationCapabilities, + configDir: status.configDir, + error: status.error, + pid: status.pid, + }); +} + +function shouldReconcileGatewayStatus(latest: GatewayStatus, current: GatewayStatus): boolean { + return runtimeStatusFingerprint(latest) !== runtimeStatusFingerprint(current); +} + +function mergeGatewayStatusUpdate(latest: GatewayStatus, current: GatewayStatus): GatewayStatus { + return { + ...latest, + gatewayReady: latest.gatewayReady ?? current.gatewayReady, + runtimeKind: latest.runtimeKind ?? current.runtimeKind, + capabilities: latest.capabilities ?? current.capabilities, + operationCapabilities: latest.operationCapabilities ?? current.operationCapabilities, + configDir: latest.configDir ?? current.configDir, + }; +} + /** Bump sidebar ordering when any session receives gateway traffic (e.g. Feishu DM). */ function touchSessionActivity(sessionKey: string | null | undefined, activityMs = Date.now()): void { if (!sessionKey) return; @@ -230,7 +263,6 @@ function handleChatRuntimeEvent(event: ChatRuntimeEvent): void { import('./chat') .then(({ useChatStore, syncCachedSessionRunIdle }) => { const state = useChatStore.getState(); - state.handleRuntimeEvent(event); // Cron runs stream under the run-scoped key; treat it as the equivalent // base cron session the user is viewing instead of an unknown session. @@ -244,6 +276,16 @@ function handleChatRuntimeEvent(event: ChatRuntimeEvent): void { && !matchesCurrentSession && !isKnownSession; + if (event.type === 'session.updated') { + maybeLoadSessions(state, true); + if (resolvedSessionKey != null && resolvedSessionKey === state.currentSessionKey) { + maybeLoadHistory(state, true); + } + return; + } + + state.handleRuntimeEvent(event); + if (event.type === 'run.started') { if (shouldRefreshSessions) { maybeLoadSessions(state, true); @@ -291,6 +333,8 @@ function handleGatewayChatMessage(data: unknown): void { state: 'final', message: payload, runId: chatData.runId ?? payload.runId, + sessionKey: chatData.sessionKey ?? payload.sessionKey, + seq: chatData.seq ?? payload.seq, }; if (!shouldProcessGatewayEvent(normalized)) return; useChatStore.getState().handleChatEvent(normalized); @@ -406,11 +450,12 @@ export const useGatewayStore = create((set, get) => ({ hostApi.gateway.status() .then((latest) => { const current = get().status; - if (latest.state !== current.state) { + const merged = mergeGatewayStatusUpdate(latest, current); + if (shouldReconcileGatewayStatus(merged, current)) { console.info( - `[gateway-store] reconciled stale state: ${current.state} → ${latest.state}`, + `[gateway-store] reconciled stale state: ${current.state} → ${merged.state}`, ); - set({ status: latest }); + set({ status: merged }); } }) .catch(() => { /* ignore */ }); @@ -424,8 +469,9 @@ export const useGatewayStore = create((set, get) => ({ try { const refreshed = await hostApi.gateway.status(); const current = get().status; - if (refreshed.state !== current.state) { - set({ status: refreshed }); + const merged = mergeGatewayStatusUpdate(refreshed, current); + if (shouldReconcileGatewayStatus(merged, current)) { + set({ status: merged }); } } catch { // Best-effort; the IPC listener will eventually reconcile. diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 08bc2741b..5a51894c8 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -10,6 +10,7 @@ import { resolveSupportedLanguage } from '@shared/language'; type Theme = 'light' | 'dark' | 'system'; type UpdateChannel = 'stable' | 'beta' | 'dev'; +type RuntimeKind = 'openclaw' | 'cc-connect'; interface SettingsState { // General @@ -21,6 +22,7 @@ interface SettingsState { // Gateway gatewayAutoStart: boolean; + runtimeKind: RuntimeKind; gatewayPort: number; proxyEnabled: boolean; proxyServer: string; @@ -49,6 +51,7 @@ interface SettingsState { setLaunchAtStartup: (value: boolean) => void; setTelemetryEnabled: (value: boolean) => void; setGatewayAutoStart: (value: boolean) => void; + setRuntimeKind: (value: RuntimeKind) => void; setGatewayPort: (port: number) => void; setProxyEnabled: (value: boolean) => void; setProxyServer: (value: string) => void; @@ -72,6 +75,7 @@ const defaultSettings = { launchAtStartup: false, telemetryEnabled: true, gatewayAutoStart: true, + runtimeKind: 'openclaw' as RuntimeKind, gatewayPort: 18789, proxyEnabled: false, proxyServer: '', @@ -100,14 +104,23 @@ export const useSettingsStore = create()( const resolvedLanguage = settings.language ? resolveSupportedLanguage(settings.language) : undefined; + const devModeUnlocked = settings.devModeUnlocked === true; + const runtimeKind = !devModeUnlocked && settings.runtimeKind === 'cc-connect' + ? 'openclaw' + : settings.runtimeKind; set((state) => ({ ...state, ...settings, + ...(runtimeKind ? { runtimeKind } : {}), + ...(settings.devModeUnlocked !== undefined ? { devModeUnlocked } : {}), ...(resolvedLanguage ? { language: resolvedLanguage } : {}), ...(typeof settings.sidebarWidth === 'number' ? { sidebarWidth: clampSidebarWidth(settings.sidebarWidth) } : {}), })); + if (settings.runtimeKind === 'cc-connect' && !devModeUnlocked) { + void hostApi.settings.set('runtimeKind', 'openclaw').catch(() => { }); + } if (resolvedLanguage) { i18n.changeLanguage(resolvedLanguage); } @@ -140,6 +153,10 @@ export const useSettingsStore = create()( set({ gatewayAutoStart }); void hostApi.settings.set('gatewayAutoStart', gatewayAutoStart).catch(() => { }); }, + setRuntimeKind: (runtimeKind) => { + set({ runtimeKind }); + void hostApi.settings.set('runtimeKind', runtimeKind).catch(() => { }); + }, setGatewayPort: (gatewayPort) => { set({ gatewayPort }); void hostApi.settings.set('gatewayPort', gatewayPort).catch(() => { }); @@ -159,8 +176,14 @@ export const useSettingsStore = create()( setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }), setSidebarWidth: (sidebarWidth) => set({ sidebarWidth: clampSidebarWidth(sidebarWidth) }), setDevModeUnlocked: (devModeUnlocked) => { - set({ devModeUnlocked }); + set((state) => ({ + devModeUnlocked, + ...(!devModeUnlocked && state.runtimeKind === 'cc-connect' ? { runtimeKind: 'openclaw' as RuntimeKind } : {}), + })); void hostApi.settings.set('devModeUnlocked', devModeUnlocked).catch(() => { }); + if (!devModeUnlocked) { + void hostApi.settings.set('runtimeKind', 'openclaw').catch(() => { }); + } }, markSetupComplete: () => set({ setupComplete: true }), resetSettings: () => set(defaultSettings), diff --git a/tests/e2e/app-smoke.spec.ts b/tests/e2e/app-smoke.spec.ts index 9f262a749..4d565b339 100644 --- a/tests/e2e/app-smoke.spec.ts +++ b/tests/e2e/app-smoke.spec.ts @@ -1,4 +1,4 @@ -import { closeElectronApp, expect, test } from './fixtures/electron'; +import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; test.describe('ClawX Electron smoke flows', () => { test('shows the setup wizard on a fresh profile', async ({ page }) => { @@ -20,8 +20,7 @@ test.describe('ClawX Electron smoke flows', () => { }); test('persists skipped setup across relaunch for the same isolated profile', async ({ electronApp, launchElectronApp }) => { - const firstWindow = await electronApp.firstWindow(); - await firstWindow.waitForLoadState('domcontentloaded'); + const firstWindow = await getStableWindow(electronApp); await firstWindow.getByTestId('setup-skip-button').click(); await expect(firstWindow.getByTestId('main-layout')).toBeVisible(); @@ -29,8 +28,7 @@ test.describe('ClawX Electron smoke flows', () => { const relaunchedApp = await launchElectronApp(); try { - const relaunchedWindow = await relaunchedApp.firstWindow(); - await relaunchedWindow.waitForLoadState('domcontentloaded'); + const relaunchedWindow = await getStableWindow(relaunchedApp); await expect(relaunchedWindow.getByTestId('main-layout')).toBeVisible(); await expect(relaunchedWindow.getByTestId('setup-page')).toHaveCount(0); diff --git a/tests/e2e/cc-connect-channel-session-history.spec.ts b/tests/e2e/cc-connect-channel-session-history.spec.ts new file mode 100644 index 000000000..10bf8b339 --- /dev/null +++ b/tests/e2e/cc-connect-channel-session-history.spec.ts @@ -0,0 +1,94 @@ +import { closeElectronApp, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron'; + +function stableStringify(value: unknown): string { + if (value == null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`; + const entries = Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`); + return `{${entries.join(',')}}`; +} + +test.describe('cc-connect channel session history', () => { + test('renders channel sessions in the sidebar and loads their history', async ({ launchElectronApp }) => { + const sessionKey = 'feishu:oc_probe:ou_probe'; + const sessionUpdatedAt = Date.now(); + const seededHistory = [ + { id: 'm1', role: 'user', content: '飞书 hello', timestamp: sessionUpdatedAt - 1_000 }, + { id: 'm2', role: 'assistant', content: '飞书 reply', timestamp: sessionUpdatedAt }, + ]; + const app = await launchElectronApp({ skipSetup: true }); + try { + await installIpcMocks(app, { + gatewayStatus: { + state: 'running', + port: 18789, + pid: 12345, + gatewayReady: true, + runtimeKind: 'cc-connect', + }, + gatewayRpc: { + [stableStringify(['sessions.list', { includeDerivedTitles: true, includeLastMessage: true }])]: { + success: true, + result: { + sessions: [{ + key: sessionKey, + displayName: '飞书会话', + derivedTitle: '飞书 hello', + lastMessagePreview: '飞书 reply', + agentId: 'coder', + updatedAt: sessionUpdatedAt, + }], + }, + }, + [stableStringify(['chat.history', { sessionKey, limit: 200, maxChars: 500000 }])]: { + success: true, + result: { messages: seededHistory }, + }, + [stableStringify(['chat.history', { sessionKey, limit: 1000, maxChars: 500000 }])]: { + success: true, + result: { messages: seededHistory }, + }, + }, + hostApi: { + [stableStringify(['/api/gateway/status', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { + state: 'running', + port: 18789, + pid: 12345, + gatewayReady: true, + runtimeKind: 'cc-connect', + }, + }, + }, + [stableStringify(['/api/agents', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { + success: true, + agents: [{ id: 'coder', name: 'Coder' }], + }, + }, + }, + }, + }); + + const page = await getStableWindow(app); + await page.reload(); + await expect(page.getByTestId(`sidebar-session-${sessionKey}`)).toBeVisible(); + await expect(page.getByTestId(`sidebar-session-${sessionKey}`)).toContainText('Feishu / Lark: 飞书 hello'); + await expect(page.getByTestId(`sidebar-session-${sessionKey}`)).toContainText('Coder'); + await page.getByTestId(`sidebar-session-${sessionKey}`).click(); + await expect(page.getByTestId('chat-message-0').getByText('飞书 hello')).toBeVisible(); + await expect(page.getByTestId('chat-message-1').getByText('飞书 reply')).toBeVisible(); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/tests/e2e/cc-connect-codex-oauth-lifecycle.spec.ts b/tests/e2e/cc-connect-codex-oauth-lifecycle.spec.ts new file mode 100644 index 000000000..2f57f1d07 --- /dev/null +++ b/tests/e2e/cc-connect-codex-oauth-lifecycle.spec.ts @@ -0,0 +1,237 @@ +import { access, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; + +const accountId = 'openai-oauth'; +const codexAccountId = 'acct_local_e2e'; +const accessToken = 'local-oauth-access-e2e'; +const refreshToken = 'local-oauth-refresh-e2e'; +const idToken = 'local-oauth-id-e2e'; + +type HostResponse = { + ok: boolean; + data?: T; + error?: string; +}; + +type CodexOAuthStatus = { + success: boolean; + managedCodexHome: string; + authPath: string; + managed: { + path: string; + exists: boolean; + complete: boolean; + accountId?: string; + }; + user: { + path: string; + exists: boolean; + complete: boolean; + accountId?: string; + }; + provider?: { + accountId: string; + vendorId: string; + authMode?: string; + hasOAuthSecret: boolean; + managedMatchesAccount?: boolean; + userMatchesAccount?: boolean; + }; +}; + +async function expectMissing(path: string): Promise { + await expect(access(path)).rejects.toBeTruthy(); +} + +function expectNoTokenLeak(value: unknown): void { + const serialized = JSON.stringify(value); + expect(serialized).not.toContain(accessToken); + expect(serialized).not.toContain(refreshToken); + expect(serialized).not.toContain(idToken); +} + +async function invokeProviderAction( + page: Awaited>, + action: string, + payload?: Record, +): Promise> { + return await page.evaluate(async ({ actionName, actionPayload }) => { + return await window.clawx.hostInvoke({ + id: `codex-oauth-${actionName}`, + module: 'providers', + action: actionName, + payload: actionPayload, + }); + }, { actionName: action, actionPayload: payload }); +} + +test.describe('cc-connect Codex OAuth Host API lifecycle', () => { + test('imports, reports, and logs out managed Codex OAuth without leaking tokens', async ({ + homeDir, + launchElectronApp, + userDataDir, + }) => { + const createdAt = '2026-06-07T00:00:00.000Z'; + const userCodexDir = join(homeDir, '.codex'); + const userCodexAuthPath = join(userCodexDir, 'auth.json'); + await mkdir(userCodexDir, { recursive: true }); + await writeFile(userCodexAuthPath, JSON.stringify({ + auth_mode: 'chatgpt', + OPENAI_API_KEY: null, + tokens: { + id_token: idToken, + access_token: accessToken, + refresh_token: refreshToken, + account_id: codexAccountId, + }, + last_refresh: createdAt, + }, null, 2), 'utf8'); + + 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: { + [accountId]: { + id: accountId, + vendorId: 'openai', + label: 'OpenAI Codex OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + metadata: { + email: 'codex-oauth-e2e@example.invalid', + resourceUrl: 'openai-codex', + }, + createdAt, + updatedAt: createdAt, + }, + }, + providerSecrets: { + [accountId]: { + type: 'oauth', + accountId, + accessToken, + refreshToken, + idToken, + expiresAt: 1_820_000_000_000, + email: 'codex-oauth-e2e@example.invalid', + subject: codexAccountId, + }, + }, + apiKeys: {}, + defaultProviderAccountId: accountId, + }, null, 2), 'utf8'); + + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_E2E_USER_CODEX_AUTH_JSON: userCodexAuthPath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + const beforeImport = await invokeProviderAction(page, 'codexOAuthStatus', { accountId }); + expect(beforeImport).toMatchObject({ + ok: true, + data: { + success: true, + managed: { exists: false, complete: false }, + user: { exists: true, complete: true, accountId: codexAccountId }, + provider: { + accountId, + vendorId: 'openai', + authMode: 'oauth_browser', + hasOAuthSecret: true, + userMatchesAccount: true, + }, + }, + }); + expectNoTokenLeak(beforeImport); + + const imported = await invokeProviderAction(page, 'importCodexOAuth', { accountId }); + if (!imported.data?.success) { + throw new Error(`Codex OAuth import failed: ${JSON.stringify(imported)}`); + } + expect(imported).toMatchObject({ + ok: true, + data: { + success: true, + managed: { + exists: true, + complete: true, + accountId: codexAccountId, + }, + provider: { + accountId, + hasOAuthSecret: true, + managedMatchesAccount: true, + }, + }, + }); + expectNoTokenLeak(imported); + + const managedAuthPath = join(userDataDir, 'credentials', 'oauth', accountId, 'codex-home', 'auth.json'); + const managedAuth = JSON.parse(await readFile(managedAuthPath, 'utf8')) as { + tokens?: Record; + }; + expect(managedAuth.tokens).toMatchObject({ + id_token: idToken, + access_token: accessToken, + refresh_token: refreshToken, + account_id: codexAccountId, + }); + + const publicProfilePath = join(userDataDir, 'runtimes', 'cc-connect', 'provider-profile.json'); + const publicProfile = await readFile(publicProfilePath, 'utf8'); + expect(publicProfile).toContain('CODEX_HOME'); + expect(publicProfile).not.toContain(accessToken); + expect(publicProfile).not.toContain(refreshToken); + expect(publicProfile).not.toContain(idToken); + + const afterImportStatus = await invokeProviderAction(page, 'codexOAuthStatus', { accountId }); + expect(afterImportStatus).toMatchObject({ + ok: true, + data: { + managed: { exists: true, complete: true, accountId: codexAccountId }, + provider: { managedMatchesAccount: true }, + }, + }); + expectNoTokenLeak(afterImportStatus); + + const loggedOut = await invokeProviderAction(page, 'logoutCodexOAuth', { accountId }); + expect(loggedOut).toMatchObject({ + ok: true, + data: { + success: true, + managed: { exists: false, complete: false }, + user: { exists: true, complete: true, accountId: codexAccountId }, + provider: { + accountId, + hasOAuthSecret: false, + userMatchesAccount: true, + }, + }, + }); + expectNoTokenLeak(loggedOut); + await expectMissing(managedAuthPath); + + const providerStore = await readFile(join(userDataDir, 'clawx-providers.json'), 'utf8'); + expect(providerStore).not.toContain(accessToken); + expect(providerStore).not.toContain(refreshToken); + expect(providerStore).not.toContain(idToken); + expect(providerStore).toContain(accountId); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/tests/e2e/cc-connect-codex-runtime.spec.ts b/tests/e2e/cc-connect-codex-runtime.spec.ts new file mode 100644 index 000000000..4d973f3cb --- /dev/null +++ b/tests/e2e/cc-connect-codex-runtime.spec.ts @@ -0,0 +1,1252 @@ +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { join } from 'node:path'; +import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; + +async function writeExecutable(path: string, content: string): Promise { + await writeFile(path, content, 'utf8'); + await chmod(path, 0o755); +} + +async function waitForPortClosed(port: number): Promise { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const available = await new Promise((resolve) => { + const server = createServer(); + server.once('error', () => resolve(false)); + server.listen(port, '127.0.0.1', () => { + server.close(() => resolve(true)); + }); + }); + if (available) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for TCP port ${port} to close`); +} + +async function createMockCodexBinary(dir: string): Promise { + const binaryPath = join(dir, 'bin', process.platform === 'win32' ? 'codex.exe' : 'codex'); + await mkdir(join(binaryPath, '..'), { recursive: true }); + await writeExecutable(binaryPath, `#!/usr/bin/env node +const fs = require('node:fs'); +const args = process.argv.slice(2); +if (args.includes('--version')) { + process.stdout.write('codex-cli e2e-mock\\n'); + process.exit(0); +} +if (args[0] !== 'exec') { + process.stderr.write('unexpected codex args: ' + JSON.stringify(args)); + process.exit(2); +} +if (process.env.CLAWX_E2E_CODEX_ARGS_PATH) { + fs.writeFileSync(process.env.CLAWX_E2E_CODEX_ARGS_PATH, JSON.stringify(args, null, 2)); +} +if (process.env.CLAWX_E2E_CODEX_ENV_PATH) { + fs.writeFileSync(process.env.CLAWX_E2E_CODEX_ENV_PATH, JSON.stringify({ + CODEX_HOME: process.env.CODEX_HOME || null, + }, null, 2)); +} +const outputIndex = args.indexOf('--output-last-message'); +if (outputIndex >= 0 && args[outputIndex + 1]) { + fs.writeFileSync(args[outputIndex + 1], 'Codex E2E response from mock binary'); +} +process.stdout.write(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call', + call_id: 'call_exec_e2e', + name: 'exec_command', + arguments: JSON.stringify({ cmd: 'pwd && ls -1' }), + }, +}) + '\\n'); +process.stdout.write(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: 'call_exec_e2e', + output: 'package.json\\nsrc\\n', + }, +}) + '\\n'); +process.stdout.write(JSON.stringify({ item: { role: 'assistant', content: [{ type: 'text', text: 'Codex E2E response from stdout' }] } }) + '\\n'); +process.exit(0); +`); + return binaryPath; +} + +async function createMockCcConnectBinary(dir: string): Promise { + const binaryPath = join(dir, process.platform === 'win32' ? 'cc-connect.exe' : 'cc-connect'); + const wsModulePath = require.resolve('ws'); + await mkdir(dir, { recursive: true }); + await writeExecutable(binaryPath, `#!/usr/bin/env node +const fs = require('node:fs'); +const http = require('node:http'); +const WebSocket = require(${JSON.stringify(wsModulePath)}); +const args = process.argv.slice(2); +if (args.includes('--version')) { + process.stdout.write('cc-connect v1.3.2 e2e-mock\\n'); + process.exit(0); +} +if (args[0] === 'doctor') { + process.stdout.write('cc-connect doctor e2e ok\\n'); + process.exit(0); +} +if (process.env.CLAWX_E2E_CC_CONNECT_ENV_PATH) { + fs.writeFileSync(process.env.CLAWX_E2E_CC_CONNECT_ENV_PATH, JSON.stringify({ + CLAWX_CODEX_OPENAI_MAIN_API_KEY: process.env.CLAWX_CODEX_OPENAI_MAIN_API_KEY || null, + CODEX_HOME: process.env.CODEX_HOME || null, + }, null, 2)); +} +let bridgePort = 9810; +let managementPort = 9820; +const configIndex = args.indexOf('-config'); +if (configIndex >= 0 && args[configIndex + 1]) { + try { + const config = fs.readFileSync(args[configIndex + 1], 'utf8'); + let section = ''; + for (const rawLine of config.split('\\n')) { + const line = rawLine.trim(); + if (line.startsWith('[') && line.endsWith(']')) { + section = line.slice(1, -1); + continue; + } + if (!line.startsWith('port =')) continue; + const value = Number(line.split('=')[1].trim().replace(/"/g, '')); + if (!Number.isFinite(value) || value <= 0) continue; + if (section === 'bridge') bridgePort = value; + if (section === 'management') managementPort = value; + } + } catch { + // Keep defaults when the mock is started without a readable config. + } +} +let cronSeq = 0; +let jobs = []; +const sessionsByProject = new Map(); +const deletedSessionIds = new Set(); +const pendingApprovals = new Map(); +function json(res, status, payload) { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(payload)); +} +function readBody(req) { + return new Promise((resolve) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + if (!text.trim()) { + resolve({}); + return; + } + try { + resolve(JSON.parse(text)); + } catch { + resolve({}); + } + }); + }); +} +function writeBridgeMessage(message) { + if (!process.env.CLAWX_E2E_CC_CONNECT_MESSAGES_PATH) return; + fs.appendFileSync(process.env.CLAWX_E2E_CC_CONNECT_MESSAGES_PATH, JSON.stringify(message) + '\\n'); +} +function fixtureSessions() { + const path = process.env.CLAWX_E2E_CC_CONNECT_SESSION_FIXTURE_PATH; + if (!path) return []; + try { + const value = JSON.parse(fs.readFileSync(path, 'utf8')); + return Array.isArray(value) ? value : []; + } catch { + return []; + } +} +function projectSessions(project) { + return [...(sessionsByProject.get(project) || []), ...fixtureSessions().filter((item) => item.project === project)] + .filter((item) => !deletedSessionIds.has(item.id)); +} +function storeBridgeSession(message, assistantText) { + const project = message.project || 'clawx-main'; + const sessionKey = message.session_key || 'clawx:main:main'; + const sessions = sessionsByProject.get(project) || []; + let session = sessions.find((item) => item.session_key === sessionKey); + const now = Date.now(); + if (!session) { + session = { + id: 'session-' + Buffer.from(project + ':' + sessionKey).toString('hex').slice(0, 24), + project, + session_key: sessionKey, + name: sessionKey, + active: true, + created_at: now, + updated_at: now, + history: [], + }; + sessions.push(session); + sessionsByProject.set(project, sessions); + } + session.history.push( + { id: 'user-' + now, role: 'user', content: message.content || '', timestamp: now }, + { id: 'assistant-' + now, role: 'assistant', content: assistantText, timestamp: now + 1 }, + ); + session.updated_at = now + 1; + session.last_message = session.history[session.history.length - 1]; +} +const handleHttpRequest = async (req, res) => { + if (req.url && req.url.startsWith('/api/v1/status')) { + json(res, 200, { ok: true }); + return; + } + if (req.url && req.url.startsWith('/api/v1/projects/')) { + const url = new URL(req.url, 'http://127.0.0.1:' + managementPort); + const parts = url.pathname.split('/').filter(Boolean); + const project = decodeURIComponent(parts[3] || 'clawx-main'); + if (parts.length === 4 && req.method === 'GET') { + json(res, 200, { name: project, platforms: [] }); + return; + } + if (parts[4] === 'sessions' && parts.length === 5 && req.method === 'GET') { + json(res, 200, { sessions: projectSessions(project).map((session) => ({ + id: session.id, + session_key: session.session_key, + name: session.name, + user_name: session.user_name, + chat_name: session.chat_name, + active: session.active !== false, + created_at: session.created_at, + updated_at: session.updated_at, + last_message: session.last_message, + })) }); + return; + } + if (parts[4] === 'sessions' && parts.length === 6) { + const id = decodeURIComponent(parts[5]); + const session = projectSessions(project).find((candidate) => candidate.id === id); + if (!session) { + json(res, 404, { error: 'session not found' }); + return; + } + if (req.method === 'GET') { + json(res, 200, { ...session, history: session.history || [] }); + return; + } + if (req.method === 'DELETE') { + deletedSessionIds.add(id); + json(res, 200, { success: true }); + return; + } + } + } + if (req.url && req.url.startsWith('/api/v1/cron')) { + const url = new URL(req.url, 'http://127.0.0.1:' + managementPort); + const parts = url.pathname.split('/').filter(Boolean); + const id = parts[3]; + if (req.method === 'GET' && parts.length === 3) { + const project = url.searchParams.get('project'); + json(res, 200, { jobs: project ? jobs.filter((job) => job.project === project) : jobs }); + return; + } + if (req.method === 'POST' && parts.length === 3) { + const body = await readBody(req); + const now = new Date().toISOString(); + const job = { + id: 'cron-e2e-' + (++cronSeq), + description: body.description || 'Scheduled task', + prompt: body.prompt || '', + cron_expr: body.cron_expr || '0 9 * * *', + enabled: body.enabled !== false, + silent: body.silent !== false, + project: body.project || 'clawx-main', + session_key: body.session_key || 'clawx:main:main', + created_at: now, + updated_at: now, + }; + jobs.push(job); + json(res, 200, { job }); + return; + } + const index = jobs.findIndex((job) => job.id === id); + if (!id || index < 0) { + json(res, 404, { error: 'cron not found' }); + return; + } + if (req.method === 'PATCH' && parts.length === 4) { + const body = await readBody(req); + jobs[index] = { ...jobs[index], ...body, updated_at: new Date().toISOString() }; + json(res, 200, { job: jobs[index] }); + return; + } + if (req.method === 'DELETE' && parts.length === 4) { + jobs.splice(index, 1); + json(res, 200, { success: true }); + return; + } + if (req.method === 'POST' && parts.length === 5 && parts[4] === 'exec') { + jobs[index] = { ...jobs[index], last_run_at: new Date().toISOString(), last_status: 'ok' }; + json(res, 200, { success: true }); + return; + } + json(res, 405, { error: 'unsupported cron method' }); + return; + } + json(res, 404, {}); +}; +const bridgeServer = http.createServer(handleHttpRequest); +const managementServer = http.createServer(handleHttpRequest); +const wss = new WebSocket.Server({ noServer: true }); +bridgeServer.on('upgrade', (req, socket, head) => { + if (!req.url || !req.url.startsWith('/bridge/ws')) { + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req)); +}); +wss.on('connection', (ws) => { + ws.on('message', (raw) => { + const msg = JSON.parse(String(raw)); + if (msg.type === 'register') { + ws.send(JSON.stringify({ type: 'register_ack', ok: true })); + return; + } + if (msg.type === 'message') { + writeBridgeMessage(msg); + if (String(msg.content || '').includes('require approval')) { + pendingApprovals.set(msg.reply_ctx, msg); + ws.send(JSON.stringify({ + type: 'buttons', + session_key: msg.session_key, + reply_ctx: msg.reply_ctx, + project: msg.project, + content: 'Allow Codex to run the requested command?', + buttons: [[ + { Text: 'Allow once', Data: 'perm:allow' }, + { Text: 'Deny', Data: 'perm:deny' }, + ]], + })); + return; + } + storeBridgeSession(msg, 'cc-connect bridge E2E response'); + ws.send(JSON.stringify({ + type: 'reply', + reply_ctx: msg.reply_ctx, + content: 'cc-connect bridge E2E response', + })); + return; + } + if (msg.type === 'card_action') { + writeBridgeMessage(msg); + const original = pendingApprovals.get(msg.reply_ctx); + if (!original) return; + const response = msg.action === 'perm:deny' + ? 'cc-connect approval denied' + : 'cc-connect approval accepted'; + storeBridgeSession(original, response); + pendingApprovals.delete(msg.reply_ctx); + ws.send(JSON.stringify({ + type: 'reply', + session_key: msg.session_key, + reply_ctx: msg.reply_ctx, + content: response, + })); + } + }); +}); +bridgeServer.listen(bridgePort, '127.0.0.1'); +managementServer.listen(managementPort, '127.0.0.1'); +process.stdout.write('cc-connect bridge e2e mock ready\\n'); +function shutdown() { + let remaining = 2; + const done = () => { + remaining -= 1; + if (remaining <= 0) process.exit(0); + }; + bridgeServer.close(done); + managementServer.close(done); +} +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); +`); + return binaryPath; +} + +async function prepareMockBundles(userDataDir: string): Promise<{ ccConnectPath: string; codexPath: string }> { + await Promise.all([ + waitForPortClosed(9810), + waitForPortClosed(9820), + ]); + const mockBundleDir = join(userDataDir, 'mock-runtime-bundles'); + const codexPath = await createMockCodexBinary(join(mockBundleDir, 'codex')); + const ccConnectPath = await createMockCcConnectBinary(join(mockBundleDir, 'cc-connect')); + await writeFile(join(userDataDir, 'codex-bundle-ready'), 'ok', 'utf8'); + return { ccConnectPath, codexPath }; +} + +async function writeMockCcConnectChannelSession(userDataDir: string): Promise { + const createdAt = Date.now() - 60_000; + const updatedAt = createdAt + 1_000; + await writeFile(join(userDataDir, 'cc-connect-session-fixtures.json'), JSON.stringify([ + { + project: 'clawx-support', + id: 'session-support-1', + session_key: 'clawx:support:member-1', + name: 'Support Channel', + chat_name: 'Support Channel', + user_name: 'Member One', + active: true, + created_at: createdAt, + updated_at: updatedAt, + last_message: { + id: 'support-assistant-1', + role: 'assistant', + content: 'reply synced from cc-connect channel', + timestamp: updatedAt, + }, + history: [ + { + id: 'support-user-1', + role: 'user', + content: 'message from connected channel', + timestamp: createdAt, + }, + { + id: 'support-assistant-1', + role: 'assistant', + content: 'reply synced from cc-connect channel', + timestamp: updatedAt, + }, + ], + }, + ], null, 2), 'utf8'); +} + +test.describe('cc-connect + Codex runtime E2E', () => { + test.skip(process.platform === 'win32', 'POSIX executable mock binaries are used in this E2E'); + + test('starts cc-connect runtime, writes managed config, and sends chat through cc-connect BridgePlatform', async ({ + launchElectronApp, + homeDir, + userDataDir, + }, testInfo) => { + const mockBundles = await prepareMockBundles(userDataDir); + const bridgeMessagesPath = join(userDataDir, 'cc-connect-bridge-messages.jsonl'); + const openClawDir = join(homeDir, '.openclaw'); + await mkdir(openClawDir, { recursive: true }); + await writeFile(join(openClawDir, 'openclaw.json'), JSON.stringify({ + agents: { + list: [ + { id: 'main', name: 'Main', default: true }, + { id: 'support', name: 'Support' }, + { id: 'analysis', name: 'Analysis' }, + ], + }, + }, null, 2), 'utf8'); + + 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: { + 'ollama-local': { + id: 'ollama-local', + vendorId: 'ollama', + label: 'Ollama Local', + authMode: 'local', + model: 'qwen3:latest', + enabled: true, + isDefault: true, + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-06-07T00:00:00.000Z', + }, + }, + providerSecrets: {}, + apiKeys: {}, + defaultProviderAccountId: 'ollama-local', + }, null, 2), 'utf8'); + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: mockBundles.ccConnectPath, + CLAWX_CODEX_PATH: mockBundles.codexPath, + CLAWX_E2E_CC_CONNECT_MESSAGES_PATH: bridgeMessagesPath, + CLAWX_E2E_CC_CONNECT_SESSION_FIXTURE_PATH: join(userDataDir, 'cc-connect-session-fixtures.json'), + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + await expect(page.getByTestId('chat-page')).toBeVisible(); + await expect(page.getByTestId('sidebar-nav-dreams')).toHaveCount(0); + + const startResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-start', + module: 'gateway', + action: 'start', + }); + }); + expect(startResult).toMatchObject({ + ok: true, + data: { success: true }, + }); + + const status = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-status', + module: 'gateway', + action: 'status', + }); + }); + expect(status).toMatchObject({ + ok: true, + data: { + state: 'running', + runtimeKind: 'cc-connect', + capabilities: expect.objectContaining({ + chat: true, + sessions: true, + history: true, + doctor: true, + providers: true, + models: true, + }), + operationCapabilities: expect.objectContaining({ + 'chat.send': expect.objectContaining({ support: 'native' }), + 'chat.approval.respond': expect.objectContaining({ support: 'native' }), + 'chat.abort': expect.objectContaining({ support: 'native' }), + 'doctor.fix': expect.objectContaining({ support: 'unsupported' }), + }), + }, + }); + + const managedConfig = join(userDataDir, 'runtimes', 'cc-connect', 'config.toml'); + await expect.poll(async () => await readFile(managedConfig, 'utf8')).toContain('path = "/bridge/ws"'); + await expect.poll(async () => await readFile(managedConfig, 'utf8')).toContain('cmd = "'); + await expect.poll(async () => await readFile(managedConfig, 'utf8')).toContain( + `work_dir = "${join(userDataDir, 'workspaces', 'agents', 'main')}"`, + ); + await expect.poll(async () => { + const content = await readFile(managedConfig, 'utf8'); + return content.split('\n').filter((line) => line.startsWith('work_dir =')).join('\n'); + }).not.toContain(process.cwd()); + + await page.getByTestId('sidebar-nav-agents').click(); + await expect(page.getByTestId('agents-page')).toBeVisible(); + await page.getByTestId('agent-settings-main').click(); + await page.getByTestId('agent-runtime-settings-main').click(); + await expect(page.getByTestId('agent-cc-connect-permission-mode')).toBeVisible(); + await page.getByTestId('agent-permission-mode-suggest').click(); + await expect(page.getByTestId('agent-permission-mode-suggest')).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByTestId('agent-runtime-settings-save')).toBeEnabled(); + await page.getByTestId('agent-runtime-settings-save').click(); + await expect.poll(async () => await readFile(managedConfig, 'utf8'), { timeout: 30_000 }) + .toContain('mode = "suggest"'); + await page.getByTestId('agent-settings-close').click(); + await page.getByTestId('sidebar-new-chat').click(); + await expect(page.getByTestId('chat-page')).toBeVisible(); + + await expect(page.getByTestId('chat-composer-input')).toBeEnabled({ timeout: 30_000 }); + await page.getByTestId('chat-composer-input').fill('hello codex runtime'); + await page.getByTestId('chat-composer-send').click(); + + const readHistory = async () => await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: `runtime-history-${Date.now()}`, + module: 'sessions', + action: 'history', + payload: { sessionKey: 'agent:main:main', limit: 20 }, + }); + }); + await expect.poll(async () => readHistory(), { timeout: 30_000 }).toMatchObject({ + ok: true, + data: { + success: true, + messages: expect.arrayContaining([ + expect.objectContaining({ role: 'user', content: 'hello codex runtime' }), + expect.objectContaining({ role: 'assistant', content: 'cc-connect bridge E2E response' }), + ]), + }, + }); + + await expect(page.getByText('cc-connect bridge E2E response')).toBeVisible({ timeout: 30_000 }); + const bridgeMessages = (await readFile(bridgeMessagesPath, 'utf8')).trim().split(/\r?\n/).map((line) => JSON.parse(line)); + expect(bridgeMessages).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'message', + content: 'hello codex runtime', + project: 'clawx-main', + session_key: 'clawx:main:main', + }), + ])); + + const history = await readHistory(); + expect(history).toMatchObject({ + ok: true, + data: { + success: true, + messages: expect.arrayContaining([ + expect.objectContaining({ role: 'user', content: 'hello codex runtime' }), + expect.objectContaining({ role: 'assistant', content: 'cc-connect bridge E2E response' }), + ]), + }, + }); + + await page.getByTestId('chat-composer-input').fill('please require approval before the command'); + await page.getByTestId('chat-composer-send').click(); + const allowApproval = page.getByTestId('chat-approval-action-perm:allow'); + await expect(allowApproval).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('Allow Codex to run the requested command?')).toBeVisible(); + await testInfo.attach('cc-connect-approval-request', { + body: await page.screenshot(), + contentType: 'image/png', + }); + await allowApproval.click(); + await expect(page.getByText('cc-connect approval accepted')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId('chat-approval-actions')).toHaveCount(0); + + const approvalBridgeMessages = (await readFile(bridgeMessagesPath, 'utf8')) + .trim() + .split(/\r?\n/) + .map((line) => JSON.parse(line)); + const approvalPacket = approvalBridgeMessages.find((message) => message.type === 'card_action'); + expect(approvalPacket).toMatchObject({ + type: 'card_action', + session_key: 'clawx:main:main', + project: 'clawx-main', + action: 'perm:allow', + }); + await testInfo.attach('cc-connect-approval-bridge-packet', { + body: Buffer.from(JSON.stringify(approvalPacket, null, 2)), + contentType: 'application/json', + }); + + await writeMockCcConnectChannelSession(userDataDir); + + const channelSessions = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channel-sessions', + module: 'sessions', + action: 'summaries', + payload: {}, + }); + }); + expect(channelSessions).toMatchObject({ + ok: true, + data: { + success: true, + sessions: expect.arrayContaining([ + expect.objectContaining({ + key: 'agent:support:member-1', + displayName: 'Support Channel / Member One', + }), + ]), + }, + }); + + const channelHistory = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channel-history', + module: 'sessions', + action: 'history', + payload: { sessionKey: 'agent:support:member-1', limit: 20 }, + }); + }); + expect(channelHistory).toMatchObject({ + ok: true, + data: { + success: true, + messages: expect.arrayContaining([ + expect.objectContaining({ role: 'user', content: 'message from connected channel' }), + expect.objectContaining({ role: 'assistant', content: 'reply synced from cc-connect channel' }), + ]), + }, + }); + + const renameChannelSession = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channel-session-rename', + module: 'sessions', + action: 'rename', + payload: { + sessionKey: 'agent:support:member-1', + title: 'Renamed support channel', + }, + }); + }); + expect(renameChannelSession).toMatchObject({ ok: true, data: { success: true } }); + + const renamedChannelSessions = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channel-sessions-after-rename', + module: 'sessions', + action: 'summaries', + payload: {}, + }); + }); + expect(renamedChannelSessions).toMatchObject({ + ok: true, + data: { + success: true, + sessions: expect.arrayContaining([ + expect.objectContaining({ + key: 'agent:support:member-1', + displayName: 'Renamed support channel', + derivedTitle: 'Renamed support channel', + }), + ]), + }, + }); + + const deleteChannelSession = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channel-session-delete', + module: 'sessions', + action: 'delete', + payload: { sessionKey: 'agent:support:member-1' }, + }); + }); + expect(deleteChannelSession).toMatchObject({ ok: true, data: { success: true } }); + + const [channelSessionsAfterDelete, channelHistoryAfterDelete] = await Promise.all([ + page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channel-sessions-after-delete', + module: 'sessions', + action: 'summaries', + payload: {}, + }); + }), + page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channel-history-after-delete', + module: 'sessions', + action: 'history', + payload: { sessionKey: 'agent:support:member-1', limit: 20 }, + }); + }), + ]); + expect(channelSessionsAfterDelete).toMatchObject({ + ok: true, + data: { + success: true, + sessions: expect.not.arrayContaining([ + expect.objectContaining({ key: 'agent:support:member-1' }), + ]), + }, + }); + expect(channelHistoryAfterDelete).toMatchObject({ + ok: true, + data: { + success: false, + error: 'Session not found', + }, + }); + + const cronCreate = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-create', + module: 'cron', + action: 'create', + payload: { + name: 'Runtime follow up', + message: 'summarize runtime state', + schedule: { kind: 'cron', expr: '0 9 * * *' }, + enabled: true, + delivery: { mode: 'none' }, + }, + }); + }); + expect(cronCreate).toMatchObject({ + ok: true, + data: { + id: 'cron-e2e-1', + name: 'Runtime follow up', + message: 'summarize runtime state', + enabled: true, + }, + }); + + const cronList = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-list', + module: 'cron', + action: 'list', + }); + }); + expect(cronList).toMatchObject({ + ok: true, + data: [expect.objectContaining({ id: 'cron-e2e-1', name: 'Runtime follow up' })], + }); + + const cronToggle = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-toggle', + module: 'cron', + action: 'toggle', + payload: { id: 'cron-e2e-1', enabled: false }, + }); + }); + expect(cronToggle).toMatchObject({ + ok: true, + data: expect.objectContaining({ id: 'cron-e2e-1', enabled: false }), + }); + + const cronRun = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-run', + module: 'cron', + action: 'trigger', + payload: { id: 'cron-e2e-1' }, + }); + }); + expect(cronRun).toMatchObject({ ok: true, data: { success: true } }); + + const cronDelete = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-delete', + module: 'cron', + action: 'delete', + payload: { id: 'cron-e2e-1' }, + }); + }); + expect(cronDelete).toMatchObject({ ok: true, data: { success: true } }); + + await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-agent-chat', + module: 'chat', + action: 'sendWithMedia', + payload: { + sessionKey: 'agent:analysis:member-2', + message: 'hello isolated agent workspace', + deliver: false, + idempotencyKey: 'analysis-agent-e2e', + }, + }); + }); + const readAnalysisHistory = async () => await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: `runtime-agent-history-${Date.now()}`, + module: 'sessions', + action: 'history', + payload: { sessionKey: 'agent:analysis:member-2', limit: 20 }, + }); + }); + await expect.poll(async () => readAnalysisHistory(), { timeout: 30_000 }).toMatchObject({ + ok: true, + data: { + success: true, + messages: expect.arrayContaining([ + expect.objectContaining({ role: 'user', content: 'hello isolated agent workspace' }), + ]), + }, + }); + const bridgeMessagesAfterCrossAgent = (await readFile(bridgeMessagesPath, 'utf8')).trim().split(/\r?\n/).map((line) => JSON.parse(line)); + expect(bridgeMessagesAfterCrossAgent).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'message', + content: 'hello isolated agent workspace', + project: 'clawx-analysis', + session_key: 'clawx:analysis:member-2', + }), + ])); + } finally { + await closeElectronApp(app); + } + }); + + test('starts cc-connect runtime with an OpenAI API key provider profile', async ({ + launchElectronApp, + userDataDir, + }) => { + const mockBundles = await prepareMockBundles(userDataDir); + const envCapturePath = join(userDataDir, 'cc-connect-env.json'); + const createdAt = '2026-06-07T00:00:00.000Z'; + + 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: { + 'openai-main': { + id: 'openai-main', + vendorId: 'openai', + label: 'OpenAI API Key', + authMode: 'api_key', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + createdAt, + updatedAt: createdAt, + }, + }, + providerSecrets: { + 'openai-main': { + type: 'api_key', + accountId: 'openai-main', + apiKey: 'sk-e2e-openai-secret', + }, + }, + apiKeys: {}, + defaultProviderAccountId: 'openai-main', + }, null, 2), 'utf8'); + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: mockBundles.ccConnectPath, + CLAWX_CODEX_PATH: mockBundles.codexPath, + CLAWX_E2E_CC_CONNECT_ENV_PATH: envCapturePath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + const startResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-start-openai-api-key', + module: 'gateway', + action: 'start', + }); + }); + expect(startResult).toMatchObject({ + ok: true, + data: { success: true }, + }); + + const managedConfig = await readFile(join(userDataDir, 'runtimes', 'cc-connect', 'config.toml'), 'utf8'); + expect(managedConfig).toContain('provider = "openai"'); + expect(managedConfig).toContain('api_key = "${CLAWX_CODEX_OPENAI_MAIN_API_KEY}"'); + expect(managedConfig).toContain('model = "gpt-5.5"'); + expect(managedConfig).not.toContain('sk-e2e-openai-secret'); + + const envCapture = JSON.parse(await readFile(envCapturePath, 'utf8')); + expect(envCapture).toMatchObject({ + CLAWX_CODEX_OPENAI_MAIN_API_KEY: 'sk-e2e-openai-secret', + }); + expect(String(envCapture.CODEX_HOME)).toContain(join(userDataDir, 'credentials', 'oauth', 'openai-main', 'codex-home')); + + const publicProfile = await readFile(join(userDataDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8'); + expect(publicProfile).toContain('CLAWX_CODEX_OPENAI_MAIN_API_KEY'); + expect(publicProfile).not.toContain('sk-e2e-openai-secret'); + + const migratedProviderStore = JSON.parse(await readFile(join(userDataDir, 'clawx-providers.json'), 'utf8')); + expect(migratedProviderStore.providerSecrets).toEqual({}); + expect(migratedProviderStore.apiKeys).toEqual({}); + const encryptedVault = await readFile(join(userDataDir, 'credentials', 'secrets.enc')); + expect(encryptedVault.toString('utf8')).not.toContain('sk-e2e-openai-secret'); + const credentialIndex = await readFile(join(userDataDir, 'credentials', 'index.json'), 'utf8'); + expect(credentialIndex).toContain('openai-main'); + expect(credentialIndex).not.toContain('sk-e2e-openai-secret'); + } finally { + await closeElectronApp(app); + } + }); + + test('starts cc-connect runtime with OpenAI OAuth Codex auth in a managed CODEX_HOME', async ({ + launchElectronApp, + userDataDir, + }) => { + const mockBundles = await prepareMockBundles(userDataDir); + const createdAt = '2026-06-07T00:00:00.000Z'; + + 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: { + 'openai-oauth': { + id: 'openai-oauth', + vendorId: 'openai', + label: 'OpenAI OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + metadata: { email: 'user@example.com', resourceUrl: 'openai-codex' }, + createdAt, + updatedAt: createdAt, + }, + }, + providerSecrets: { + 'openai-oauth': { + type: 'oauth', + accountId: 'openai-oauth', + accessToken: 'fake-access-token', + refreshToken: 'fake-refresh-token', + idToken: 'fake-id-token', + expiresAt: 1_780_000_000_000, + email: 'user@example.com', + subject: 'acct_e2e', + }, + }, + apiKeys: {}, + defaultProviderAccountId: 'openai-oauth', + }, null, 2), 'utf8'); + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: mockBundles.ccConnectPath, + CLAWX_CODEX_PATH: mockBundles.codexPath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + const startResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-start-oauth', + module: 'gateway', + action: 'start', + }); + }); + expect(startResult).toMatchObject({ + ok: true, + data: { success: true }, + }); + + const managedCodexHome = join(userDataDir, 'credentials', 'oauth', 'openai-oauth', 'codex-home'); + + const authJson = JSON.parse(await readFile(join(managedCodexHome, 'auth.json'), 'utf8')); + expect(authJson).toMatchObject({ + auth_mode: 'chatgpt', + OPENAI_API_KEY: null, + tokens: { + id_token: 'fake-id-token', + access_token: 'fake-access-token', + refresh_token: 'fake-refresh-token', + account_id: 'acct_e2e', + }, + }); + + const publicProfile = await readFile(join(userDataDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8'); + expect(publicProfile).toContain('CODEX_HOME'); + expect(publicProfile).not.toContain('fake-access-token'); + expect(publicProfile).not.toContain('fake-refresh-token'); + expect(publicProfile).not.toContain('fake-id-token'); + } finally { + await closeElectronApp(app); + } + }); + + test('replaces stale managed Codex auth after browser OAuth runtime sync', async ({ + launchElectronApp, + userDataDir, + }) => { + const mockBundles = await prepareMockBundles(userDataDir); + const createdAt = '2026-06-07T00:00:00.000Z'; + const managedCodexHome = join(userDataDir, 'credentials', 'oauth', 'openai-oauth', 'codex-home'); + const managedAuthPath = join(managedCodexHome, 'auth.json'); + + await mkdir(managedCodexHome, { recursive: true }); + await writeFile(managedAuthPath, JSON.stringify({ + auth_mode: 'chatgpt', + OPENAI_API_KEY: null, + tokens: { + id_token: 'stale-managed-e2e-id-token', + access_token: 'stale-managed-e2e-access-token', + refresh_token: 'stale-managed-e2e-refresh-token', + account_id: 'acct_relogin_e2e', + }, + last_refresh: createdAt, + }, null, 2), 'utf8'); + 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: { + 'openai-oauth': { + id: 'openai-oauth', + vendorId: 'openai', + label: 'OpenAI OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + createdAt, + updatedAt: '2026-07-12T00:00:00.000Z', + }, + }, + providerSecrets: { + 'openai-oauth': { + type: 'oauth', + accountId: 'openai-oauth', + accessToken: 'new-browser-e2e-access-token', + refreshToken: 'new-browser-e2e-refresh-token', + idToken: 'new-browser-e2e-id-token', + expiresAt: 1_820_000_000_000, + subject: 'acct_relogin_e2e', + }, + }, + apiKeys: {}, + defaultProviderAccountId: 'openai-oauth', + }, null, 2), 'utf8'); + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: mockBundles.ccConnectPath, + CLAWX_CODEX_PATH: mockBundles.codexPath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + await expect(page.evaluate(async () => await window.clawx.hostInvoke({ + id: 'runtime-start-before-oauth-relogin', + module: 'gateway', + action: 'start', + }))).resolves.toMatchObject({ ok: true, data: { success: true } }); + + const beforeSync = await readFile(managedAuthPath, 'utf8'); + expect(beforeSync).toContain('stale-managed-e2e-access-token'); + expect(beforeSync).not.toContain('new-browser-e2e-access-token'); + + await expect(page.evaluate(async () => await window.clawx.hostInvoke({ + id: 'runtime-provider-sync-after-oauth-relogin', + module: 'gateway', + action: 'rpc', + payload: { + method: 'providers.sync', + params: { providerId: 'openai-oauth', reason: 'oauth' }, + }, + }))).resolves.toMatchObject({ + ok: true, + data: { + success: true, + profile: { + providerId: 'openai-oauth', + supported: true, + }, + }, + }); + + const afterSync = await readFile(managedAuthPath, 'utf8'); + expect(afterSync).toContain('new-browser-e2e-access-token'); + expect(afterSync).toContain('new-browser-e2e-refresh-token'); + expect(afterSync).toContain('new-browser-e2e-id-token'); + expect(afterSync).not.toContain('stale-managed-e2e-access-token'); + + const publicProfile = await readFile(join(userDataDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8'); + expect(publicProfile).toContain('CODEX_HOME'); + expect(publicProfile).not.toContain('new-browser-e2e-access-token'); + expect(publicProfile).not.toContain('new-browser-e2e-refresh-token'); + expect(publicProfile).not.toContain('new-browser-e2e-id-token'); + } finally { + await closeElectronApp(app); + } + }); + + test('starts cc-connect runtime with existing managed Codex OAuth auth and no provider secret', async ({ + launchElectronApp, + userDataDir, + }) => { + const mockBundles = await prepareMockBundles(userDataDir); + const createdAt = '2026-06-07T00:00:00.000Z'; + const legacyCodexHome = join(userDataDir, 'runtimes', 'cc-connect', 'codex-home'); + const managedCodexHome = join(userDataDir, 'credentials', 'oauth', 'openai-oauth', 'codex-home'); + + await mkdir(legacyCodexHome, { recursive: true }); + await writeFile(join(legacyCodexHome, 'auth.json'), JSON.stringify({ + auth_mode: 'chatgpt', + OPENAI_API_KEY: null, + tokens: { + id_token: 'managed-e2e-id-token', + access_token: 'managed-e2e-access-token', + refresh_token: 'managed-e2e-refresh-token', + account_id: 'acct_managed_e2e', + }, + last_refresh: createdAt, + }, null, 2), 'utf8'); + 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: { + 'openai-oauth': { + id: 'openai-oauth', + vendorId: 'openai', + label: 'OpenAI Codex OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + metadata: { email: 'user@example.com', resourceUrl: 'openai-codex' }, + createdAt, + updatedAt: createdAt, + }, + }, + providerSecrets: {}, + apiKeys: {}, + defaultProviderAccountId: 'openai-oauth', + }, null, 2), 'utf8'); + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: mockBundles.ccConnectPath, + CLAWX_CODEX_PATH: mockBundles.codexPath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + const startResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-start-managed-oauth', + module: 'gateway', + action: 'start', + }); + }); + expect(startResult).toMatchObject({ + ok: true, + data: { success: true }, + }); + + const authJson = JSON.parse(await readFile(join(managedCodexHome, 'auth.json'), 'utf8')); + expect(authJson).toMatchObject({ + auth_mode: 'chatgpt', + OPENAI_API_KEY: null, + last_refresh: createdAt, + tokens: { + id_token: 'managed-e2e-id-token', + access_token: 'managed-e2e-access-token', + refresh_token: 'managed-e2e-refresh-token', + account_id: 'acct_managed_e2e', + }, + }); + + const publicProfile = await readFile(join(userDataDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8'); + expect(publicProfile).toContain('CODEX_HOME'); + expect(publicProfile).not.toContain('managed-e2e-access-token'); + expect(publicProfile).not.toContain('managed-e2e-refresh-token'); + expect(publicProfile).not.toContain('managed-e2e-id-token'); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/tests/e2e/cc-connect-real-bundle-smoke.spec.ts b/tests/e2e/cc-connect-real-bundle-smoke.spec.ts new file mode 100644 index 000000000..7786381c0 --- /dev/null +++ b/tests/e2e/cc-connect-real-bundle-smoke.spec.ts @@ -0,0 +1,2185 @@ +import { access, chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { createConnection, createServer, type Server } from 'node:net'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import WebSocket from 'ws'; +import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; + +const execFileAsync = promisify(execFile); + +type BridgePacket = { + type?: string; + ok?: boolean; + reply_ctx?: string; + content?: string; + card?: unknown; + error?: string; +}; + +async function connectChannelCronProbe(options: { + port: number; + token: string; + project: string; +}): Promise<{ + socket: WebSocket; + packets: BridgePacket[]; + command: (content: string) => Promise; + cardAction: (action: string) => void; +}> { + const socket = new WebSocket(`ws://127.0.0.1:${options.port}/bridge/ws?token=${encodeURIComponent(options.token)}`); + const packets: BridgePacket[] = []; + socket.on('message', (data) => { + try { + packets.push(JSON.parse(data.toString()) as BridgePacket); + } catch { + // The public protocol is JSON-only; malformed packets are ignored so the assertion times out with context. + } + }); + await new Promise((resolve, reject) => { + socket.once('open', resolve); + socket.once('error', reject); + }); + socket.send(JSON.stringify({ + type: 'register', + platform: 'feishu', + project: options.project, + capabilities: ['text', 'card', 'buttons'], + metadata: { protocol_version: 1, description: 'ClawX channel Cron E2E probe' }, + })); + await expect.poll(() => packets.find((packet) => packet.type === 'register_ack'), { + timeout: 10_000, + intervals: [100, 250, 500], + }).toMatchObject({ ok: true }); + + return { + socket, + packets, + command: async (content) => { + const replyContext = `cron-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const packetOffset = packets.length; + socket.send(JSON.stringify({ + type: 'message', + msg_id: replyContext, + session_key: 'feishu:cron-admin', + user_id: 'clawx-desktop', + user_name: 'ClawX Admin', + content, + reply_ctx: replyContext, + project: options.project, + })); + try { + await expect.poll(() => packets.find((packet) => ( + packet.reply_ctx === replyContext + && ['reply', 'card', 'buttons', 'error'].includes(packet.type ?? '') + )), { + timeout: 10_000, + intervals: [100, 250, 500], + message: `cc-connect should answer channel command: ${content}`, + }).toBeTruthy(); + } catch (error) { + const packetSummary = packets.slice(packetOffset).map((packet) => ({ + type: packet.type, + reply_ctx: packet.reply_ctx, + content: packet.content, + hasCard: packet.card !== undefined, + error: packet.error, + })); + throw new Error( + `${error instanceof Error ? error.message : String(error)}; packets=${JSON.stringify(packetSummary)}`, + { cause: error }, + ); + } + return packets.find((packet) => ( + packet.reply_ctx === replyContext + && ['reply', 'card', 'buttons', 'error'].includes(packet.type ?? '') + ))!; + }, + cardAction: (action) => { + socket.send(JSON.stringify({ + type: 'card_action', + session_key: 'feishu:cron-admin', + action, + reply_ctx: `cron-action-${Date.now()}-${Math.random().toString(16).slice(2)}`, + project: options.project, + })); + }, + }; +} + +async function closeBridgeSocket(socket: WebSocket | undefined): Promise { + if (!socket || socket.readyState === WebSocket.CLOSED) return; + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 2_000); + socket.once('close', () => { + clearTimeout(timeout); + resolve(); + }); + socket.close(); + }); +} + +async function realRuntimeBundles(): Promise<{ ccConnectPath: string; codexPath: string } | null> { + const platformArch = `${process.platform}-${process.arch}`; + const ccConnectPath = join( + process.cwd(), + 'build', + 'cc-connect', + platformArch, + process.platform === 'win32' ? 'cc-connect.exe' : 'cc-connect', + ); + const codexPath = join( + process.cwd(), + 'build', + 'codex', + platformArch, + 'bin', + process.platform === 'win32' ? 'codex.cmd' : 'codex', + ); + try { + await access(ccConnectPath); + await access(codexPath); + return { ccConnectPath, codexPath }; + } catch { + return null; + } +} + +async function createDeterministicCodexAppServer(root: string): Promise { + const binDir = join(root, 'bin'); + const scriptPath = join(binDir, 'mock-app-server.cjs'); + const binaryPath = join(binDir, process.platform === 'win32' ? 'codex.cmd' : 'codex'); + await mkdir(binDir, { recursive: true }); + await writeFile(scriptPath, `const readline = require('node:readline'); +const args = process.argv.slice(2); +if (args.includes('--version')) { + process.stdout.write('codex-cli 0.137.0 deterministic-app-server\\n'); + process.exit(0); +} +if (args[0] !== 'app-server') { + process.stderr.write('expected app-server, got ' + JSON.stringify(args)); + process.exit(2); +} +const send = (value) => process.stdout.write(JSON.stringify(value) + '\\n'); +const notify = (method, params) => send({ jsonrpc: '2.0', method, params }); +const rl = readline.createInterface({ input: process.stdin }); +rl.on('line', (line) => { + let request; + try { request = JSON.parse(line); } catch { return; } + if (request.id == null) return; + if (request.method === 'initialize') { + send({ jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2026-06-01' } }); + return; + } + if (request.method === 'thread/start') { + send({ jsonrpc: '2.0', id: request.id, result: { + cwd: process.cwd(), model: 'gpt-5.4-mini', reasoningEffort: 'medium', thread: { id: 'thread-rich-preview' }, + } }); + return; + } + if (request.method === 'account/rateLimits/read') { + send({ jsonrpc: '2.0', id: request.id, result: { rateLimits: {}, rateLimitsByLimitId: {} } }); + return; + } + if (request.method === 'turn/start') { + const turnId = 'turn-rich-preview'; + send({ jsonrpc: '2.0', id: request.id, result: { turn: { id: turnId } } }); + notify('turn/started', { threadId: 'thread-rich-preview', turn: { id: turnId, status: 'inProgress' } }); + setTimeout(() => notify('item/completed', { + threadId: 'thread-rich-preview', turnId, + item: { id: 'reasoning-1', type: 'reasoning', summary: ['Inspecting through cc-connect'] }, + }), 50); + setTimeout(() => notify('item/started', { + threadId: 'thread-rich-preview', turnId, + item: { id: 'command-1', type: 'commandExecution', command: 'printf cc-connect-preview', status: 'inProgress' }, + }), 1_200); + setTimeout(() => notify('item/completed', { + threadId: 'thread-rich-preview', turnId, + item: { + id: 'command-1', type: 'commandExecution', command: 'printf cc-connect-preview', + status: 'completed', aggregatedOutput: 'cc-connect-preview', exitCode: 0, + }, + }), 2_400); + setTimeout(() => notify('item/completed', { + threadId: 'thread-rich-preview', turnId, + item: { id: 'message-1', type: 'agentMessage', text: 'CLAWX_REAL_RICH_PREVIEW_OK' }, + }), 2_700); + setTimeout(() => notify('turn/completed', { + threadId: 'thread-rich-preview', turn: { id: turnId, status: 'completed' }, + }), 2_850); + return; + } + send({ jsonrpc: '2.0', id: request.id, result: {} }); +}); +`, 'utf8'); + if (process.platform === 'win32') { + await writeFile(binaryPath, `@echo off\r\nnode "%~dp0\\mock-app-server.cjs" %*\r\n`, 'utf8'); + } else { + await writeFile(binaryPath, `#!/bin/sh\nexec node "$(dirname "$0")/mock-app-server.cjs" "$@"\n`, 'utf8'); + await chmod(binaryPath, 0o755); + } + return binaryPath; +} + +async function isPortOpen(port: number): Promise { + return await new Promise((resolve) => { + const socket = createConnection({ host: '127.0.0.1', port }); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('error', () => resolve(false)); + socket.setTimeout(500, () => { + socket.destroy(); + resolve(false); + }); + }); +} + +async function waitForPortClosed(port: number): Promise { + await expect.poll(async () => await isPortOpen(port), { + timeout: 15_000, + intervals: [250, 500, 1_000], + message: `cc-connect port ${port} should be free before real bundle smoke starts`, + }).toBe(false); +} + +async function occupyPort(port: number): Promise { + return await new Promise((resolve) => { + const server = createServer(); + server.once('error', () => resolve(null)); + server.listen(port, '127.0.0.1', () => resolve(server)); + }); +} + +async function closeServer(server: Server | null | undefined): Promise { + if (!server) return; + await new Promise((resolve) => server.close(() => resolve())); +} + +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function listProcessCommandsContaining(needle: string): Promise { + if (process.platform === 'win32') return []; + 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 expectRuntimeProcessCleanedUp(options: { + pid?: number; + runtimeDir: string; + managementPort?: number; + bridgePort?: number; +}): Promise { + if (typeof options.pid === 'number') { + await expect.poll(() => isPidAlive(options.pid), { + timeout: 15_000, + intervals: [250, 500, 1_000], + message: `cc-connect pid ${options.pid} should exit`, + }).toBe(false); + } + + for (const port of [options.managementPort, options.bridgePort].filter((value): value is number => typeof value === 'number')) { + await expect.poll(async () => await isPortOpen(port), { + timeout: 15_000, + intervals: [250, 500, 1_000], + message: `cc-connect port ${port} should close`, + }).toBe(false); + } + + if (process.platform !== 'win32') { + await expect.poll(async () => await listProcessCommandsContaining(options.runtimeDir), { + timeout: 15_000, + intervals: [250, 500, 1_000], + message: `no process should reference ${options.runtimeDir}`, + }).toEqual([]); + } +} + +async function seedCcConnectRuntimeSettings(userDataDir: string): Promise { + const createdAt = '2026-06-07T00:00:00.000Z'; + 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: { + 'ollama-local': { + id: 'ollama-local', + vendorId: 'ollama', + label: 'Ollama', + authMode: 'local', + model: 'qwen3:latest', + enabled: true, + isDefault: true, + createdAt, + updatedAt: createdAt, + }, + }, + providerSecrets: {}, + apiKeys: {}, + defaultProviderAccountId: 'ollama-local', + }, null, 2), 'utf8'); +} + +async function seedCanonicalRuntimeConfig(userDataDir: string, config: Record): Promise { + const appDir = join(userDataDir, 'app'); + await mkdir(appDir, { recursive: true }); + await writeFile(join(appDir, 'runtime-config.json'), JSON.stringify({ + schema: 'clawx-runtime-config', + version: 1, + updatedAt: new Date().toISOString(), + config, + }, null, 2), 'utf8'); +} + +test.describe('cc-connect real runtime bundle smoke', () => { + test('probes the public Management and Bridge session APIs exposed by the bundled runtime', async ({ + launchElectronApp, + homeDir, + userDataDir, + }) => { + const bundles = await realRuntimeBundles(); + test.skip(!bundles, 'Run pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current first.'); + await waitForPortClosed(9810); + await waitForPortClosed(9820); + + await seedCcConnectRuntimeSettings(userDataDir); + const mainWorkspace = join(userDataDir, 'workspaces', 'agents', 'main'); + const researchWorkspace = join(userDataDir, 'workspaces', 'agents', 'research'); + await mkdir(join(homeDir, '.openclaw'), { recursive: true }); + await mkdir(mainWorkspace, { recursive: true }); + await mkdir(researchWorkspace, { recursive: true }); + await writeFile(join(homeDir, '.openclaw', '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 app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: bundles!.ccConnectPath, + CLAWX_CODEX_PATH: bundles!.codexPath, + }, + }); + let channelCronProbe: Awaited> | undefined; + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + await expect.poll(async () => { + const result = await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-start-session-api-probe', + module: 'gateway', + action: 'start', + })); + return result.ok; + }, { timeout: 30_000 }).toBe(true); + + const config = await readFile(join(userDataDir, 'runtimes', 'cc-connect', 'config.toml'), 'utf8'); + const managementBlock = config.match(/\[management\]([\s\S]*?)(?=\n\[|$)/)?.[1] ?? ''; + const bridgeBlock = config.match(/\[bridge\]([\s\S]*?)(?=\n\[|$)/)?.[1] ?? ''; + const managementPort = Number(managementBlock.match(/^port\s*=\s*(\d+)/m)?.[1]); + const managementToken = managementBlock.match(/^token\s*=\s*"([^"]+)"/m)?.[1] ?? ''; + const bridgePort = Number(bridgeBlock.match(/^port\s*=\s*(\d+)/m)?.[1]); + const bridgeToken = bridgeBlock.match(/^token\s*=\s*"([^"]+)"/m)?.[1] ?? ''; + expect(managementPort).toBeGreaterThan(0); + expect(bridgePort).toBeGreaterThan(0); + expect(managementToken).not.toBe(''); + expect(bridgeToken).not.toBe(''); + expect(config).toContain('admin_from = "clawx-desktop"'); + + const managementSessions = await fetch(`http://127.0.0.1:${managementPort}/api/v1/projects/clawx-main/sessions`, { + headers: { Authorization: `Bearer ${managementToken}` }, + }); + expect(managementSessions.status).toBe(200); + await expect(managementSessions.json()).resolves.toMatchObject({ ok: true }); + + const managementProviders = await fetch(`http://127.0.0.1:${managementPort}/api/v1/projects/clawx-main/providers`, { + headers: { Authorization: `Bearer ${managementToken}` }, + }); + expect(managementProviders.status).toBe(200); + await expect(managementProviders.json()).resolves.toMatchObject({ ok: true }); + + const managementModels = await fetch(`http://127.0.0.1:${managementPort}/api/v1/projects/clawx-main/models`, { + headers: { Authorization: `Bearer ${managementToken}` }, + }); + expect(managementModels.status).toBe(200); + await expect(managementModels.json()).resolves.toMatchObject({ ok: true }); + + const researchProviders = await fetch(`http://127.0.0.1:${managementPort}/api/v1/projects/clawx-research/providers`, { + headers: { Authorization: `Bearer ${managementToken}` }, + }); + expect(researchProviders.status).toBe(200); + await expect(researchProviders.json()).resolves.toMatchObject({ ok: true }); + + const researchModels = await fetch(`http://127.0.0.1:${managementPort}/api/v1/projects/clawx-research/models`, { + headers: { Authorization: `Bearer ${managementToken}` }, + }); + expect(researchModels.status).toBe(200); + await expect(researchModels.json()).resolves.toMatchObject({ ok: true }); + + const statusBeforeProfiles = await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-status-before-public-profiles', + module: 'gateway', + action: 'status', + })); + const providerProfileResult = await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-public-provider-profile', + module: 'gateway', + action: 'rpc', + payload: { method: 'providers.profile' }, + })); + const modelProfileResult = await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-public-model-profile', + module: 'gateway', + action: 'rpc', + payload: { method: 'models.profile' }, + })); + const statusAfterProfiles = await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-status-after-public-profiles', + module: 'gateway', + action: 'status', + })); + for (const result of [providerProfileResult, modelProfileResult]) { + expect(result).toMatchObject({ + ok: true, + data: { + success: true, + runtimeState: 'running', + projects: expect.arrayContaining([ + expect.objectContaining({ projectName: 'clawx-main' }), + expect.objectContaining({ projectName: 'clawx-research' }), + ]), + }, + }); + } + expect(statusAfterProfiles).toMatchObject({ + ok: true, + data: expect.objectContaining({ + pid: (statusBeforeProfiles as { data?: { pid?: number } }).data?.pid, + }), + }); + const providerProjects = (providerProfileResult as { + data?: { projects?: Array<{ projectName?: string }> }; + }).data?.projects?.map((project) => project.projectName).filter(Boolean) ?? []; + const profileEvidenceDir = join(process.cwd(), 'artifacts', 'cc-connect'); + await mkdir(profileEvidenceDir, { recursive: true }); + await writeFile(join(profileEvidenceDir, 'real-management-profiles.json'), `${JSON.stringify({ + schema: 'clawx-cc-connect-management-profile-evidence', + version: 1, + runtimeKind: 'cc-connect', + projects: providerProjects, + providersEndpoint: true, + modelsEndpoint: true, + hostApiProviderProfile: providerProfileResult.ok === true, + hostApiModelProfile: modelProfileResult.ok === true, + pidPreserved: (statusBeforeProfiles as { data?: { pid?: number } }).data?.pid + === (statusAfterProfiles as { data?: { pid?: number } }).data?.pid, + }, null, 2)}\n`, 'utf8'); + + channelCronProbe = await connectChannelCronProbe({ + port: bridgePort, + token: bridgeToken, + project: 'clawx-main', + }); + const listCron = async () => await page.evaluate(async () => window.clawx.hostInvoke({ + id: `runtime-channel-cron-list-${Date.now()}`, + module: 'cron', + action: 'list', + })) as { ok?: boolean; data?: Array<{ id?: string; message?: string; enabled?: boolean }> }; + const channelMarker = 'CLAWX_CHANNEL_CRON_SHARED_SCHEDULER'; + const channelAddReply = await channelCronProbe.command(`/cron add 0 0 1 1 * ${channelMarker}`); + await expect.poll(listCron, { + timeout: 10_000, + intervals: [100, 250, 500], + }).toMatchObject({ + ok: true, + data: expect.arrayContaining([ + expect.objectContaining({ message: channelMarker, enabled: true }), + ]), + }); + const channelJob = (await listCron()).data?.find((job) => job.message === channelMarker); + expect(channelJob?.id).toBeTruthy(); + + const guiCron = await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-gui-cron-create-for-channel-list', + module: 'cron', + action: 'create', + payload: { + name: 'GUI Cron visible to Channel', + message: 'CLAWX_GUI_CRON_VISIBLE_IN_CHANNEL', + schedule: { kind: 'cron', expr: '30 0 1 1 *' }, + enabled: true, + delivery: { mode: 'announce', channel: 'feishu', to: 'cron-admin' }, + agentId: 'main', + }, + })) as { ok?: boolean; data?: { id?: string } }; + expect(guiCron).toMatchObject({ ok: true, data: { id: expect.any(String) } }); + const channelListReply = await channelCronProbe.command('/cron'); + expect(channelAddReply.type).toBe('reply'); + expect(channelListReply.type).toBe('card'); + expect(JSON.stringify(channelListReply)).toContain(guiCron.data!.id!); + expect(JSON.stringify(channelListReply.card)).toContain(`act:/cron disable ${channelJob!.id}`); + expect(JSON.stringify(channelListReply.card)).toContain(`act:/cron delete ${channelJob!.id}`); + + channelCronProbe.cardAction(`act:/cron disable ${channelJob!.id}`); + await expect.poll(async () => (await listCron()).data?.find((job) => job.id === channelJob!.id)?.enabled) + .toBe(false); + channelCronProbe.cardAction(`act:/cron enable ${channelJob!.id}`); + await expect.poll(async () => (await listCron()).data?.find((job) => job.id === channelJob!.id)?.enabled) + .toBe(true); + channelCronProbe.cardAction(`act:/cron delete ${channelJob!.id}`); + await expect.poll(async () => (await listCron()).data?.some((job) => job.id === channelJob!.id)) + .toBe(false); + await expect(page.evaluate(async (id) => window.clawx.hostInvoke({ + id: 'runtime-gui-cron-delete-after-channel-list', + module: 'cron', + action: 'delete', + payload: { id }, + }), guiCron.data!.id)).resolves.toMatchObject({ ok: true, data: { success: true } }); + + const statusAfterChannelCron = await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-status-after-channel-cron', + module: 'gateway', + action: 'status', + })) as { data?: { pid?: number } }; + expect(statusAfterChannelCron.data?.pid) + .toBe((statusBeforeProfiles as { data?: { pid?: number } }).data?.pid); + await writeFile(join(profileEvidenceDir, 'real-channel-cron-bridge.json'), `${JSON.stringify({ + schema: 'clawx-cc-connect-channel-cron-evidence', + version: 1, + runtimeKind: 'cc-connect', + platform: 'feishu', + transport: 'public-bridge-simulation', + publicBridgeProtocol: true, + authorizedAdminIdentity: true, + channelCreatedHostObserved: true, + guiCreatedChannelObserved: true, + channelToggleObserved: true, + channelDeleteObserved: true, + declaredRichCapabilities: ['card', 'buttons'], + cronCommandPacketTypes: [channelAddReply.type, channelListReply.type], + richCommandCardAvailable: true, + cardActionsObserved: true, + textFallbackUsable: true, + pidPreserved: statusAfterChannelCron.data?.pid + === (statusBeforeProfiles as { data?: { pid?: number } }).data?.pid, + }, null, 2)}\n`, 'utf8'); + + const createResponse = await fetch(`http://127.0.0.1:${bridgePort}/bridge/sessions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${bridgeToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + project: 'clawx-main', + session_key: 'clawx:main:public-api-probe', + name: 'Public API probe', + }), + }); + expect(createResponse.status).toBe(200); + const created = await createResponse.json() as { ok?: boolean; data?: { id?: string }; id?: string }; + expect(created.ok ?? true).toBe(true); + const sessionId = created.data?.id ?? created.id; + expect(sessionId).toBeTruthy(); + + const listResponse = await fetch( + `http://127.0.0.1:${bridgePort}/bridge/sessions?project=clawx-main&session_key=clawx%3Amain%3Apublic-api-probe`, + { headers: { Authorization: `Bearer ${bridgeToken}` } }, + ); + expect(listResponse.status).toBe(200); + const bridgeListBody = await listResponse.json(); + expect(JSON.stringify(bridgeListBody)).toContain(String(sessionId)); + + const detailResponse = await fetch( + `http://127.0.0.1:${bridgePort}/bridge/sessions/${encodeURIComponent(String(sessionId))}?project=clawx-main&session_key=clawx%3Amain%3Apublic-api-probe`, + { headers: { Authorization: `Bearer ${bridgeToken}` } }, + ); + expect(detailResponse.status).toBe(200); + expect(JSON.stringify(await detailResponse.json())).toContain(String(sessionId)); + + const deleteResponse = await fetch( + `http://127.0.0.1:${bridgePort}/bridge/sessions/${encodeURIComponent(String(sessionId))}?project=clawx-main&session_key=clawx%3Amain%3Apublic-api-probe`, + { + method: 'DELETE', + headers: { Authorization: `Bearer ${bridgeToken}` }, + }, + ); + expect(deleteResponse.status).toBe(200); + + const listAfterDelete = await fetch( + `http://127.0.0.1:${bridgePort}/bridge/sessions?project=clawx-main&session_key=clawx%3Amain%3Apublic-api-probe`, + { headers: { Authorization: `Bearer ${bridgeToken}` } }, + ); + expect(listAfterDelete.status).toBe(200); + expect(JSON.stringify(await listAfterDelete.json())).not.toContain(String(sessionId)); + } finally { + await closeBridgeSocket(channelCronProbe?.socket); + await closeElectronApp(app); + } + }); + + test('starts cc-connect from bundled binaries in a local dev Electron run', async ({ + launchElectronApp, + userDataDir, + }) => { + const bundles = await realRuntimeBundles(); + test.skip(!bundles, 'Run pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current first.'); + await waitForPortClosed(9810); + await waitForPortClosed(9820); + + await seedCcConnectRuntimeSettings(userDataDir); + + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: bundles!.ccConnectPath, + CLAWX_CODEX_PATH: bundles!.codexPath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + const startResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-start-real-bundle', + module: 'gateway', + action: 'start', + }); + }); + expect(startResult).toMatchObject({ + ok: true, + data: { success: true }, + }); + + const statusResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-status-real-bundle', + module: 'gateway', + action: 'status', + }); + }); + expect(statusResult).toMatchObject({ + ok: true, + data: { + runtimeKind: 'cc-connect', + }, + }); + const healthResult = await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-health-real-bundle', + module: 'gateway', + action: 'health', + payload: { probe: true }, + })); + expect(healthResult).toMatchObject({ ok: true, data: { ok: true } }); + + const managedConfig = await readFile(join(userDataDir, 'runtimes', 'cc-connect', 'config.toml'), 'utf8'); + expect(managedConfig).toContain('Managed by ClawX'); + expect(managedConfig).toContain('BridgePlatform'); + expect(managedConfig).toContain(`work_dir = "${join(userDataDir, 'workspaces', 'agents', 'main')}"`); + const workDirLines = managedConfig.split('\n').filter((line) => line.startsWith('work_dir =')).join('\n'); + expect(workDirLines).not.toContain(process.cwd()); + const publicProfile = await readFile(join(userDataDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8'); + expect(publicProfile).toContain('"vendorId": "ollama"'); + expect(publicProfile).toContain('qwen3:latest'); + + const diagnostics = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-diagnostics-real-bundle', + module: 'diagnostics', + action: 'gatewaySnapshot', + }); + }) as { + ok: boolean; + data?: { + runtime?: { + activeKind?: string; + status?: { runtimeKind?: string; state?: string }; + operationCapabilities?: Record; + ccConnect?: { + managedDir?: string; + configPath?: string; + codexHomeDir?: string; + providerProfilePath?: string; + providerProfile?: { vendorId?: string; envKeys?: string[] }; + managementApi?: { success?: boolean; port?: number }; + cron?: { success?: boolean; jobCount?: number; knownGaps?: string[]; jobs?: unknown[] }; + binaries?: { + ccConnect?: { versionCommand?: { success?: boolean; output?: string } }; + codex?: { versionCommand?: { success?: boolean; output?: string } }; + }; + logTail?: string; + }; + }; + }; + }; + expect(diagnostics).toMatchObject({ + ok: true, + data: { + runtime: { + activeKind: 'cc-connect', + status: { runtimeKind: 'cc-connect', state: 'running' }, + operationCapabilities: expect.objectContaining({ + 'chat.send': expect.objectContaining({ support: 'native' }), + 'doctor.fix': expect.objectContaining({ support: 'unsupported' }), + }), + ccConnect: expect.objectContaining({ + managedDir: join(userDataDir, 'runtimes', 'cc-connect'), + configPath: join(userDataDir, 'runtimes', 'cc-connect', 'config.toml'), + codexHomeDir: join(userDataDir, 'runtimes', 'cc-connect', 'codex-home'), + providerProfilePath: join(userDataDir, 'runtimes', 'cc-connect', 'provider-profile.json'), + providerProfile: expect.objectContaining({ vendorId: 'ollama' }), + managementApi: expect.objectContaining({ success: true }), + cron: expect.objectContaining({ + success: true, + jobCount: 0, + jobs: [], + knownGaps: expect.arrayContaining([ + 'scheduled-prompt-delivery-unproven', + ]), + }), + binaries: expect.objectContaining({ + ccConnect: expect.objectContaining({ + versionCommand: expect.objectContaining({ success: true }), + }), + codex: expect.objectContaining({ + versionCommand: expect.objectContaining({ success: true }), + }), + }), + }), + }, + }, + }); + const diagnosticsText = JSON.stringify(diagnostics); + expect(diagnosticsText).not.toContain('runtime-management-token'); + expect(diagnosticsText).not.toContain('token = "'); + const runtimeLogTail = diagnostics.data?.runtime?.ccConnect?.logTail ?? ''; + expect(runtimeLogTail).toContain('## cc-connect stdout/stderr'); + expect(runtimeLogTail).toContain('## ClawX runtime manager'); + expect(runtimeLogTail).toContain('## Managed config (redacted)'); + const runtimeLogPath = join(userDataDir, 'runtimes', 'cc-connect', 'logs', 'runtime.log'); + await expect.poll(async () => { + try { + await access(runtimeLogPath); + return true; + } catch { + return false; + } + }, { timeout: 10_000 }).toBe(true); + const runtimeLogFile = await readFile(runtimeLogPath, 'utf8'); + expect(runtimeLogFile.length).toBeGreaterThan(0); + expect(runtimeLogFile).not.toContain('runtime-management-token'); + } finally { + await closeElectronApp(app); + } + }); + + test('starts bundled cc-connect on fallback ports when defaults are occupied', async ({ + launchElectronApp, + userDataDir, + }) => { + const bundles = await realRuntimeBundles(); + test.skip(!bundles, 'Run pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current first.'); + const occupiedBridge = await occupyPort(9810); + const occupiedManagement = await occupyPort(9820); + await seedCcConnectRuntimeSettings(userDataDir); + + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: bundles!.ccConnectPath, + CLAWX_CODEX_PATH: bundles!.codexPath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + const startResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-start-real-bundle-port-fallback', + module: 'gateway', + action: 'start', + }); + }); + expect(startResult).toMatchObject({ + ok: true, + data: { success: true }, + }); + + const statusResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-status-real-bundle-port-fallback', + module: 'gateway', + action: 'status', + }); + }) as { ok: boolean; data?: { runtimeKind?: string; port?: number } }; + expect(statusResult).toMatchObject({ + ok: true, + data: { runtimeKind: 'cc-connect' }, + }); + expect(statusResult.data?.port).not.toBe(9820); + + const controlUiResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-control-ui-real-bundle-port-fallback', + module: 'gateway', + action: 'controlUi', + }); + }) as { ok: boolean; data?: { success?: boolean; port?: number; url?: string } }; + expect(controlUiResult).toMatchObject({ + ok: true, + data: { + success: true, + port: statusResult.data?.port, + }, + }); + expect(controlUiResult.data?.url).toBe(`http://127.0.0.1:${statusResult.data?.port}/`); + + const managedConfig = await readFile(join(userDataDir, 'runtimes', 'cc-connect', 'config.toml'), 'utf8'); + expect(managedConfig).toContain('[management]'); + expect(managedConfig).toContain(`port = ${statusResult.data?.port}`); + if (occupiedBridge) { + expect(managedConfig).not.toContain('\nport = 9810\n'); + } + } finally { + await closeElectronApp(app); + await closeServer(occupiedBridge); + await closeServer(occupiedManagement); + } + }); + + test('reloads managed channel config and reads live project platform status without restarting cc-connect', async ({ + launchElectronApp, + homeDir, + userDataDir, + }) => { + const bundles = await realRuntimeBundles(); + test.skip(!bundles, 'Run pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current first.'); + await waitForPortClosed(9810); + await waitForPortClosed(9820); + + await seedCcConnectRuntimeSettings(join(userDataDir, 'app')); + const workspace = join(userDataDir, 'line-reload-workspace'); + await seedCanonicalRuntimeConfig(userDataDir, { + agents: { + defaults: { workspace }, + list: [{ id: 'main', name: 'Main Agent', default: true, workspace }], + }, + }); + + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_DATA_HOME: userDataDir, + CLAWX_USER_DATA_DIR: join(userDataDir, 'system', 'electron'), + CLAWX_CC_CONNECT_PATH: bundles!.ccConnectPath, + CLAWX_CODEX_PATH: bundles!.codexPath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + const startResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-start-real-bundle-channel-reload', + module: 'gateway', + action: 'start', + }); + }); + expect(startResult).toMatchObject({ + ok: true, + data: { success: true }, + }); + + const beforeReloadStatus = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-status-before-channel-reload', + module: 'gateway', + action: 'status', + }); + }) as { ok: boolean; data?: { pid?: number; port?: number; runtimeKind?: string } }; + expect(beforeReloadStatus).toMatchObject({ + ok: true, + data: { runtimeKind: 'cc-connect' }, + }); + expect(beforeReloadStatus.data?.pid).toBeGreaterThan(0); + + const saveResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channel-save-real-bundle-line-reload', + module: 'channels', + action: 'saveConfig', + payload: { + channelType: 'line', + accountId: 'local_line', + config: { + channelSecret: 'line-secret', + channelToken: 'line-token', + port: '0', + callbackPath: '/callback', + }, + }, + }); + }); + expect(saveResult).toMatchObject({ ok: true, data: { success: true } }); + + const connectResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channel-connect-real-bundle-line-reload', + module: 'gateway', + action: 'rpc', + payload: { + method: 'channels.connect', + params: { channelType: 'line' }, + timeoutMs: 60_000, + }, + }); + }); + expect(connectResult).toMatchObject({ ok: true, data: { success: true } }); + + const afterReloadStatus = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-status-after-channel-reload', + module: 'gateway', + action: 'status', + }); + }) as { ok: boolean; data?: { pid?: number; port?: number; runtimeKind?: string } }; + expect(afterReloadStatus).toMatchObject({ + ok: true, + data: { + runtimeKind: 'cc-connect', + pid: beforeReloadStatus.data?.pid, + port: beforeReloadStatus.data?.port, + }, + }); + + const managedConfig = await readFile(join(userDataDir, 'runtimes', 'cc-connect', 'config.toml'), 'utf8'); + expect(managedConfig).toContain('type = "line"'); + expect(managedConfig).toContain('channel_secret = "${CLAWX_CHANNEL_LINE_LOCAL_LINE_CHANNEL_SECRET}"'); + expect(managedConfig).toContain('channel_token = "${CLAWX_CHANNEL_LINE_LOCAL_LINE_CHANNEL_TOKEN}"'); + expect(managedConfig).not.toContain('line-secret'); + expect(managedConfig).not.toContain('line-token'); + await expect(access(join(homeDir, '.openclaw', 'openclaw.json'))).rejects.toThrow(); + + const channelsAccounts = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channels-accounts-real-bundle-line-reload', + module: 'channels', + action: 'accounts', + payload: { probe: true }, + }); + }); + expect(channelsAccounts).toMatchObject({ + ok: true, + data: { + success: true, + channels: expect.arrayContaining([ + expect.objectContaining({ + channelType: 'line', + accounts: expect.arrayContaining([ + expect.objectContaining({ + accountId: 'local_line', + configured: true, + connected: true, + running: true, + linked: true, + }), + ]), + }), + ]), + }, + }); + + const disableResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channel-disable-real-bundle-line-reload', + module: 'channels', + action: 'setEnabled', + payload: { channelType: 'line', enabled: false }, + }); + }); + expect(disableResult).toMatchObject({ ok: true, data: { success: true } }); + + const disconnectResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channel-disconnect-real-bundle-line-reload', + module: 'gateway', + action: 'rpc', + payload: { + method: 'channels.disconnect', + params: { channelType: 'line' }, + timeoutMs: 60_000, + }, + }); + }); + expect(disconnectResult).toMatchObject({ ok: true, data: { success: true } }); + + const afterDisconnectStatus = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-status-after-channel-disconnect', + module: 'gateway', + action: 'status', + }); + }) as { ok: boolean; data?: { pid?: number; port?: number; runtimeKind?: string } }; + expect(afterDisconnectStatus).toMatchObject({ + ok: true, + data: { + runtimeKind: 'cc-connect', + pid: beforeReloadStatus.data?.pid, + port: beforeReloadStatus.data?.port, + }, + }); + + const managedConfigAfterDisconnect = await readFile(join(userDataDir, 'runtimes', 'cc-connect', 'config.toml'), 'utf8'); + expect(managedConfigAfterDisconnect).not.toContain('channel_secret = "line-secret"'); + expect(managedConfigAfterDisconnect).not.toContain('channel_token = "line-token"'); + expect(managedConfigAfterDisconnect).toContain('channel_secret = "clawx-local-placeholder"'); + expect(managedConfigAfterDisconnect).toContain('channel_token = "clawx-local-placeholder"'); + + const channelsAccountsAfterDisconnect = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channels-accounts-after-disconnect-real-bundle-line-reload', + module: 'channels', + action: 'accounts', + payload: { probe: true }, + }); + }); + expect(channelsAccountsAfterDisconnect).toMatchObject({ + ok: true, + data: { + success: true, + channels: expect.not.arrayContaining([ + expect.objectContaining({ channelType: 'line' }), + ]), + }, + }); + } finally { + await closeElectronApp(app); + } + }); + + test('projects Feishu and Lark channel config through cc-connect runtime lifecycle without tenant credentials', async ({ + launchElectronApp, + homeDir, + userDataDir, + }) => { + const bundles = await realRuntimeBundles(); + test.skip(!bundles, 'Run pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current first.'); + await waitForPortClosed(9810); + await waitForPortClosed(9820); + + await seedCcConnectRuntimeSettings(join(userDataDir, 'app')); + const mainWorkspace = join(userDataDir, 'feishu-main-workspace'); + const opsWorkspace = join(userDataDir, 'feishu-ops-workspace'); + await mkdir(mainWorkspace, { recursive: true }); + await mkdir(opsWorkspace, { recursive: true }); + await seedCanonicalRuntimeConfig(userDataDir, { + agents: { + defaults: { workspace: mainWorkspace }, + list: [ + { + id: 'main', + name: 'Main Agent', + default: true, + workspace: mainWorkspace, + }, + { + id: 'ops', + name: 'Ops Agent', + workspace: opsWorkspace, + }, + ], + }, + }); + + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_DATA_HOME: userDataDir, + CLAWX_USER_DATA_DIR: join(userDataDir, 'system', 'electron'), + CLAWX_CC_CONNECT_PATH: bundles!.ccConnectPath, + CLAWX_CODEX_PATH: bundles!.codexPath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + const startResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-start-real-bundle-feishu-projection', + module: 'gateway', + action: 'start', + }); + }); + expect(startResult).toMatchObject({ + ok: true, + data: { success: true }, + }); + + for (const [agentId, modelRef] of [ + ['main', 'ollama/main-model-e2e'], + ['ops', 'ollama/ops-model-e2e'], + ] as const) { + const updateModelResult = await page.evaluate(async ({ id, model }) => await window.clawx.hostInvoke({ + id: `runtime-agent-model-${id}-real-bundle-feishu-projection`, + module: 'agents', + action: 'updateModel', + payload: { id, modelRef: model, providerAccountId: 'ollama-local' }, + }), { id: agentId, model: modelRef }); + expect(updateModelResult).toMatchObject({ ok: true, data: { success: true } }); + } + + const beforeReloadStatus = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-status-before-feishu-projection', + module: 'gateway', + action: 'status', + }); + }) as { ok: boolean; data?: { pid?: number; port?: number; runtimeKind?: string } }; + expect(beforeReloadStatus).toMatchObject({ + ok: true, + data: { runtimeKind: 'cc-connect' }, + }); + expect(beforeReloadStatus.data?.pid).toBeGreaterThan(0); + + const saveCnResult = await page.evaluate(async () => await window.clawx.hostInvoke({ + id: 'runtime-channel-save-real-bundle-feishu-cn', + module: 'channels', + action: 'saveConfig', + payload: { + channelType: 'feishu', + accountId: 'cn_bot', + config: { + appId: 'cli_feishu_cn', + appSecret: 'feishu-secret-cn', + domain: 'feishu', + allowFrom: ['oc_main', 'ou_user'], + adminUsers: 'ou_cron_admin', + shareSessionInChannel: true, + enableFeishuCard: false, + callbackPath: '/feishu/callback', + }, + }, + })); + expect(saveCnResult).toMatchObject({ ok: true, data: { success: true } }); + const bindCnResult = await page.evaluate(async () => await window.clawx.hostInvoke({ + id: 'runtime-channel-bind-real-bundle-feishu-cn', + module: 'channels', + action: 'bindingSave', + payload: { channelType: 'feishu', accountId: 'cn_bot', agentId: 'ops' }, + })); + expect(bindCnResult).toMatchObject({ ok: true, data: { success: true } }); + + const saveGlobalResult = await page.evaluate(async () => await window.clawx.hostInvoke({ + id: 'runtime-channel-save-real-bundle-lark-global', + module: 'channels', + action: 'saveConfig', + payload: { + channelType: 'feishu', + accountId: 'global_bot', + config: { + appId: 'cli_lark_global', + appSecret: 'lark-secret-global', + domain: 'global', + allowFrom: '*', + adminUsers: 'ou_lark_admin', + shareSessionInChannel: false, + enableFeishuCard: true, + }, + }, + })); + expect(saveGlobalResult).toMatchObject({ ok: true, data: { success: true } }); + const bindGlobalResult = await page.evaluate(async () => await window.clawx.hostInvoke({ + id: 'runtime-channel-bind-real-bundle-lark-global', + module: 'channels', + action: 'bindingSave', + payload: { channelType: 'feishu', accountId: 'global_bot', agentId: 'main' }, + })); + expect(bindGlobalResult).toMatchObject({ ok: true, data: { success: true } }); + + const connectResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channel-connect-real-bundle-feishu-projection', + module: 'gateway', + action: 'rpc', + payload: { + method: 'channels.connect', + params: { channelType: 'feishu' }, + timeoutMs: 60_000, + }, + }); + }); + expect(connectResult).toMatchObject({ ok: true, data: { success: true } }); + + const afterReloadStatus = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-status-after-feishu-projection', + module: 'gateway', + action: 'status', + }); + }) as { ok: boolean; data?: { pid?: number; port?: number; runtimeKind?: string } }; + expect(afterReloadStatus).toMatchObject({ + ok: true, + data: { + runtimeKind: 'cc-connect', + pid: beforeReloadStatus.data?.pid, + port: beforeReloadStatus.data?.port, + }, + }); + + const managedConfigPath = join(userDataDir, 'runtimes', 'cc-connect', 'config.toml'); + const managedConfig = await readFile(managedConfigPath, 'utf8'); + expect(managedConfig).toContain('name = "clawx-ops"'); + expect(managedConfig).toContain('name = "clawx-main"'); + expect(managedConfig).toContain(`work_dir = "${opsWorkspace}"`); + expect(managedConfig).toContain(`work_dir = "${mainWorkspace}"`); + const mainProjectBlock = managedConfig.slice( + managedConfig.indexOf('name = "clawx-main"'), + managedConfig.indexOf('name = "clawx-ops"'), + ); + const opsProjectBlock = managedConfig.slice(managedConfig.indexOf('name = "clawx-ops"')); + expect(mainProjectBlock).toContain('model = "main-model-e2e"'); + expect(opsProjectBlock).toContain('model = "ops-model-e2e"'); + expect(managedConfig).toContain('type = "feishu"'); + expect(managedConfig).toContain('type = "lark"'); + expect(managedConfig).toContain('app_id = "cli_feishu_cn"'); + expect(managedConfig).toContain('app_id = "cli_lark_global"'); + expect(managedConfig).toContain('domain = "https://open.feishu.cn"'); + expect(managedConfig).toContain('domain = "https://open.larksuite.com"'); + expect(managedConfig).toContain('allow_from = "oc_main,ou_user"'); + expect(managedConfig).toContain('allow_from = "*"'); + expect(managedConfig).toContain('admin_from = "clawx-desktop,ou_cron_admin"'); + expect(managedConfig).toContain('admin_from = "clawx-desktop,ou_lark_admin"'); + expect(managedConfig).not.toContain('feishu-secret-cn'); + expect(managedConfig).not.toContain('lark-secret-global'); + expect(managedConfig).toContain('share_session_in_channel = true'); + expect(managedConfig).toContain('share_session_in_channel = false'); + expect(managedConfig).toContain('enable_feishu_card = false'); + expect(managedConfig).toContain('enable_feishu_card = true'); + expect(managedConfig).toContain('callback_path = "/feishu/callback"'); + const workDirLines = managedConfig.split('\n').filter((line) => line.startsWith('work_dir =')).join('\n'); + expect(workDirLines).not.toContain(process.cwd()); + + const canonicalConfig = await readFile(join(userDataDir, 'app', 'runtime-config.json'), 'utf8'); + expect(canonicalConfig).toContain('cli_feishu_cn'); + expect(canonicalConfig).toContain('cli_lark_global'); + expect(canonicalConfig).toContain('ou_cron_admin'); + expect(canonicalConfig).not.toContain('feishu-secret-cn'); + expect(canonicalConfig).not.toContain('lark-secret-global'); + const encryptedVault = await readFile(join(userDataDir, 'credentials', 'secrets.enc')); + expect(encryptedVault.includes(Buffer.from('feishu-secret-cn', 'utf8'))).toBe(false); + expect(encryptedVault.includes(Buffer.from('lark-secret-global', 'utf8'))).toBe(false); + const credentialIndex = await readFile(join(userDataDir, 'credentials', 'index.json'), 'utf8'); + expect(credentialIndex).toContain('feishu:cn_bot'); + expect(credentialIndex).toContain('feishu:global_bot'); + expect(credentialIndex).not.toContain('feishu-secret-cn'); + expect(credentialIndex).not.toContain('lark-secret-global'); + await expect(access(join(homeDir, '.openclaw', 'openclaw.json'))).rejects.toThrow(); + + const channelsAccounts = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channels-accounts-real-bundle-feishu-projection', + module: 'channels', + action: 'accounts', + payload: { probe: true }, + }); + }); + expect(channelsAccounts).toMatchObject({ + ok: true, + data: { + success: true, + channels: expect.arrayContaining([ + expect.objectContaining({ + channelType: 'feishu', + defaultAccountId: 'cn_bot', + accounts: expect.arrayContaining([ + expect.objectContaining({ + accountId: 'cn_bot', + configured: true, + linked: true, + name: 'feishu', + }), + expect.objectContaining({ + accountId: 'global_bot', + configured: true, + linked: true, + name: 'lark', + }), + ]), + }), + ]), + }, + }); + + const deleteConfigResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channel-delete-config-real-bundle-feishu-projection', + module: 'channels', + action: 'deleteConfig', + payload: { channelType: 'feishu', accountId: 'cn_bot' }, + }); + }); + expect(deleteConfigResult).toMatchObject({ ok: true, data: { success: true } }); + + await expect.poll(async () => { + const refreshedConfig = await readFile(managedConfigPath, 'utf8'); + return { + hasCnBot: refreshedConfig.includes('cli_feishu_cn') || refreshedConfig.includes('feishu-secret-cn'), + hasGlobalBot: refreshedConfig.includes('cli_lark_global') + && refreshedConfig.includes('CLAWX_CHANNEL_FEISHU_GLOBAL_BOT_APP_SECRET') + && !refreshedConfig.includes('lark-secret-global'), + }; + }, { + timeout: 60_000, + intervals: [1_000, 2_000, 5_000], + }).toEqual({ hasCnBot: false, hasGlobalBot: true }); + + const channelsAfterDelete = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channels-after-delete-real-bundle-feishu-projection', + module: 'channels', + action: 'accounts', + payload: { probe: true }, + }); + }); + expect(channelsAfterDelete).toMatchObject({ + ok: true, + data: { + success: true, + channels: expect.arrayContaining([ + expect.objectContaining({ + channelType: 'feishu', + defaultAccountId: 'global_bot', + accounts: [ + expect.objectContaining({ + accountId: 'global_bot', + configured: true, + linked: true, + name: 'lark', + }), + ], + }), + ]), + }, + }); + } finally { + await closeElectronApp(app); + } + }); + + test('manages cron lifecycle through the real cc-connect Management API without model credentials', async ({ + launchElectronApp, + homeDir, + userDataDir, + }) => { + const bundles = await realRuntimeBundles(); + test.skip(!bundles, 'Run pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current first.'); + await waitForPortClosed(9810); + await waitForPortClosed(9820); + + await seedCcConnectRuntimeSettings(userDataDir); + const openClawConfigDir = join(homeDir, '.openclaw'); + const researchWorkspace = join(userDataDir, 'cron-research-workspace'); + await mkdir(openClawConfigDir, { recursive: true }); + await mkdir(researchWorkspace, { recursive: true }); + await writeFile(join(openClawConfigDir, 'openclaw.json'), JSON.stringify({ + agents: { + defaults: { workspace: join(userDataDir, 'cron-main-workspace') }, + list: [ + { id: 'main', name: 'Main Agent', default: true, workspace: join(userDataDir, 'cron-main-workspace') }, + { id: 'research', name: 'Research Agent', workspace: researchWorkspace }, + ], + }, + }, null, 2), 'utf8'); + + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: bundles!.ccConnectPath, + CLAWX_CODEX_PATH: bundles!.codexPath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + const startResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-start-real-bundle-cron-lifecycle', + module: 'gateway', + action: 'start', + }); + }); + expect(startResult).toMatchObject({ ok: true, data: { success: true } }); + + const createResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-create-real-bundle-lifecycle', + module: 'cron', + action: 'create', + payload: { + name: 'Real bundle research cron', + message: 'Local real bundle cron prompt', + schedule: { kind: 'cron', expr: '0 11 * * *' }, + enabled: true, + delivery: { mode: 'none' }, + mute: true, + agentId: 'research', + }, + }); + }); + expect(createResult).toMatchObject({ + ok: true, + data: expect.objectContaining({ + name: 'Real bundle research cron', + message: 'Local real bundle cron prompt', + enabled: true, + agentId: 'research', + mute: true, + }), + }); + const cronId = (createResult as { data?: { id?: string } }).data?.id; + expect(cronId).toBeTruthy(); + + const listResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-list-real-bundle-lifecycle', + module: 'cron', + action: 'list', + }); + }); + expect(listResult).toMatchObject({ + ok: true, + data: expect.arrayContaining([ + expect.objectContaining({ + id: cronId, + agentId: 'research', + message: 'Local real bundle cron prompt', + }), + ]), + }); + + const updateResult = await page.evaluate(async (id) => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-update-real-bundle-lifecycle', + module: 'cron', + action: 'update', + payload: { + id, + input: { + name: 'Real bundle research cron updated', + message: 'Updated real bundle cron prompt', + schedule: { kind: 'cron', expr: '30 11 * * *' }, + delivery: { mode: 'announce' }, + enabled: true, + mute: false, + agentId: 'research', + }, + }, + }); + }, cronId); + expect(updateResult).toMatchObject({ + ok: true, + data: expect.objectContaining({ + id: cronId, + name: 'Real bundle research cron updated', + message: 'Updated real bundle cron prompt', + enabled: true, + agentId: 'research', + delivery: { mode: 'announce' }, + }), + }); + + const toggleResult = await page.evaluate(async (id) => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-toggle-real-bundle-lifecycle', + module: 'cron', + action: 'toggle', + payload: { id, enabled: false }, + }); + }, cronId); + expect(toggleResult).toMatchObject({ + ok: true, + data: expect.objectContaining({ + id: cronId, + enabled: false, + agentId: 'research', + }), + }); + + const execMarkerPath = join(researchWorkspace, 'cc-connect-exec-cron-marker.txt'); + const execCreateResult = await page.evaluate(async (workDir) => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-create-exec-real-bundle-lifecycle', + module: 'cron', + action: 'create', + payload: { + name: 'Real bundle research exec cron', + exec: "node -e \"require('fs').writeFileSync('cc-connect-exec-cron-marker.txt', 'CLAWX_EXEC_CRON_OK')\"", + workDir, + sessionMode: 'new_per_run', + timeoutMins: 3, + schedule: { kind: 'cron', expr: '15 12 * * *' }, + enabled: true, + delivery: { mode: 'none' }, + agentId: 'research', + }, + }); + }, researchWorkspace); + expect(execCreateResult).toMatchObject({ + ok: true, + data: expect.objectContaining({ + name: 'Real bundle research exec cron', + enabled: true, + agentId: 'research', + exec: expect.stringContaining('cc-connect-exec-cron-marker.txt'), + workDir: researchWorkspace, + sessionMode: 'new_per_run', + timeoutMins: 3, + }), + }); + const execCronId = (execCreateResult as { data?: { id?: string } }).data?.id; + expect(execCronId).toBeTruthy(); + + const execUpdateResult = await page.evaluate(async ({ id, workDir }) => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-update-exec-real-bundle-lifecycle', + module: 'cron', + action: 'update', + payload: { + id, + input: { + exec: "node -e \"require('fs').writeFileSync('cc-connect-exec-cron-marker.txt', 'CLAWX_EXEC_CRON_UPDATED_OK')\"", + workDir, + sessionMode: 'continue', + timeoutMins: 4, + agentId: 'research', + }, + }, + }); + }, { id: execCronId, workDir: researchWorkspace }); + expect(execUpdateResult, JSON.stringify(execUpdateResult)).toMatchObject({ + ok: true, + data: expect.objectContaining({ + id: execCronId, + agentId: 'research', + exec: expect.stringContaining('CLAWX_EXEC_CRON_UPDATED_OK'), + workDir: researchWorkspace, + sessionMode: 'continue', + timeoutMins: 4, + }), + }); + + const execListResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-list-exec-real-bundle-lifecycle', + module: 'cron', + action: 'list', + }); + }); + expect(execListResult).toMatchObject({ + ok: true, + data: expect.arrayContaining([ + expect.objectContaining({ + id: execCronId, + agentId: 'research', + exec: expect.stringContaining('CLAWX_EXEC_CRON_UPDATED_OK'), + workDir: researchWorkspace, + sessionMode: 'continue', + timeoutMins: 4, + }), + ]), + }); + + const execRunResult = await page.evaluate(async (id) => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-run-exec-real-bundle-lifecycle', + module: 'cron', + action: 'trigger', + payload: { id }, + }); + }, execCronId) as { ok?: boolean; data?: unknown; error?: string }; + expect(execRunResult).toMatchObject({ ok: true, data: { success: true } }); + await expect.poll(async () => (await readFile(execMarkerPath, 'utf8').catch(() => '')).trim(), { + timeout: 30_000, + intervals: [500, 1_000, 2_000], + }).toBe('CLAWX_EXEC_CRON_UPDATED_OK'); + + const execDeleteResult = await page.evaluate(async (id) => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-delete-exec-real-bundle-lifecycle', + module: 'cron', + action: 'delete', + payload: { id }, + }); + }, execCronId); + expect(execDeleteResult).toMatchObject({ ok: true, data: { success: true } }); + + const deleteResult = await page.evaluate(async (id) => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-delete-real-bundle-lifecycle', + module: 'cron', + action: 'delete', + payload: { id }, + }); + }, cronId); + expect(deleteResult).toMatchObject({ ok: true, data: { success: true } }); + + const listAfterDelete = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-list-after-delete-real-bundle-lifecycle', + module: 'cron', + action: 'list', + }); + }); + const jobs = (listAfterDelete as { data?: Array<{ id?: string }> }).data ?? []; + expect(jobs.some((job) => job.id === cronId)).toBe(false); + } finally { + await closeElectronApp(app); + } + }); + + test('runs cc-connect doctor against the managed config through the Host API', async ({ + launchElectronApp, + userDataDir, + }) => { + const bundles = await realRuntimeBundles(); + test.skip(!bundles, 'Run pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current first.'); + + await seedCcConnectRuntimeSettings(userDataDir); + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: bundles!.ccConnectPath, + CLAWX_CODEX_PATH: bundles!.codexPath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + const doctorResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-doctor-real-bundle', + 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')), + stdout: expect.any(String), + stderr: expect.any(String), + auditPath: expect.stringContaining(join('runtimes', 'cc-connect', 'audits', 'runtime-')), + audit: expect.objectContaining({ + schema: 'clawx-cc-connect-runtime-doctor', + runtimeKind: 'cc-connect', + ccConnect: expect.objectContaining({ auditGenerated: false }), + codex: expect.objectContaining({ report: expect.any(Object) }), + }), + }), + }); + expect((doctorResult as { data?: { error?: string; timedOut?: boolean } }).data?.timedOut).not.toBe(true); + expect((doctorResult as { data?: { error?: string } }).data?.error || '').not.toContain('spawn'); + const doctorData = (doctorResult as { + data?: { + auditPath?: string; + stdout?: string; + audit?: { + schema?: string; + ccConnect?: { success?: boolean; auditGenerated?: boolean }; + codex?: { success?: boolean; exitCode?: number | null; report?: unknown }; + }; + }; + }).data; + expect(doctorData?.auditPath).toBeTruthy(); + expect(doctorData?.auditPath?.startsWith(join(userDataDir, 'runtimes', 'cc-connect', 'audits'))).toBe(true); + expect(doctorData?.auditPath).not.toContain(join(userDataDir, '.cc-connect')); + const auditText = await readFile(doctorData!.auditPath!, 'utf8'); + expect(JSON.parse(auditText)).toMatchObject({ + schema: 'clawx-cc-connect-runtime-doctor', + ccConnect: { auditGenerated: false }, + codex: { report: expect.any(Object) }, + }); + expect((await stat(doctorData!.auditPath!)).mode & 0o777).toBe(0o600); + expect(doctorData?.stdout).toContain('## Codex doctor'); + const doctorEvidenceDir = join(process.cwd(), 'artifacts', 'cc-connect'); + await mkdir(doctorEvidenceDir, { recursive: true }); + await writeFile(join(doctorEvidenceDir, 'real-runtime-doctor.json'), `${JSON.stringify({ + schema: 'clawx-cc-connect-runtime-doctor-evidence', + version: 1, + runtimeKind: 'cc-connect', + managedAuditPath: doctorData?.auditPath?.startsWith(join(userDataDir, 'runtimes', 'cc-connect', 'audits')) === true, + auditMode: (await stat(doctorData!.auditPath!)).mode & 0o777, + ccConnect: doctorData?.audit?.ccConnect, + codex: { + success: doctorData?.audit?.codex?.success, + exitCode: doctorData?.audit?.codex?.exitCode, + reportPresent: Boolean(doctorData?.audit?.codex?.report), + }, + }, null, 2)}\n`, 'utf8'); + } finally { + await closeElectronApp(app); + } + }); + + test('renders progress generated by the real cc-connect engine through preview and update packets', async ({ + launchElectronApp, + userDataDir, + }) => { + test.setTimeout(180_000); + const bundles = await realRuntimeBundles(); + test.skip(!bundles, 'Run pnpm run bundle:cc-connect:current first.'); + await waitForPortClosed(9810); + await waitForPortClosed(9820); + await seedCcConnectRuntimeSettings(userDataDir); + const codexPath = await createDeterministicCodexAppServer(join(userDataDir, 'deterministic-codex')); + + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: bundles!.ccConnectPath, + CLAWX_CODEX_PATH: codexPath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + await page.evaluate(() => { + const testWindow = window as typeof window & { + __ccConnectRichRuntimeEvents?: Array>; + }; + testWindow.__ccConnectRichRuntimeEvents = []; + window.electron.ipcRenderer.on('chat:runtime-event', (payload) => { + testWindow.__ccConnectRichRuntimeEvents!.push(payload as Record); + }); + }); + + await expect.poll(async () => await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-start-real-rich-progress', + module: 'gateway', + action: 'start', + })), { timeout: 30_000 }).toMatchObject({ ok: true, data: { success: true } }); + + await expect(page.getByTestId('chat-composer-input')).toBeEnabled({ timeout: 60_000 }); + await page.getByTestId('chat-composer-input').fill('Exercise the cc-connect progress bridge.'); + await page.getByTestId('chat-composer-send').click(); + await expect(page.getByTestId('chat-message-role-assistant').filter({ hasText: 'CLAWX_REAL_RICH_PREVIEW_OK' }).last()) + .toBeVisible({ timeout: 60_000 }); + await expect(page.getByTestId('chat-execution-graph')).toBeVisible({ timeout: 60_000 }); + + const events = await page.evaluate(() => { + const testWindow = window as typeof window & { + __ccConnectRichRuntimeEvents?: Array>; + }; + return testWindow.__ccConnectRichRuntimeEvents ?? []; + }); + const eventTypes = events.map((event) => String(event.type || 'unknown')); + expect(eventTypes).toEqual(expect.arrayContaining([ + 'thinking.delta', + 'tool.started', + 'tool.completed', + 'run.ended', + ])); + + const diagnostics = await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-diagnostics-real-rich-progress', + module: 'diagnostics', + action: 'gatewaySnapshot', + })) as { + ok: boolean; + data?: { runtime?: { ccConnect?: { logTail?: string } } }; + }; + expect(diagnostics).toMatchObject({ ok: true }); + const logTail = diagnostics.data?.runtime?.ccConnect?.logTail; + expect(logTail).toContain('progress packet type=preview_start'); + expect(logTail).toContain('progress packet type=update_message'); + expect(logTail).toContain('codex app-server session started'); + + await expect.poll(async () => await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-usage-real-rich-progress', + module: 'usage', + action: 'recentTokenHistory', + payload: { limit: 20, runtimeKind: 'cc-connect' }, + })), { + timeout: 30_000, + intervals: [500, 1_000, 2_000], + }).toMatchObject({ + ok: true, + data: expect.arrayContaining([expect.objectContaining({ + runtimeKind: 'cc-connect', + sessionId: 'agent:main:main', + agentId: 'main', + usageStatus: 'missing', + totalTokens: 0, + })]), + }); + const usageResult = await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-usage-real-rich-progress-evidence', + module: 'usage', + action: 'recentTokenHistory', + payload: { limit: 20, runtimeKind: 'cc-connect' }, + })); + const usageRows = (usageResult as { data?: Array> }).data ?? []; + expect(usageRows.some((entry) => entry.usageStatus === 'available')).toBe(false); + + const evidenceDir = join(process.cwd(), 'artifacts', 'cc-connect'); + await mkdir(evidenceDir, { recursive: true }); + await page.screenshot({ + path: join(evidenceDir, 'real-rich-progress-bridge.png'), + fullPage: true, + }); + await writeFile(join(evidenceDir, 'real-rich-progress-bridge.json'), `${JSON.stringify({ + schema: 'clawx-cc-connect-real-rich-progress-evidence', + version: 1, + runtimeKind: 'cc-connect', + runtimeBinary: 'real-bundled-v1.4.1', + codexBoundary: 'deterministic-app-server-protocol-double', + publicBridgeProtocol: true, + packetTypesObserved: ['preview_start', 'update_message'], + runtimeEventTypes: Array.from(new Set(eventTypes)), + guiExecutionGraphObserved: true, + finalAssistantObserved: true, + screenshot: 'artifacts/cc-connect/real-rich-progress-bridge.png', + }, null, 2)}\n`, 'utf8'); + + await page.getByTestId('sidebar-nav-models').click(); + await expect(page.getByTestId('models-page')).toBeVisible(); + const missingUsageRow = page.getByTestId('token-usage-entry').filter({ hasText: 'agent:main:main' }).first(); + await expect(missingUsageRow).toBeVisible({ timeout: 30_000 }); + await expect(missingUsageRow.getByText('No usage').first()).toBeVisible(); + await missingUsageRow.scrollIntoViewIfNeeded(); + await page.screenshot({ + path: join(evidenceDir, 'real-token-usage-runtime-contract.png'), + fullPage: false, + }); + await writeFile(join(evidenceDir, 'real-token-usage-runtime-contract.json'), `${JSON.stringify({ + schema: 'clawx-cc-connect-runtime-usage-evidence', + version: 1, + runtimeKind: 'cc-connect', + source: 'public-management-history-via-runtime-provider', + logicalSessionId: 'agent:main:main', + assistantTurnObserved: true, + usageStatus: 'missing', + availableCounterRows: usageRows.filter((entry) => entry.usageStatus === 'available').length, + privateRuntimeFilesRead: false, + guiMissingStateObserved: true, + screenshot: 'artifacts/cc-connect/real-token-usage-runtime-contract.png', + }, null, 2)}\n`, 'utf8'); + } finally { + await closeElectronApp(app); + } + }); + + test('executes a real cc-connect card choice through the GUI card_action loop', async ({ + launchElectronApp, + userDataDir, + }) => { + test.setTimeout(120_000); + const bundles = await realRuntimeBundles(); + test.skip(!bundles, 'Run pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current first.'); + await waitForPortClosed(9810); + await waitForPortClosed(9820); + await seedCcConnectRuntimeSettings(userDataDir); + + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: bundles!.ccConnectPath, + CLAWX_CODEX_PATH: bundles!.codexPath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + await page.evaluate(() => { + const testWindow = window as typeof window & { + __ccConnectCardChoiceEvents?: Array>; + }; + testWindow.__ccConnectCardChoiceEvents = []; + window.electron.ipcRenderer.on('chat:runtime-event', (payload) => { + testWindow.__ccConnectCardChoiceEvents!.push(payload as Record); + }); + }); + + await expect.poll(async () => await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-start-real-card-choice', + module: 'gateway', + action: 'start', + })), { timeout: 30_000 }).toMatchObject({ ok: true, data: { success: true } }); + const statusBefore = await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-status-before-real-card-choice', + module: 'gateway', + action: 'status', + })) as { ok: boolean; data?: { pid?: number } }; + + await expect(page.getByTestId('chat-composer-input')).toBeEnabled({ timeout: 60_000 }); + await page.getByTestId('chat-composer-input').fill('/lang'); + await page.getByTestId('chat-composer-send').click(); + const japaneseAction = page.getByTestId('chat-approval-action-act:/lang ja'); + await expect(japaneseAction).toBeVisible({ timeout: 30_000 }); + await expect(japaneseAction).toHaveText('日本語'); + await expect(page.getByText('Choose an action')).toBeVisible(); + + const evidenceDir = join(process.cwd(), 'artifacts', 'cc-connect'); + await mkdir(evidenceDir, { recursive: true }); + await page.screenshot({ + path: join(evidenceDir, 'real-card-choice-request.png'), + fullPage: true, + }); + await japaneseAction.click(); + await expect(page.getByTestId('chat-message-role-assistant').filter({ hasText: '言語を' }).last()) + .toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId('chat-approval-actions')).toHaveCount(0); + const configPath = join(userDataDir, 'runtimes', 'cc-connect', 'config.toml'); + const config = await readFile(configPath, 'utf8'); + const managementBlock = config.match(/\[management\]([\s\S]*?)(?=\n\[|$)/)?.[1] ?? ''; + const managementPort = Number(managementBlock.match(/^port\s*=\s*(\d+)/m)?.[1]); + const managementToken = managementBlock.match(/^token\s*=\s*"([^"]+)"/m)?.[1] ?? ''; + expect(managementPort).toBeGreaterThan(0); + expect(managementToken).not.toBe(''); + await expect.poll(async () => { + const response = await fetch(`http://127.0.0.1:${managementPort}/api/v1/projects/clawx-main`, { + headers: { Authorization: `Bearer ${managementToken}` }, + }); + if (!response.ok) return null; + return await response.json() as unknown; + }, { timeout: 30_000 }).toMatchObject({ + ok: true, + data: { settings: { language: 'ja' } }, + }); + + const statusAfter = await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-status-after-real-card-choice', + module: 'gateway', + action: 'status', + })) as { ok: boolean; data?: { pid?: number } }; + expect(statusAfter.data?.pid).toBe(statusBefore.data?.pid); + const events = await page.evaluate(() => { + const testWindow = window as typeof window & { + __ccConnectCardChoiceEvents?: Array>; + }; + return testWindow.__ccConnectCardChoiceEvents ?? []; + }); + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'approval.updated', + kind: 'choice', + phase: 'requested', + status: 'pending', + actions: expect.arrayContaining([ + expect.objectContaining({ action: 'act:/lang ja', label: '日本語' }), + ]), + }), + expect.objectContaining({ + type: 'approval.updated', + kind: 'choice', + phase: 'resolved', + status: 'answered', + }), + expect.objectContaining({ type: 'run.ended', status: 'completed' }), + ])); + await page.screenshot({ + path: join(evidenceDir, 'real-card-choice-bridge.png'), + fullPage: true, + }); + await writeFile(join(evidenceDir, 'real-card-choice-bridge.json'), `${JSON.stringify({ + schema: 'clawx-cc-connect-real-card-choice-evidence', + version: 1, + runtimeKind: 'cc-connect', + runtimeBinary: 'real-bundled-v1.4.1', + command: '/lang', + selectedAction: 'act:/lang ja', + publicBridgePackets: ['card', 'card_action', 'card'], + runtimeChoiceRequested: true, + runtimeChoiceResolved: true, + managementApiLanguage: 'ja', + pidPreserved: statusAfter.data?.pid === statusBefore.data?.pid, + requestScreenshot: 'artifacts/cc-connect/real-card-choice-request.png', + resultScreenshot: 'artifacts/cc-connect/real-card-choice-bridge.png', + }, null, 2)}\n`, 'utf8'); + } finally { + await closeElectronApp(app); + } + }); + + test('cleans up the bundled cc-connect process tree on app quit', async ({ + launchElectronApp, + userDataDir, + }) => { + const bundles = await realRuntimeBundles(); + test.skip(!bundles, 'Run pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current first.'); + await waitForPortClosed(9810); + await waitForPortClosed(9820); + await seedCcConnectRuntimeSettings(userDataDir); + const runtimeDir = join(userDataDir, 'runtimes', 'cc-connect'); + + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: bundles!.ccConnectPath, + CLAWX_CODEX_PATH: bundles!.codexPath, + }, + }); + + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + const startResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-start-real-bundle-quit-cleanup', + module: 'gateway', + action: 'start', + }); + }); + expect(startResult).toMatchObject({ + ok: true, + data: { success: true }, + }); + + const statusResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-status-real-bundle-quit-cleanup', + module: 'gateway', + action: 'status', + }); + }) as { ok: boolean; data?: { pid?: number; port?: number } }; + expect(statusResult).toMatchObject({ ok: true }); + expect(statusResult.data?.pid).toBeGreaterThan(0); + + await closeElectronApp(app); + + await expectRuntimeProcessCleanedUp({ + pid: statusResult.data?.pid, + runtimeDir, + managementPort: statusResult.data?.port, + bridgePort: 9810, + }); + }); + + test('cleans up bundled cc-connect when rolling back to OpenClaw runtime', async ({ + launchElectronApp, + userDataDir, + }) => { + const bundles = await realRuntimeBundles(); + test.skip(!bundles, 'Run pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current first.'); + await waitForPortClosed(9810); + await waitForPortClosed(9820); + await seedCcConnectRuntimeSettings(userDataDir); + const runtimeDir = join(userDataDir, 'runtimes', 'cc-connect'); + + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: bundles!.ccConnectPath, + CLAWX_CODEX_PATH: bundles!.codexPath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + const startResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-start-real-bundle-rollback-cleanup', + module: 'gateway', + action: 'start', + }); + }); + expect(startResult).toMatchObject({ + ok: true, + data: { success: true }, + }); + + const statusResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-status-real-bundle-rollback-cleanup', + module: 'gateway', + action: 'status', + }); + }) as { ok: boolean; data?: { pid?: number; port?: number; runtimeKind?: string } }; + expect(statusResult).toMatchObject({ + ok: true, + data: { runtimeKind: 'cc-connect' }, + }); + expect(statusResult.data?.pid).toBeGreaterThan(0); + + const switchResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-switch-openclaw-real-bundle-rollback-cleanup', + 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: 'runtime-status-openclaw-after-rollback-cleanup', + module: 'gateway', + action: 'status', + }); + }); + expect(openClawStatus).toMatchObject({ + ok: true, + data: { runtimeKind: 'openclaw' }, + }); + + await expectRuntimeProcessCleanedUp({ + pid: statusResult.data?.pid, + runtimeDir, + managementPort: statusResult.data?.port, + bridgePort: 9810, + }); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/tests/e2e/cc-connect-real-comprehensive.spec.ts b/tests/e2e/cc-connect-real-comprehensive.spec.ts new file mode 100644 index 000000000..20f964790 --- /dev/null +++ b/tests/e2e/cc-connect-real-comprehensive.spec.ts @@ -0,0 +1,802 @@ +import { access, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { createConnection } from 'node:net'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; +import type { Page } from '@playwright/test'; + +const execFileAsync = promisify(execFile); + +type HostInvokeResult = { + ok?: boolean; + data?: T; +}; + +type HistoryPayload = { + success?: boolean; + messages?: Array<{ + role?: string; + content?: unknown; + toolCallId?: string; + toolName?: string; + }>; +}; + +async function collectCronCompletionDiagnostics(page: Page, cronId: string) { + return await page.evaluate(async (id) => { + const [cronList, health, snapshot, sessions] = await Promise.all([ + window.clawx.hostInvoke({ + id: `runtime-comprehensive-cron-list-diagnostics-${Date.now()}`, + module: 'cron', + action: 'list', + }), + window.clawx.hostInvoke({ + id: `runtime-comprehensive-health-diagnostics-${Date.now()}`, + module: 'gateway', + action: 'health', + payload: { probe: true }, + }), + window.clawx.hostInvoke({ + id: `runtime-comprehensive-snapshot-diagnostics-${Date.now()}`, + module: 'diagnostics', + action: 'gatewaySnapshot', + }), + window.clawx.hostInvoke({ + id: `runtime-comprehensive-sessions-diagnostics-${Date.now()}`, + module: 'sessions', + action: 'summaries', + payload: {}, + }), + ]); + const jobs = Array.isArray(cronList.data) ? cronList.data : []; + const runtime = snapshot.data && typeof snapshot.data === 'object' + ? (snapshot.data as { runtime?: { ccConnect?: { logTail?: string } } }).runtime + : undefined; + return { + job: jobs.find((job) => job && typeof job === 'object' && (job as { id?: string }).id === id) ?? null, + health, + sessions, + logTail: runtime?.ccConnect?.logTail?.slice(-4_000) ?? '', + }; + }, cronId); +} + +async function realRuntimeBundles(): Promise<{ ccConnectPath: string; codexPath: string } | null> { + const platformArch = `${process.platform}-${process.arch}`; + const ccConnectPath = join( + process.cwd(), + 'build', + 'cc-connect', + platformArch, + process.platform === 'win32' ? 'cc-connect.exe' : 'cc-connect', + ); + const codexPath = join( + process.cwd(), + 'build', + 'codex', + platformArch, + 'bin', + process.platform === 'win32' ? 'codex.cmd' : 'codex', + ); + try { + await access(ccConnectPath); + await access(codexPath); + return { ccConnectPath, codexPath }; + } catch { + return null; + } +} + +async function isPortOpen(port: number): Promise { + return await new Promise((resolve) => { + const socket = createConnection({ host: '127.0.0.1', port }); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('error', () => resolve(false)); + socket.setTimeout(500, () => { + socket.destroy(); + resolve(false); + }); + }); +} + +async function waitForPortClosed(port: number): Promise { + await expect.poll(async () => await isPortOpen(port), { + timeout: 15_000, + intervals: [250, 500, 1_000], + message: `cc-connect port ${port} should be free before real comprehensive smoke starts`, + }).toBe(false); +} + +async function listProcessCommandsContaining(needle: string): Promise { + if (process.platform === 'win32') return []; + 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 waitForNoRuntimeProcesses(runtimeDir: string): Promise { + if (process.platform === 'win32') return; + await expect.poll(async () => await listProcessCommandsContaining(runtimeDir), { + timeout: 15_000, + intervals: [250, 500, 1_000], + message: `no real comprehensive runtime process should reference ${runtimeDir}`, + }).toEqual([]); +} + +async function copyLocalCodexAuthToManagedHome(userDataDir: string): Promise { + const source = process.env.CLAWX_REAL_CODEX_AUTH_JSON?.trim(); + test.skip(!source, 'Set CLAWX_REAL_CODEX_AUTH_JSON to the auth.json that may be copied into the managed CODEX_HOME.'); + const managedCodexHome = join(userDataDir, 'credentials', 'oauth', 'openai-oauth', 'codex-home'); + await mkdir(managedCodexHome, { recursive: true }); + await copyFile(source, join(managedCodexHome, 'auth.json')); + return source ?? ''; +} + +function historyContentText(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content.map((block) => { + if (!block || typeof block !== 'object') return ''; + const record = block as { text?: unknown; content?: unknown }; + if (typeof record.text === 'string') return record.text; + if (typeof record.content === 'string') return record.content; + return ''; + }).filter(Boolean).join('\n'); +} + +async function expectAssistantText(page: Page, text: string, timeout = 180_000): Promise { + await expect(page.getByTestId('chat-message-role-assistant').filter({ hasText: text }).last()).toBeVisible({ timeout }); +} + +test.describe('cc-connect real comprehensive runtime smoke', () => { + test('validates chat, sessions, project workspace, skills, and cron through real cc-connect + Codex OAuth', async ({ + launchElectronApp, + homeDir, + userDataDir, + }, testInfo) => { + test.setTimeout(900_000); + test.skip(process.env.CLAWX_REAL_OAUTH_E2E !== '1', 'Set CLAWX_REAL_OAUTH_E2E=1 with an explicit CLAWX_REAL_CODEX_AUTH_JSON.'); + const bundles = await realRuntimeBundles(); + test.skip(!bundles, 'Run pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current first.'); + await waitForPortClosed(9810); + await waitForPortClosed(9820); + + const authSource = await copyLocalCodexAuthToManagedHome(userDataDir); + await access(authSource); + + const skillDir = join(homeDir, '.agents', 'skills', 'real-smoke-skill'); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, 'SKILL.md'), [ + '---', + 'name: real-smoke-skill', + 'description: Real cc-connect smoke skill.', + '---', + 'Use this skill only as a local sync sentinel.', + '', + ].join('\n'), 'utf8'); + + const openClawConfigDir = join(homeDir, '.openclaw'); + const runtimeDir = join(userDataDir, 'runtimes', 'cc-connect'); + const mainWorkspace = join(userDataDir, 'real-workspaces', 'main'); + const researchWorkspace = join(userDataDir, 'real-workspaces', 'research'); + await mkdir(openClawConfigDir, { recursive: true }); + await mkdir(mainWorkspace, { recursive: true }); + await mkdir(researchWorkspace, { recursive: true }); + + const createdAt = new Date().toISOString(); + 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: { + 'openai-oauth': { + id: 'openai-oauth', + vendorId: 'openai', + label: 'OpenAI Codex OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + metadata: { resourceUrl: 'openai-codex' }, + createdAt, + updatedAt: createdAt, + }, + }, + providerSecrets: {}, + apiKeys: {}, + defaultProviderAccountId: 'openai-oauth', + }, null, 2), 'utf8'); + 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 app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: bundles!.ccConnectPath, + CLAWX_CODEX_PATH: bundles!.codexPath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + await page.evaluate(() => { + const testWindow = window as typeof window & { + __ccConnectRuntimeEvents?: Array<{ type?: unknown; runId?: unknown }>; + }; + testWindow.__ccConnectRuntimeEvents = []; + window.electron.ipcRenderer.on('chat:runtime-event', (payload) => { + testWindow.__ccConnectRuntimeEvents!.push(payload as { type?: unknown; runId?: unknown }); + }); + }); + + const startResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-start-real-comprehensive', + module: 'gateway', + action: 'start', + }); + }); + expect(startResult).toMatchObject({ ok: true, data: { success: true } }); + + await expect(page.getByTestId('chat-composer-input')).toBeEnabled({ timeout: 60_000 }); + await page.getByTestId('chat-composer-input').fill('Reply exactly: CLAWX_REAL_COMPREHENSIVE_CHAT_OK'); + await page.getByTestId('chat-composer-send').click(); + await expectAssistantText(page, 'CLAWX_REAL_COMPREHENSIVE_CHAT_OK'); + + const uiArtifactFile = join(mainWorkspace, 'clawx-real-ui-artifact.md'); + await page.getByTestId('chat-composer-input').fill([ + 'Use the apply_patch tool to create a file named clawx-real-ui-artifact.md in the current workspace.', + 'The file content must be exactly two lines:', + '# ClawX UI Artifact', + 'CLAWX_REAL_UI_ARTIFACT_OK', + 'After creating the file, finish the turn.', + ].join('\n')); + await page.getByTestId('chat-composer-send').click(); + await expect.poll(async () => (await readFile(uiArtifactFile, 'utf8').catch(() => '')).trim(), { + timeout: 180_000, + intervals: [1_000, 2_000, 5_000, 10_000], + }).toBe('# ClawX UI Artifact\nCLAWX_REAL_UI_ARTIFACT_OK'); + await expect(page.getByTestId('generated-files-panel')).toBeVisible({ timeout: 60_000 }); + await expect(page.getByTestId('generated-file-card-clawx-real-ui-artifact.md')).toBeVisible({ timeout: 60_000 }); + + const sessionsResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-sessions-real-comprehensive', + module: 'sessions', + action: 'summaries', + payload: {}, + }); + }); + expect(sessionsResult).toMatchObject({ + ok: true, + data: { + success: true, + sessions: expect.arrayContaining([ + expect.objectContaining({ key: 'agent:main:main' }), + ]), + }, + }); + + const readHistory = async () => await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-history-real-comprehensive', + module: 'sessions', + action: 'history', + payload: { sessionKey: 'agent:main:main', limit: 20 }, + }); + }); + await expect.poll(readHistory, { + timeout: 60_000, + intervals: [1_000, 2_000, 5_000], + }).toMatchObject({ + ok: true, + data: { + success: true, + messages: expect.arrayContaining([ + expect.objectContaining({ role: 'user', content: 'Reply exactly: CLAWX_REAL_COMPREHENSIVE_CHAT_OK' }), + expect.objectContaining({ role: 'assistant' }), + ]), + }, + }); + + const restartResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-restart-real-comprehensive', + module: 'gateway', + action: 'restart', + }); + }); + expect(restartResult).toMatchObject({ ok: true, data: { success: true } }); + await expect(page.getByTestId('chat-composer-input')).toBeEnabled({ timeout: 60_000 }); + await expect.poll(readHistory, { + timeout: 60_000, + intervals: [1_000, 2_000, 5_000], + }).toMatchObject({ + ok: true, + data: { + success: true, + messages: expect.arrayContaining([ + expect.objectContaining({ role: 'user', content: 'Reply exactly: CLAWX_REAL_COMPREHENSIVE_CHAT_OK' }), + expect.objectContaining({ role: 'assistant' }), + ]), + }, + }); + + await access(mainWorkspace); + await access(researchWorkspace); + const managedConfig = await readFile(join(runtimeDir, 'config.toml'), 'utf8'); + expect(managedConfig).toContain(`work_dir = "${mainWorkspace}"`); + expect(managedConfig).toContain('name = "clawx-research"'); + expect(managedConfig).toContain(`work_dir = "${researchWorkspace}"`); + const workDirLines = managedConfig.split('\n').filter((line) => line.startsWith('work_dir =')).join('\n'); + expect(workDirLines).not.toContain(process.cwd()); + + const researchChat = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-chat-send-research-real-comprehensive', + module: 'gateway', + action: 'rpc', + payload: { + method: 'chat.send', + params: { + sessionKey: 'agent:research:main', + message: 'Reply exactly: CLAWX_REAL_RESEARCH_CHAT_OK', + }, + timeoutMs: 60_000, + }, + }); + }); + expect(researchChat).toMatchObject({ + ok: true, + data: expect.objectContaining({ runId: expect.any(String) }), + }); + + await expect.poll(async () => { + return await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-history-research-chat-real-comprehensive', + module: 'sessions', + action: 'history', + payload: { sessionKey: 'agent:research:main', limit: 20 }, + }); + }); + }, { + timeout: 180_000, + intervals: [1_000, 2_000, 5_000], + }).toMatchObject({ + ok: true, + data: { + success: true, + messages: expect.arrayContaining([ + expect.objectContaining({ role: 'user', content: 'Reply exactly: CLAWX_REAL_RESEARCH_CHAT_OK' }), + expect.objectContaining({ role: 'assistant' }), + ]), + }, + }); + + const crossAgentSessions = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-cross-agent-sessions-real-comprehensive', + module: 'sessions', + action: 'summaries', + payload: {}, + }); + }); + expect(crossAgentSessions).toMatchObject({ + ok: true, + data: { + success: true, + sessions: expect.arrayContaining([ + expect.objectContaining({ + key: 'agent:research:main', + agentId: 'research', + derivedTitle: expect.stringContaining('CLAWX_REAL_RESEARCH_CHAT_OK'), + lastMessagePreview: expect.any(String), + }), + ]), + }, + }); + + const renameResearchSession = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-session-rename-research-real-comprehensive', + module: 'sessions', + action: 'rename', + payload: { + sessionKey: 'agent:research:main', + title: 'Renamed research runtime session', + }, + }); + }); + expect(renameResearchSession).toMatchObject({ ok: true, data: { success: true } }); + + await expect.poll(async () => { + return await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-sessions-after-rename-real-comprehensive', + module: 'sessions', + action: 'summaries', + payload: {}, + }); + }); + }, { + timeout: 60_000, + intervals: [1_000, 2_000, 5_000], + }).toMatchObject({ + ok: true, + data: { + success: true, + sessions: expect.arrayContaining([ + expect.objectContaining({ + key: 'agent:research:main', + displayName: 'Renamed research runtime session', + derivedTitle: 'Renamed research runtime session', + }), + ]), + }, + }); + + const toolSmokeFile = join(researchWorkspace, 'clawx-real-tool-smoke.txt'); + const toolSmokePrompt = [ + 'Use the apply_patch tool to create or overwrite a file named clawx-real-tool-smoke.txt in the current workspace.', + 'The file content must be exactly: CLAWX_REAL_TOOL_FILE_OK', + 'After writing the file, reply exactly: CLAWX_REAL_TOOL_FILE_DONE', + ].join(' '); + const toolSmoke = await page.evaluate(async (message) => { + return await window.clawx.hostInvoke({ + id: 'runtime-chat-send-tool-smoke-real-comprehensive', + module: 'gateway', + action: 'rpc', + payload: { + method: 'chat.send', + params: { + sessionKey: 'agent:research:tool-smoke', + message, + }, + timeoutMs: 60_000, + }, + }); + }, toolSmokePrompt); + expect(toolSmoke).toMatchObject({ + ok: true, + data: expect.objectContaining({ runId: expect.any(String) }), + }); + const toolSmokeRunId = (toolSmoke as { data?: { runId?: string } }).data?.runId; + expect(toolSmokeRunId).toBeTruthy(); + + await expect.poll(async () => { + const [historyResult, fileContent] = await Promise.all([ + page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-history-tool-smoke-real-comprehensive', + module: 'sessions', + action: 'history', + payload: { sessionKey: 'agent:research:tool-smoke', limit: 50 }, + }); + }) as Promise>, + readFile(toolSmokeFile, 'utf8').catch(() => ''), + ]); + return { + fileContent: fileContent.trim(), + hasFinalReply: (historyResult.data?.messages ?? []).some((message) => + historyContentText(message.content).includes('CLAWX_REAL_TOOL_FILE_DONE') + ), + }; + }, { + timeout: 180_000, + intervals: [2_000, 5_000, 10_000], + }).toEqual({ + fileContent: 'CLAWX_REAL_TOOL_FILE_OK', + hasFinalReply: true, + }); + await expect.poll(async () => await page.evaluate((runId) => { + const testWindow = window as typeof window & { + __ccConnectRuntimeEvents?: Array<{ type?: unknown; runId?: unknown }>; + }; + const eventTypes = (testWindow.__ccConnectRuntimeEvents ?? []) + .filter((event) => event.runId === runId) + .map((event) => event.type); + return eventTypes.includes('tool.started') && eventTypes.includes('tool.completed'); + }, toolSmokeRunId), { + timeout: 60_000, + message: 'the direct research tool turn should expose run-correlated cc-connect Bridge tool events', + }).toBe(true); + + await expect.poll(async () => { + return await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-token-usage-real-comprehensive', + module: 'usage', + action: 'recentTokenHistory', + payload: { limit: 50 }, + }); + }); + }, { + timeout: 120_000, + intervals: [1_000, 2_000, 5_000], + }).toMatchObject({ + ok: true, + data: expect.arrayContaining([ + expect.objectContaining({ + sessionId: 'agent:main:main', + agentId: 'main', + totalTokens: expect.any(Number), + }), + expect.objectContaining({ + sessionId: 'agent:research:main', + agentId: 'research', + totalTokens: expect.any(Number), + }), + ]), + }); + + const skillsStatus = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-skills-real-comprehensive', + module: 'skills', + action: 'status', + }); + }); + expect(skillsStatus).toMatchObject({ + ok: true, + data: { + skills: expect.arrayContaining([ + expect.objectContaining({ skillKey: 'real-smoke-skill' }), + ]), + }, + }); + const accountCodexHome = join(userDataDir, 'credentials', 'oauth', 'openai-oauth', 'codex-home'); + await expect(readFile(join(skillDir, 'SKILL.md'), 'utf8')) + .resolves.toContain('Real cc-connect smoke skill'); + const skillManifest = await readFile(join(accountCodexHome, 'skills', 'manifest.json'), 'utf8'); + expect(skillManifest).toContain('real-smoke-skill'); + expect(skillManifest).toContain('codex-native'); + expect(skillManifest).toContain(skillDir); + + const cronCreate = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-create-real-comprehensive', + module: 'cron', + action: 'create', + payload: { + name: 'Real cc-connect smoke cron', + message: 'Reply exactly: CLAWX_REAL_COMPREHENSIVE_CRON_OK', + schedule: { kind: 'cron', expr: '0 9 * * *' }, + enabled: true, + delivery: { mode: 'none' }, + }, + }); + }); + expect(cronCreate).toMatchObject({ + ok: true, + data: expect.objectContaining({ + name: 'Real cc-connect smoke cron', + enabled: true, + }), + }); + const cronId = (cronCreate as { data?: { id?: string } }).data?.id; + expect(cronId).toBeTruthy(); + + const cronList = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-list-real-comprehensive', + module: 'cron', + action: 'list', + }); + }); + expect(cronList).toMatchObject({ + ok: true, + data: expect.arrayContaining([ + expect.objectContaining({ id: cronId }), + ]), + }); + + const cronRun = await page.evaluate(async (id) => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-run-real-comprehensive', + module: 'cron', + action: 'trigger', + payload: { id }, + }); + }, cronId); + if (!cronRun.ok) { + throw new Error(`cron trigger failed: ${JSON.stringify(cronRun)}`); + } + expect(cronRun).toMatchObject({ ok: true, data: { success: true } }); + + const cronToggle = await page.evaluate(async (id) => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-toggle-real-comprehensive', + module: 'cron', + action: 'toggle', + payload: { id, enabled: false }, + }); + }, cronId); + expect(cronToggle).toMatchObject({ + ok: true, + data: expect.objectContaining({ id: cronId, enabled: false }), + }); + + const cronDelete = await page.evaluate(async (id) => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-delete-real-comprehensive', + module: 'cron', + action: 'delete', + payload: { id }, + }); + }, cronId); + expect(cronDelete).toMatchObject({ ok: true, data: { success: true } }); + + const researchCronCreate = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-create-research-real-comprehensive', + module: 'cron', + action: 'create', + payload: { + name: 'Real cc-connect research cron', + message: 'Reply exactly: CLAWX_REAL_RESEARCH_CRON_OK', + schedule: { kind: 'cron', expr: '0 10 * * *' }, + enabled: true, + delivery: { mode: 'none' }, + agentId: 'research', + timeoutMins: 3, + }, + }); + }); + expect(researchCronCreate).toMatchObject({ + ok: true, + data: expect.objectContaining({ + name: 'Real cc-connect research cron', + enabled: true, + agentId: 'research', + }), + }); + const researchCronId = (researchCronCreate as { data?: { id?: string } }).data?.id; + expect(researchCronId).toBeTruthy(); + + const researchCronList = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-list-research-real-comprehensive', + module: 'cron', + action: 'list', + }); + }); + expect(researchCronList).toMatchObject({ + ok: true, + data: expect.arrayContaining([ + expect.objectContaining({ id: researchCronId, agentId: 'research' }), + ]), + }); + + await page.getByTestId('sidebar-nav-cron').click(); + const researchCronCard = page.getByTestId(`cron-job-card-${researchCronId}`); + await expect(researchCronCard).toBeVisible(); + await researchCronCard.hover(); + await researchCronCard.getByRole('button', { name: 'Run Now' }).click(); + await expect(page.getByText('Task triggered successfully')).toBeVisible(); + try { + await expect(page.getByTestId(`cron-job-card-last-run-${researchCronId}`)) + .toHaveAttribute('data-run-status', 'success', { timeout: 210_000 }); + } catch (error) { + const diagnostics = await collectCronCompletionDiagnostics(page, researchCronId!); + await testInfo.attach('research-cron-completion-diagnostics', { + body: Buffer.from(JSON.stringify(diagnostics, null, 2)), + contentType: 'application/json', + }); + throw error; + } + + try { + await expect.poll(async () => { + return await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-history-research-cron-real-comprehensive', + module: 'sessions', + action: 'history', + payload: { sessionKey: 'agent:research:cron:scheduled', limit: 20 }, + }); + }); + }, { + timeout: 60_000, + intervals: [1_000, 2_000, 5_000], + }).toMatchObject({ + ok: true, + data: { + success: true, + messages: expect.arrayContaining([ + expect.objectContaining({ role: 'user', content: 'Reply exactly: CLAWX_REAL_RESEARCH_CRON_OK' }), + expect.objectContaining({ role: 'assistant' }), + ]), + }, + }); + } catch (error) { + const diagnostics = await collectCronCompletionDiagnostics(page, researchCronId!); + await testInfo.attach('research-cron-history-diagnostics', { + body: Buffer.from(JSON.stringify(diagnostics, null, 2)), + contentType: 'application/json', + }); + throw error; + } + + const researchCronDelete = await page.evaluate(async (id) => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-delete-research-real-comprehensive', + module: 'cron', + action: 'delete', + payload: { id }, + }); + }, researchCronId); + expect(researchCronDelete).toMatchObject({ ok: true, data: { success: true } }); + + const publicProfile = await readFile(join(runtimeDir, 'provider-profile.json'), 'utf8'); + expect(publicProfile).toContain('CODEX_HOME'); + expect(publicProfile).not.toContain('access_token'); + expect(publicProfile).not.toContain('refresh_token'); + expect(publicProfile).not.toContain('id_token'); + + const deleteSession = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-session-delete-real-comprehensive', + module: 'sessions', + action: 'delete', + payload: { sessionKey: 'agent:main:main' }, + }); + }); + expect(deleteSession).toMatchObject({ ok: true, data: { success: true } }); + + await expect.poll(async () => { + const [sessionsAfterDelete, historyAfterDelete] = await Promise.all([ + page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-sessions-after-delete-real-comprehensive', + module: 'sessions', + action: 'summaries', + payload: {}, + }); + }), + page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-history-after-delete-real-comprehensive', + module: 'sessions', + action: 'history', + payload: { sessionKey: 'agent:main:main', limit: 20 }, + }); + }), + ]); + const sessions = (sessionsAfterDelete as { data?: { sessions?: Array<{ key?: string }> } }).data?.sessions ?? []; + const messages = (historyAfterDelete as { data?: { messages?: unknown[] } }).data?.messages ?? []; + return { + sessionRemoved: !sessions.some((session) => session.key === 'agent:main:main'), + historyEmpty: messages.length === 0, + }; + }, { + timeout: 60_000, + intervals: [1_000, 2_000, 5_000], + }).toEqual({ sessionRemoved: true, historyEmpty: true }); + } finally { + await closeElectronApp(app); + await waitForNoRuntimeProcesses(runtimeDir); + } + }); +}); diff --git a/tests/e2e/cc-connect-real-feishu-channel.spec.ts b/tests/e2e/cc-connect-real-feishu-channel.spec.ts new file mode 100644 index 000000000..edf1d1754 --- /dev/null +++ b/tests/e2e/cc-connect-real-feishu-channel.spec.ts @@ -0,0 +1,616 @@ +import { access, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { createConnection } from 'node:net'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; +import { loadDefaultCcConnectLocalRealEnv } from './helpers/local-real-env'; +import { writeFeishuInboundMarkerArtifact } from './helpers/feishu-inbound-marker'; +import type { Page } from '@playwright/test'; + +const execFileAsync = promisify(execFile); + +loadDefaultCcConnectLocalRealEnv(); + +type RuntimeBundles = { + ccConnectPath: string; + codexPath: string; +}; + +async function realRuntimeBundles(): Promise { + const platformArch = `${process.platform}-${process.arch}`; + const ccConnectPath = join( + process.cwd(), + 'build', + 'cc-connect', + platformArch, + process.platform === 'win32' ? 'cc-connect.exe' : 'cc-connect', + ); + const codexPath = join( + process.cwd(), + 'build', + 'codex', + platformArch, + 'bin', + process.platform === 'win32' ? 'codex.cmd' : 'codex', + ); + try { + await access(ccConnectPath); + await access(codexPath); + return { ccConnectPath, codexPath }; + } catch { + return null; + } +} + +async function isPortOpen(port: number): Promise { + return await new Promise((resolve) => { + const socket = createConnection({ host: '127.0.0.1', port }); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('error', () => resolve(false)); + socket.setTimeout(500, () => { + socket.destroy(); + resolve(false); + }); + }); +} + +async function waitForPortClosed(port: number): Promise { + await expect.poll(async () => await isPortOpen(port), { + timeout: 15_000, + intervals: [250, 500, 1_000], + message: `cc-connect port ${port} should be free before real Feishu channel smoke starts`, + }).toBe(false); +} + +async function listProcessCommandsContaining(needle: string): Promise { + if (process.platform === 'win32') return []; + 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 waitForNoRuntimeProcesses(runtimeDir: string): Promise { + if (process.platform === 'win32') return; + await expect.poll(async () => await listProcessCommandsContaining(runtimeDir), { + timeout: 15_000, + intervals: [250, 500, 1_000], + message: `no real Feishu runtime process should reference ${runtimeDir}`, + }).toEqual([]); +} + +async function findMarkerThroughHostApi(page: Page, marker: string): Promise<{ + found: boolean; + matchingSessionKeys: string[]; +}> { + const summaries = await page.evaluate(async () => window.clawx.hostInvoke({ + id: `runtime-feishu-inbound-summaries-${Date.now()}`, + module: 'sessions', + action: 'summaries', + payload: {}, + })) as { ok?: boolean; data?: { sessions?: Array<{ key?: string }> } }; + if (!summaries.ok) return { found: false, matchingSessionKeys: [] }; + + const sessionKeys = (summaries.data?.sessions ?? []) + .map((session) => session.key) + .filter((key): key is string => Boolean(key)); + const matchingSessionKeys: string[] = []; + for (const sessionKey of sessionKeys) { + const history = await page.evaluate(async (key) => window.clawx.hostInvoke({ + id: `runtime-feishu-inbound-history-${Date.now()}`, + module: 'sessions', + action: 'history', + payload: { sessionKey: key, limit: 200 }, + }), sessionKey) as { ok?: boolean; data?: { success?: boolean; messages?: unknown[] } }; + if (history.ok && history.data?.success && JSON.stringify(history.data.messages ?? []).includes(marker)) { + matchingSessionKeys.push(sessionKey); + } + } + return { found: matchingSessionKeys.length > 0, matchingSessionKeys }; +} + +async function copyLocalCodexAuthToManagedHome(userDataDir: string): Promise { + const source = process.env.CLAWX_REAL_CODEX_AUTH_JSON?.trim(); + test.skip(!source, 'Set CLAWX_REAL_CODEX_AUTH_JSON to the auth.json that may be copied into the managed CODEX_HOME.'); + const managedCodexHome = join(userDataDir, 'credentials', 'oauth', 'openai-oauth', 'codex-home'); + await mkdir(managedCodexHome, { recursive: true }); + await copyFile(source, join(managedCodexHome, 'auth.json')); + return source ?? ''; +} + +function requiredEnv(name: string): string { + const value = process.env[name]?.trim(); + test.skip(!value, `Set ${name} for the real cc-connect Feishu channel smoke.`); + return value ?? ''; +} + +function feishuDomainInput(): string { + const value = process.env.CLAWX_REAL_FEISHU_DOMAIN?.trim(); + if (!value) return 'feishu'; + if (value === 'cn') return 'feishu'; + if (value === 'global') return 'lark'; + return value; +} + +function expectedPlatformType(domain: string): 'feishu' | 'lark' { + const normalized = domain.toLowerCase(); + return normalized === 'lark' || normalized.includes('larksuite.com') ? 'lark' : 'feishu'; +} + +function expectedDomainUrl(domain: string, platformType: 'feishu' | 'lark'): string { + const normalized = domain.toLowerCase(); + if (normalized === 'lark') return 'https://open.larksuite.com'; + if (normalized === 'feishu') return 'https://open.feishu.cn'; + if (platformType === 'lark' && !domain.includes('larksuite.com')) return 'https://open.larksuite.com'; + return domain; +} + +test.describe('cc-connect real Feishu channel runtime smoke', () => { + test('validates Feishu/Lark channel config, runtime status, and lifecycle refresh through cc-connect', async ({ + launchElectronApp, + homeDir, + userDataDir, + }) => { + test.skip(process.env.CLAWX_REAL_FEISHU_E2E !== '1', 'Set CLAWX_REAL_FEISHU_E2E=1 to run with real Feishu/Lark credentials.'); + const bundles = await realRuntimeBundles(); + test.skip(!bundles, 'Run pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current first.'); + await waitForPortClosed(9810); + await waitForPortClosed(9820); + + const appId = requiredEnv('CLAWX_REAL_FEISHU_APP_ID'); + const appSecret = requiredEnv('CLAWX_REAL_FEISHU_APP_SECRET'); + const accountId = process.env.CLAWX_REAL_FEISHU_ACCOUNT_ID?.trim() || 'real_feishu_bot'; + const allowFrom = process.env.CLAWX_REAL_FEISHU_ALLOW_FROM?.trim() || '*'; + const adminFrom = requiredEnv('CLAWX_REAL_FEISHU_ADMIN_FROM'); + const domain = feishuDomainInput(); + const platformType = expectedPlatformType(domain); + const expectedDomain = expectedDomainUrl(domain, platformType); + + const authSource = await copyLocalCodexAuthToManagedHome(userDataDir); + await access(authSource); + + const openClawConfigDir = join(homeDir, '.openclaw'); + const runtimeDir = join(userDataDir, 'runtimes', 'cc-connect'); + const mainWorkspace = join(userDataDir, 'real-feishu-workspaces', 'main'); + const opsWorkspace = join(userDataDir, 'real-feishu-workspaces', 'ops'); + await mkdir(openClawConfigDir, { recursive: true }); + await mkdir(mainWorkspace, { recursive: true }); + await mkdir(opsWorkspace, { recursive: true }); + + const createdAt = new Date().toISOString(); + 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: { + 'openai-oauth': { + id: 'openai-oauth', + vendorId: 'openai', + label: 'OpenAI Codex OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + metadata: { resourceUrl: 'openai-codex' }, + createdAt, + updatedAt: createdAt, + }, + }, + providerSecrets: {}, + apiKeys: {}, + defaultProviderAccountId: 'openai-oauth', + }, null, 2), 'utf8'); + const compatibilityConfigPath = join(openClawConfigDir, 'openclaw.json'); + const compatibilityConfig = JSON.stringify({ + agents: { + defaults: { workspace: mainWorkspace }, + list: [ + { id: 'main', name: 'Main Agent', default: true, workspace: mainWorkspace }, + { id: 'ops', name: 'Ops Agent', workspace: opsWorkspace }, + ], + }, + bindings: [ + { match: { channel: 'feishu', accountId }, agentId: 'ops' }, + ], + channels: { + feishu: { + enabled: true, + defaultAccount: accountId, + accounts: { + [accountId]: { + appId, + appSecret, + domain, + allowFrom, + adminFrom, + shareSessionInChannel: true, + enableFeishuCard: false, + }, + }, + }, + }, + }, null, 2); + await writeFile(compatibilityConfigPath, compatibilityConfig, 'utf8'); + + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: bundles!.ccConnectPath, + CLAWX_CODEX_PATH: bundles!.codexPath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + const startResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-start-real-feishu-channel', + module: 'gateway', + action: 'start', + }); + }); + expect(startResult).toMatchObject({ ok: true, data: { success: true } }); + + const managedConfigPath = join(runtimeDir, 'config.toml'); + const managedConfig = await readFile(managedConfigPath, 'utf8'); + expect(managedConfig).toContain('name = "clawx-ops"'); + expect(managedConfig).toContain(`work_dir = "${opsWorkspace}"`); + expect(managedConfig).toContain(`type = "${platformType}"`); + expect(managedConfig).toContain(`app_id = "${appId}"`); + expect(managedConfig).toContain(`domain = "${expectedDomain}"`); + expect(managedConfig).toContain(`allow_from = "${allowFrom}"`); + const opsProjectConfig = managedConfig + .split('[[projects]]') + .find((section) => section.includes('name = "clawx-ops"')) ?? ''; + const projectedAdmins = opsProjectConfig + .match(/^admin_from = "([^"]*)"$/m)?.[1] + .split(',') + .map((value) => value.trim()) + .filter(Boolean) ?? []; + expect(projectedAdmins).toEqual(expect.arrayContaining(['clawx-desktop', adminFrom])); + expect(managedConfig).toContain('share_session_in_channel = true'); + expect(managedConfig).toContain('enable_feishu_card = false'); + const workDirLines = managedConfig.split('\n').filter((line) => line.startsWith('work_dir =')).join('\n'); + expect(workDirLines).not.toContain(process.cwd()); + + const canonicalConfigPath = join(userDataDir, 'runtime-config.json'); + const canonicalConfig = await readFile(canonicalConfigPath, 'utf8'); + expect(canonicalConfig).toContain('"schema": "clawx-runtime-config"'); + expect(canonicalConfig).toContain(appId); + expect(canonicalConfig).not.toContain(appSecret); + const encryptedVault = await readFile(join(userDataDir, 'credentials', 'secrets.enc')); + expect(encryptedVault.includes(Buffer.from(appSecret, 'utf8'))).toBe(false); + const credentialIndex = await readFile(join(userDataDir, 'credentials', 'index.json'), 'utf8'); + expect(credentialIndex).toContain(`feishu:${accountId}`); + expect(credentialIndex).not.toContain(appSecret); + await expect(readFile(compatibilityConfigPath, 'utf8')).resolves.toBe(compatibilityConfig); + + const channelsAccounts = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channels-accounts-real-feishu', + module: 'channels', + action: 'accounts', + payload: { probe: true }, + }); + }); + expect(channelsAccounts).toMatchObject({ + ok: true, + data: { + success: true, + channels: expect.arrayContaining([ + expect.objectContaining({ + channelType: 'feishu', + accounts: expect.arrayContaining([ + expect.objectContaining({ + accountId, + configured: true, + connected: true, + running: true, + linked: true, + }), + ]), + }), + ]), + }, + }); + + const disconnectResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channel-disconnect-real-feishu', + module: 'gateway', + action: 'rpc', + payload: { + method: 'channels.disconnect', + params: { channelType: 'feishu' }, + timeoutMs: 60_000, + }, + }); + }); + expect(disconnectResult).toMatchObject({ ok: true, data: { success: true } }); + + const connectResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channel-connect-real-feishu', + module: 'gateway', + action: 'rpc', + payload: { + method: 'channels.connect', + params: { channelType: 'feishu' }, + timeoutMs: 60_000, + }, + }); + }); + expect(connectResult).toMatchObject({ ok: true, data: { success: true } }); + + const channelsAfterReload = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channels-after-reload-real-feishu', + module: 'channels', + action: 'accounts', + payload: { probe: true }, + }); + }); + expect(channelsAfterReload).toMatchObject({ + ok: true, + data: { + success: true, + channels: expect.arrayContaining([ + expect.objectContaining({ + channelType: 'feishu', + accounts: expect.arrayContaining([ + expect.objectContaining({ + accountId, + configured: true, + connected: true, + running: true, + linked: true, + }), + ]), + }), + ]), + }, + }); + + const deleteConfigResult = await page.evaluate(async (targetAccountId) => { + return await window.clawx.hostInvoke({ + id: 'runtime-channel-delete-config-real-feishu', + module: 'channels', + action: 'deleteConfig', + payload: { channelType: 'feishu', accountId: targetAccountId }, + }); + }, accountId); + expect(deleteConfigResult).toMatchObject({ ok: true, data: { success: true } }); + + await expect.poll(async () => { + const refreshedConfig = await readFile(managedConfigPath, 'utf8'); + return { + hasFeishuPlatform: refreshedConfig.includes(`type = "${platformType}"`), + hasAppId: refreshedConfig.includes(appId), + }; + }, { + timeout: 60_000, + intervals: [1_000, 2_000, 5_000], + }).toEqual({ hasFeishuPlatform: false, hasAppId: false }); + + const channelsAfterDelete = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channels-after-delete-real-feishu', + module: 'channels', + action: 'accounts', + payload: { probe: true }, + }); + }); + expect(channelsAfterDelete).toMatchObject({ + ok: true, + data: { + success: true, + channels: expect.not.arrayContaining([ + expect.objectContaining({ channelType: 'feishu' }), + ]), + }, + }); + + const canonicalAfterDelete = JSON.parse(await readFile(canonicalConfigPath, 'utf8')) as { + config?: { channels?: Record }; + }; + expect(canonicalAfterDelete.config?.channels?.feishu).toBeUndefined(); + await expect(readFile(compatibilityConfigPath, 'utf8')).resolves.toBe(compatibilityConfig); + + const evidenceDir = join(process.cwd(), 'artifacts', 'cc-connect'); + await mkdir(evidenceDir, { recursive: true }); + await writeFile(join(evidenceDir, 'real-feishu-lifecycle.json'), `${JSON.stringify({ + schema: 'clawx-cc-connect-real-feishu-lifecycle-evidence', + runtimeKind: 'cc-connect', + platformType, + canonicalConfigOwner: true, + canonicalSecretStripped: true, + vaultPlaintextSecretAbsent: true, + compatibilitySourceUnchangedAfterImport: true, + connectedBeforeReload: true, + disconnectReloadSucceeded: true, + connectedAfterReload: true, + canonicalChannelDeleted: true, + compatibilitySourceUnchangedAfterDelete: true, + managedRuntimePlatformRemoved: true, + }, null, 2)}\n`, 'utf8'); + } finally { + await closeElectronApp(app); + await waitForNoRuntimeProcesses(runtimeDir); + } + }); + + test('observes a real inbound Feishu/Lark tenant message through the public session API', async ({ + launchElectronApp, + homeDir, + userDataDir, + }) => { + test.skip( + process.env.CLAWX_REAL_FEISHU_INBOUND_E2E !== '1', + 'Set CLAWX_REAL_FEISHU_INBOUND_E2E=1 to run the manual tenant-message inbound smoke.', + ); + const timeoutMs = Number(process.env.CLAWX_REAL_FEISHU_INBOUND_TIMEOUT_MS || 180_000); + test.setTimeout(Math.max(60_000, timeoutMs + 45_000)); + + const bundles = await realRuntimeBundles(); + test.skip(!bundles, 'Run pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current first.'); + await waitForPortClosed(9810); + await waitForPortClosed(9820); + + const appId = requiredEnv('CLAWX_REAL_FEISHU_APP_ID'); + const appSecret = requiredEnv('CLAWX_REAL_FEISHU_APP_SECRET'); + const accountId = process.env.CLAWX_REAL_FEISHU_ACCOUNT_ID?.trim() || 'real_feishu_bot'; + const allowFrom = process.env.CLAWX_REAL_FEISHU_ALLOW_FROM?.trim() || '*'; + const domain = feishuDomainInput(); + const marker = process.env.CLAWX_REAL_FEISHU_INBOUND_MARKER?.trim() + || `CLAWX_FEISHU_INBOUND_${Date.now()}`; + const markerArtifactPath = await writeFeishuInboundMarkerArtifact(process.cwd(), { + marker, + accountId, + domain, + timeoutMs, + }); + + const authSource = await copyLocalCodexAuthToManagedHome(userDataDir); + await access(authSource); + + const openClawConfigDir = join(homeDir, '.openclaw'); + const runtimeDir = join(userDataDir, 'runtimes', 'cc-connect'); + const mainWorkspace = join(userDataDir, 'real-feishu-inbound-workspaces', 'main'); + const opsWorkspace = join(userDataDir, 'real-feishu-inbound-workspaces', 'ops'); + await mkdir(openClawConfigDir, { recursive: true }); + await mkdir(mainWorkspace, { recursive: true }); + await mkdir(opsWorkspace, { recursive: true }); + + const createdAt = new Date().toISOString(); + 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: { + 'openai-oauth': { + id: 'openai-oauth', + vendorId: 'openai', + label: 'OpenAI Codex OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + metadata: { resourceUrl: 'openai-codex' }, + createdAt, + updatedAt: createdAt, + }, + }, + providerSecrets: {}, + apiKeys: {}, + defaultProviderAccountId: 'openai-oauth', + }, null, 2), 'utf8'); + await writeFile(join(openClawConfigDir, 'openclaw.json'), JSON.stringify({ + agents: { + defaults: { workspace: mainWorkspace }, + list: [ + { id: 'main', name: 'Main Agent', default: true, workspace: mainWorkspace }, + { id: 'ops', name: 'Ops Agent', workspace: opsWorkspace }, + ], + }, + bindings: [ + { match: { channel: 'feishu', accountId }, agentId: 'ops' }, + ], + channels: { + feishu: { + enabled: true, + defaultAccount: accountId, + accounts: { + [accountId]: { + appId, + appSecret, + domain, + allowFrom, + shareSessionInChannel: true, + enableFeishuCard: false, + }, + }, + }, + }, + }, null, 2), 'utf8'); + + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: bundles!.ccConnectPath, + CLAWX_CODEX_PATH: bundles!.codexPath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + const startResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-start-real-feishu-inbound', + module: 'gateway', + action: 'start', + }); + }); + expect(startResult).toMatchObject({ ok: true, data: { success: true } }); + + const channelsAccounts = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-channels-accounts-real-feishu-inbound', + module: 'channels', + action: 'accounts', + payload: { probe: true }, + }); + }); + expect(channelsAccounts).toMatchObject({ + ok: true, + data: { + success: true, + channels: expect.arrayContaining([ + expect.objectContaining({ + channelType: 'feishu', + accounts: expect.arrayContaining([ + expect.objectContaining({ + accountId, + configured: true, + connected: true, + running: true, + }), + ]), + }), + ]), + }, + }); + + console.log(`[cc-connect-real-feishu-inbound] Send this exact message to the configured Feishu/Lark bot before timeout: ${marker}`); + console.log(`[cc-connect-real-feishu-inbound] Marker artifact: ${markerArtifactPath}`); + + await expect.poll(async () => await findMarkerThroughHostApi(page, marker), { + timeout: timeoutMs, + intervals: [1_000, 2_000, 5_000], + message: `real Feishu/Lark tenant message "${marker}" should appear through ClawX public session history`, + }).toMatchObject({ found: true }); + } finally { + await closeElectronApp(app); + await waitForNoRuntimeProcesses(runtimeDir); + } + }); +}); diff --git a/tests/e2e/cc-connect-real-oauth-chat.spec.ts b/tests/e2e/cc-connect-real-oauth-chat.spec.ts new file mode 100644 index 000000000..8f1bb208a --- /dev/null +++ b/tests/e2e/cc-connect-real-oauth-chat.spec.ts @@ -0,0 +1,244 @@ +import { access, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; +import type { Page } from '@playwright/test'; + +async function realRuntimeBundles(): Promise<{ ccConnectPath: string; codexPath: string } | null> { + const platformArch = `${process.platform}-${process.arch}`; + const ccConnectPath = join( + process.cwd(), + 'build', + 'cc-connect', + platformArch, + process.platform === 'win32' ? 'cc-connect.exe' : 'cc-connect', + ); + const codexPath = join( + process.cwd(), + 'build', + 'codex', + platformArch, + 'bin', + process.platform === 'win32' ? 'codex.cmd' : 'codex', + ); + try { + await access(ccConnectPath); + await access(codexPath); + return { ccConnectPath, codexPath }; + } catch { + return null; + } +} + +async function expectAssistantText(page: Page, text: string, timeout = 180_000): Promise { + await expect(page.getByTestId('chat-message-role-assistant').filter({ hasText: text }).last()).toBeVisible({ timeout }); +} + +function oauthEvidencePathMasks(page: Page, workspace: string) { + return [ + page.getByTestId('chat-execution-step').filter({ hasText: workspace }), + page.getByTestId('generated-file-card-clawx-real-oauth-tool.txt') + .locator('span') + .filter({ hasText: workspace }), + ]; +} + +test.describe('cc-connect real OpenAI OAuth chat', () => { + test('sends a chat message through real cc-connect and Codex OAuth', async ({ + launchElectronApp, + userDataDir, + }) => { + test.setTimeout(480_000); + test.skip(process.env.CLAWX_REAL_OAUTH_E2E !== '1', 'Set CLAWX_REAL_OAUTH_E2E=1 with an explicit CLAWX_REAL_CODEX_AUTH_JSON.'); + const bundles = await realRuntimeBundles(); + test.skip(!bundles, 'Run pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current first.'); + + const authSource = process.env.CLAWX_REAL_CODEX_AUTH_JSON?.trim(); + test.skip(!authSource, 'Set CLAWX_REAL_CODEX_AUTH_JSON to the auth.json copied into the isolated managed CODEX_HOME.'); + const managedCodexHome = join(userDataDir, 'credentials', 'oauth', 'openai-oauth', 'codex-home'); + await mkdir(managedCodexHome, { recursive: true }); + await copyFile(authSource!, join(managedCodexHome, 'auth.json')); + const workspace = join(userDataDir, 'workspaces', 'agents', 'main'); + await mkdir(workspace, { recursive: true }); + + const createdAt = '2026-06-07T00:00:00.000Z'; + 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: { + 'openai-oauth': { + id: 'openai-oauth', + vendorId: 'openai', + label: 'OpenAI Codex OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.4-mini', + enabled: true, + isDefault: true, + metadata: { resourceUrl: 'openai-codex' }, + createdAt, + updatedAt: createdAt, + }, + }, + providerSecrets: {}, + apiKeys: {}, + defaultProviderAccountId: 'openai-oauth', + }, null, 2), 'utf8'); + // E2E uses CLAWX_USER_DATA_DIR compatibility mode, where app config is + // stored at the isolated root rather than under root/app. + await writeFile(join(userDataDir, 'agent-bindings.json'), JSON.stringify({ + schema: 'clawx-agent-bindings', + version: 1, + agents: { + main: { + providerAccountId: 'openai-oauth', + permissionMode: 'suggest', + updatedAt: createdAt, + }, + }, + }, null, 2), 'utf8'); + + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: bundles!.ccConnectPath, + CLAWX_CODEX_PATH: bundles!.codexPath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + await page.evaluate(() => { + const testWindow = window as typeof window & { + __ccConnectRuntimeEvents?: Array<{ type?: unknown; phase?: unknown; status?: unknown }>; + }; + testWindow.__ccConnectRuntimeEvents = []; + window.electron.ipcRenderer.on('chat:runtime-event', (payload) => { + testWindow.__ccConnectRuntimeEvents!.push(payload as { type?: unknown; phase?: unknown; status?: unknown }); + }); + }); + + const startResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-start-real-oauth', + module: 'gateway', + action: 'start', + }); + }); + expect(startResult).toMatchObject({ + ok: true, + data: { success: true }, + }); + const managedConfigPath = join(userDataDir, 'runtimes', 'cc-connect', 'config.toml'); + await expect.poll(async () => await readFile(managedConfigPath, 'utf8'), { timeout: 30_000 }) + .toContain('mode = "suggest"'); + + await expect(page.getByTestId('chat-composer-input')).toBeEnabled({ timeout: 60_000 }); + await page.getByTestId('chat-composer-input').fill([ + 'Use the apply_patch tool to create a file named clawx-real-oauth-tool.txt in the current workspace.', + 'The file content must be exactly: CLAWX_REAL_OAUTH_TOOL_OK', + 'After writing the file, reply exactly: CLAWX_REAL_OAUTH_E2E_OK', + ].join(' ')); + await page.getByTestId('chat-composer-send').click(); + const allowApproval = page.getByTestId('chat-approval-action-perm:allow'); + const evidenceDir = join(process.cwd(), 'artifacts', 'cc-connect'); + await mkdir(evidenceDir, { recursive: true }); + try { + await expect(allowApproval).toBeVisible({ timeout: 240_000 }); + } catch (error) { + const diagnosticEvents = await page.evaluate(() => { + const testWindow = window as typeof window & { + __ccConnectRuntimeEvents?: Array<{ type?: unknown; phase?: unknown; status?: unknown }>; + }; + return testWindow.__ccConnectRuntimeEvents ?? []; + }); + const runtimeLog = await readFile( + join(userDataDir, 'runtimes', 'cc-connect', 'logs', 'runtime.log'), + 'utf8', + ).catch(() => 'runtime log unavailable'); + await writeFile(join(evidenceDir, 'real-oauth-approval-failure.json'), JSON.stringify({ + configMode: 'suggest', + runtimeEvents: diagnosticEvents, + runtimeLog, + }, null, 2), 'utf8'); + await page.screenshot({ + path: join(evidenceDir, 'real-oauth-approval-failure.png'), + fullPage: true, + mask: oauthEvidencePathMasks(page, workspace), + maskColor: '#e5e7eb', + }); + throw error; + } + await page.screenshot({ + path: join(evidenceDir, 'real-oauth-approval-request.png'), + fullPage: true, + mask: oauthEvidencePathMasks(page, workspace), + maskColor: '#e5e7eb', + }); + await allowApproval.click(); + await expect.poll(async () => await readFile(join(workspace, 'clawx-real-oauth-tool.txt'), 'utf8').catch(() => ''), { + timeout: 180_000, + intervals: [1_000, 2_000, 5_000, 10_000], + }).toContain('CLAWX_REAL_OAUTH_TOOL_OK'); + await expectAssistantText(page, 'CLAWX_REAL_OAUTH_E2E_OK'); + await expect(page.getByTestId('generated-files-panel')).toBeVisible({ timeout: 60_000 }); + await expect(page.getByTestId('generated-file-card-clawx-real-oauth-tool.txt')).toBeVisible({ timeout: 60_000 }); + await expect.poll(async () => await page.evaluate(() => { + const testWindow = window as typeof window & { + __ccConnectRuntimeEvents?: Array<{ type?: unknown; phase?: unknown; status?: unknown }>; + }; + const events = testWindow.__ccConnectRuntimeEvents ?? []; + const types = events.map((event) => event.type); + return types.includes('tool.started') + && types.includes('tool.completed') + && events.some((event) => event.type === 'approval.updated' && event.phase === 'requested') + && events.some((event) => event.type === 'approval.updated' && event.phase === 'resolved' && event.status === 'approved'); + }), { + timeout: 60_000, + message: 'cc-connect public progress packets should surface the real OAuth tool lifecycle', + }).toBe(true); + await expect(page.getByTestId('chat-execution-graph')).toBeVisible({ timeout: 60_000 }); + const eventTypes = await page.evaluate(() => { + const testWindow = window as typeof window & { + __ccConnectRuntimeEvents?: Array<{ type?: unknown; phase?: unknown; status?: unknown }>; + }; + return (testWindow.__ccConnectRuntimeEvents ?? []).map((event) => String(event.type || 'unknown')); + }); + const runtimeLog = await readFile(join(userDataDir, 'runtimes', 'cc-connect', 'logs', 'runtime.log'), 'utf8'); + expect(runtimeLog).toContain('codex app-server session started'); + expect(runtimeLog).toContain('transport=stdio'); + expect(runtimeLog).toMatch(/turn complete.*tools=1/); + await page.screenshot({ + path: join(evidenceDir, 'real-oauth-tool-events.png'), + fullPage: true, + mask: oauthEvidencePathMasks(page, workspace), + maskColor: '#e5e7eb', + }); + await writeFile(join(evidenceDir, 'real-oauth-tool-events.json'), JSON.stringify({ + runtime: 'cc-connect', + codexBackend: 'app_server', + transport: 'stdio', + workspaceManaged: true, + toolCount: 1, + approvalMode: 'suggest', + approvalRequested: true, + approvalResolved: true, + eventTypes: Array.from(new Set(eventTypes)), + screenshot: 'artifacts/cc-connect/real-oauth-tool-events.png', + approvalScreenshot: 'artifacts/cc-connect/real-oauth-approval-request.png', + }, null, 2), 'utf8'); + + const publicProfile = await readFile(join(userDataDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8'); + expect(publicProfile).toContain('CODEX_HOME'); + expect(publicProfile).not.toContain('access_token'); + expect(publicProfile).not.toContain('refresh_token'); + expect(publicProfile).not.toContain('id_token'); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/tests/e2e/cc-connect-real-openai-api-key.spec.ts b/tests/e2e/cc-connect-real-openai-api-key.spec.ts new file mode 100644 index 000000000..ceabf78f2 --- /dev/null +++ b/tests/e2e/cc-connect-real-openai-api-key.spec.ts @@ -0,0 +1,1096 @@ +import { access, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http'; +import { createConnection } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; +import { loadDefaultCcConnectLocalRealEnv } from './helpers/local-real-env'; +import type { Page } from '@playwright/test'; + +const execFileAsync = promisify(execFile); + +loadDefaultCcConnectLocalRealEnv(); + +type RuntimeBundles = { + ccConnectPath: string; + codexPath: string; +}; + +type OpenAiCompatibleRequest = { + method?: string; + url?: string; + authorization?: string | string[]; + body?: string; +}; + +type Deferred = { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +}; + +function deferred(): Deferred { + let resolve!: Deferred['resolve']; + let reject!: Deferred['reject']; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +async function realRuntimeBundles(): Promise { + const platformArch = `${process.platform}-${process.arch}`; + const ccConnectPath = join( + process.cwd(), + 'build', + 'cc-connect', + platformArch, + process.platform === 'win32' ? 'cc-connect.exe' : 'cc-connect', + ); + const codexPath = join( + process.cwd(), + 'build', + 'codex', + platformArch, + 'bin', + process.platform === 'win32' ? 'codex.cmd' : 'codex', + ); + try { + await access(ccConnectPath); + await access(codexPath); + return { ccConnectPath, codexPath }; + } catch { + return null; + } +} + +async function isPortOpen(port: number): Promise { + return await new Promise((resolve) => { + const socket = createConnection({ host: '127.0.0.1', port }); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('error', () => resolve(false)); + socket.setTimeout(500, () => { + socket.destroy(); + resolve(false); + }); + }); +} + +async function waitForPortClosed(port: number): Promise { + await expect.poll(async () => await isPortOpen(port), { + timeout: 15_000, + intervals: [250, 500, 1_000], + message: `cc-connect port ${port} should be free before real OpenAI API key smoke starts`, + }).toBe(false); +} + +async function listProcessCommandsContaining(needle: string): Promise { + if (process.platform === 'win32') return []; + 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 waitForNoRuntimeProcesses(runtimeDir: string): Promise { + if (process.platform === 'win32') return; + await expect.poll(async () => await listProcessCommandsContaining(runtimeDir), { + timeout: 15_000, + intervals: [250, 500, 1_000], + message: `no real OpenAI API key runtime process should reference ${runtimeDir}`, + }).toEqual([]); +} + +async function stopRuntime(page: Page): Promise { + await page.evaluate(async () => { + await window.clawx.hostInvoke({ + id: `runtime-stop-${Date.now()}`, + module: 'gateway', + action: 'stop', + }); + }).catch(() => undefined); +} + +function requiredOpenAiApiKey(): string { + const value = process.env.OPENAI_API_KEY?.trim() || process.env.CLAWX_REAL_OPENAI_API_KEY?.trim(); + test.skip(!value, 'Set CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY for the real cc-connect OpenAI API key smoke.'); + return value ?? ''; +} + +async function expectAssistantText(page: Page, text: string, timeout = 180_000): Promise { + await expect(page.getByTestId('chat-message-role-assistant').filter({ hasText: text }).last()).toBeVisible({ timeout }); +} + +async function listen(server: Server): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('OpenAI-compatible mock server did not bind to a TCP port'); + } + return address.port; +} + +async function closeServer(server: Server): Promise { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }).catch(() => {}); +} + +function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +function writeSse(res: ServerResponse, type: string, payload: Record): void { + res.write(`event: ${type}\n`); + res.write(`data: ${JSON.stringify({ type, ...payload })}\n\n`); +} + +function responseStreamParts(text: string, model: string) { + const now = Math.floor(Date.now() / 1000); + const response = { + id: 'resp_clawx_local_openai', + object: 'response', + created_at: now, + status: 'in_progress', + model, + output: [], + parallel_tool_calls: false, + tool_choice: 'auto', + tools: [], + usage: null, + }; + const item = { + id: 'msg_clawx_local_openai', + type: 'message', + status: 'in_progress', + role: 'assistant', + content: [], + }; + const doneItem = { + ...item, + status: 'completed', + content: [{ type: 'output_text', text, annotations: [] }], + }; + + return { response, item, doneItem }; +} + +function writeResponseStreamStart(res: ServerResponse, text: string, model: string): void { + const { response, item } = responseStreamParts(text, model); + writeSse(res, 'response.created', { response }); + writeSse(res, 'response.in_progress', { response }); + writeSse(res, 'response.output_item.added', { output_index: 0, item }); + writeSse(res, 'response.content_part.added', { + item_id: item.id, + output_index: 0, + content_index: 0, + part: { type: 'output_text', text: '', annotations: [] }, + }); +} + +function writeResponseStreamCompletion(res: ServerResponse, text: string, model: string): void { + const { response, item, doneItem } = responseStreamParts(text, model); + writeSse(res, 'response.output_text.delta', { + item_id: item.id, + output_index: 0, + content_index: 0, + delta: text, + }); + writeSse(res, 'response.output_text.done', { + item_id: item.id, + output_index: 0, + content_index: 0, + text, + }); + writeSse(res, 'response.content_part.done', { + item_id: item.id, + output_index: 0, + content_index: 0, + part: { type: 'output_text', text, annotations: [] }, + }); + writeSse(res, 'response.output_item.done', { output_index: 0, item: doneItem }); + writeSse(res, 'response.completed', { + response: { + ...response, + status: 'completed', + output: [doneItem], + usage: { + input_tokens: 12, + output_tokens: 7, + total_tokens: 19, + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }); + res.write('data: [DONE]\n\n'); + res.end(); +} + +function writeResponseStream(res: ServerResponse, text: string, model: string): void { + writeResponseStreamStart(res, text, model); + writeResponseStreamCompletion(res, text, model); +} + +function writeFunctionCallStream( + res: ServerResponse, + model: string, + name: string, + args: Record, +): void { + const { response } = responseStreamParts('', model); + const argumentsJson = JSON.stringify(args); + const item = { + id: 'call_clawx_local_skill', + call_id: 'call_clawx_local_skill', + type: 'function_call', + status: 'in_progress', + name, + arguments: '', + }; + const doneItem = { ...item, status: 'completed', arguments: argumentsJson }; + writeSse(res, 'response.created', { response }); + writeSse(res, 'response.in_progress', { response }); + writeSse(res, 'response.output_item.added', { output_index: 0, item }); + writeSse(res, 'response.function_call_arguments.delta', { + item_id: item.id, + output_index: 0, + delta: argumentsJson, + }); + writeSse(res, 'response.function_call_arguments.done', { + item_id: item.id, + output_index: 0, + arguments: argumentsJson, + }); + writeSse(res, 'response.output_item.done', { output_index: 0, item: doneItem }); + writeSse(res, 'response.completed', { + response: { + ...response, + status: 'completed', + output: [doneItem], + usage: { + input_tokens: 12, + output_tokens: 7, + total_tokens: 19, + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }); + res.write('data: [DONE]\n\n'); + res.end(); +} + +function createOpenAiCompatibleServer(options: { + expectedApiKey: string; + model: string; + responseText: string; + requests: OpenAiCompatibleRequest[]; + delayCompletion?: boolean; + responseStarted?: Deferred; + releaseCompletion?: Promise; + responseClosed?: Deferred; + skillTool?: { + promptToken: string; + outputMarker: string; + command: string; + }; +}): Server { + return createServer(async (req, res) => { + const body = await readRequestBody(req); + options.requests.push({ + method: req.method, + url: req.url, + authorization: req.headers.authorization, + body, + }); + + if (req.url?.includes('/models')) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ object: 'list', data: [{ id: options.model, object: 'model' }] })); + return; + } + + if (!req.url?.includes('/responses')) { + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end('not found'); + return; + } + + if (req.headers.authorization !== `Bearer ${options.expectedApiKey}`) { + res.writeHead(401, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'missing bearer token' } })); + return; + } + + res.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }); + if ( + options.skillTool + && body.includes(options.skillTool.promptToken) + && !body.includes(options.skillTool.outputMarker) + ) { + writeFunctionCallStream(res, options.model, 'exec_command', { + cmd: options.skillTool.command, + yield_time_ms: 10_000, + max_output_chars: 20_000, + }); + return; + } + if (options.delayCompletion) { + let closed = false; + const markClosed = () => { + if (closed) return; + closed = true; + options.responseClosed?.resolve(); + }; + req.once('close', markClosed); + res.once('close', markClosed); + writeResponseStreamStart(res, options.responseText, options.model); + options.responseStarted?.resolve(); + await options.releaseCompletion; + if (!closed && !res.destroyed) { + writeResponseStreamCompletion(res, options.responseText, options.model); + } + return; + } + writeResponseStream(res, options.responseText, options.model); + }); +} + +test.describe('cc-connect real OpenAI API key runtime smoke', () => { + test.fixme('reports per-turn usage through a public cc-connect runtime API', async () => { + // cc-connect v1.4.1 does not expose token usage over Bridge or Management API. + }); + + test('sends a chat message through real cc-connect and Codex with a local OpenAI-compatible API key server', async ({ + launchElectronApp, + homeDir, + userDataDir, + }) => { + const bundles = await realRuntimeBundles(); + test.skip(!bundles, 'Run pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current first.'); + await waitForPortClosed(9810); + await waitForPortClosed(9820); + + const createdAt = new Date().toISOString(); + const apiKey = 'clawx-local-openai-compatible-key'; + const model = 'gpt-clawx-local'; + const responseText = 'CLAWX_LOCAL_OPENAI_COMPATIBLE_OK'; + const skillMarker = 'CLAWX_CC_CONNECT_SKILL_BODY_MARKER_20260711'; + const runtimeDir = join(userDataDir, 'runtimes', 'cc-connect'); + const accountSkillPath = join( + userDataDir, + 'credentials', + 'oauth', + 'openai-local-api-key', + 'codex-home', + 'skills', + 'clawx-local-proof', + 'SKILL.md', + ); + const requests: OpenAiCompatibleRequest[] = []; + const server = createOpenAiCompatibleServer({ + expectedApiKey: apiKey, + model, + responseText, + requests, + skillTool: { + promptToken: '$clawx-local-proof', + outputMarker: skillMarker, + command: `sed -n '1,80p' ${JSON.stringify(accountSkillPath)}`, + }, + }); + const port = await listen(server); + const shortDataRoot = join(process.platform === 'win32' ? tmpdir() : '/tmp', `cx-${process.pid}-${Date.now().toString(36)}`); + await symlink(userDataDir, shortDataRoot, process.platform === 'win32' ? 'junction' : 'dir'); + + const skillDir = join(homeDir, '.openclaw', 'skills', 'clawx-local-proof'); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, 'SKILL.md'), [ + '---', + 'name: clawx-local-proof', + 'description: Proves that cc-connect launched Codex discovers ClawX managed skills.', + '---', + '', + `When invoked, include this instruction marker in the active context: ${skillMarker}`, + '', + ].join('\n'), 'utf8'); + + const appConfigDir = join(userDataDir, 'app'); + await mkdir(appConfigDir, { recursive: true }); + await writeFile(join(appConfigDir, 'settings.json'), JSON.stringify({ + language: 'en', + devModeUnlocked: true, + runtimeKind: 'cc-connect', + gatewayAutoStart: false, + }, null, 2), 'utf8'); + await writeFile(join(appConfigDir, 'clawx-providers.json'), JSON.stringify({ + schemaVersion: 0, + providerAccounts: { + 'openai-local-api-key': { + id: 'openai-local-api-key', + vendorId: 'openai', + label: 'OpenAI Local API Key', + authMode: 'api_key', + baseUrl: `http://127.0.0.1:${port}/v1/responses`, + model, + enabled: true, + isDefault: true, + createdAt, + updatedAt: createdAt, + }, + }, + providerSecrets: { + 'openai-local-api-key': { + type: 'api_key', + accountId: 'openai-local-api-key', + apiKey, + }, + }, + apiKeys: {}, + defaultProviderAccountId: 'openai-local-api-key', + }, null, 2), 'utf8'); + + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_DATA_HOME: shortDataRoot, + CLAWX_USER_DATA_DIR: join(shortDataRoot, 'system', 'electron'), + CLAWX_CC_CONNECT_PATH: bundles!.ccConnectPath, + CLAWX_CODEX_PATH: bundles!.codexPath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + const startResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-start-local-openai-compatible', + module: 'gateway', + action: 'start', + }); + }); + expect(startResult).toMatchObject({ + ok: true, + data: { success: true }, + }); + + await expect(page.getByTestId('chat-composer-input')).toBeEnabled({ timeout: 60_000 }); + await page.getByTestId('chat-composer-input').fill(`Reply exactly: ${responseText}`); + await page.getByTestId('chat-composer-send').click(); + await expectAssistantText(page, responseText); + + await expect.poll(() => requests.some((request) => request.url?.includes('/responses')), { + timeout: 10_000, + }).toBe(true); + expect(requests.some((request) => request.authorization === `Bearer ${apiKey}`)).toBe(true); + + await expect(readFile(accountSkillPath, 'utf8')).resolves.toContain(skillMarker); + await page.getByTestId('chat-composer-input').fill('$clawx-local-proof verify the managed skill'); + await page.getByTestId('chat-composer-send').click(); + await expect.poll(() => requests.filter((request) => request.url?.includes('/responses')).length, { + timeout: 30_000, + }).toBeGreaterThan(1); + const responseBodies = requests + .filter((request) => request.url?.includes('/responses')) + .map((request) => request.body ?? ''); + const skillCatalogBody = responseBodies.find((body) => body.includes('clawx-local-proof')) ?? ''; + expect(skillCatalogBody).toContain('credentials/oauth/openai-local-api-key/codex-home/skills/clawx-local-proof/SKILL.md'); + expect(skillCatalogBody.match(/- clawx-local-proof:/g)).toHaveLength(1); + await expect.poll(() => responseBodies.concat( + requests.filter((request) => request.url?.includes('/responses')).map((request) => request.body ?? ''), + ).some((body) => body.includes(skillMarker)), { + timeout: 30_000, + intervals: [250, 500, 1_000, 2_000], + message: 'Codex should execute the skill read and return its body marker to the model', + }).toBe(true); + await expect(page.getByTestId('chat-message-role-assistant').filter({ hasText: responseText })) + .toHaveCount(2, { timeout: 30_000 }); + + const managedConfig = await readFile(join(runtimeDir, 'config.toml'), 'utf8'); + const managedMainWorkspace = join(shortDataRoot, 'workspaces', 'agents', 'main'); + expect(managedConfig).toContain('provider = "clawx-openai"'); + expect(managedConfig).toContain('api_key = "${CLAWX_CODEX_OPENAI_LOCAL_API_KEY_API_KEY}"'); + expect(managedConfig).toContain(`base_url = "http://127.0.0.1:${port}/v1"`); + expect(managedConfig).toContain(`model = "${model}"`); + expect(managedConfig).toContain(`work_dir = "${managedMainWorkspace}"`); + expect(managedConfig).not.toContain(`work_dir = "${process.cwd()}"`); + expect(managedConfig).not.toContain(apiKey); + await expect(access(managedMainWorkspace)).resolves.toBeUndefined(); + + const publicProviderStore = await readFile(join(appConfigDir, 'clawx-providers.json'), 'utf8'); + expect(publicProviderStore).toContain('openai-local-api-key'); + expect(publicProviderStore).not.toContain(apiKey); + const encryptedVault = await readFile(join(userDataDir, 'credentials', 'secrets.enc')); + expect(encryptedVault.includes(Buffer.from(apiKey, 'utf8'))).toBe(false); + const credentialIndex = await readFile(join(userDataDir, 'credentials', 'index.json'), 'utf8'); + expect(credentialIndex).toContain('openai-local-api-key'); + expect(credentialIndex).not.toContain(apiKey); + + const codexConfig = await readFile(join(userDataDir, 'credentials', 'oauth', 'openai-local-api-key', 'codex-home', 'config.toml'), 'utf8'); + expect(codexConfig).toContain('model_provider = "clawx-openai"'); + expect(codexConfig).toContain(`base_url = "http://127.0.0.1:${port}/v1"`); + expect(codexConfig).toContain('env_key = "OPENAI_API_KEY"'); + expect(codexConfig).not.toContain(apiKey); + + const publicProfile = await readFile(join(runtimeDir, 'provider-profile.json'), 'utf8'); + expect(publicProfile).toContain('CLAWX_CODEX_OPENAI_LOCAL_API_KEY_API_KEY'); + expect(publicProfile).toContain('clawx-openai'); + expect(publicProfile).not.toContain(apiKey); + + const launcher = await readFile(join(runtimeDir, 'config', 'launchers', 'codex-openai-local-api-key'), 'utf8'); + expect(launcher).toContain('export OPENAI_API_KEY="${CLAWX_CODEX_OPENAI_LOCAL_API_KEY_API_KEY}"'); + expect(launcher).not.toContain(apiKey); + + const channelCronPrompt = 'CLAWX_BRIDGE_NATIVE_CRON_PROMPT'; + await page.getByTestId('chat-composer-input').fill(`/cron add 0 0 1 1 * ${channelCronPrompt}`); + await page.getByTestId('chat-composer-send').click(); + const listCron = async () => await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-cron-list-after-bridge-command', + module: 'cron', + action: 'list', + })); + await expect.poll(listCron, { + timeout: 15_000, + intervals: [250, 500, 1_000], + }).toMatchObject({ + ok: true, + data: expect.arrayContaining([ + expect.objectContaining({ + message: channelCronPrompt, + agentId: 'main', + enabled: true, + }), + ]), + }); + const cronListResult = await listCron() as { data?: Array<{ id?: string; message?: string }> }; + const bridgeCronId = cronListResult.data?.find((job) => job.message === channelCronPrompt)?.id; + expect(bridgeCronId).toBeTruthy(); + const toggleCronResult = await page.evaluate(async (id) => window.clawx.hostInvoke({ + id: 'runtime-cron-toggle-bridge-created-job', + module: 'cron', + action: 'toggle', + payload: { id, enabled: false }, + }), bridgeCronId); + expect(toggleCronResult).toMatchObject({ + ok: true, + data: { id: bridgeCronId, enabled: false }, + }); + await expect.poll(listCron).toMatchObject({ + data: expect.arrayContaining([ + expect.objectContaining({ id: bridgeCronId, enabled: false }), + ]), + }); + const deleteCronResult = await page.evaluate(async (id) => window.clawx.hostInvoke({ + id: 'runtime-cron-delete-bridge-created-job', + module: 'cron', + action: 'delete', + payload: { id }, + }), bridgeCronId); + expect(deleteCronResult).toMatchObject({ ok: true, data: { success: true } }); + await expect.poll(async () => { + const result = await listCron() as { data?: Array<{ id?: string }> }; + return result.data?.some((job) => job.id === bridgeCronId) ?? false; + }).toBe(false); + + const readSessions = async () => await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-sessions-local-openai-compatible', + module: 'sessions', + action: 'summaries', + payload: {}, + })); + await expect.poll(readSessions, { + timeout: 30_000, + intervals: [500, 1_000, 2_000], + }).toMatchObject({ + ok: true, + data: { + success: true, + sessions: expect.arrayContaining([ + expect.objectContaining({ key: 'agent:main:main' }), + ]), + }, + }); + + const historyResult = await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-history-local-openai-compatible', + module: 'sessions', + action: 'history', + payload: { sessionKey: 'agent:main:main', limit: 20 }, + })); + expect(historyResult).toMatchObject({ + ok: true, + data: { + success: true, + messages: expect.arrayContaining([ + expect.objectContaining({ role: 'user', content: `Reply exactly: ${responseText}` }), + expect.objectContaining({ role: 'assistant' }), + ]), + }, + }); + + const mediaFixtureDir = join(managedMainWorkspace, 'cc-connect-cli-media'); + const imagePath = join(mediaFixtureDir, 'real-bridge-image.png'); + const filePath = join(mediaFixtureDir, 'real-bridge-report.pdf'); + const audioPath = join(mediaFixtureDir, 'real-bridge-audio.wav'); + const videoPath = join(mediaFixtureDir, 'real-bridge-video.mp4'); + const imageBytes = await readFile(join(process.cwd(), 'src', 'assets', 'community', '20260212-185822.png')); + const fileBytes = Buffer.from('%PDF-1.4\n% CLAWX_REAL_CC_CONNECT_FILE\n', 'utf8'); + const audioBytes = Buffer.from('RIFF0000WAVEfmt CLAWX_REAL_CC_CONNECT_AUDIO', 'utf8'); + const videoBytes = Buffer.from('0000ftypisomCLAWX_REAL_CC_CONNECT_VIDEO', 'utf8'); + await mkdir(mediaFixtureDir, { recursive: true }); + await Promise.all([ + writeFile(imagePath, imageBytes), + writeFile(filePath, fileBytes), + writeFile(audioPath, audioBytes), + writeFile(videoPath, videoBytes), + ]); + const managedDataDir = managedConfig.match(/^data_dir\s*=\s*"([^"]+)"/m)?.[1]?.replace(/\\\\/g, '\\'); + expect(managedDataDir).toBeTruthy(); + await expect.poll(async () => { + try { + await access(join(managedDataDir!, 'run', 'api.sock')); + return true; + } catch { + return false; + } + }, { + timeout: 10_000, + intervals: [100, 250, 500], + message: 'cc-connect local send API socket should be ready', + }).toBe(true); + const mediaMarker = 'CLAWX_REAL_CC_CONNECT_CLI_MEDIA'; + const mediaCli = await execFileAsync(bundles!.ccConnectPath, [ + 'send', + '--data-dir', managedDataDir!, + '--project', 'clawx-main', + '--session', 'clawx:main:main', + '--message', mediaMarker, + '--image', imagePath, + '--file', filePath, + '--audio', audioPath, + '--video', videoPath, + ], { + env: { ...process.env, HOME: homeDir }, + timeout: 15_000, + }); + expect(mediaCli.stdout).toContain('Message sent successfully.'); + expect(mediaCli.stderr).toBe(''); + const expectedMedia = [ + { fileName: 'real-bridge-image.png', mimeType: 'image/png', bytes: imageBytes }, + { fileName: 'real-bridge-report.pdf', mimeType: 'application/pdf', bytes: fileBytes }, + { fileName: 'audio.wav', mimeType: 'audio/wav', bytes: audioBytes }, + { fileName: 'real-bridge-video.mp4', mimeType: 'video/mp4', bytes: videoBytes }, + ]; + const readMediaHistory = async () => await page.evaluate(async () => window.clawx.hostInvoke({ + id: `runtime-real-cli-media-history-${Date.now()}`, + module: 'sessions', + action: 'history', + payload: { sessionKey: 'agent:main:main', limit: 50 }, + })) as { + data?: { + messages?: Array<{ + content?: unknown; + _attachedFiles?: Array<{ + fileName?: string; + mimeType?: string; + fileSize?: number; + filePath?: string; + preview?: string | null; + }>; + }>; + }; + }; + await expect.poll(async () => { + const history = await readMediaHistory(); + return (history.data?.messages ?? []).flatMap((message) => message._attachedFiles ?? []).map((file) => ({ + fileName: file.fileName, + mimeType: file.mimeType, + })); + }, { + timeout: 10_000, + intervals: [100, 250, 500], + }).toEqual(expect.arrayContaining(expectedMedia.map(({ fileName, mimeType }) => ({ fileName, mimeType })))); + const mediaHistory = await readMediaHistory(); + const attachedMedia = (mediaHistory.data?.messages ?? []).flatMap((message) => message._attachedFiles ?? []); + for (const expected of expectedMedia) { + const attachment = attachedMedia.find((candidate) => candidate.fileName === expected.fileName); + expect(attachment).toMatchObject({ + fileName: expected.fileName, + mimeType: expected.mimeType, + fileSize: expected.bytes.byteLength, + filePath: expect.stringContaining(join('runtimes', 'cc-connect', 'media', 'outgoing', 'bridge')), + }); + await expect(readFile(attachment!.filePath!)).resolves.toEqual(expected.bytes); + } + expect(attachedMedia.find((attachment) => attachment.fileName === 'real-bridge-image.png')?.preview) + .toMatch(/^data:image\/png;base64,/); + await expect(page.getByRole('img', { name: 'real-bridge-image.png' }).last()) + .toBeVisible({ timeout: 10_000 }); + for (const expected of expectedMedia.filter(({ mimeType }) => !mimeType.startsWith('image/'))) { + await expect(page.getByText(expected.fileName, { exact: true }).last()) + .toBeVisible({ timeout: 10_000 }); + } + const evidenceDir = join(process.cwd(), 'artifacts', 'cc-connect'); + await mkdir(evidenceDir, { recursive: true }); + await page.evaluate(() => { + document.body.style.zoom = '0.45'; + const scrollContainer = document.querySelector('[data-testid="chat-scroll-container"]'); + const imagePreview = document.querySelector('img[alt="real-bridge-image.png"]'); + if (imagePreview) { + imagePreview.style.width = '160px'; + imagePreview.style.maxHeight = '160px'; + imagePreview.style.objectFit = 'contain'; + } + const mediaNames = ['real-bridge-image.png', 'real-bridge-report.pdf', 'audio.wav', 'real-bridge-video.mp4']; + const firstMediaMessage = Array.from(document.querySelectorAll('[data-testid^="chat-message-"]')) + .find((element) => mediaNames.some((name) => ( + element.textContent?.includes(name) || element.querySelector(`img[alt="${name}"]`) + ))); + if (scrollContainer && firstMediaMessage) { + scrollContainer.scrollTop += firstMediaMessage.getBoundingClientRect().top + - scrollContainer.getBoundingClientRect().top; + } + }); + await page.getByTestId('chat-scroll-container') + .screenshot({ path: join(evidenceDir, 'real-cli-media-bridge.png') }); + await writeFile(join(evidenceDir, 'real-cli-media-bridge.json'), `${JSON.stringify({ + schema: 'clawx-cc-connect-real-cli-media-evidence', + version: 1, + runtimeKind: 'cc-connect', + source: 'bundled-cc-connect-send-cli', + publicBridgeProtocol: true, + cliAcknowledged: true, + bridgeMediaKinds: ['image', 'file', 'audio', 'video'], + hostHistoryObserved: true, + guiAttachmentsObserved: true, + managedMediaCopiesObserved: true, + imagePreviewObserved: true, + screenshot: 'artifacts/cc-connect/real-cli-media-bridge.png', + }, null, 2)}\n`, 'utf8'); + + const renameResult = await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-session-rename-local-openai-compatible', + module: 'sessions', + action: 'rename', + payload: { sessionKey: 'agent:main:main', label: 'Local API session' }, + })); + expect(renameResult).toMatchObject({ ok: true, data: { success: true } }); + await expect.poll(readSessions).toMatchObject({ + data: { + sessions: expect.arrayContaining([ + expect.objectContaining({ key: 'agent:main:main', displayName: 'Local API session' }), + ]), + }, + }); + const sessionMetadataPath = join(appConfigDir, 'cc-connect-session-metadata.json'); + const renamedSessionMetadata = await readFile(sessionMetadataPath, 'utf8'); + expect(renamedSessionMetadata).toContain('"agent:main:main": "Local API session"'); + await expect(access(join(runtimeDir, 'data', 'sessions', '.clawx-supplemental-history.json'))).rejects.toThrow(); + + const deleteResult = await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-session-delete-local-openai-compatible', + module: 'sessions', + action: 'delete', + payload: { sessionKey: 'agent:main:main' }, + })); + expect(deleteResult).toMatchObject({ ok: true, data: { success: true } }); + await expect.poll(async () => { + const result = await readSessions() as { data?: { sessions?: Array<{ key?: string }> } }; + return result.data?.sessions?.some((session) => session.key === 'agent:main:main') ?? false; + }).toBe(false); + await expect(readFile(sessionMetadataPath, 'utf8')).resolves.not.toContain('agent:main:main'); + } finally { + const page = await getStableWindow(app).catch(() => null); + if (page) await stopRuntime(page); + await closeElectronApp(app); + await closeServer(server); + await waitForNoRuntimeProcesses(join(shortDataRoot, 'runtimes', 'cc-connect')); + await rm(shortDataRoot, { force: true }); + } + }); + + test('stops a long-running local OpenAI-compatible API key chat without rendering late output', async ({ + launchElectronApp, + userDataDir, + }) => { + const bundles = await realRuntimeBundles(); + test.skip(!bundles, 'Run pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current first.'); + await waitForPortClosed(9810); + await waitForPortClosed(9820); + + const createdAt = new Date().toISOString(); + const apiKey = 'clawx-local-openai-compatible-abort-key'; + const model = 'gpt-clawx-local-abort'; + const responseText = 'CLAWX_ABORT_SHOULD_NOT_RENDER'; + const runtimeDir = join(userDataDir, 'runtimes', 'cc-connect'); + const requests: OpenAiCompatibleRequest[] = []; + const responseStarted = deferred(); + const releaseCompletion = deferred(); + const responseClosed = deferred(); + const server = createOpenAiCompatibleServer({ + expectedApiKey: apiKey, + model, + responseText, + requests, + delayCompletion: true, + responseStarted, + releaseCompletion: releaseCompletion.promise, + responseClosed, + }); + const port = await listen(server); + + 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: { + 'openai-local-api-key-abort': { + id: 'openai-local-api-key-abort', + vendorId: 'openai', + label: 'OpenAI Local API Key Abort', + authMode: 'api_key', + baseUrl: `http://127.0.0.1:${port}/v1/responses`, + model, + enabled: true, + isDefault: true, + createdAt, + updatedAt: createdAt, + }, + }, + providerSecrets: { + 'openai-local-api-key-abort': { + type: 'api_key', + accountId: 'openai-local-api-key-abort', + apiKey, + }, + }, + apiKeys: {}, + defaultProviderAccountId: 'openai-local-api-key-abort', + }, null, 2), 'utf8'); + + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: bundles!.ccConnectPath, + CLAWX_CODEX_PATH: bundles!.codexPath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + const startResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-start-local-openai-compatible-abort', + module: 'gateway', + action: 'start', + }); + }); + expect(startResult).toMatchObject({ + ok: true, + data: { success: true }, + }); + + const statusBeforeAbort = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-status-before-local-openai-compatible-abort', + module: 'gateway', + action: 'status', + }); + }) as { ok?: boolean; data?: { state?: string; pid?: number } }; + expect(statusBeforeAbort).toMatchObject({ + ok: true, + data: expect.objectContaining({ state: 'running', pid: expect.any(Number) }), + }); + const runtimePid = statusBeforeAbort.data?.pid; + + await expect(page.getByTestId('chat-composer-input')).toBeEnabled({ timeout: 60_000 }); + await page.getByTestId('chat-composer-input').fill(`Delay, then reply exactly: ${responseText}`); + await page.getByTestId('chat-composer-send').click(); + await expect(page.getByTestId('chat-composer-send')).toHaveAttribute('title', 'Stop', { timeout: 30_000 }); + await responseStarted.promise; + await expect.poll(() => requests.some((request) => request.url?.includes('/responses')), { + timeout: 10_000, + }).toBe(true); + + await page.getByTestId('chat-composer-send').click(); + await expect(page.getByTestId('chat-composer-send')).toHaveAttribute('title', 'Send', { timeout: 60_000 }); + const upstreamClosedByAbort = await Promise.race([ + responseClosed.promise.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 10_000)), + ]); + expect(upstreamClosedByAbort).toBe(true); + releaseCompletion.resolve(); + + await expect( + page.getByTestId('chat-message-role-assistant').filter({ hasText: responseText }), + ).toHaveCount(0, { timeout: 10_000 }); + const readRuntimeStatus = async () => { + return await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-status-after-local-openai-compatible-abort', + module: 'gateway', + action: 'status', + }); + }); + }; + await expect.poll(readRuntimeStatus, { timeout: 60_000 }).toMatchObject({ + ok: true, + data: expect.objectContaining({ state: 'running', pid: runtimePid }), + }); + const statusAfterAbort = await readRuntimeStatus() as { + ok?: boolean; + data?: { state?: string; pid?: number; runtimeKind?: string }; + }; + const lateAssistantCount = await page.getByTestId('chat-message-role-assistant') + .filter({ hasText: responseText }) + .count(); + const evidenceDir = join(process.cwd(), 'artifacts', 'cc-connect'); + await mkdir(evidenceDir, { recursive: true }); + await page.screenshot({ + path: join(evidenceDir, 'real-local-chat-abort.png'), + fullPage: true, + }); + await writeFile(join(evidenceDir, 'real-local-chat-abort.json'), JSON.stringify({ + runtimeKind: statusAfterAbort.data?.runtimeKind || 'cc-connect', + transport: 'BridgePlatform /stop', + upstreamClosedByAbort, + runtimePidBefore: runtimePid, + runtimePidAfter: statusAfterAbort.data?.pid, + runtimePidUnchanged: statusAfterAbort.data?.pid === runtimePid, + lateAssistantCount, + runtimeStateAfter: statusAfterAbort.data?.state, + }, null, 2), 'utf8'); + } finally { + releaseCompletion.resolve(); + const page = await getStableWindow(app).catch(() => null); + if (page) await stopRuntime(page); + await closeElectronApp(app); + await closeServer(server); + await waitForNoRuntimeProcesses(runtimeDir); + } + }); + + test('sends a chat message through real cc-connect and Codex with an OpenAI API key', async ({ + launchElectronApp, + userDataDir, + }) => { + test.skip(process.env.CLAWX_REAL_OPENAI_API_KEY_E2E !== '1', 'Set CLAWX_REAL_OPENAI_API_KEY_E2E=1 to run with a real CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY.'); + const bundles = await realRuntimeBundles(); + test.skip(!bundles, 'Run pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current first.'); + await waitForPortClosed(9810); + await waitForPortClosed(9820); + + const apiKey = requiredOpenAiApiKey(); + const createdAt = new Date().toISOString(); + const model = process.env.CLAWX_REAL_OPENAI_MODEL?.trim() || 'gpt-5.5'; + const runtimeDir = join(userDataDir, 'runtimes', 'cc-connect'); + + 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: { + 'openai-api-key': { + id: 'openai-api-key', + vendorId: 'openai', + label: 'OpenAI API Key', + authMode: 'api_key', + model, + enabled: true, + isDefault: true, + createdAt, + updatedAt: createdAt, + }, + }, + providerSecrets: { + 'openai-api-key': { + type: 'api_key', + accountId: 'openai-api-key', + apiKey, + }, + }, + apiKeys: {}, + defaultProviderAccountId: 'openai-api-key', + }, null, 2), 'utf8'); + + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: bundles!.ccConnectPath, + CLAWX_CODEX_PATH: bundles!.codexPath, + }, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + const startResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-start-real-openai-api-key', + module: 'gateway', + action: 'start', + }); + }); + expect(startResult).toMatchObject({ + ok: true, + data: { success: true }, + }); + + await expect(page.getByTestId('chat-composer-input')).toBeEnabled({ timeout: 60_000 }); + await page.getByTestId('chat-composer-input').fill('Reply exactly: CLAWX_REAL_OPENAI_API_KEY_OK'); + await page.getByTestId('chat-composer-send').click(); + await expectAssistantText(page, 'CLAWX_REAL_OPENAI_API_KEY_OK'); + + const managedConfig = await readFile(join(runtimeDir, 'config.toml'), 'utf8'); + expect(managedConfig).toContain('provider = "openai"'); + expect(managedConfig).toContain('api_key = "${CLAWX_CODEX_OPENAI_API_KEY_API_KEY}"'); + expect(managedConfig).toContain(`model = "${model}"`); + expect(managedConfig).not.toContain(apiKey); + + const publicProfile = await readFile(join(runtimeDir, 'provider-profile.json'), 'utf8'); + expect(publicProfile).toContain('CLAWX_CODEX_OPENAI_API_KEY_API_KEY'); + expect(publicProfile).not.toContain(apiKey); + } finally { + const page = await getStableWindow(app).catch(() => null); + if (page) await stopRuntime(page); + await closeElectronApp(app); + await waitForNoRuntimeProcesses(runtimeDir); + } + }); +}); diff --git a/tests/e2e/cc-connect-real-scheduled-cron.spec.ts b/tests/e2e/cc-connect-real-scheduled-cron.spec.ts new file mode 100644 index 000000000..16a898ecf --- /dev/null +++ b/tests/e2e/cc-connect-real-scheduled-cron.spec.ts @@ -0,0 +1,583 @@ +import { access, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createConnection } from 'node:net'; +import { join } from 'node:path'; +import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; +import { loadDefaultCcConnectLocalRealEnv } from './helpers/local-real-env'; +import type { Page } from '@playwright/test'; + +loadDefaultCcConnectLocalRealEnv(); + +async function realRuntimeBundles(): Promise<{ ccConnectPath: string; codexPath: string } | null> { + const platformArch = `${process.platform}-${process.arch}`; + const ccConnectPath = join( + process.cwd(), + 'build', + 'cc-connect', + platformArch, + process.platform === 'win32' ? 'cc-connect.exe' : 'cc-connect', + ); + const codexPath = join( + process.cwd(), + 'build', + 'codex', + platformArch, + 'bin', + process.platform === 'win32' ? 'codex.cmd' : 'codex', + ); + try { + await access(ccConnectPath); + await access(codexPath); + return { ccConnectPath, codexPath }; + } catch { + return null; + } +} + +async function isPortOpen(port: number): Promise { + return await new Promise((resolve) => { + const socket = createConnection({ host: '127.0.0.1', port }); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('error', () => resolve(false)); + socket.setTimeout(500, () => { + socket.destroy(); + resolve(false); + }); + }); +} + +async function waitForPortClosed(port: number): Promise { + await expect.poll(async () => await isPortOpen(port), { + timeout: 15_000, + intervals: [250, 500, 1_000], + message: `cc-connect port ${port} should be free before scheduled cron smoke starts`, + }).toBe(false); +} + +async function seedCcConnectRuntimeSettings(userDataDir: string): Promise { + const createdAt = '2026-06-07T00:00:00.000Z'; + 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: { + 'ollama-local': { + id: 'ollama-local', + vendorId: 'ollama', + label: 'Ollama', + authMode: 'local', + model: 'qwen3:latest', + baseUrl: 'http://127.0.0.1:11434/v1', + enabled: true, + isDefault: true, + createdAt, + updatedAt: createdAt, + }, + }, + providerSecrets: {}, + apiKeys: {}, + defaultProviderAccountId: 'ollama-local', + }, null, 2), 'utf8'); +} + +async function seedCcConnectOauthRuntimeSettings(userDataDir: string): Promise { + const createdAt = '2026-06-07T00:00:00.000Z'; + 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: { + 'openai-oauth': { + id: 'openai-oauth', + vendorId: 'openai', + label: 'OpenAI Codex OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + metadata: { resourceUrl: 'openai-codex' }, + createdAt, + updatedAt: createdAt, + }, + }, + providerSecrets: {}, + apiKeys: {}, + defaultProviderAccountId: 'openai-oauth', + }, null, 2), 'utf8'); +} + +async function copyLocalCodexAuthToManagedHome(userDataDir: string): Promise { + const source = process.env.CLAWX_REAL_CODEX_AUTH_JSON?.trim(); + test.skip(!source, 'Set CLAWX_REAL_CODEX_AUTH_JSON to the auth.json that may be copied into the managed CODEX_HOME.'); + const managedCodexHome = join(userDataDir, 'credentials', 'oauth', 'openai-oauth', 'codex-home'); + await mkdir(managedCodexHome, { recursive: true }); + await copyFile(source, join(managedCodexHome, 'auth.json')); + return source ?? ''; +} + +function scheduledMarkerCommand(markerPath: string): string { + return [ + JSON.stringify(process.execPath), + '-e', + JSON.stringify("require('fs').writeFileSync(process.argv[1], 'CLAWX_SCHEDULED_EXEC_CRON_OK')"), + JSON.stringify(markerPath), + ].join(' '); +} + +function nextSafeMinuteCronExpression(now = new Date()): string { + const target = new Date(now); + target.setMilliseconds(0); + target.setSeconds(0); + target.setMinutes(target.getMinutes() + (now.getSeconds() >= 45 ? 2 : 1)); + return `${target.getMinutes()} ${target.getHours()} ${target.getDate()} ${target.getMonth() + 1} *`; +} + +async function findPromptCronHistory(page: Page, marker: string): Promise<{ + found: boolean; + matchingSessionKeys: string[]; +}> { + const summaries = await page.evaluate(async () => window.clawx.hostInvoke({ + id: `runtime-scheduled-prompt-summaries-${Date.now()}`, + module: 'sessions', + action: 'summaries', + payload: {}, + })) as { ok?: boolean; data?: { sessions?: Array<{ key?: string }> } }; + const sessionKeys = (summaries.data?.sessions ?? []) + .map((session) => session.key) + .filter((key): key is string => Boolean(key)); + const matchingSessionKeys: string[] = []; + for (const sessionKey of sessionKeys) { + const history = await page.evaluate(async (key) => window.clawx.hostInvoke({ + id: `runtime-scheduled-prompt-history-${Date.now()}`, + module: 'sessions', + action: 'history', + payload: { sessionKey: key, limit: 200 }, + }), sessionKey) as { + ok?: boolean; + data?: { success?: boolean; messages?: Array<{ role?: string; content?: unknown }> }; + }; + const messages = history.data?.messages ?? []; + const hasPrompt = messages.some((message) => ( + message.role === 'user' && JSON.stringify(message.content).includes(marker) + )); + const hasReply = messages.some((message) => ( + message.role === 'assistant' && JSON.stringify(message.content).includes(marker) + )); + if (history.ok && history.data?.success && hasPrompt && hasReply) { + matchingSessionKeys.push(sessionKey); + } + } + return { found: matchingSessionKeys.length > 0, matchingSessionKeys }; +} + +async function collectPromptCronDiagnostics(page: Page, cronId: string) { + return await page.evaluate(async (id) => { + const [cronList, health, snapshot, sessions] = await Promise.all([ + window.clawx.hostInvoke({ + id: `runtime-scheduled-prompt-cron-list-${Date.now()}`, + module: 'gateway', + action: 'rpc', + payload: { method: 'cron.list' }, + }), + window.clawx.hostInvoke({ + id: `runtime-scheduled-prompt-health-${Date.now()}`, + module: 'gateway', + action: 'health', + payload: { probe: true }, + }), + window.clawx.hostInvoke({ + id: `runtime-scheduled-prompt-diagnostics-${Date.now()}`, + module: 'diagnostics', + action: 'gatewaySnapshot', + }), + window.clawx.hostInvoke({ + id: `runtime-scheduled-prompt-session-list-${Date.now()}`, + module: 'sessions', + action: 'summaries', + payload: {}, + }), + ]); + const jobs = Array.isArray(cronList.data) ? cronList.data : []; + const sessionRows = sessions.data && typeof sessions.data === 'object' + && Array.isArray((sessions.data as { sessions?: unknown[] }).sessions) + ? (sessions.data as { sessions: unknown[] }).sessions + : []; + const runtime = snapshot.data && typeof snapshot.data === 'object' + ? (snapshot.data as { runtime?: { ccConnect?: { logTail?: string } } }).runtime + : undefined; + return { + job: jobs.find((job) => job && typeof job === 'object' && (job as { id?: string }).id === id) ?? null, + health, + sessionRows, + logTail: runtime?.ccConnect?.logTail?.slice(-4_000) ?? '', + }; + }, cronId); +} + +async function deleteCronAndVerify(page: Page, cronId: string, requestId: string): Promise { + const deleteResult = await page.evaluate(async ({ id, requestId: deleteRequestId }) => { + return await window.clawx.hostInvoke({ + id: deleteRequestId, + module: 'cron', + action: 'delete', + payload: { id }, + }); + }, { id: cronId, requestId }); + expect(deleteResult).toMatchObject({ ok: true, data: { success: true } }); + + await expect.poll(async () => { + const listResult = await page.evaluate(async () => window.clawx.hostInvoke({ + id: `runtime-cron-list-after-scheduled-cleanup-${Date.now()}`, + module: 'cron', + action: 'list', + })) as { ok?: boolean; data?: Array<{ id?: string }> }; + expect(listResult.ok).toBe(true); + return (listResult.data ?? []).some((job) => job.id === cronId); + }, { + timeout: 15_000, + intervals: [250, 500, 1_000], + message: `scheduled cron ${cronId} should be absent after deletion`, + }).toBe(false); +} + +test.describe('cc-connect real scheduled cron delivery smoke', () => { + test('delivers an enabled exec cron through the real cc-connect scheduler', async ({ + launchElectronApp, + homeDir, + userDataDir, + }) => { + test.skip( + process.env.CLAWX_REAL_SCHEDULED_CRON_E2E !== '1', + 'Set CLAWX_REAL_SCHEDULED_CRON_E2E=1 to wait for the next real cron minute.', + ); + test.setTimeout(180_000); + + const bundles = await realRuntimeBundles(); + test.skip(!bundles, 'Run pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current first.'); + await waitForPortClosed(9810); + await waitForPortClosed(9820); + + await seedCcConnectRuntimeSettings(userDataDir); + const openClawConfigDir = join(homeDir, '.openclaw'); + const workspace = join(userDataDir, 'scheduled-cron-workspace'); + const markerPath = join(workspace, 'cc-connect-scheduled-exec-cron-marker.txt'); + await mkdir(openClawConfigDir, { recursive: true }); + await mkdir(workspace, { recursive: true }); + await writeFile(join(openClawConfigDir, 'openclaw.json'), JSON.stringify({ + agents: { + defaults: { workspace }, + list: [ + { id: 'main', name: 'Main Agent', default: true, workspace }, + ], + }, + }, null, 2), 'utf8'); + + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: bundles!.ccConnectPath, + CLAWX_CODEX_PATH: bundles!.codexPath, + }, + }); + let cronId = ''; + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + const startResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-start-real-scheduled-cron', + module: 'gateway', + action: 'start', + }); + }); + expect(startResult).toMatchObject({ ok: true, data: { success: true } }); + const statusBefore = await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-status-before-real-scheduled-exec', + module: 'gateway', + action: 'status', + })) as { ok?: boolean; data?: { pid?: number; runtimeKind?: string } }; + expect(statusBefore).toMatchObject({ ok: true, data: { runtimeKind: 'cc-connect', pid: expect.any(Number) } }); + + const createResult = await page.evaluate(async ({ command, workDir }) => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-create-real-scheduled-exec', + module: 'cron', + action: 'create', + payload: { + name: 'Real scheduled exec cron smoke', + exec: command, + workDir, + sessionMode: 'new_per_run', + timeoutMins: 1, + schedule: { kind: 'cron', expr: '*/1 * * * *' }, + enabled: true, + delivery: { mode: 'none' }, + agentId: 'main', + }, + }); + }, { command: scheduledMarkerCommand(markerPath), workDir: workspace }); + expect(createResult).toMatchObject({ + ok: true, + data: expect.objectContaining({ + name: 'Real scheduled exec cron smoke', + enabled: true, + exec: expect.stringContaining('CLAWX_SCHEDULED_EXEC_CRON_OK'), + workDir: workspace, + timeoutMins: 1, + }), + }); + cronId = (createResult as { data?: { id?: string } }).data?.id ?? ''; + expect(cronId).toBeTruthy(); + + await expect.poll(async () => (await readFile(markerPath, 'utf8').catch(() => '')).trim(), { + timeout: 95_000, + intervals: [1_000, 2_000, 5_000], + message: 'enabled real cc-connect exec cron should fire on a real scheduler tick', + }).toBe('CLAWX_SCHEDULED_EXEC_CRON_OK'); + + const listResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-list-after-real-scheduled-exec', + module: 'cron', + action: 'list', + }); + }); + expect(listResult).toMatchObject({ + ok: true, + data: expect.arrayContaining([ + expect.objectContaining({ + id: cronId, + enabled: true, + agentId: 'main', + }), + ]), + }); + + const statusAfter = await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-status-after-real-scheduled-exec', + module: 'gateway', + action: 'status', + })) as { ok?: boolean; data?: { pid?: number; runtimeKind?: string } }; + expect(statusAfter.data?.pid).toBe(statusBefore.data?.pid); + + await page.getByTestId('sidebar-nav-cron').click(); + await expect(page.getByTestId('cron-page')).toBeVisible(); + const jobCard = page.getByTestId(`cron-job-card-${cronId}`); + await expect(jobCard).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId(`cron-job-card-title-${cronId}`)).toHaveText('Real scheduled exec cron smoke'); + await jobCard.scrollIntoViewIfNeeded(); + const commandLine = jobCard.locator('p').filter({ hasText: process.execPath }); + await expect(commandLine).toBeVisible(); + const evidenceDir = join(process.cwd(), 'artifacts', 'cc-connect'); + await mkdir(evidenceDir, { recursive: true }); + await page.screenshot({ + path: join(evidenceDir, 'real-scheduled-exec-cron.png'), + fullPage: false, + mask: [commandLine], + maskColor: '#e5e7eb', + }); + await deleteCronAndVerify(page, cronId, 'runtime-cron-delete-real-scheduled-exec'); + cronId = ''; + await writeFile(join(evidenceDir, 'real-scheduled-exec-cron.json'), `${JSON.stringify({ + schema: 'clawx-cc-connect-real-scheduled-exec-cron-evidence', + version: 1, + runtimeKind: 'cc-connect', + runtimeBinary: 'real-bundled-v1.4.1', + schedulerTickObserved: true, + marker: 'CLAWX_SCHEDULED_EXEC_CRON_OK', + markerWrittenInConfiguredWorkspace: true, + jobListedThroughHostApi: true, + runtimePidPreserved: statusAfter.data?.pid === statusBefore.data?.pid, + scheduledJobCleanupObserved: true, + screenshot: 'artifacts/cc-connect/real-scheduled-exec-cron.png', + }, null, 2)}\n`, 'utf8'); + } finally { + if (cronId) { + const page = await getStableWindow(app).catch(() => null); + await page?.evaluate(async (id) => { + await window.clawx.hostInvoke({ + id: 'runtime-cron-delete-real-scheduled-exec-cleanup', + module: 'cron', + action: 'delete', + payload: { id }, + }); + }, cronId).catch(() => undefined); + } + await closeElectronApp(app); + } + }); + + test('delivers scheduled prompt cron through the cc-connect runtime', async ({ + launchElectronApp, + homeDir, + userDataDir, + }, testInfo) => { + test.skip( + process.env.CLAWX_REAL_SCHEDULED_PROMPT_CRON_E2E !== '1', + 'Set CLAWX_REAL_SCHEDULED_PROMPT_CRON_E2E=1 with an explicit CLAWX_REAL_CODEX_AUTH_JSON to probe scheduled prompt delivery.', + ); + test.setTimeout(300_000); + + const bundles = await realRuntimeBundles(); + test.skip(!bundles, 'Run pnpm run bundle:cc-connect:current && pnpm run bundle:codex:current first.'); + await waitForPortClosed(9810); + await waitForPortClosed(9820); + + await seedCcConnectOauthRuntimeSettings(userDataDir); + const authSource = await copyLocalCodexAuthToManagedHome(userDataDir); + await access(authSource); + + const openClawConfigDir = join(homeDir, '.openclaw'); + const workspace = join(userDataDir, 'scheduled-prompt-cron-workspace'); + await mkdir(openClawConfigDir, { recursive: true }); + await mkdir(workspace, { recursive: true }); + await writeFile(join(openClawConfigDir, 'openclaw.json'), JSON.stringify({ + agents: { + defaults: { workspace }, + list: [ + { id: 'main', name: 'Main Agent', default: true, workspace }, + ], + }, + }, null, 2), 'utf8'); + + const app = await launchElectronApp({ + skipSetup: true, + env: { + CLAWX_CC_CONNECT_PATH: bundles!.ccConnectPath, + CLAWX_CODEX_PATH: bundles!.codexPath, + }, + }); + let cronId = ''; + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + const startResult = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'runtime-start-real-scheduled-prompt-cron', + module: 'gateway', + action: 'start', + }); + }); + expect(startResult).toMatchObject({ ok: true, data: { success: true } }); + const statusBefore = await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-status-before-real-scheduled-prompt', + module: 'gateway', + action: 'status', + })) as { ok?: boolean; data?: { pid?: number; runtimeKind?: string } }; + expect(statusBefore).toMatchObject({ ok: true, data: { runtimeKind: 'cc-connect', pid: expect.any(Number) } }); + + const promptCronExpression = nextSafeMinuteCronExpression(); + const createResult = await page.evaluate(async (cronExpression) => { + return await window.clawx.hostInvoke({ + id: 'runtime-cron-create-real-scheduled-prompt', + module: 'cron', + action: 'create', + payload: { + name: 'Real scheduled prompt cron smoke', + message: 'Reply exactly: CLAWX_SCHEDULED_PROMPT_CRON_OK', + sessionMode: 'new_per_run', + timeoutMins: 2, + schedule: { kind: 'cron', expr: cronExpression }, + enabled: true, + delivery: { mode: 'none' }, + agentId: 'main', + }, + }); + }, promptCronExpression); + expect(createResult).toMatchObject({ + ok: true, + data: expect.objectContaining({ + name: 'Real scheduled prompt cron smoke', + enabled: true, + message: 'Reply exactly: CLAWX_SCHEDULED_PROMPT_CRON_OK', + agentId: 'main', + }), + }); + cronId = (createResult as { data?: { id?: string } }).data?.id ?? ''; + expect(cronId).toBeTruthy(); + + try { + await expect.poll(async () => await findPromptCronHistory(page, 'CLAWX_SCHEDULED_PROMPT_CRON_OK'), { + timeout: 225_000, + intervals: [1_000, 2_000, 5_000, 10_000], + message: 'enabled real cc-connect prompt cron should fire into a public runtime session on a scheduler tick', + }).toMatchObject({ found: true, matchingSessionKeys: expect.any(Array) }); + } catch (error) { + const diagnostics = await collectPromptCronDiagnostics(page, cronId); + await testInfo.attach('scheduled-prompt-diagnostics', { + body: Buffer.from(JSON.stringify(diagnostics, null, 2)), + contentType: 'application/json', + }); + throw new Error( + `Scheduled prompt cron did not produce a public reply. Diagnostics: ${JSON.stringify(diagnostics)}`, + { cause: error }, + ); + } + + const promptHistory = await findPromptCronHistory(page, 'CLAWX_SCHEDULED_PROMPT_CRON_OK'); + expect(promptHistory).toMatchObject({ found: true, matchingSessionKeys: expect.any(Array) }); + const statusAfter = await page.evaluate(async () => window.clawx.hostInvoke({ + id: 'runtime-status-after-real-scheduled-prompt', + module: 'gateway', + action: 'status', + })) as { ok?: boolean; data?: { pid?: number; runtimeKind?: string } }; + expect(statusAfter.data?.pid).toBe(statusBefore.data?.pid); + + await page.getByTestId('sidebar-nav-cron').click(); + await expect(page.getByTestId('cron-page')).toBeVisible(); + const jobCard = page.getByTestId(`cron-job-card-${cronId}`); + await expect(jobCard).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId(`cron-job-card-title-${cronId}`)).toHaveText('Real scheduled prompt cron smoke'); + await jobCard.scrollIntoViewIfNeeded(); + const evidenceDir = join(process.cwd(), 'artifacts', 'cc-connect'); + await mkdir(evidenceDir, { recursive: true }); + await page.screenshot({ + path: join(evidenceDir, 'real-scheduled-prompt-cron.png'), + fullPage: false, + }); + await deleteCronAndVerify(page, cronId, 'runtime-cron-delete-real-scheduled-prompt'); + cronId = ''; + await writeFile(join(evidenceDir, 'real-scheduled-prompt-cron.json'), `${JSON.stringify({ + schema: 'clawx-cc-connect-real-scheduled-prompt-cron-evidence', + version: 1, + runtimeKind: 'cc-connect', + runtimeBinary: 'real-bundled-v1.4.1', + schedulerTickObserved: true, + promptMarker: 'CLAWX_SCHEDULED_PROMPT_CRON_OK', + publicSessionHistoryObserved: promptHistory.found, + matchingSessionKeys: promptHistory.matchingSessionKeys, + codexReachedOnlyThroughCcConnect: true, + runtimePidPreserved: statusAfter.data?.pid === statusBefore.data?.pid, + scheduledJobCleanupObserved: true, + screenshot: 'artifacts/cc-connect/real-scheduled-prompt-cron.png', + }, null, 2)}\n`, 'utf8'); + } finally { + if (cronId) { + const page = await getStableWindow(app).catch(() => null); + await page?.evaluate(async (id) => { + await window.clawx.hostInvoke({ + id: 'runtime-cron-delete-real-scheduled-prompt-cleanup', + module: 'cron', + action: 'delete', + payload: { id }, + }); + }, cronId).catch(() => undefined); + } + await closeElectronApp(app); + } + }); +}); diff --git a/tests/e2e/channels-binding-regression.spec.ts b/tests/e2e/channels-binding-regression.spec.ts index ed2cd243c..86f95ff8b 100644 --- a/tests/e2e/channels-binding-regression.spec.ts +++ b/tests/e2e/channels-binding-regression.spec.ts @@ -7,6 +7,7 @@ test.describe('Channels binding regression', () => { nextAccountId: 'feishu-a1b2c3d4', saveCount: 0, bindingCount: 0, + lastSavedConfig: {} as Record, channels: [ { channelType: 'feishu', @@ -60,6 +61,9 @@ test.describe('Channels binding regression', () => { if (request?.module === 'channels' && request.action === 'saveConfig') { current.saveCount += 1; const body = request.payload ?? {}; + current.lastSavedConfig = (body.config && typeof body.config === 'object') + ? body.config as Record + : {}; const accountId = body.accountId || current.nextAccountId; const feishu = current.channels[0]; if (!feishu.accounts.some((account) => account.accountId === accountId)) { @@ -114,6 +118,7 @@ test.describe('Channels binding regression', () => { await expect(accountIdInput).toHaveValue(/feishu-/); await page.locator('#appId').fill('cli_test'); await page.locator('#appSecret').fill('secret_test'); + await page.locator('#adminUsers').fill('ou_cron_admin, ou_ops_admin'); await page.getByRole('button', { name: /Save & Connect|dialog\.saveAndConnect/ }).click(); await expect(page.getByText(/Configure Feishu \/ Lark|dialog\.configureTitle/)).toBeHidden(); @@ -128,11 +133,20 @@ test.describe('Channels binding regression', () => { const counters = await electronApp.evaluate(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const state = (globalThis as any).__clawxE2eBindingRegression as { saveCount: number; bindingCount: number }; - return { saveCount: state.saveCount, bindingCount: state.bindingCount }; + const state = (globalThis as any).__clawxE2eBindingRegression as { + saveCount: number; + bindingCount: number; + lastSavedConfig: Record; + }; + return { + saveCount: state.saveCount, + bindingCount: state.bindingCount, + lastSavedConfig: state.lastSavedConfig, + }; }); expect(counters.saveCount).toBe(1); expect(counters.bindingCount).toBe(1); + expect(counters.lastSavedConfig.adminUsers).toBe('ou_cron_admin, ou_ops_admin'); }); }); diff --git a/tests/e2e/chat-new-session-date.spec.ts b/tests/e2e/chat-new-session-date.spec.ts index 3f1c1675b..b1d721aed 100644 --- a/tests/e2e/chat-new-session-date.spec.ts +++ b/tests/e2e/chat-new-session-date.spec.ts @@ -21,7 +21,7 @@ test.describe('ClawX chat session date grouping', () => { const app = await launchElectronApp({ skipSetup: true }); const nowMs = Date.now(); const sessions = [ - { key: MAIN_SESSION_KEY, displayName: 'Today conversation', updatedAt: nowMs - 60 * 60 * 1000 }, + { key: MAIN_SESSION_KEY, displayName: 'Today conversation', updatedAt: nowMs - 60 * 1000 }, { key: `agent:main:session-${nowMs - 2 * DAY_MS}`, displayName: 'Week conversation', updatedAt: nowMs - 2 * DAY_MS }, { key: `agent:main:session-${nowMs - 10 * DAY_MS}`, displayName: 'Month conversation', updatedAt: nowMs - 10 * DAY_MS }, { key: `agent:main:session-${nowMs - 40 * DAY_MS}`, displayName: 'Older conversation', updatedAt: nowMs - 40 * DAY_MS }, diff --git a/tests/e2e/clawx-data-layout-migration.spec.ts b/tests/e2e/clawx-data-layout-migration.spec.ts new file mode 100644 index 000000000..3bc5ae218 --- /dev/null +++ b/tests/e2e/clawx-data-layout-migration.spec.ts @@ -0,0 +1,94 @@ +import { access, mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; + +test.describe('ClawX canonical data layout migration', () => { + test('boots Electron on the shared root and imports legacy app state without deleting it', async ({ + launchElectronApp, + homeDir, + userDataDir: dataRoot, + }) => { + const legacyDir = join(homeDir, 'legacy-electron-user-data'); + const legacySettingsPath = join(legacyDir, 'settings.json'); + const legacyProvidersPath = join(legacyDir, 'clawx-providers.json'); + await mkdir(legacyDir, { recursive: true }); + await writeFile(legacySettingsPath, JSON.stringify({ + language: 'en', + runtimeKind: 'cc-connect', + gatewayAutoStart: false, + devModeUnlocked: true, + }, null, 2), 'utf8'); + await writeFile(legacyProvidersPath, JSON.stringify({ + schemaVersion: 0, + providerAccounts: { + 'legacy-openai-oauth': { + id: 'legacy-openai-oauth', + vendorId: 'openai', + label: 'Legacy OpenAI OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + }, + }, + providerSecrets: {}, + apiKeys: {}, + defaultProviderAccountId: 'legacy-openai-oauth', + }, null, 2), 'utf8'); + + const launchOptions = { + skipSetup: true, + omitUserDataOverride: true, + initialUserDataDir: legacyDir, + env: { CLAWX_DATA_HOME: dataRoot }, + } as const; + const app = await launchElectronApp(launchOptions); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('main-layout')).toBeVisible(); + + const electronUserData = await app.evaluate(({ app: electronApp }) => electronApp.getPath('userData')); + expect(electronUserData).toBe(join(dataRoot, 'system', 'electron')); + + const [settings, providers, versionFile, migrationJournal] = await Promise.all([ + readFile(join(dataRoot, 'app', 'settings.json'), 'utf8'), + readFile(join(dataRoot, 'app', 'clawx-providers.json'), 'utf8'), + readFile(join(dataRoot, 'state', 'data-version.json'), 'utf8'), + readFile(join(dataRoot, 'state', 'migration-journal.jsonl'), 'utf8'), + ]); + expect(JSON.parse(settings)).toMatchObject({ runtimeKind: 'cc-connect', gatewayAutoStart: false }); + expect(JSON.parse(providers)).toMatchObject({ + providerAccounts: { + 'legacy-openai-oauth': expect.objectContaining({ authMode: 'oauth_browser' }), + }, + }); + expect(JSON.parse(versionFile)).toMatchObject({ schema: 'clawx-data', version: 1 }); + const migrationRecord = JSON.parse(migrationJournal.trim().split('\n')[0]); + expect(migrationRecord).toMatchObject({ migration: 'legacy-electron-user-data-import' }); + expect(migrationRecord.source).toBe(await realpath(legacyDir)); + expect(migrationJournal).toContain('settings.json'); + expect(migrationJournal).toContain('clawx-providers.json'); + + await expect(access(legacySettingsPath)).resolves.toBeUndefined(); + await expect(access(legacyProvidersPath)).resolves.toBeUndefined(); + } finally { + await closeElectronApp(app); + } + + await writeFile(legacySettingsPath, JSON.stringify({ + language: 'en', + runtimeKind: 'openclaw', + gatewayAutoStart: true, + }, null, 2), 'utf8'); + const relaunchedApp = await launchElectronApp(launchOptions); + try { + const page = await getStableWindow(relaunchedApp); + await expect(page.getByTestId('main-layout')).toBeVisible(); + const canonicalSettings = JSON.parse(await readFile(join(dataRoot, 'app', 'settings.json'), 'utf8')); + expect(canonicalSettings).toMatchObject({ runtimeKind: 'cc-connect', gatewayAutoStart: false }); + } finally { + await closeElectronApp(relaunchedApp); + } + }); +}); diff --git a/tests/e2e/clawx-shared-root-single-writer.spec.ts b/tests/e2e/clawx-shared-root-single-writer.spec.ts new file mode 100644 index 000000000..a658721b3 --- /dev/null +++ b/tests/e2e/clawx-shared-root-single-writer.spec.ts @@ -0,0 +1,161 @@ +import { access, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; + +async function waitForMissing(path: string): Promise { + await expect.poll(async () => { + try { + await access(path); + return false; + } catch { + return true; + } + }, { timeout: 10_000 }).toBe(true); +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +test.describe('ClawX shared-root writer ownership', () => { + test('allows only one Electron writer and transfers ownership after shutdown', async ({ + homeDir, + launchElectronApp, + }, testInfo) => { + const dataRoot = join(homeDir, '.clawx-shared-root'); + const lockPath = join(dataRoot, 'locks', 'writer.lock'); + const duplicateUserDataDir = join(homeDir, 'electron-beta'); + const launchOptions = (electronUserDataDir: string, channel: string) => ({ + skipSetup: true, + timeoutMs: 15_000, + env: { + CLAWX_DATA_HOME: dataRoot, + CLAWX_USER_DATA_DIR: electronUserDataDir, + CLAWX_E2E_ENFORCE_WRITER_LOCK: '1', + CLAWX_RELEASE_CHANNEL: channel, + }, + } as const); + + const first = await launchElectronApp(launchOptions(join(homeDir, 'electron-stable'), 'stable')); + let firstClosed = false; + let successor: Awaited> | undefined; + try { + const firstPage = await getStableWindow(first); + const firstMainPid = await first.evaluate(() => process.pid); + await expect(firstPage.getByTestId('main-layout')).toBeVisible(); + + const firstOwner = JSON.parse(await readFile(lockPath, 'utf8')) as { + schema?: string; + version?: number; + pid?: number; + channel?: string; + }; + expect(firstOwner).toMatchObject({ + schema: 'clawx-instance-lock', + version: 2, + pid: firstMainPid, + channel: 'stable', + }); + + let duplicateLaunchError: unknown; + try { + const duplicate = await launchElectronApp(launchOptions(duplicateUserDataDir, 'beta')); + await closeElectronApp(duplicate); + } catch (error) { + duplicateLaunchError = error; + } + expect(duplicateLaunchError).toBeDefined(); + + const ownerAfterDuplicate = JSON.parse(await readFile(lockPath, 'utf8')) as { pid?: number }; + expect(ownerAfterDuplicate.pid).toBe(firstMainPid); + expect(await pathExists(duplicateUserDataDir)).toBe(false); + await expect(firstPage.getByTestId('main-layout')).toBeVisible(); + + await testInfo.attach('shared-root-first-writer', { + body: await firstPage.screenshot({ fullPage: true }), + contentType: 'image/png', + }); + + await closeElectronApp(first); + firstClosed = true; + await waitForMissing(lockPath); + + successor = await launchElectronApp(launchOptions(join(homeDir, 'electron-dev'), 'dev')); + const successorPage = await getStableWindow(successor); + const successorMainPid = await successor.evaluate(() => process.pid); + await expect(successorPage.getByTestId('main-layout')).toBeVisible(); + const successorOwner = JSON.parse(await readFile(lockPath, 'utf8')) as { pid?: number; channel?: string }; + expect(successorOwner).toMatchObject({ pid: successorMainPid, channel: 'dev' }); + expect(successorOwner.pid).not.toBe(firstOwner.pid); + + await closeElectronApp(successor); + successor = undefined; + await waitForMissing(lockPath); + + await testInfo.attach('shared-root-writer-evidence', { + body: Buffer.from(JSON.stringify({ + schema: 'clawx-shared-root-writer-evidence', + duplicateRejected: true, + duplicateLayoutInitializationBlocked: true, + firstOwnerMatched: true, + lockReleasedAfterShutdown: true, + successorAcquired: true, + successorReleased: true, + }, null, 2)), + contentType: 'application/json', + }); + } finally { + if (!firstClosed) { + await closeElectronApp(first); + } + if (successor) { + await closeElectronApp(successor); + } + } + }); + + test('fails closed before shared-root initialization when the writer lock cannot be acquired', async ({ + homeDir, + launchElectronApp, + }, testInfo) => { + const dataRoot = join(homeDir, '.clawx-invalid-lock-root'); + const electronUserDataDir = join(homeDir, 'electron-invalid-lock'); + await mkdir(dataRoot, { recursive: true }); + await writeFile(join(dataRoot, 'locks'), 'not-a-directory', 'utf8'); + + let launchError: unknown; + try { + const app = await launchElectronApp({ + skipSetup: true, + timeoutMs: 15_000, + env: { + CLAWX_DATA_HOME: dataRoot, + CLAWX_USER_DATA_DIR: electronUserDataDir, + CLAWX_E2E_ENFORCE_WRITER_LOCK: '1', + }, + }); + await closeElectronApp(app); + } catch (error) { + launchError = error; + } + + expect(launchError).toBeDefined(); + expect(await pathExists(join(dataRoot, 'state', 'data-version.json'))).toBe(false); + expect(await pathExists(electronUserDataDir)).toBe(false); + + await testInfo.attach('shared-root-lock-failure-evidence', { + body: Buffer.from(JSON.stringify({ + schema: 'clawx-shared-root-lock-failure-evidence', + launchRejected: true, + dataVersionWriteBlocked: true, + electronUserDataInitializationBlocked: true, + }, null, 2)), + contentType: 'application/json', + }); + }); +}); diff --git a/tests/e2e/cron-trigger-completion-refresh.spec.ts b/tests/e2e/cron-trigger-completion-refresh.spec.ts new file mode 100644 index 000000000..b4817a18d --- /dev/null +++ b/tests/e2e/cron-trigger-completion-refresh.spec.ts @@ -0,0 +1,94 @@ +import { completeSetup, expect, installIpcMocks, test } from './fixtures/electron'; + +function stableStringify(value: unknown): string { + if (value == null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`; + const entries = Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`); + return `{${entries.join(',')}}`; +} + +const pendingJob = { + id: 'job-async-completion', + name: 'Async completion', + message: 'Observe runtime completion', + schedule: { kind: 'cron', expr: '0 9 * * *' }, + enabled: true, + createdAt: '2026-07-13T00:00:00.000Z', + updatedAt: '2026-07-13T00:00:00.000Z', + agentId: 'main', + timeoutMins: 1, +}; + +test.describe('Cron trigger completion refresh', () => { + test('renders an asynchronous runtime result after trigger acknowledgement', async ({ electronApp, page }) => { + await installIpcMocks(electronApp, { + gatewayStatus: { + state: 'running', + port: 18789, + pid: 12345, + gatewayReady: true, + runtimeKind: 'cc-connect', + }, + hostApi: { + [stableStringify(['/api/cron/jobs', 'GET'])]: { + ok: true, + data: { status: 200, ok: true, json: [pendingJob] }, + }, + [stableStringify(['cron', 'trigger', { id: pendingJob.id }])]: { + success: true, + }, + [stableStringify(['/api/channels/accounts', 'GET'])]: { + ok: true, + data: { status: 200, ok: true, json: { success: true, channels: [] } }, + }, + }, + }); + + await electronApp.evaluate(async ({ app: _app }, job) => { + const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron'); + type InvokeHandler = (event: unknown, request: unknown) => Promise; + const handlers = (ipcMain as unknown as { _invokeHandlers?: Map })._invokeHandlers; + const original = handlers?.get('host:invoke'); + let listCalls = 0; + ipcMain.removeHandler('host:invoke'); + ipcMain.handle('host:invoke', async (event, request: { + id?: string; + module?: string; + action?: string; + }) => { + if (request.module === 'cron' && request.action === 'list') { + listCalls += 1; + const completed = listCalls >= 3; + return { + id: request.id, + ok: true, + data: [{ + ...job, + ...(completed ? { + lastRun: { + time: '2026-07-13T01:00:00.000Z', + success: false, + error: 'cc-connect provider request failed', + }, + } : {}), + }], + }; + } + return await original?.(event, request); + }); + }, pendingJob); + + await completeSetup(page); + await page.getByTestId('sidebar-nav-cron').click(); + const card = page.getByTestId(`cron-job-card-${pendingJob.id}`); + await expect(card).toBeVisible(); + await card.getByRole('button', { name: 'Run Now' }).click(); + + await expect(page.getByText('Task triggered successfully')).toBeVisible(); + await expect(page.getByTestId(`cron-job-card-last-run-${pendingJob.id}`)) + .toHaveAttribute('data-run-status', 'error', { timeout: 10_000 }); + await expect(card.getByText('cc-connect provider request failed')).toBeVisible(); + }); +}); diff --git a/tests/e2e/fixtures/electron.ts b/tests/e2e/fixtures/electron.ts index 502f4dfb9..2042ab39f 100644 --- a/tests/e2e/fixtures/electron.ts +++ b/tests/e2e/fixtures/electron.ts @@ -4,9 +4,14 @@ import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; +import { randomUUID } from 'node:crypto'; type LaunchElectronOptions = { skipSetup?: boolean; + omitUserDataOverride?: boolean; + initialUserDataDir?: string; + timeoutMs?: number; + env?: Record; }; type IpcMockConfig = { @@ -48,6 +53,20 @@ async function allocatePort(): Promise { }); } +async function removeDirWithRetry(path: string): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await rm(path, { recursive: true, force: true }); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1))); + } + } + throw lastError; +} + async function getStableWindow(app: ElectronApplication): Promise { const deadline = Date.now() + 30_000; let page = await app.firstWindow(); @@ -79,6 +98,30 @@ async function getStableWindow(app: ElectronApplication): Promise { async function closeElectronApp(app: ElectronApplication, timeoutMs = 5_000): Promise { let closed = false; + const waitForProcessExit = async (processTimeoutMs = 2_000): Promise => { + try { + const child = app.process(); + if (child.exitCode !== null || child.killed) return true; + return await Promise.race([ + new Promise((resolve) => child.once('exit', () => resolve(true))), + new Promise((resolve) => setTimeout(() => resolve(false), processTimeoutMs)), + ]); + } catch { + // Ignore process inspection failures during e2e teardown. + return true; + } + }; + const forceKillProcess = async () => { + try { + const child = app.process(); + if (child.exitCode === null && !child.killed) { + child.kill('SIGKILL'); + } + await waitForProcessExit(1_000); + } catch { + // Ignore process kill failures during e2e teardown. + } + }; await Promise.race([ (async () => { @@ -97,21 +140,23 @@ async function closeElectronApp(app: ElectronApplication, timeoutMs = 5_000): Pr ]); if (closed) { + if (!await waitForProcessExit()) { + await forceKillProcess(); + } return; } try { await app.close(); + if (!await waitForProcessExit()) { + await forceKillProcess(); + } return; } catch { // Fall through to process kill if Playwright cannot close the app cleanly. } - try { - app.process().kill('SIGKILL'); - } catch { - // Ignore process kill failures during e2e teardown. - } + await forceKillProcess(); } async function seedE2eSettings(userDataDir: string): Promise { @@ -142,7 +187,11 @@ async function launchClawXElectron( : {}; return await electron.launch({ executablePath: electronBinaryPath, - args: ['--lang=en-US', electronEntry], + args: [ + '--lang=en-US', + ...(options.initialUserDataDir ? [`--user-data-dir=${options.initialUserDataDir}`] : []), + electronEntry, + ], env: { ...process.env, ...electronEnv, @@ -155,33 +204,41 @@ async function launchClawXElectron( LC_ALL: 'en_US.UTF-8', LANGUAGE: 'en', CLAWX_E2E: '1', - CLAWX_USER_DATA_DIR: userDataDir, + CLAWX_E2E_CREDENTIAL_KEY: randomUUID(), + ...(options.omitUserDataOverride ? {} : { CLAWX_USER_DATA_DIR: userDataDir }), ...(options.skipSetup ? { CLAWX_E2E_SKIP_SETUP: '1' } : {}), CLAWX_PORT_CLAWX_HOST_API: String(hostApiPort), + ...(options.env ?? {}), }, - timeout: 90_000, + timeout: options.timeoutMs ?? 90_000, }); } export const test = base.extend({ homeDir: async ({ browserName: _browserName }, provideHomeDir) => { - const homeDir = await mkdtemp(join(tmpdir(), 'clawx-e2e-home-')); + const overrideHomeDir = process.env.CLAWX_E2E_HOME_DIR?.trim(); + const homeDir = overrideHomeDir || await mkdtemp(join(tmpdir(), 'clawx-e2e-home-')); await mkdir(join(homeDir, '.config'), { recursive: true }); await mkdir(join(homeDir, 'AppData', 'Local'), { recursive: true }); await mkdir(join(homeDir, 'AppData', 'Roaming'), { recursive: true }); try { await provideHomeDir(homeDir); } finally { - await rm(homeDir, { recursive: true, force: true }); + if (!overrideHomeDir) { + await removeDirWithRetry(homeDir); + } } }, userDataDir: async ({ browserName: _browserName }, provideUserDataDir) => { - const userDataDir = await mkdtemp(join(tmpdir(), 'clawx-e2e-user-data-')); + const overrideUserDataDir = process.env.CLAWX_E2E_USER_DATA_DIR?.trim(); + const userDataDir = overrideUserDataDir || await mkdtemp(join(tmpdir(), 'clawx-e2e-user-data-')); try { await provideUserDataDir(userDataDir); } finally { - await rm(userDataDir, { recursive: true, force: true }); + if (!overrideUserDataDir) { + await removeDirWithRetry(userDataDir); + } } }, diff --git a/tests/e2e/helpers/feishu-inbound-marker.ts b/tests/e2e/helpers/feishu-inbound-marker.ts new file mode 100644 index 000000000..e82d8a6b0 --- /dev/null +++ b/tests/e2e/helpers/feishu-inbound-marker.ts @@ -0,0 +1,41 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +export type FeishuInboundMarkerArtifact = { + createdAt: string; + instruction: string; + marker: string; + accountId: string; + domain: string; + timeoutMs: number; +}; + +export function buildFeishuInboundMarkerArtifact(details: { + marker: string; + accountId: string; + domain: string; + timeoutMs: number; + now?: Date; +}): FeishuInboundMarkerArtifact { + return { + createdAt: (details.now ?? new Date()).toISOString(), + instruction: 'Send marker exactly as message text to the configured Feishu/Lark bot before timeout.', + marker: details.marker, + accountId: details.accountId, + domain: details.domain, + timeoutMs: details.timeoutMs, + }; +} + +export async function writeFeishuInboundMarkerArtifact(root: string, details: { + marker: string; + accountId: string; + domain: string; + timeoutMs: number; +}): Promise { + const artifactDir = join(root, 'artifacts', 'cc-connect'); + const artifactPath = join(artifactDir, 'feishu-inbound-marker.json'); + await mkdir(artifactDir, { recursive: true }); + await writeFile(artifactPath, `${JSON.stringify(buildFeishuInboundMarkerArtifact(details), null, 2)}\n`, 'utf8'); + return artifactPath; +} diff --git a/tests/e2e/helpers/local-real-env.ts b/tests/e2e/helpers/local-real-env.ts new file mode 100644 index 000000000..e31d43689 --- /dev/null +++ b/tests/e2e/helpers/local-real-env.ts @@ -0,0 +1,150 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { basename, delimiter, join, relative, resolve } from 'node:path'; + +type EnvTarget = NodeJS.ProcessEnv; + +export type LoadedLocalRealEnvFile = { + name: string; + loaded: boolean; + variableNames: string[]; + safety?: { + location: 'repo' | 'outside-repo'; + gitignored: boolean; + tracked: boolean; + safe: boolean; + }; + skippedReason?: string; +}; + +function execGit(root: string, args: string[]): boolean { + try { + execFileSync('git', args, { + cwd: root, + stdio: 'ignore', + }); + return true; + } catch { + return false; + } +} + +function isPathInsideRoot(root: string, path: string): boolean { + const relativePath = relative(root, path); + return Boolean(relativePath) && !relativePath.startsWith('..') && !relativePath.startsWith('/'); +} + +function localEnvFileSafety(root: string, path: string): LoadedLocalRealEnvFile['safety'] { + if (!execGit(root, ['rev-parse', '--is-inside-work-tree']) || !isPathInsideRoot(root, path)) { + return { location: 'outside-repo', gitignored: true, tracked: false, safe: true }; + } + const relativePath = relative(root, path); + const tracked = execGit(root, ['ls-files', '--error-unmatch', '--', relativePath]); + const gitignored = execGit(root, ['check-ignore', '--quiet', '--', relativePath]); + return { + location: 'repo', + gitignored, + tracked, + safe: gitignored && !tracked, + }; +} + +function displayEnvFileName(file: string): string { + return basename(file) || file; +} + +export function extraLocalRealEnvFiles(env: EnvTarget): string[] { + const single = env.CLAWX_REAL_ENV_FILE?.trim(); + const multiple = env.CLAWX_REAL_ENV_FILES + ?.split(delimiter) + .map((file) => file.trim()) + .filter(Boolean) ?? []; + return [...new Set([ + ...(single ? [single] : []), + ...multiple, + ])]; +} + +function stripOptionalQuotes(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) + || (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +export function parseLocalRealEnvFile(contents: string): Record { + const parsed: Record = {}; + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const normalized = line.startsWith('export ') ? line.slice('export '.length).trim() : line; + const separator = normalized.indexOf('='); + if (separator <= 0) continue; + const key = normalized.slice(0, separator).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + parsed[key] = stripOptionalQuotes(normalized.slice(separator + 1)); + } + return parsed; +} + +export function loadLocalRealEnvFiles(options: { + root?: string; + env?: EnvTarget; + files?: string[]; +} = {}): LoadedLocalRealEnvFile[] { + const root = resolve(options.root ?? process.cwd()); + const env = options.env ?? process.env; + const files = options.files ?? [ + '.env.cc-connect.local', + '.env.local', + '.env', + ]; + const summaries: LoadedLocalRealEnvFile[] = []; + + for (const file of files) { + const path = resolve(root, file); + const safety = localEnvFileSafety(root, path); + if (!existsSync(path)) { + summaries.push({ name: displayEnvFileName(file), loaded: false, variableNames: [], safety }); + continue; + } + if (safety?.location === 'repo' && !safety.safe) { + summaries.push({ + name: displayEnvFileName(file), + loaded: false, + variableNames: [], + safety, + skippedReason: 'repo-local env files must be untracked and gitignored before they can be loaded', + }); + continue; + } + const parsed = parseLocalRealEnvFile(readFileSync(path, 'utf8')); + for (const [key, value] of Object.entries(parsed)) { + if (env[key] === undefined) env[key] = value; + } + summaries.push({ + name: displayEnvFileName(file), + loaded: true, + variableNames: Object.keys(parsed).sort(), + safety, + }); + } + + return summaries; +} + +export function loadDefaultCcConnectLocalRealEnv(): LoadedLocalRealEnvFile[] { + return loadLocalRealEnvFiles({ + root: join(__dirname, '..', '..', '..'), + files: [ + '.env.cc-connect.local', + '.env.local', + '.env', + ...extraLocalRealEnvFiles(process.env), + ], + }); +} diff --git a/tests/e2e/provider-lifecycle.spec.ts b/tests/e2e/provider-lifecycle.spec.ts index 7fb4274d0..b82d9b7f6 100644 --- a/tests/e2e/provider-lifecycle.spec.ts +++ b/tests/e2e/provider-lifecycle.spec.ts @@ -127,6 +127,109 @@ test.describe('ClawX provider lifecycle', () => { await expect(page.getByTestId('add-provider-api-key-input')).toHaveCount(0); }); + test('shows cc-connect Codex OAuth status and actions for OpenAI OAuth accounts', async ({ electronApp, page }) => { + await completeSetup(page); + + await electronApp.evaluate(async ({ app: _app }) => { + const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron'); + const now = new Date().toISOString(); + const account = { + id: 'openai-oauth', + vendorId: 'openai', + label: 'OpenAI Codex OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + createdAt: now, + updatedAt: now, + }; + let managedComplete = false; + let providerSecret = true; + const originalHostInvoke = (ipcMain as unknown as { + _invokeHandlers?: Map Promise>; + })._invokeHandlers?.get('host:invoke'); + + const respond = (id: unknown, data: unknown) => ({ + id: typeof id === 'string' ? id : undefined, + ok: true, + data, + }); + const codexStatus = () => ({ + success: true, + managedCodexHome: '/tmp/clawx-e2e/codex-home', + authPath: '/tmp/clawx-e2e/codex-home/auth.json', + managed: { + path: '/tmp/clawx-e2e/codex-home/auth.json', + exists: managedComplete, + complete: managedComplete, + accountId: managedComplete ? 'acct_e2e' : undefined, + }, + user: { + path: '/tmp/home/.codex/auth.json', + exists: true, + complete: true, + accountId: 'acct_e2e', + }, + provider: { + accountId: 'openai-oauth', + vendorId: 'openai', + authMode: 'oauth_browser', + hasOAuthSecret: providerSecret, + managedMatchesAccount: managedComplete, + userMatchesAccount: true, + }, + }); + + ipcMain.removeHandler('host:invoke'); + ipcMain.handle('host:invoke', async (event: unknown, request: { + id?: string; + module?: string; + action?: string; + }) => { + if (request?.module !== 'providers') { + return originalHostInvoke?.(event, request) ?? respond(request?.id, undefined); + } + + if (request.action === 'accounts') return respond(request.id, [account]); + if (request.action === 'accountKeyInfo') return respond(request.id, []); + if (request.action === 'vendors') return respond(request.id, []); + if (request.action === 'getDefaultAccount') return respond(request.id, { accountId: account.id }); + if (request.action === 'list') return respond(request.id, []); + if (request.action === 'codexOAuthStatus') return respond(request.id, codexStatus()); + if (request.action === 'importCodexOAuth') { + managedComplete = true; + return respond(request.id, codexStatus()); + } + if (request.action === 'logoutCodexOAuth') { + managedComplete = false; + providerSecret = false; + return respond(request.id, codexStatus()); + } + + return respond(request.id, { success: true }); + }); + }); + + await page.getByTestId('sidebar-nav-models').click(); + await expect(page.getByTestId('provider-card-openai-oauth')).toContainText('OpenAI Codex OAuth'); + + await page.getByTestId('provider-card-openai-oauth').hover(); + await page.getByTestId('provider-edit-openai-oauth').click(); + + await expect(page.getByTestId('provider-codex-oauth-panel-openai-oauth')).toBeVisible(); + await expect(page.getByTestId('provider-codex-oauth-managed-openai-oauth')).toContainText('Missing'); + await expect(page.getByTestId('provider-codex-oauth-secret-openai-oauth')).toContainText('OK'); + await expect(page.getByTestId('provider-codex-oauth-user-openai-oauth')).toContainText('OK'); + + await page.getByTestId('provider-codex-oauth-import-openai-oauth').click(); + await expect(page.getByTestId('provider-codex-oauth-managed-openai-oauth')).toContainText('OK'); + + await page.getByTestId('provider-codex-oauth-logout-openai-oauth').click(); + await expect(page.getByTestId('provider-codex-oauth-managed-openai-oauth')).toContainText('Missing'); + await expect(page.getByTestId('provider-codex-oauth-secret-openai-oauth')).toContainText('Missing'); + }); + test('trims whitespace before validating and saving a custom provider key', async ({ electronApp, page }) => { await completeSetup(page); diff --git a/tests/e2e/settings-runtime-selector.spec.ts b/tests/e2e/settings-runtime-selector.spec.ts new file mode 100644 index 000000000..4f81eaeb9 --- /dev/null +++ b/tests/e2e/settings-runtime-selector.spec.ts @@ -0,0 +1,112 @@ +import { completeSetup, expect, installIpcMocks, test } from './fixtures/electron'; + +test.describe('Settings runtime selector', () => { + test('switches to cc-connect and shows runtime capabilities', async ({ electronApp, page }) => { + await installIpcMocks(electronApp, { + gatewayStatus: { + state: 'stopped', + port: 0, + runtimeKind: 'cc-connect', + configDir: '/tmp/clawx/runtimes/cc-connect', + capabilities: { + chat: true, + sessions: true, + history: true, + providers: true, + models: true, + channels: true, + cron: true, + logs: true, + skills: true, + doctor: true, + controlUi: true, + }, + operationCapabilities: { + 'chat.send': { capability: 'chat', support: 'native', notes: 'Delivered through cc-connect BridgePlatform into Codex.' }, + 'chat.abort': { capability: 'chat', support: 'native', notes: 'Stops the active cc-connect Bridge session.' }, + 'doctor.fix': { capability: 'doctor', support: 'unsupported', notes: 'cc-connect Doctor does not support fix mode.' }, + }, + }, + hostApi: { + [JSON.stringify(['/api/diagnostics/gateway-snapshot', 'GET'])]: { + capturedAt: 123, + platform: 'darwin', + gateway: { state: 'healthy', reasons: [] }, + runtime: { + activeKind: 'cc-connect', + status: { + runtimeKind: 'cc-connect', + configDir: '/tmp/clawx/runtimes/cc-connect', + }, + ccConnect: { + managedDir: '/tmp/clawx/runtimes/cc-connect', + codexHomeDir: '/tmp/clawx/runtimes/cc-connect/codex-home', + oauth: { success: true, managed: { complete: true } }, + }, + }, + channels: [], + clawxLogTail: 'clawx-log', + gatewayLogTail: '', + gatewayErrLogTail: '', + }, + }, + }); + + await completeSetup(page); + await page.evaluate(() => { + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: (value: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).__copiedRuntimeDiagnostics = value; + return Promise.resolve(); + }, + }, + configurable: true, + }); + }); + await page.getByTestId('sidebar-nav-settings').click(); + + await expect(page.getByTestId('settings-runtime-section')).toHaveCount(0); + const devModeToggle = page.getByTestId('settings-dev-mode-switch'); + await devModeToggle.click(); + + await expect(page.getByTestId('settings-runtime-section')).toBeVisible(); + await page.getByTestId('settings-runtime-cc-connect').click(); + + await expect(page.getByTestId('settings-runtime-cc-connect')).toBeVisible(); + await expect(page.getByTestId('settings-runtime-config-dir')).toContainText('cc-connect'); + await expect(page.getByTestId('settings-runtime-capabilities')).toContainText('Doctor'); + const statusAfterSwitch = await page.evaluate(async () => { + return await window.clawx.hostInvoke({ + id: 'settings-runtime-status', + module: 'gateway', + action: 'status', + }); + }); + expect(statusAfterSwitch).toMatchObject({ + ok: true, + data: { + operationCapabilities: expect.objectContaining({ + 'chat.abort': expect.objectContaining({ support: 'native' }), + }), + }, + }); + await expect(page.getByTestId('settings-runtime-operation-gaps')).not.toContainText('chat.abort'); + await expect(page.getByTestId('settings-runtime-operation-gaps')).toContainText('Limited'); + await expect(page.getByTestId('settings-runtime-operation-gaps')).toContainText('doctor.fix'); + + await expect(page.getByTestId('settings-run-doctor-button')).toBeVisible(); + await expect(page.getByTestId('settings-run-doctor-fix-button')).toBeDisabled(); + await expect(page.getByTestId('settings-runtime-diagnostics-section')).toBeVisible(); + await page.getByTestId('settings-copy-runtime-diagnostics').click(); + const copiedDiagnostics = await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (window as any).__copiedRuntimeDiagnostics as string; + }); + expect(copiedDiagnostics).toContain('"activeKind": "cc-connect"'); + expect(copiedDiagnostics).toContain('"codexHomeDir": "/tmp/clawx/runtimes/cc-connect/codex-home"'); + await expect(page.getByTestId('sidebar-open-dev-console')).toContainText('CC Connect Page'); + await expect(page.getByTestId('sidebar-nav-dreams')).toHaveCount(0); + }); +}); diff --git a/tests/e2e/skills-gateway-readiness.spec.ts b/tests/e2e/skills-gateway-readiness.spec.ts index bea054ce4..58d986509 100644 --- a/tests/e2e/skills-gateway-readiness.spec.ts +++ b/tests/e2e/skills-gateway-readiness.spec.ts @@ -1,5 +1,14 @@ import { completeSetup, expect, installIpcMocks, test } from './fixtures/electron'; +const openClawSkillsTarget = { + success: true, + runtimeKind: 'openclaw', + sourceDir: '/tmp/.openclaw/skills', + openDir: '/tmp/.openclaw/skills', + runtimeDir: '/tmp/.openclaw/skills', + mirrorMode: 'source', +}; + test.describe('Skills page gateway readiness', () => { test('shows local skills even when gateway is stopped', async ({ electronApp, page }) => { await completeSetup(page); @@ -11,6 +20,7 @@ test.describe('Skills page gateway readiness', () => { }, hostApi: { '["skills","status",null]': { skills: [] }, + '["skills","target",null]': openClawSkillsTarget, '["skills","clawhubCapability",null]': { success: true, capability: { canSearch: false, canInstall: false }, @@ -64,6 +74,7 @@ test.describe('Skills page gateway readiness', () => { }, hostApi: { '["skills","status",null]': { skills: [] }, + '["skills","target",null]': openClawSkillsTarget, '["skills","clawhubCapability",null]': { success: true, capability: { canSearch: false, canInstall: false }, @@ -99,6 +110,7 @@ test.describe('Skills page gateway readiness', () => { }, hostApi: { '["skills","status",null]': { skills: [] }, + '["skills","target",null]': openClawSkillsTarget, '["skills","clawhubCapability",null]': { success: true, capability: { canSearch: false, canInstall: false }, @@ -134,6 +146,7 @@ test.describe('Skills page gateway readiness', () => { }, hostApi: { '["skills","status",null]': { skills: [] }, + '["skills","target",null]': openClawSkillsTarget, '["skills","local",null]': { success: true, skills: [] }, '["skills","clawhubCapability",null]': { success: true, @@ -155,4 +168,45 @@ test.describe('Skills page gateway readiness', () => { await expect(page.getByTestId('skills-gateway-banner')).toHaveCount(0, { timeout: 2_000 }); }); + + test('shows the cc-connect Codex skills mirror target', async ({ electronApp, page }) => { + await completeSetup(page); + + await installIpcMocks(electronApp, { + gatewayStatus: { state: 'running', port: 9820, runtimeKind: 'cc-connect', gatewayReady: true }, + hostApi: { + '["skills","status",null]': { skills: [] }, + '["skills","target",null]': { + success: true, + runtimeKind: 'cc-connect', + sourceDir: '/tmp/.openclaw/skills', + openDir: '/tmp/clawx/runtimes/cc-connect/codex-home/skills', + runtimeDir: '/tmp/clawx/runtimes/cc-connect/codex-home/skills', + manifestPath: '/tmp/clawx/runtimes/cc-connect/codex-home/skills/manifest.json', + mirrorMode: 'runtime-mirror', + }, + '["skills","local",null]': { + success: true, + skills: [{ + id: 'pdf', + slug: 'pdf', + name: 'PDF', + description: 'Local PDF tools', + enabled: true, + source: 'openclaw-managed', + baseDir: '/tmp/.openclaw/skills/pdf', + }], + }, + '["skills","clawhubCapability",null]': { + success: true, + capability: { canSearch: false, canInstall: false }, + }, + }, + }); + + await page.getByTestId('sidebar-nav-skills').click(); + await expect(page.getByTestId('skills-page')).toBeVisible(); + await expect(page.getByTestId('skills-runtime-target')).toContainText('/tmp/clawx/runtimes/cc-connect/codex-home/skills'); + await expect(page.getByRole('button', { name: /Open Runtime Skills Folder/i })).toBeVisible(); + }); }); diff --git a/tests/e2e/token-usage.spec.ts b/tests/e2e/token-usage.spec.ts index 5371787f7..c0ad47772 100644 --- a/tests/e2e/token-usage.spec.ts +++ b/tests/e2e/token-usage.spec.ts @@ -141,6 +141,38 @@ test.describe('ClawX token usage history', () => { expect(nonzeroEntry?.provider).toBe('kimi'); }); + test('does not read cc-connect private session or Codex transcript usage', async ({ page, userDataDir }) => { + const privateSessionDir = join(userDataDir, 'runtimes', 'cc-connect', 'data', 'sessions'); + const privateCodexDir = join(userDataDir, 'runtimes', 'cc-connect', 'codex-home', 'sessions'); + await mkdir(privateSessionDir, { recursive: true }); + await mkdir(privateCodexDir, { recursive: true }); + await writeFile(join(privateSessionDir, 'private.json'), JSON.stringify({ usage: { total_tokens: 99 } }), 'utf8'); + await writeFile(join(privateCodexDir, 'private.jsonl'), JSON.stringify({ type: 'token_count', total_tokens: 99 }), 'utf8'); + await completeSetup(page); + + const usageHistory = await page.evaluate(async () => { + return window.electron.ipcRenderer.invoke('usage:recentTokenHistory', { limit: 20, runtimeKind: 'cc-connect' }); + }); + + expect(usageHistory).toEqual([]); + }); + + test('filters IPC token usage history by runtime kind', async ({ page, homeDir }) => { + await seedTokenUsageTranscripts(homeDir); + await completeSetup(page); + + const openclawUsageHistory = await page.evaluate(async () => { + return window.electron.ipcRenderer.invoke('usage:recentTokenHistory', { limit: 20, runtimeKind: 'openclaw' }); + }); + const ccConnectUsageHistory = await page.evaluate(async () => { + return window.electron.ipcRenderer.invoke('usage:recentTokenHistory', { limit: 20, runtimeKind: 'cc-connect' }); + }); + + expect(openclawUsageHistory.some((entry) => entry?.runtimeKind === 'openclaw')).toBe(true); + expect(openclawUsageHistory.some((entry) => entry?.runtimeKind === 'cc-connect')).toBe(false); + expect(ccConnectUsageHistory).toEqual([]); + }); + // TODO: This test needs a reliable way to inject mocked gateway status into // the renderer's Zustand store in CI (where no real OpenClaw runtime exists). // The IPC mock + page.reload approach fails because the reload diff --git a/tests/fixtures/cc-connect-bundle-api.ts b/tests/fixtures/cc-connect-bundle-api.ts new file mode 100644 index 000000000..e2c6d07ef --- /dev/null +++ b/tests/fixtures/cc-connect-bundle-api.ts @@ -0,0 +1,7 @@ +export { + buildArchiveExtractionCommand, + buildCcConnectAssetName, + buildVersionCommand, + normalizeCcConnectTarget, + parseCcConnectBundleArgs, +} from '../../scripts/cc-connect-bundle-lib.mjs'; diff --git a/tests/fixtures/codex-bundle-api.ts b/tests/fixtures/codex-bundle-api.ts new file mode 100644 index 000000000..9bb1190f4 --- /dev/null +++ b/tests/fixtures/codex-bundle-api.ts @@ -0,0 +1,7 @@ +export { + buildCodexArchiveExtractionCommand, + buildCodexNativeTarballName, + buildCodexVersionCommand, + getCodexNativePackageName, + normalizeCodexTarget, +} from '../../scripts/codex-bundle-lib.mjs'; diff --git a/tests/unit/agent-config.test.ts b/tests/unit/agent-config.test.ts index 23f186aae..6691826fc 100644 --- a/tests/unit/agent-config.test.ts +++ b/tests/unit/agent-config.test.ts @@ -28,6 +28,11 @@ vi.mock('electron', () => ({ getPath: () => testUserData, getVersion: () => '0.0.0-test', }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8'), + }, })); async function writeOpenClawJson(config: unknown): Promise { @@ -567,6 +572,35 @@ describe('agent config lifecycle', () => { await createAgent('Research'); - await expect(readFile(join(testHome, '.openclaw', 'workspace-research', 'IDENTITY.md'), 'utf8')).resolves.toContain('ClawX'); + const workspace = join(testUserData, 'workspaces', 'agents', 'research'); + await expect(readFile(join(workspace, 'IDENTITY.md'), 'utf8')).resolves.toContain('ClawX'); + const config = await readOpenClawJson(); + const research = ((config.agents as { list: Array<{ id: string; workspace?: string }> }).list) + .find((agent) => agent.id === 'research'); + expect(research?.workspace).toBe(workspace); + }); + + it('reuses an existing OpenClaw workspace as the inheritance source without owning it', async () => { + const openClawWorkspace = join(testHome, '.openclaw', 'workspace'); + await mkdir(openClawWorkspace, { recursive: true }); + await writeFile(join(openClawWorkspace, 'AGENTS.md'), '# Existing OpenClaw instructions', 'utf8'); + await writeOpenClawJson({ + agents: { + defaults: { workspace: '~/.openclaw/workspace' }, + list: [{ id: 'main', name: 'Main', default: true }], + }, + }); + + const { createAgent } = await import('@electron/utils/agent-config'); + await createAgent('Research', { inheritWorkspace: true }); + + const clawXWorkspace = join(testUserData, 'workspaces', 'agents', 'research'); + await expect(readFile(join(clawXWorkspace, 'AGENTS.md'), 'utf8')).resolves.toBe( + '# Existing OpenClaw instructions', + ); + await expect(readFile(join(openClawWorkspace, 'AGENTS.md'), 'utf8')).resolves.toBe( + '# Existing OpenClaw instructions', + ); + expect(clawXWorkspace).not.toContain(process.cwd()); }); }); diff --git a/tests/unit/browser-oauth.test.ts b/tests/unit/browser-oauth.test.ts new file mode 100644 index 000000000..275c9c4cd --- /dev/null +++ b/tests/unit/browser-oauth.test.ts @@ -0,0 +1,115 @@ +import { once } from 'node:events'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + createAccountMock, + getAccountMock, + loginOpenAICodexOAuthMock, + secretSetMock, +} = vi.hoisted(() => ({ + createAccountMock: vi.fn(), + getAccountMock: vi.fn(), + loginOpenAICodexOAuthMock: vi.fn(), + secretSetMock: vi.fn(), +})); + +vi.mock('electron', () => ({ + shell: { openExternal: vi.fn() }, +})); + +vi.mock('@electron/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('@electron/utils/openai-codex-oauth', () => ({ + loginOpenAICodexOAuth: (...args: unknown[]) => loginOpenAICodexOAuthMock(...args), +})); + +vi.mock('@electron/services/providers/provider-service', () => ({ + getProviderService: () => ({ + createAccount: (...args: unknown[]) => createAccountMock(...args), + getAccount: (...args: unknown[]) => getAccountMock(...args), + }), +})); + +vi.mock('@electron/services/secrets/secret-store', () => ({ + getSecretStore: () => ({ + set: (...args: unknown[]) => secretSetMock(...args), + }), +})); + +describe('BrowserOAuthManager', () => { + beforeEach(() => { + vi.clearAllMocks(); + getAccountMock.mockResolvedValue(null); + createAccountMock.mockImplementation(async (account) => account); + loginOpenAICodexOAuthMock.mockResolvedValue({ + access: 'access-token', + refresh: 'refresh-token', + idToken: 'id-token', + expires: 1_800_000_000_000, + accountId: 'oauth-subject', + email: 'developer@example.com', + }); + }); + + it('persists canonical credentials and awaits runtime projection before success', async () => { + const { BrowserOAuthManager } = await import('@electron/utils/browser-oauth'); + const manager = new BrowserOAuthManager(); + const order: string[] = []; + manager.setSuccessHandler(async (payload) => { + order.push(`runtime:${payload.accountId}`); + }); + manager.on('oauth:success', ({ accountId }) => { + order.push(`event:${accountId}`); + }); + + const completed = once(manager, 'oauth:success'); + await manager.startFlow('openai', { + accountId: 'openai-oauth', + label: 'Work account', + }); + await completed; + + expect(createAccountMock).toHaveBeenCalledWith(expect.objectContaining({ + id: 'openai-oauth', + vendorId: 'openai', + authMode: 'oauth_browser', + label: 'Work account', + model: 'gpt-5.5', + })); + expect(secretSetMock).toHaveBeenCalledWith({ + type: 'oauth', + accountId: 'openai-oauth', + accessToken: 'access-token', + refreshToken: 'refresh-token', + idToken: 'id-token', + expiresAt: 1_800_000_000_000, + email: 'developer@example.com', + subject: 'oauth-subject', + }); + expect(order).toEqual([ + 'runtime:openai-oauth', + 'event:openai-oauth', + ]); + }); + + it('emits an error instead of success when runtime projection fails', async () => { + const { BrowserOAuthManager } = await import('@electron/utils/browser-oauth'); + const manager = new BrowserOAuthManager(); + const success = vi.fn(); + manager.on('oauth:success', success); + manager.setSuccessHandler(async () => { + throw new Error('runtime projection failed'); + }); + + const failed = once(manager, 'oauth:error'); + await manager.startFlow('openai', { accountId: 'openai-oauth' }); + await expect(failed).resolves.toEqual([{ message: 'runtime projection failed' }]); + expect(success).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/cc-connect-agent-bindings.test.ts b/tests/unit/cc-connect-agent-bindings.test.ts new file mode 100644 index 000000000..c5718cbc6 --- /dev/null +++ b/tests/unit/cc-connect-agent-bindings.test.ts @@ -0,0 +1,75 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +let root: string; + +vi.mock('electron', () => ({ + app: { getPath: () => root }, +})); + +beforeEach(async () => { + vi.resetModules(); + root = await mkdtemp(join(tmpdir(), 'clawx-agent-bindings-')); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe('cc-connect Agent provider bindings', () => { + it('keeps provider accounts isolated by Agent and persists no credentials', async () => { + const { + listCcConnectAgentProviderBindings, + setCcConnectAgentProviderBinding, + } = await import('@electron/runtime/cc-connect-agent-bindings'); + + await setCcConnectAgentProviderBinding('main', 'openai-oauth-a'); + await setCcConnectAgentProviderBinding('reviewer', 'openai-api-key-b'); + await expect(listCcConnectAgentProviderBindings()).resolves.toEqual({ + main: 'openai-oauth-a', + reviewer: 'openai-api-key-b', + }); + + const file = await readFile(join(root, 'app', 'agent-bindings.json'), 'utf8'); + expect(file).toContain('openai-oauth-a'); + expect(file).toContain('openai-api-key-b'); + expect(file).not.toMatch(/accessToken|refreshToken|apiKey|sk-/); + + await setCcConnectAgentProviderBinding('reviewer', null); + await expect(listCcConnectAgentProviderBindings()).resolves.toEqual({ main: 'openai-oauth-a' }); + }); + + it('persists an independent permission mode without dropping the provider binding', async () => { + const { + deleteCcConnectAgentBinding, + listCcConnectAgentPermissionModes, + listCcConnectAgentProviderBindings, + setCcConnectAgentPermissionMode, + setCcConnectAgentProviderBinding, + } = await import('@electron/runtime/cc-connect-agent-bindings'); + + await setCcConnectAgentProviderBinding('reviewer', 'openai-oauth-reviewer'); + await setCcConnectAgentPermissionMode('reviewer', 'suggest'); + await expect(listCcConnectAgentProviderBindings()).resolves.toEqual({ + reviewer: 'openai-oauth-reviewer', + }); + await expect(listCcConnectAgentPermissionModes()).resolves.toEqual({ + reviewer: 'suggest', + }); + + await setCcConnectAgentProviderBinding('reviewer', null); + await expect(listCcConnectAgentPermissionModes()).resolves.toEqual({ + reviewer: 'suggest', + }); + await deleteCcConnectAgentBinding('reviewer'); + await expect(listCcConnectAgentPermissionModes()).resolves.toEqual({}); + }); + + it('rejects unsupported permission modes', async () => { + const { setCcConnectAgentPermissionMode } = await import('@electron/runtime/cc-connect-agent-bindings'); + await expect(setCcConnectAgentPermissionMode('main', 'yolo' as never)) + .rejects.toThrow('permissionMode must be suggest or full-auto'); + }); +}); diff --git a/tests/unit/cc-connect-bridge-adapter.test.ts b/tests/unit/cc-connect-bridge-adapter.test.ts new file mode 100644 index 000000000..92e873d96 --- /dev/null +++ b/tests/unit/cc-connect-bridge-adapter.test.ts @@ -0,0 +1,1449 @@ +// @vitest-environment node +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import WebSocket, { WebSocketServer } from 'ws'; +import { + CLAWX_BRIDGE_ADMIN_USER_ID, + CcConnectBridgeAdapter, + ccConnectProjectNameForSessionKey, + toCcConnectBridgeSessionKey, +} from '@electron/runtime/cc-connect-bridge-adapter'; + +const electronMockState = vi.hoisted(() => ({ userData: '' })); + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: vi.fn(() => electronMockState.userData), + }, +})); + +describe('cc-connect BridgePlatform adapter', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'clawx-cc-bridge-adapter-')); + electronMockState.userData = join(tempDir, 'userData'); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('only maps ClawX agent sessions to bridge session keys', () => { + expect(toCcConnectBridgeSessionKey('agent:main:main')).toBe('clawx:main:main'); + expect(toCcConnectBridgeSessionKey('agent:research:desk')).toBe('clawx:research:desk'); + expect(toCcConnectBridgeSessionKey('agent:main:cron:job-123')).toBe('clawx:main:cron:job-123'); + expect(toCcConnectBridgeSessionKey('clawx:main:main')).toBe('clawx:main:main'); + expect(toCcConnectBridgeSessionKey('feishu:chat-1:user-1')).toBe('feishu:chat-1:user-1'); + expect(ccConnectProjectNameForSessionKey('agent:research:desk')).toBe('clawx-research'); + expect(ccConnectProjectNameForSessionKey('agent:research:cron:job-123')).toBe('clawx-research'); + expect(ccConnectProjectNameForSessionKey('feishu:chat-1:user-1')).toBe('clawx-main'); + }); + + it('closes a bridge socket that is still waiting for registration acknowledgement', async () => { + const server = new WebSocketServer({ port: 0 }); + const port = await new Promise((resolve) => { + server.once('listening', () => { + const address = server.address(); + resolve(typeof address === 'object' && address ? address.port : 0); + }); + }); + let connectionCount = 0; + const connected = new Promise((resolve) => { + server.on('connection', () => { + connectionCount += 1; + resolve(); + }); + }); + const adapter = new CcConnectBridgeAdapter({ + port, + token: 'token', + project: 'clawx-main', + emit: vi.fn(), + reconnectDelayMs: 10, + }); + + try { + const connectPromise = adapter.connect(); + await connected; + await adapter.close(); + await expect(connectPromise).rejects.toThrow(); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(connectionCount).toBe(1); + expect(adapter.isConnected()).toBe(false); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('routes app sends to the selected agent project and mirrors OpenClaw stream events', async () => { + const server = new WebSocketServer({ port: 0 }); + const port = await new Promise((resolve) => { + server.once('listening', () => { + const address = server.address(); + resolve(typeof address === 'object' && address ? address.port : 0); + }); + }); + const emitted: Array<[string, unknown]> = []; + const received: Record[] = []; + + server.on('connection', (socket) => { + socket.on('message', (data) => { + const parsed = JSON.parse(String(data)) as Record; + received.push(parsed); + if (parsed.type === 'register') { + socket.send(JSON.stringify({ type: 'register_ack', ok: true })); + return; + } + if (parsed.type === 'message') { + socket.send(JSON.stringify({ + type: 'reply_stream', + reply_ctx: parsed.reply_ctx, + delta: 'pong', + done: false, + })); + socket.send(JSON.stringify({ + type: 'reply_stream', + reply_ctx: parsed.reply_ctx, + full_text: 'pong', + done: true, + })); + } + }); + }); + + try { + const adapter = new CcConnectBridgeAdapter({ + port, + token: 'token', + project: 'clawx-main', + projectForSessionKey: ccConnectProjectNameForSessionKey, + emit: ((event: string, payload: unknown) => emitted.push([event, payload])) as never, + }); + + await expect(adapter.send({ + sessionKey: 'agent:research:desk', + message: 'ping', + idempotencyKey: 'idem-1', + })).resolves.toEqual(expect.objectContaining({ runId: expect.stringMatching(/^cc-connect-/) })); + + await vi.waitFor(() => { + expect(emitted).toEqual(expect.arrayContaining([ + ['chat:runtime-event', expect.objectContaining({ + type: 'assistant.delta', + sessionKey: 'agent:research:desk', + delta: 'pong', + })], + ['chat:message', expect.objectContaining({ + sessionKey: 'agent:research:desk', + message: expect.objectContaining({ role: 'assistant', content: 'pong' }), + })], + ['chat:runtime-event', expect.objectContaining({ + type: 'run.ended', + sessionKey: 'agent:research:desk', + status: 'completed', + })], + ])); + }); + + expect(received).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'message', + session_key: 'clawx:research:desk', + project: 'clawx-research', + }), + ])); + await expect(adapter.listSessions()).resolves.toEqual([ + expect.objectContaining({ + key: 'agent:research:desk', + agentId: 'research', + derivedTitle: 'ping', + lastMessagePreview: 'pong', + }), + ]); + await adapter.close(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it.each([ + { + kind: 'image', + mimeType: 'image/png', + fileName: 'screenshot.png', + content: 'fake image bytes', + expectedPreview: true, + }, + { + kind: 'file', + mimeType: 'application/pdf', + fileName: 'brief.pdf', + content: 'fake pdf bytes', + expectedPreview: false, + }, + { + kind: 'audio', + mimeType: 'audio/mpeg', + fileName: 'voice.mp3', + content: 'fake audio bytes', + expectedPreview: false, + }, + { + kind: 'video', + mimeType: 'video/mp4', + fileName: 'demo.mp4', + content: 'fake video bytes', + expectedPreview: false, + }, + ])('declares media capabilities and converts bridge $kind packets to attached files', async ({ + kind, + mimeType, + fileName, + content, + expectedPreview, + }) => { + const server = new WebSocketServer({ port: 0 }); + const port = await new Promise((resolve) => { + server.once('listening', () => { + const address = server.address(); + resolve(typeof address === 'object' && address ? address.port : 0); + }); + }); + const emitted: Array<[string, unknown]> = []; + const received: Record[] = []; + const mediaData = Buffer.from(content).toString('base64'); + + server.on('connection', (socket) => { + socket.on('message', (data) => { + const parsed = JSON.parse(String(data)) as Record; + received.push(parsed); + if (parsed.type === 'register') { + socket.send(JSON.stringify({ type: 'register_ack', ok: true })); + return; + } + if (parsed.type === 'message') { + socket.send(JSON.stringify({ + type: kind, + session_key: parsed.session_key, + reply_ctx: parsed.reply_ctx, + data: mediaData, + mime_type: mimeType, + file_name: fileName, + })); + } + }); + }); + + try { + const adapter = new CcConnectBridgeAdapter({ + port, + token: 'token', + project: 'clawx-main', + emit: ((event: string, payload: unknown) => emitted.push([event, payload])) as never, + }); + + const result = await adapter.send({ + sessionKey: 'agent:main:main', + message: 'make image', + idempotencyKey: 'idem-image', + }); + + await vi.waitFor(() => { + expect(emitted).toEqual(expect.arrayContaining([ + ['chat:message', expect.objectContaining({ + runId: result.runId, + sessionKey: 'agent:main:main', + message: expect.objectContaining({ + role: 'assistant', + content: fileName, + _attachedFiles: [ + expect.objectContaining({ + fileName, + mimeType, + fileSize: Buffer.byteLength(content), + preview: expectedPreview ? `data:${mimeType};base64,${mediaData}` : null, + source: 'gateway-media', + }), + ], + }), + })], + ['chat:runtime-event', expect.objectContaining({ + type: 'run.ended', + runId: result.runId, + sessionKey: 'agent:main:main', + status: 'completed', + })], + ])); + }); + + const register = received.find((message) => message.type === 'register'); + expect(register?.capabilities).toEqual(expect.arrayContaining(['image', 'file', 'audio', 'video'])); + const messageEvent = emitted.find(([event]) => event === 'chat:message')?.[1] as { + message?: { _attachedFiles?: Array<{ filePath?: string }> }; + } | undefined; + const filePath = messageEvent?.message?._attachedFiles?.[0]?.filePath; + expect(filePath).toContain(join('runtimes', 'cc-connect', 'media', 'outgoing', 'bridge')); + await expect(readFile(filePath || '', 'utf8')).resolves.toBe(content); + await adapter.close(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('handles bridge rich packets without claiming unsupported upstream delivery parity', async () => { + const server = new WebSocketServer({ port: 0 }); + const port = await new Promise((resolve) => { + server.once('listening', () => { + const address = server.address(); + resolve(typeof address === 'object' && address ? address.port : 0); + }); + }); + const emitted: Array<[string, unknown]> = []; + const received: Record[] = []; + let messageCount = 0; + + server.on('connection', (socket) => { + socket.on('message', (data) => { + const parsed = JSON.parse(String(data)) as Record; + received.push(parsed); + if (parsed.type === 'register') { + socket.send(JSON.stringify({ type: 'register_ack', ok: true })); + socket.send(JSON.stringify({ type: 'preview_start', ref_id: 'preview-1' })); + return; + } + if (parsed.type === 'message') { + messageCount += 1; + if (messageCount === 1) { + socket.send(JSON.stringify({ + type: 'preview_start', + ref_id: 'text-preview-1', + session_key: parsed.session_key, + reply_ctx: parsed.reply_ctx, + content: 'initial preview text', + })); + socket.send(JSON.stringify({ + type: 'update_message', + preview_handle: 'text-preview-1', + session_key: parsed.session_key, + reply_ctx: parsed.reply_ctx, + content: 'draft preview text', + })); + socket.send(JSON.stringify({ + type: 'typing_start', + session_key: parsed.session_key, + reply_ctx: parsed.reply_ctx, + })); + socket.send(JSON.stringify({ + type: 'delete_message', + session_key: parsed.session_key, + preview_handle: 'text-preview-1', + })); + socket.send(JSON.stringify({ + type: 'card', + session_key: parsed.session_key, + reply_ctx: parsed.reply_ctx, + card: { title: 'Card title', body: 'Card body' }, + })); + return; + } + socket.send(JSON.stringify({ + type: 'buttons', + session_key: parsed.session_key, + reply_ctx: parsed.reply_ctx, + content: 'Choose an option', + buttons: [{ text: 'Approve', value: 'approve' }], + })); + } + }); + }); + + try { + const adapter = new CcConnectBridgeAdapter({ + port, + token: 'token', + project: 'clawx-main', + emit: ((event: string, payload: unknown) => emitted.push([event, payload])) as never, + }); + + const cardRun = await adapter.send({ + sessionKey: 'agent:main:main', + message: 'show card', + idempotencyKey: 'idem-card', + }); + await vi.waitFor(() => { + expect(emitted).toEqual(expect.arrayContaining([ + ['chat:runtime-event', expect.objectContaining({ + type: 'assistant.delta', + runId: cardRun.runId, + sessionKey: 'agent:main:main', + text: 'initial preview text', + replace: true, + })], + ['chat:runtime-event', expect.objectContaining({ + type: 'assistant.delta', + runId: cardRun.runId, + sessionKey: 'agent:main:main', + text: 'draft preview text', + replace: true, + })], + ['chat:runtime-event', expect.objectContaining({ + type: 'assistant.delta', + runId: cardRun.runId, + sessionKey: 'agent:main:main', + text: '', + replace: true, + })], + ['chat:message', expect.objectContaining({ + runId: cardRun.runId, + sessionKey: 'agent:main:main', + message: expect.objectContaining({ + role: 'assistant', + content: JSON.stringify({ title: 'Card title', body: 'Card body' }), + }), + })], + ['chat:runtime-event', expect.objectContaining({ + type: 'run.ended', + runId: cardRun.runId, + status: 'completed', + })], + ])); + }); + + const buttonRun = await adapter.send({ + sessionKey: 'agent:main:buttons', + message: 'show buttons', + idempotencyKey: 'idem-buttons', + }); + await vi.waitFor(() => { + expect(emitted).toEqual(expect.arrayContaining([ + ['chat:message', expect.objectContaining({ + runId: buttonRun.runId, + sessionKey: 'agent:main:buttons', + message: expect.objectContaining({ + role: 'assistant', + content: 'Choose an option', + }), + })], + ['chat:runtime-event', expect.objectContaining({ + type: 'run.ended', + runId: buttonRun.runId, + sessionKey: 'agent:main:buttons', + status: 'completed', + })], + ])); + }); + + const register = received.find((message) => message.type === 'register'); + expect(register?.capabilities).toEqual(expect.arrayContaining([ + 'card', + 'buttons', + 'preview', + 'update_message', + 'delete_message', + ])); + expect(register?.metadata).toMatchObject({ + adapter: 'clawx', + progress_style: 'card', + supports_progress_card_payload: true, + }); + expect(received).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'preview_ack', + ref_id: 'preview-1', + preview_handle: 'preview-1', + }), + expect.objectContaining({ + type: 'preview_ack', + ref_id: 'text-preview-1', + preview_handle: 'text-preview-1', + }), + ])); + expect(emitted).not.toEqual(expect.arrayContaining([ + ['chat:message', expect.objectContaining({ + runId: cardRun.runId, + message: expect.objectContaining({ content: 'transient-message' }), + })], + ])); + await adapter.close(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('routes handle-only text preview updates to the correct concurrent Agent run', async () => { + const server = new WebSocketServer({ port: 0 }); + const port = await new Promise((resolve) => { + server.once('listening', () => { + const address = server.address(); + resolve(typeof address === 'object' && address ? address.port : 0); + }); + }); + const emitted: Array<[string, unknown]> = []; + const inbound: Record[] = []; + + server.on('connection', (socket) => { + socket.on('message', (data) => { + const parsed = JSON.parse(String(data)) as Record; + if (parsed.type === 'register') { + socket.send(JSON.stringify({ type: 'register_ack', ok: true })); + return; + } + if (parsed.type !== 'message') return; + inbound.push(parsed); + if (inbound.length !== 2) return; + for (const [index, message] of inbound.entries()) { + const handle = `concurrent-preview-${index + 1}`; + socket.send(JSON.stringify({ + type: 'preview_start', + ref_id: handle, + session_key: message.session_key, + reply_ctx: message.reply_ctx, + content: `initial-${index + 1}`, + })); + socket.send(JSON.stringify({ + type: 'update_message', + preview_handle: handle, + content: `updated-${index + 1}`, + })); + } + }); + }); + + try { + const adapter = new CcConnectBridgeAdapter({ + port, + token: 'token', + project: 'clawx-main', + projectForSessionKey: (sessionKey) => sessionKey.includes('research') ? 'clawx-research' : 'clawx-main', + emit: ((event: string, payload: unknown) => emitted.push([event, payload])) as never, + }); + const mainRun = await adapter.send({ + sessionKey: 'agent:main:main', + message: 'main preview', + idempotencyKey: 'main-preview', + }); + const researchRun = await adapter.send({ + sessionKey: 'agent:research:main', + message: 'research preview', + idempotencyKey: 'research-preview', + }); + + await vi.waitFor(() => { + expect(emitted).toEqual(expect.arrayContaining([ + ['chat:runtime-event', expect.objectContaining({ + type: 'assistant.delta', + runId: mainRun.runId, + sessionKey: 'agent:main:main', + text: 'updated-1', + })], + ['chat:runtime-event', expect.objectContaining({ + type: 'assistant.delta', + runId: researchRun.runId, + sessionKey: 'agent:research:main', + text: 'updated-2', + })], + ])); + }); + await adapter.close(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('maps the public cc-connect progress-card payload to thinking and tool runtime events', async () => { + const server = new WebSocketServer({ port: 0 }); + const port = await new Promise((resolve) => { + server.once('listening', () => { + const address = server.address(); + resolve(typeof address === 'object' && address ? address.port : 0); + }); + }); + const emitted: Array<[string, unknown]> = []; + const prefix = '__cc_connect_progress_card_v1__:'; + const progress = (items: unknown[]) => `${prefix}${JSON.stringify({ version: 2, state: 'running', items })}`; + + server.on('connection', (socket) => { + socket.on('message', (data) => { + const parsed = JSON.parse(String(data)) as Record; + if (parsed.type === 'register') { + socket.send(JSON.stringify({ type: 'register_ack', ok: true })); + return; + } + if (parsed.type === 'message') { + const inferCompletion = parsed.content === 'infer completion'; + const applyPatch = parsed.content === 'apply patch'; + const refId = inferCompletion ? 'progress-inferred' : applyPatch ? 'progress-apply-patch' : 'progress-1'; + const firstItems = applyPatch + ? [{ + kind: 'tool_use', + tool: 'Patch', + text: JSON.stringify([{ + diff: '# Progress\n', + kind: { type: 'add' }, + path: 'reports/progress.md', + }]), + }] + : [ + { kind: 'thinking', text: 'Inspecting the workspace' }, + { kind: 'tool_use', tool: 'Bash', text: 'pwd' }, + ]; + const completedItems = applyPatch + ? [ + firstItems[0], + { kind: 'tool_result', tool: 'Patch', text: 'Done!', status: 'completed', success: true }, + ] + : [ + firstItems[1], + { kind: 'tool_result', tool: 'Bash', text: '/tmp/project', status: 'completed', exit_code: 0, success: true }, + ]; + socket.send(JSON.stringify({ + type: 'preview_start', + ref_id: refId, + session_key: parsed.session_key, + reply_ctx: parsed.reply_ctx, + content: progress(firstItems), + })); + if (!inferCompletion) { + socket.send(JSON.stringify({ + type: 'update_message', + preview_handle: refId, + content: progress(completedItems), + })); + socket.send(JSON.stringify({ + type: 'update_message', + preview_handle: refId, + content: progress(completedItems), + })); + } + socket.send(JSON.stringify({ + type: 'delete_message', + preview_handle: refId, + })); + socket.send(JSON.stringify({ + type: 'reply', + session_key: parsed.session_key, + reply_ctx: parsed.reply_ctx, + content: 'done', + })); + } + }); + }); + + try { + const adapter = new CcConnectBridgeAdapter({ + port, + token: 'token', + project: 'clawx-main', + emit: ((event: string, payload: unknown) => emitted.push([event, payload])) as never, + }); + const result = await adapter.send({ + sessionKey: 'agent:main:main', + message: 'inspect', + idempotencyKey: 'idem-progress', + }); + + await vi.waitFor(() => expect(emitted).toEqual(expect.arrayContaining([ + ['chat:runtime-event', expect.objectContaining({ + type: 'thinking.delta', + runId: result.runId, + text: 'Inspecting the workspace', + })], + ['chat:runtime-event', expect.objectContaining({ + type: 'tool.started', + runId: result.runId, + toolCallId: `${result.runId}:progress:1`, + name: 'Bash', + args: 'pwd', + })], + ['chat:runtime-event', expect.objectContaining({ + type: 'tool.completed', + runId: result.runId, + toolCallId: `${result.runId}:progress:1`, + name: 'Bash', + result: '/tmp/project', + })], + ['chat:runtime-event', expect.objectContaining({ + type: 'run.ended', + runId: result.runId, + status: 'completed', + })], + ]))); + const started = emitted.filter(([, payload]) => ( + payload && typeof payload === 'object' && (payload as { type?: unknown }).type === 'tool.started' + )); + expect(started).toHaveLength(1); + expect(emitted).not.toEqual(expect.arrayContaining([ + ['chat:runtime-event', expect.objectContaining({ + type: 'assistant.delta', + text: expect.stringContaining(prefix), + })], + ])); + + const patched = await adapter.send({ + sessionKey: 'agent:main:patch', + message: 'apply patch', + idempotencyKey: 'idem-progress-patch', + }); + await vi.waitFor(() => expect(emitted).toEqual(expect.arrayContaining([ + ['chat:message', expect.objectContaining({ + runId: patched.runId, + sessionKey: 'agent:main:patch', + message: expect.objectContaining({ + role: 'assistant', + content: [expect.objectContaining({ + type: 'toolCall', + name: 'Patch', + arguments: [expect.objectContaining({ + diff: '# Progress\n', + path: 'reports/progress.md', + kind: { type: 'add' }, + })], + })], + }), + })], + ]))); + + const inferred = await adapter.send({ + sessionKey: 'agent:main:inferred', + message: 'infer completion', + idempotencyKey: 'idem-progress-inferred', + }); + await vi.waitFor(() => expect(emitted).toEqual(expect.arrayContaining([ + ['chat:runtime-event', expect.objectContaining({ + type: 'tool.completed', + runId: inferred.runId, + toolCallId: `${inferred.runId}:progress:1`, + name: 'Bash', + meta: expect.objectContaining({ + status: 'completed', + success: true, + inferredFromRunCompletion: true, + }), + })], + ['chat:runtime-event', expect.objectContaining({ + type: 'run.ended', + runId: inferred.runId, + status: 'completed', + })], + ]))); + await adapter.close(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('maps bridge tool, command, and patch packets to runtime events and generated-file messages', async () => { + const server = new WebSocketServer({ port: 0 }); + const port = await new Promise((resolve) => { + server.once('listening', () => { + const address = server.address(); + resolve(typeof address === 'object' && address ? address.port : 0); + }); + }); + const emitted: Array<[string, unknown]> = []; + const received: Record[] = []; + + server.on('connection', (socket) => { + socket.on('message', (data) => { + const parsed = JSON.parse(String(data)) as Record; + received.push(parsed); + if (parsed.type === 'register') { + socket.send(JSON.stringify({ type: 'register_ack', ok: true })); + return; + } + if (parsed.type === 'message') { + socket.send(JSON.stringify({ + type: 'tool_call', + reply_ctx: parsed.reply_ctx, + session_key: parsed.session_key, + tool_call_id: 'edit-1', + name: 'Edit', + arguments: { + file_path: '/workspace/demo.ts', + old_string: 'const value = 1\n', + new_string: 'const value = 2\n', + }, + })); + socket.send(JSON.stringify({ + type: 'command_output', + reply_ctx: parsed.reply_ctx, + session_key: parsed.session_key, + tool_call_id: 'edit-1', + item_id: 'cmd-1', + title: 'apply edit', + output: 'patched /workspace/demo.ts', + status: 'running', + cwd: '/workspace', + })); + socket.send(JSON.stringify({ + type: 'patch_completed', + reply_ctx: parsed.reply_ctx, + session_key: parsed.session_key, + tool_call_id: 'edit-1', + item_id: 'patch-1', + title: 'demo.ts', + summary: '1 file changed', + added: 1, + modified: 1, + })); + socket.send(JSON.stringify({ + type: 'tool_result', + reply_ctx: parsed.reply_ctx, + session_key: parsed.session_key, + tool_call_id: 'edit-1', + name: 'Edit', + result: 'ok', + })); + socket.send(JSON.stringify({ + type: 'reply', + reply_ctx: parsed.reply_ctx, + session_key: parsed.session_key, + content: 'done', + })); + } + }); + }); + + try { + const adapter = new CcConnectBridgeAdapter({ + port, + token: 'token', + project: 'clawx-main', + emit: ((event: string, payload: unknown) => emitted.push([event, payload])) as never, + }); + + const result = await adapter.send({ + sessionKey: 'agent:main:main', + message: 'edit demo.ts', + idempotencyKey: 'idem-tools', + }); + + await vi.waitFor(() => { + expect(emitted).toEqual(expect.arrayContaining([ + ['chat:runtime-event', expect.objectContaining({ + type: 'tool.started', + runId: result.runId, + sessionKey: 'agent:main:main', + toolCallId: 'edit-1', + name: 'Edit', + args: expect.objectContaining({ file_path: '/workspace/demo.ts' }), + })], + ['chat:message', expect.objectContaining({ + runId: result.runId, + sessionKey: 'agent:main:main', + message: expect.objectContaining({ + role: 'assistant', + content: [expect.objectContaining({ + type: 'toolCall', + id: 'edit-1', + name: 'Edit', + arguments: expect.objectContaining({ + file_path: '/workspace/demo.ts', + old_string: 'const value = 1\n', + new_string: 'const value = 2\n', + }), + })], + }), + })], + ['chat:runtime-event', expect.objectContaining({ + type: 'command.output', + runId: result.runId, + toolCallId: 'edit-1', + itemId: 'cmd-1', + output: 'patched /workspace/demo.ts', + cwd: '/workspace', + })], + ['chat:runtime-event', expect.objectContaining({ + type: 'patch.completed', + runId: result.runId, + toolCallId: 'edit-1', + itemId: 'patch-1', + summary: '1 file changed', + added: 1, + modified: 1, + deleted: 0, + })], + ['chat:runtime-event', expect.objectContaining({ + type: 'tool.completed', + runId: result.runId, + toolCallId: 'edit-1', + result: 'ok', + })], + ['chat:runtime-event', expect.objectContaining({ + type: 'run.ended', + runId: result.runId, + sessionKey: 'agent:main:main', + status: 'completed', + })], + ])); + }); + + const register = received.find((message) => message.type === 'register'); + expect(register?.capabilities).toEqual(expect.arrayContaining(['text', 'preview', 'reconstruct_reply'])); + expect(register?.capabilities).not.toEqual(expect.arrayContaining(['tool_events', 'command_output', 'patch_events'])); + await expect(adapter.loadHistory('agent:main:main')).resolves.toEqual(expect.arrayContaining([ + expect.objectContaining({ + role: 'assistant', + content: [expect.objectContaining({ + type: 'toolCall', + id: 'edit-1', + name: 'Edit', + arguments: expect.objectContaining({ file_path: '/workspace/demo.ts' }), + })], + }), + expect.objectContaining({ role: 'assistant', content: 'done' }), + ])); + await adapter.close(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('keeps the BridgePlatform socket alive, reconnects after a drop, and stops reconnecting on close', async () => { + const server = new WebSocketServer({ port: 0 }); + const port = await new Promise((resolve) => { + server.once('listening', () => { + const address = server.address(); + resolve(typeof address === 'object' && address ? address.port : 0); + }); + }); + const sockets: WebSocket[] = []; + const received: Record[] = []; + + server.on('connection', (socket) => { + sockets.push(socket); + socket.on('message', (data) => { + const parsed = JSON.parse(String(data)) as Record; + received.push(parsed); + if (parsed.type === 'register') { + socket.send(JSON.stringify({ type: 'register_ack', ok: true })); + } + }); + }); + + const adapter = new CcConnectBridgeAdapter({ + port, + token: 'token', + project: 'clawx-main', + emit: vi.fn(), + heartbeatIntervalMs: 20, + reconnectDelayMs: 20, + }); + + try { + await adapter.connect(); + await vi.waitFor(() => { + expect(received).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'ping', ts: expect.any(Number) }), + ])); + }); + + sockets[0]?.close(); + await vi.waitFor(() => { + expect(sockets).toHaveLength(2); + expect(adapter.isConnected()).toBe(true); + }); + + await adapter.close(); + const connectionCountAfterClose = sockets.length; + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(sockets).toHaveLength(connectionCountAfterClose); + expect(adapter.isConnected()).toBe(false); + } finally { + await adapter.close(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('maps bridge replies without reply_ctx back to the pending app run', async () => { + const server = new WebSocketServer({ port: 0 }); + const port = await new Promise((resolve) => { + server.once('listening', () => { + const address = server.address(); + resolve(typeof address === 'object' && address ? address.port : 0); + }); + }); + const emitted: Array<[string, unknown]> = []; + + server.on('connection', (socket) => { + socket.on('message', (data) => { + const parsed = JSON.parse(String(data)) as Record; + if (parsed.type === 'register') { + socket.send(JSON.stringify({ type: 'register_ack', ok: true })); + return; + } + if (parsed.type === 'message') { + socket.send(JSON.stringify({ + type: 'reply', + session_key: parsed.session_key, + content: 'pong without ctx', + })); + } + }); + }); + + try { + const adapter = new CcConnectBridgeAdapter({ + port, + token: 'token', + project: 'clawx-main', + emit: ((event: string, payload: unknown) => emitted.push([event, payload])) as never, + }); + + const result = await adapter.send({ + sessionKey: 'agent:main:main', + message: 'ping', + idempotencyKey: 'idem-no-ctx', + }); + + await vi.waitFor(() => { + expect(emitted).toEqual(expect.arrayContaining([ + ['chat:message', expect.objectContaining({ + runId: result.runId, + sessionKey: 'agent:main:main', + message: expect.objectContaining({ role: 'assistant', content: 'pong without ctx' }), + })], + ['chat:runtime-event', expect.objectContaining({ + type: 'run.ended', + runId: result.runId, + sessionKey: 'agent:main:main', + status: 'completed', + })], + ])); + }); + await adapter.close(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('aborts pending app runs and ignores late bridge replies', async () => { + const server = new WebSocketServer({ port: 0 }); + const port = await new Promise((resolve) => { + server.once('listening', () => { + const address = server.address(); + resolve(typeof address === 'object' && address ? address.port : 0); + }); + }); + const emitted: Array<[string, unknown]> = []; + const received: Record[] = []; + let capturedReplyCtx = ''; + + server.on('connection', (socket) => { + socket.on('message', (data) => { + const parsed = JSON.parse(String(data)) as Record; + received.push(parsed); + if (parsed.type === 'register') { + socket.send(JSON.stringify({ type: 'register_ack', ok: true })); + return; + } + if (parsed.type === 'message') { + capturedReplyCtx = String(parsed.reply_ctx || ''); + if (parsed.content === '/stop') { + socket.send(JSON.stringify({ + type: 'reply', + reply_ctx: parsed.reply_ctx, + session_key: parsed.session_key, + content: 'Execution stopped.', + })); + } + } + }); + }); + + try { + const adapter = new CcConnectBridgeAdapter({ + port, + token: 'token', + project: 'clawx-main', + emit: ((event: string, payload: unknown) => emitted.push([event, payload])) as never, + }); + + const result = await adapter.send({ + sessionKey: 'agent:main:main', + message: 'slow ping', + idempotencyKey: 'idem-abort', + }); + await vi.waitFor(() => expect(capturedReplyCtx).toBe(result.runId)); + + await expect(adapter.abort({ sessionKey: 'agent:main:main' })).resolves.toEqual({ + success: true, + abortedRuns: [result.runId], + stoppedSessions: ['agent:main:main'], + upstreamStopRequested: true, + }); + + await vi.waitFor(() => { + expect(received).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'message', + content: '/stop', + session_key: 'clawx:main:main', + project: 'clawx-main', + reply_ctx: result.runId, + user_id: CLAWX_BRIDGE_ADMIN_USER_ID, + }), + ])); + }); + + await vi.waitFor(() => { + expect(emitted).toEqual(expect.arrayContaining([ + ['chat:runtime-event', expect.objectContaining({ + type: 'run.ended', + runId: result.runId, + sessionKey: 'agent:main:main', + status: 'aborted', + stopReason: 'user', + })], + ])); + }); + + const socket = Array.from(server.clients)[0]; + socket.send(JSON.stringify({ + type: 'reply', + reply_ctx: result.runId, + session_key: 'clawx:main:main', + content: 'late pong', + })); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(emitted).not.toEqual(expect.arrayContaining([ + ['chat:message', expect.objectContaining({ + runId: result.runId, + message: expect.objectContaining({ content: 'late pong' }), + })], + ])); + await adapter.close(); + } finally { + for (const client of server.clients) client.terminate(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('round-trips a validated Codex approval through cc-connect card_action', async () => { + const server = new WebSocketServer({ port: 0 }); + const port = await new Promise((resolve) => { + server.once('listening', () => { + const address = server.address(); + resolve(typeof address === 'object' && address ? address.port : 0); + }); + }); + const emitted: Array<[string, unknown]> = []; + const received: Record[] = []; + + server.on('connection', (socket) => { + socket.on('message', (data) => { + const parsed = JSON.parse(String(data)) as Record; + received.push(parsed); + if (parsed.type === 'register') { + socket.send(JSON.stringify({ type: 'register_ack', ok: true })); + return; + } + if (parsed.type === 'message') { + socket.send(JSON.stringify({ + type: 'buttons', + session_key: parsed.session_key, + reply_ctx: parsed.reply_ctx, + project: parsed.project, + content: 'Allow Codex to run pwd?', + buttons: [[ + { Text: 'Allow once', Data: 'perm:allow' }, + { Text: 'Deny', Data: 'perm:deny' }, + ]], + })); + return; + } + if (parsed.type === 'card_action') { + socket.send(JSON.stringify({ + type: 'reply', + session_key: parsed.session_key, + reply_ctx: parsed.reply_ctx, + content: 'approval accepted by cc-connect', + })); + } + }); + }); + + try { + const adapter = new CcConnectBridgeAdapter({ + port, + token: 'token', + project: 'clawx-main', + emit: ((event: string, payload: unknown) => emitted.push([event, payload])) as never, + }); + const result = await adapter.send({ + sessionKey: 'agent:main:main', + message: 'run pwd with approval', + idempotencyKey: 'idem-approval', + }); + + await vi.waitFor(() => expect(emitted).toEqual(expect.arrayContaining([ + ['chat:runtime-event', expect.objectContaining({ + type: 'approval.updated', + runId: result.runId, + phase: 'requested', + status: 'pending', + actions: [ + { action: 'perm:allow', label: 'Allow once' }, + { action: 'perm:deny', label: 'Deny' }, + ], + })], + ]))); + + await expect(adapter.respondApproval({ runId: result.runId, action: 'perm:other' })) + .rejects.toThrow('action is not available'); + await expect(adapter.respondApproval({ runId: result.runId, action: 'perm:allow' })) + .resolves.toEqual({ + success: true, + runId: result.runId, + action: 'perm:allow', + status: 'approved', + }); + + await vi.waitFor(() => { + expect(received).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'card_action', + session_key: 'clawx:main:main', + reply_ctx: result.runId, + project: 'clawx-main', + action: 'perm:allow', + }), + ])); + expect(emitted).toEqual(expect.arrayContaining([ + ['chat:runtime-event', expect.objectContaining({ + type: 'approval.updated', + runId: result.runId, + phase: 'resolved', + status: 'approved', + })], + ['chat:message', expect.objectContaining({ + runId: result.runId, + message: expect.objectContaining({ content: 'approval accepted by cc-connect' }), + })], + ])); + }); + await expect(adapter.respondApproval({ runId: result.runId, action: 'perm:allow' })) + .rejects.toThrow('No pending cc-connect approval'); + await adapter.close(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('round-trips a validated runtime card choice without ending the run early', async () => { + const server = new WebSocketServer({ port: 0 }); + const port = await new Promise((resolve) => { + server.once('listening', () => { + const address = server.address(); + resolve(typeof address === 'object' && address ? address.port : 0); + }); + }); + const emitted: Array<[string, unknown]> = []; + const received: Record[] = []; + + server.on('connection', (socket) => { + socket.on('message', (data) => { + const parsed = JSON.parse(String(data)) as Record; + received.push(parsed); + if (parsed.type === 'register') { + socket.send(JSON.stringify({ type: 'register_ack', ok: true })); + return; + } + if (parsed.type === 'message') { + socket.send(JSON.stringify({ + type: 'card', + session_key: parsed.session_key, + reply_ctx: parsed.reply_ctx, + project: parsed.project, + card: { + header: { title: 'Language', color: 'blue' }, + elements: [ + { type: 'markdown', content: 'Choose a language.' }, + { + type: 'actions', + buttons: [ + { text: 'English', value: 'cmd:/lang en' }, + { text: 'Unsafe', value: 'javascript:alert(1)' }, + ], + }, + ], + }, + })); + return; + } + if (parsed.type === 'card_action') { + socket.send(JSON.stringify({ + type: 'reply', + session_key: parsed.session_key, + reply_ctx: parsed.reply_ctx, + content: 'Language switched to English.', + })); + } + }); + }); + + try { + const adapter = new CcConnectBridgeAdapter({ + port, + token: 'token', + project: 'clawx-main', + emit: ((event: string, payload: unknown) => emitted.push([event, payload])) as never, + }); + const result = await adapter.send({ + sessionKey: 'agent:main:main', + message: '/lang', + idempotencyKey: 'idem-language-card', + }); + + await vi.waitFor(() => expect(emitted).toEqual(expect.arrayContaining([ + ['chat:runtime-event', expect.objectContaining({ + type: 'approval.updated', + runId: result.runId, + kind: 'choice', + phase: 'requested', + status: 'pending', + message: '**Language**\n\nChoose a language.', + actions: [{ action: 'cmd:/lang en', label: 'English' }], + })], + ]))); + expect(emitted).not.toEqual(expect.arrayContaining([ + ['chat:runtime-event', expect.objectContaining({ + type: 'run.ended', + runId: result.runId, + })], + ])); + + await expect(adapter.respondApproval({ + runId: result.runId, + action: 'cmd:/lang en', + })).resolves.toMatchObject({ status: 'answered' }); + await vi.waitFor(() => expect(received).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'card_action', + session_key: 'clawx:main:main', + action: 'cmd:/lang en', + project: 'clawx-main', + }), + ]))); + await vi.waitFor(() => expect(emitted).toEqual(expect.arrayContaining([ + ['chat:message', expect.objectContaining({ + runId: result.runId, + message: expect.objectContaining({ content: 'Language switched to English.' }), + })], + ['chat:runtime-event', expect.objectContaining({ + type: 'run.ended', + runId: result.runId, + status: 'completed', + })], + ]))); + await adapter.close(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('completes a select-card run after cc-connect returns the selected state card', async () => { + const server = new WebSocketServer({ port: 0 }); + const port = await new Promise((resolve) => { + server.once('listening', () => { + const address = server.address(); + resolve(typeof address === 'object' && address ? address.port : 0); + }); + }); + const emitted: Array<[string, unknown]> = []; + + server.on('connection', (socket) => { + socket.on('message', (data) => { + const parsed = JSON.parse(String(data)) as Record; + if (parsed.type === 'register') { + socket.send(JSON.stringify({ type: 'register_ack', ok: true })); + return; + } + if (parsed.type === 'message') { + socket.send(JSON.stringify({ + type: 'card', + session_key: parsed.session_key, + reply_ctx: parsed.reply_ctx, + project: parsed.project, + card: { + header: { title: 'Language' }, + elements: [{ + type: 'select', + placeholder: 'Choose a language', + options: [{ text: '日本語', value: 'act:/lang ja' }], + }], + }, + })); + return; + } + if (parsed.type === 'card_action') { + socket.send(JSON.stringify({ + type: 'card', + session_key: parsed.session_key, + reply_ctx: parsed.reply_ctx, + project: parsed.project, + card: { + header: { title: '言語' }, + elements: [{ type: 'markdown', content: '現在の言語: 日本語' }], + }, + })); + } + }); + }); + + try { + const adapter = new CcConnectBridgeAdapter({ + port, + token: 'token', + project: 'clawx-main', + emit: ((event: string, payload: unknown) => emitted.push([event, payload])) as never, + }); + const result = await adapter.send({ + sessionKey: 'agent:main:main', + message: '/lang', + idempotencyKey: 'idem-language-select-card', + }); + + await vi.waitFor(() => expect(emitted).toEqual(expect.arrayContaining([ + ['chat:runtime-event', expect.objectContaining({ + type: 'approval.updated', + runId: result.runId, + kind: 'choice', + actions: [{ action: 'act:/lang ja', label: '日本語' }], + })], + ]))); + await adapter.respondApproval({ runId: result.runId, action: 'act:/lang ja' }); + await vi.waitFor(() => expect(emitted).toEqual(expect.arrayContaining([ + ['chat:message', expect.objectContaining({ + runId: result.runId, + message: expect.objectContaining({ content: '**言語**\n\n現在の言語: 日本語' }), + })], + ['chat:runtime-event', expect.objectContaining({ + type: 'run.ended', + runId: result.runId, + status: 'completed', + })], + ]))); + await adapter.close(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('does not expose Codex transcript reconciliation as a runtime transport', () => { + const adapter = new CcConnectBridgeAdapter({ + port: 0, + token: 'token', + project: 'clawx-main', + emit: vi.fn(), + }); + expect('reconcilePendingRunsFromHistory' in adapter).toBe(false); + }); + + +}); diff --git a/tests/unit/cc-connect-bundle.test.ts b/tests/unit/cc-connect-bundle.test.ts new file mode 100644 index 000000000..d62ed8b42 --- /dev/null +++ b/tests/unit/cc-connect-bundle.test.ts @@ -0,0 +1,40 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { + buildArchiveExtractionCommand, + buildCcConnectAssetName, + buildVersionCommand, + normalizeCcConnectTarget, + parseCcConnectBundleArgs, +} from '../fixtures/cc-connect-bundle-api'; + +describe('cc-connect bundle helpers', () => { + it('maps Node platform and arch to cc-connect release asset names', () => { + expect(normalizeCcConnectTarget('darwin', 'arm64')).toEqual({ platform: 'darwin', arch: 'arm64' }); + expect(normalizeCcConnectTarget('win32', 'x64')).toEqual({ platform: 'windows', arch: 'amd64' }); + expect(buildCcConnectAssetName('1.3.2', { platform: 'linux', arch: 'amd64' })).toBe('cc-connect-v1.3.2-linux-amd64.tar.gz'); + expect(buildCcConnectAssetName('1.3.2', { platform: 'windows', arch: 'amd64' })).toBe('cc-connect-v1.3.2-windows-amd64.zip'); + }); + + it('expands platform bundle presets without relying on runtime downloads', () => { + expect(parseCcConnectBundleArgs(['--platform=mac']).targets).toEqual([ + { nodePlatform: 'darwin', nodeArch: 'x64' }, + { nodePlatform: 'darwin', nodeArch: 'arm64' }, + ]); + expect(parseCcConnectBundleArgs(['--all']).targets).toContainEqual({ nodePlatform: 'win32', nodeArch: 'x64' }); + }); + + it('passes Windows archive paths as opaque process arguments', () => { + const archivePath = String.raw`D:\a\ClawX\build\cc-connect\win32-x64\cc-connect.zip`; + const outputDir = String.raw`D:\a\ClawX\build\cc-connect\win32-x64`; + + expect(buildArchiveExtractionCommand(archivePath, outputDir, true)).toEqual({ + command: 'tar', + args: ['-xf', archivePath, '-C', outputDir], + }); + expect(buildVersionCommand(String.raw`D:\a\ClawX\build\cc-connect\cc-connect.exe`)).toEqual({ + command: String.raw`D:\a\ClawX\build\cc-connect\cc-connect.exe`, + args: ['--version'], + }); + }); +}); diff --git a/tests/unit/cc-connect-codex-launcher.test.ts b/tests/unit/cc-connect-codex-launcher.test.ts new file mode 100644 index 000000000..4e0d3bae8 --- /dev/null +++ b/tests/unit/cc-connect-codex-launcher.test.ts @@ -0,0 +1,50 @@ +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +let root: string; + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: () => root, + }, +})); + +beforeEach(async () => { + vi.resetModules(); + root = await mkdtemp(join(tmpdir(), 'clawx-codex-launcher-')); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe('cc-connect Codex account launcher', () => { + it('sets the account CODEX_HOME, maps account-scoped env, and delegates all arguments to Codex', async () => { + const { ensureCcConnectCodexLauncher } = await import('@electron/runtime/cc-connect-codex-launcher'); + const codexHomeDir = join(root, 'credentials', 'oauth', 'account-a', 'codex-home'); + const codexPath = join(root, 'bundles', 'codex'); + const launcherPath = await ensureCcConnectCodexLauncher({ + accountId: 'account-a', + codexHomeDir, + codexPath, + envAliases: { + OPENAI_API_KEY: 'CLAWX_CODEX_ACCOUNT_A_API_KEY', + }, + }); + const content = await readFile(launcherPath, 'utf8'); + + expect(content).toContain(codexHomeDir); + expect(content).toContain(codexPath); + expect(content).toContain(process.platform === 'win32' + ? 'set "OPENAI_API_KEY=%CLAWX_CODEX_ACCOUNT_A_API_KEY%"' + : 'export OPENAI_API_KEY="${CLAWX_CODEX_ACCOUNT_A_API_KEY}"'); + expect(content).not.toMatch(/sk-[A-Za-z0-9]/); + if (process.platform !== 'win32') { + expect(content).toContain('exec '); + expect((await stat(launcherPath)).mode & 0o777).toBe(0o700); + } + }); +}); diff --git a/tests/unit/cc-connect-local-real-verifier.test.ts b/tests/unit/cc-connect-local-real-verifier.test.ts new file mode 100644 index 000000000..65835da47 --- /dev/null +++ b/tests/unit/cc-connect-local-real-verifier.test.ts @@ -0,0 +1,1771 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { + COVERAGE_IDS, + REPLACEMENT_CONTRACT_ITEMS, + REPLACEMENT_REQUIRED_COVERAGE_IDS, + RESIDUAL_VALIDATION_GAPS, + analyzeCcConnectCliSurface, + buildCoverage, + buildNextActions, + buildReplacementContract, + buildReplacementReadiness, + buildValidationGaps, + classifyCommandExit, + codexAuthExpirySummary, + coverageRecords, + extraLocalRealEnvFiles, + isPathInsideRoot, + localEnvFileSafety, + loadLocalEnvFiles, + missingPreconditions, + openAiApiKeyCandidateSummary, + openAiApiKeyPreconditionMessage, + parseArgs, + replacementReadinessCheck, + requiredCoverageCheck, + requiredCoverageIds, + resolveOpenAiApiKeyEnv, + runtimeMatrixStatus, + sanitizeReportPaths, + shouldWriteHandoff, + shouldWriteReport, + summarizeCommandAttempts, + toConsoleSummaryLines, + toMarkdown, +} from '../../scripts/verify-cc-connect-local-real.mjs'; +import { tmpdir } from 'node:os'; +import { delimiter, join, resolve } from 'node:path'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; + +function coverageRows(statusById: Record) { + return COVERAGE_IDS.map((id) => ({ + id, + status: statusById[id] || 'pass', + covers: [], + evidence: `${id} evidence`, + reason: statusById[id] === 'skipped' ? `${id} skipped` : '', + })); +} + +describe('cc-connect local real verifier', () => { + it('does not treat an all-skipped Playwright command as real passing evidence', () => { + expect(classifyCommandExit(0, ' 2 skipped\n')).toEqual({ + status: 'skipped', + reason: 'Test command exited successfully but executed no passing tests (2 skipped).', + }); + expect(classifyCommandExit(0, ' 3 passed\n 1 skipped\n')).toEqual({ + status: 'pass', + reason: '', + }); + expect(classifyCommandExit(1, ' 2 skipped\n')).toEqual({ + status: 'fail', + reason: '', + }); + expect(classifyCommandExit(0, '\u001B[33m 2 skipped\u001B[39m\n')).toEqual({ + status: 'skipped', + reason: 'Test command exited successfully but executed no passing tests (2 skipped).', + }); + }); + + it('parses required coverage ids from CLI flags', () => { + expect(parseArgs(['--run', '--external-gates-only', '--include-feishu-inbound', '--require-coverage=all', '--require-replacement-ready', '--write-handoff', '--no-write'])).toMatchObject({ + run: true, + externalGatesOnly: true, + includeFeishuInbound: true, + requireCoverage: ['all'], + requireReplacementReady: true, + writeHandoff: true, + noWrite: true, + }); + expect(parseArgs(['--require-coverage=oauth-core-runtime-parity,feishu-live-channel-lifecycle'])).toMatchObject({ + requireCoverage: ['oauth-core-runtime-parity', 'feishu-live-channel-lifecycle'], + }); + }); + + it('can evaluate replacement-readiness gates without overwriting saved reports', () => { + expect(shouldWriteReport(parseArgs([]))).toBe(true); + expect(shouldWriteReport(parseArgs(['--require-replacement-ready', '--no-write']))).toBe(false); + expect(shouldWriteHandoff(parseArgs(['--write-handoff']))).toBe(true); + expect(shouldWriteHandoff(parseArgs(['--write-handoff', '--no-write']))).toBe(false); + }); + + it('keeps package-level external gate scripts focused and non-destructive by default', async () => { + const packageJson = JSON.parse(await readFile(resolve('package.json'), 'utf8')); + const scripts = packageJson.scripts ?? {}; + + expect(scripts['verify:cc-connect:local-real:external-gates:check']).toBe([ + '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', + ].join(' ')); + expect(scripts['verify:cc-connect:local-real:external-gates']).toBe([ + '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', + ].join(' ')); + expect(scripts['verify:cc-connect:local-real:handoff']).toBe('node scripts/cc-connect-real-gate-handoff.mjs'); + }); + + it('prints sanitized console summary lines for no-write gate checks', () => { + const lines = toConsoleSummaryLines({ + missingPreconditions: [ + { + id: 'openai-api-key-env', + required: ['CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY'], + optional: ['CLAWX_REAL_OPENAI_MODEL'], + nextCommand: 'pnpm run verify:cc-connect:local-real:api-key', + note: 'Set key without exposing redacted-secret-value-that-must-not-leak', + }, + ], + replacementReadiness: { + missingCoverage: [ + { + id: 'openai-api-key-provider-model-chat', + status: 'skipped', + nextCommand: 'pnpm run verify:cc-connect:local-real:api-key', + reason: 'CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY is not configured.', + }, + ], + }, + nextActions: [ + { + id: 'configure-openai-api-key-env', + command: 'pnpm run verify:cc-connect:local-real:api-key', + reason: 'secret value should not be printed', + }, + ], + }); + + expect(lines).toEqual(expect.arrayContaining([ + 'Missing preconditions:', + 'Missing replacement coverage:', + 'Next actions:', + expect.stringContaining('openai-api-key-env'), + expect.stringContaining('CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY'), + expect.stringContaining('pnpm run verify:cc-connect:local-real:api-key'), + ])); + expect(lines.join('\n')).not.toContain('redacted-secret-value-that-must-not-leak'); + expect(lines.join('\n')).not.toContain('access_token'); + expect(lines.join('\n')).not.toContain('refresh_token'); + }); + + it('redacts local absolute paths from persisted verifier evidence', () => { + const report = sanitizeReportPaths({ + command: 'pnpm run smoke -- --app=/workspace/clawx/release/mac/ClawX.app', + details: { + bundle: '/workspace/clawx/build/cc-connect/darwin-arm64/cc-connect', + auth: '/Users/example/.codex/auth.json', + temp: ['/private/var/folders/test/runtime/config.toml'], + source: 'https://github.com/example/release', + }, + }, { + repoRoot: '/workspace/clawx', + homeDir: '/Users/example', + tempDir: '/var/folders/test', + }); + + expect(report).toEqual({ + command: 'pnpm run smoke -- --app=/release/mac/ClawX.app', + details: { + bundle: '/build/cc-connect/darwin-arm64/cc-connect', + auth: '/.codex/auth.json', + temp: ['/runtime/config.toml'], + source: 'https://github.com/example/release', + }, + }); + }); + + it('can focus console summary coverage on selected external gates', () => { + const lines = toConsoleSummaryLines({ + missingPreconditions: [], + replacementReadiness: { + missingCoverage: [ + { + id: 'codex-oauth-host-api-lifecycle-local', + status: 'not-run', + nextCommand: 'pnpm run verify:cc-connect:local-real:all-strict', + reason: 'Command was not requested.', + }, + { + id: 'openai-api-key-provider-model-chat', + status: 'skipped', + nextCommand: 'pnpm run verify:cc-connect:local-real:api-key', + reason: 'CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY is not configured.', + }, + ], + }, + nextActions: [ + { + id: 'verify-codex-oauth-host-api-lifecycle-local', + type: 'coverage', + command: 'pnpm run verify:cc-connect:local-real:all-strict', + }, + { + id: 'verify-openai-api-key-provider-model-chat', + type: 'coverage', + command: 'pnpm run verify:cc-connect:local-real:api-key', + }, + { + id: 'configure-openai-api-key-env', + type: 'precondition', + command: 'pnpm run verify:cc-connect:local-real:api-key', + }, + { + id: 'upstream-documented-per-platform-channel-connect-disconnect', + type: 'upstream-gap', + command: 'Track upstream cc-connect support.', + }, + ], + }, { + focusCoverageIds: ['openai-api-key-provider-model-chat'], + }); + + expect(lines.join('\n')).toContain('openai-api-key-provider-model-chat'); + expect(lines.join('\n')).toContain('configure-openai-api-key-env'); + expect(lines.join('\n')).not.toContain('codex-oauth-host-api-lifecycle-local'); + expect(lines.join('\n')).not.toContain('upstream-documented-per-platform-channel-connect-disconnect'); + }); + + it('ignores pnpm-style bare argument separators', () => { + expect(parseArgs(['--', '--run', '--include-oauth', '--include-scheduled-cron'])).toMatchObject({ + run: true, + includeOAuth: true, + includeScheduledCron: true, + }); + }); + + it('expands all required coverage ids deterministically', () => { + expect(requiredCoverageIds(['all'])).toEqual(COVERAGE_IDS); + expect(requiredCoverageIds(['oauth-core-runtime-parity', 'oauth-core-runtime-parity,packaged-oauth-runtime-smoke'])) + .toEqual(['oauth-core-runtime-parity', 'packaged-oauth-runtime-smoke']); + expect(REPLACEMENT_REQUIRED_COVERAGE_IDS).not.toContain('provider-model-profile-local-diagnostics'); + expect(REPLACEMENT_REQUIRED_COVERAGE_IDS).toContain('token-usage-contract-local-diagnostics'); + expect(REPLACEMENT_REQUIRED_COVERAGE_IDS).not.toContain('runtime-management-bundle-local-diagnostics'); + expect(COVERAGE_IDS).toContain('bridge-media-packets-local-diagnostics'); + expect(REPLACEMENT_REQUIRED_COVERAGE_IDS).not.toContain('bridge-media-packets-local-diagnostics'); + expect(COVERAGE_IDS).toContain('bridge-media-send-real-bundle'); + expect(REPLACEMENT_REQUIRED_COVERAGE_IDS).not.toContain('bridge-media-send-real-bundle'); + expect(COVERAGE_IDS).toContain('bridge-rich-packets-local-diagnostics'); + expect(REPLACEMENT_REQUIRED_COVERAGE_IDS).not.toContain('bridge-rich-packets-local-diagnostics'); + expect(COVERAGE_IDS).toContain('bridge-rich-progress-real-bundle'); + expect(REPLACEMENT_REQUIRED_COVERAGE_IDS).not.toContain('bridge-rich-progress-real-bundle'); + expect(COVERAGE_IDS).toContain('bridge-rich-card-action-real-bundle'); + expect(REPLACEMENT_REQUIRED_COVERAGE_IDS).not.toContain('bridge-rich-card-action-real-bundle'); + expect(COVERAGE_IDS).toContain('bridge-runtime-choice-real-bundle'); + expect(REPLACEMENT_REQUIRED_COVERAGE_IDS).not.toContain('bridge-runtime-choice-real-bundle'); + expect(COVERAGE_IDS).toContain('session-history-parity-local-diagnostics'); + expect(REPLACEMENT_REQUIRED_COVERAGE_IDS).toContain('session-history-parity-local-diagnostics'); + expect(REPLACEMENT_REQUIRED_COVERAGE_IDS).not.toContain('channel-lifecycle-local-bundle'); + expect(REPLACEMENT_REQUIRED_COVERAGE_IDS).toContain('channel-cron-command-local-diagnostics'); + expect(REPLACEMENT_REQUIRED_COVERAGE_IDS).not.toContain('cron-lifecycle-local-bundle'); + expect(REPLACEMENT_REQUIRED_COVERAGE_IDS).not.toContain('local-openai-compatible-api-key-chat'); + expect(COVERAGE_IDS).toContain('generated-file-card-real-oauth'); + expect(REPLACEMENT_REQUIRED_COVERAGE_IDS).not.toContain('generated-file-card-real-oauth'); + expect(REPLACEMENT_REQUIRED_COVERAGE_IDS).toContain('chat-abort-local-openai-compatible'); + expect(REPLACEMENT_REQUIRED_COVERAGE_IDS).toContain('codex-oauth-lifecycle-local-diagnostics'); + expect(REPLACEMENT_REQUIRED_COVERAGE_IDS).toContain('codex-oauth-host-api-lifecycle-local'); + expect(REPLACEMENT_REQUIRED_COVERAGE_IDS).toContain('operation-capabilities-local-diagnostics'); + expect(REPLACEMENT_REQUIRED_COVERAGE_IDS).toContain('feishu-live-inbound-delivery'); + }); + + it('passes only when every requested coverage row is pass', () => { + expect(requiredCoverageCheck(coverageRows({}), ['all'])).toMatchObject({ + id: 'required-coverage', + status: 'pass', + }); + + expect(requiredCoverageCheck(coverageRows({ + 'openai-api-key-provider-model-chat': 'skipped', + }), ['all'])).toMatchObject({ + id: 'required-coverage', + status: 'fail', + details: { + missing: [ + expect.objectContaining({ + id: 'openai-api-key-provider-model-chat', + status: 'skipped', + reason: 'openai-api-key-provider-model-chat skipped', + }), + ], + }, + }); + }); + + it('summarizes retried command attempts without hiding the failed attempt', () => { + const result = summarizeCommandAttempts('pnpm', ['run', 'test:e2e:cc-connect:real-comprehensive'], [ + { + command: 'pnpm run test:e2e:cc-connect:real-comprehensive', + status: 'fail', + exitCode: 1, + durationMs: 100, + }, + { + command: 'pnpm run test:e2e:cc-connect:real-comprehensive', + status: 'pass', + exitCode: 0, + durationMs: 200, + }, + ]); + + expect(result).toMatchObject({ + command: 'pnpm run test:e2e:cc-connect:real-comprehensive', + status: 'pass', + exitCode: 0, + durationMs: 300, + reason: 'Passed after 2 attempts; 1 previous attempt(s) failed.', + attempts: [ + expect.objectContaining({ status: 'fail', exitCode: 1 }), + expect.objectContaining({ status: 'pass', exitCode: 0 }), + ], + }); + }); + + it('fails unknown requested coverage ids instead of silently accepting them', () => { + expect(requiredCoverageCheck(coverageRows({}), ['not-a-real-coverage-id'])).toMatchObject({ + id: 'required-coverage', + status: 'fail', + details: { + unknown: ['not-a-real-coverage-id'], + missing: [ + expect.objectContaining({ + id: 'not-a-real-coverage-id', + status: 'not-run', + }), + ], + }, + }); + }); + + it('maps command records into runtime parity coverage rows', () => { + const report = { + checks: [ + { + id: 'current-runtime-bundles', + status: 'pass', + message: 'bundles ready', + }, + { + id: 'local-validation-commands', + status: 'pass', + details: { + commands: [ + { + command: 'pnpm run test:e2e -- tests/e2e/cc-connect-real-openai-api-key.spec.ts tests/e2e/cc-connect-real-feishu-channel.spec.ts', + status: 'pass', + }, + { + command: 'pnpm exec vitest run tests/unit/cc-connect-provider-profile.test.ts tests/unit/cc-connect-runtime-provider.test.ts tests/unit/cc-connect-bridge-adapter.test.ts', + status: 'pass', + }, + { + command: 'pnpm exec vitest run tests/unit/runtime-rpc-contract.test.ts tests/unit/runtime-operation-capabilities.test.ts tests/unit/channel-store-operation-capabilities.test.ts', + status: 'pass', + }, + { + command: 'pnpm run test:e2e:cc-connect:codex-oauth-lifecycle', + status: 'pass', + }, + { + command: 'pnpm exec vitest run tests/unit/cc-connect-local-real-verifier.test.ts tests/unit/e2e-local-real-env.test.ts', + status: 'pass', + }, + { + command: 'pnpm exec vitest run tests/unit/token-usage.test.ts tests/unit/token-usage-scan.test.ts tests/unit/runtime-usage.test.ts tests/unit/usage-api.test.ts tests/unit/openclaw-runtime-provider.test.ts tests/unit/cc-connect-runtime-provider.test.ts', + status: 'pass', + }, + { + command: 'pnpm run test:e2e -- tests/e2e/token-usage.spec.ts', + status: 'pass', + }, + { + command: 'pnpm run test:e2e -- tests/e2e/cc-connect-real-bundle-smoke.spec.ts', + status: 'pass', + }, + { + command: 'pnpm run test:e2e:cc-connect:real-comprehensive', + status: 'pass', + }, + { + command: 'pnpm run test:e2e:cc-connect:real-scheduled-cron', + status: 'pass', + coverageAliases: ['test:e2e:cc-connect:real-scheduled-prompt-cron'], + }, + { + command: 'pnpm run test:e2e:cc-connect:real-openai-api-key', + status: 'skipped', + reason: 'OPENAI_API_KEY is not configured.', + }, + { + command: 'pnpm run smoke:cc-connect:packaged -- --app=/tmp/ClawX.app --real-oauth=1', + status: 'pass', + }, + ], + }, + }, + ], + }; + + expect(buildCoverage(report)).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: 'runtime-bundles-current-platform', + status: 'pass', + evidence: 'bundles ready', + }), + expect.objectContaining({ + id: 'provider-model-profile-local-diagnostics', + status: 'pass', + evidence: 'pnpm exec vitest run tests/unit/cc-connect-provider-profile.test.ts tests/unit/cc-connect-runtime-provider.test.ts tests/unit/cc-connect-bridge-adapter.test.ts', + covers: expect.arrayContaining([ + 'browser OAuth re-login secret precedence over stale same-account managed auth', + 'Codex-refreshed managed auth precedence during ordinary runtime start', + ]), + }), + expect.objectContaining({ + id: 'runtime-boundary-bridgeplatform-only', + covers: expect.arrayContaining([ + 'Electron Host API provider sync replaces stale same-account managed OAuth after browser re-login', + 'ordinary runtime start preserves Codex-refreshed managed OAuth over an older vault snapshot', + ]), + }), + expect.objectContaining({ + id: 'codex-oauth-lifecycle-local-diagnostics', + status: 'pass', + evidence: 'pnpm exec vitest run tests/unit/cc-connect-local-real-verifier.test.ts tests/unit/e2e-local-real-env.test.ts', + covers: expect.arrayContaining([ + 'explicit auth import requirement', + 'complete Codex token field requirement', + 'expired access-token refresh through isolated real execution', + ]), + }), + expect.objectContaining({ + id: 'codex-oauth-host-api-lifecycle-local', + status: 'pass', + evidence: 'pnpm run test:e2e:cc-connect:codex-oauth-lifecycle', + covers: expect.arrayContaining([ + 'Electron Host API providers.importCodexOAuth', + 'provider OAuth secret cleanup', + 'token redaction in Host API responses and public provider profile', + ]), + }), + expect.objectContaining({ + id: 'operation-capabilities-local-diagnostics', + status: 'pass', + evidence: 'pnpm exec vitest run tests/unit/runtime-rpc-contract.test.ts tests/unit/runtime-operation-capabilities.test.ts tests/unit/channel-store-operation-capabilities.test.ts && pnpm run test:e2e -- tests/e2e/cc-connect-real-bundle-smoke.spec.ts', + covers: expect.arrayContaining([ + 'renderer fail-closed behavior for undeclared operations after status publication', + 'channel add and QR entry points stop before runtime RPC when explicitly unsupported', + 'real bundled cc-connect operation capabilities exposed through runtime status', + ]), + }), + expect.objectContaining({ + id: 'token-usage-contract-local-diagnostics', + status: 'partial', + evidence: 'pnpm exec vitest run tests/unit/token-usage.test.ts tests/unit/token-usage-scan.test.ts tests/unit/runtime-usage.test.ts tests/unit/usage-api.test.ts tests/unit/openclaw-runtime-provider.test.ts tests/unit/cc-connect-runtime-provider.test.ts && pnpm run test:e2e -- tests/e2e/token-usage.spec.ts && pnpm run test:e2e -- tests/e2e/cc-connect-real-bundle-smoke.spec.ts', + covers: expect.arrayContaining([ + 'RuntimeProvider.listUsage ownership for OpenClaw and cc-connect', + 'cache tokens remain an input subset and are not added twice to inferred totals', + 'cc-connect private session-store exclusion', + 'managed and user-global Codex transcript exclusion', + 'real bundled cc-connect Host API and GUI missing-usage evidence', + 'runtimeKind filtering without OpenClaw data leakage', + ]), + }), + expect.objectContaining({ + id: 'runtime-management-bundle-local-diagnostics', + status: 'pass', + evidence: 'pnpm run test:e2e -- tests/e2e/cc-connect-real-bundle-smoke.spec.ts', + covers: expect.arrayContaining([ + 'Management API sessions/providers/models across managed projects', + 'read-only Host API provider/model profiles without runtime restart', + 'provider/model Management response field allowlist without secret pass-through', + 'managed cc-connect user-isolation plus Codex doctor JSON audit', + ]), + }), + expect.objectContaining({ + id: 'bridge-media-packets-local-diagnostics', + status: 'pass', + evidence: 'pnpm exec vitest run tests/unit/cc-connect-provider-profile.test.ts tests/unit/cc-connect-runtime-provider.test.ts tests/unit/cc-connect-bridge-adapter.test.ts', + covers: expect.arrayContaining([ + 'BridgePlatform image packet to renderer attached file', + 'BridgePlatform file packet to renderer attached file', + 'BridgePlatform audio packet to renderer attached file', + 'BridgePlatform video packet to renderer attached file', + 'cc-connect managed media directory writes', + ]), + }), + expect.objectContaining({ + id: 'bridge-media-send-real-bundle', + status: 'pass', + evidence: 'pnpm run test:e2e -- tests/e2e/cc-connect-real-openai-api-key.spec.ts tests/e2e/cc-connect-real-feishu-channel.spec.ts', + covers: expect.arrayContaining([ + 'real bundled cc-connect send CLI against an active managed session', + 'public Bridge image/file/audio/video packets', + 'Host API session history attachment merge', + 'GUI image preview plus PDF/audio/video file cards', + ]), + }), + expect.objectContaining({ + id: 'bridge-rich-packets-local-diagnostics', + status: 'pass', + evidence: 'pnpm exec vitest run tests/unit/cc-connect-provider-profile.test.ts tests/unit/cc-connect-runtime-provider.test.ts tests/unit/cc-connect-bridge-adapter.test.ts', + covers: expect.arrayContaining([ + 'BridgePlatform card packet to shared assistant message', + 'BridgePlatform buttons packet to shared assistant message', + 'BridgePlatform preview_start acknowledgement', + 'BridgePlatform update_message assistant delta', + 'BridgePlatform text preview delete clears transient assistant content', + ]), + }), + expect.objectContaining({ + id: 'bridge-rich-progress-real-bundle', + status: 'pass', + evidence: 'pnpm run test:e2e -- tests/e2e/cc-connect-real-bundle-smoke.spec.ts', + covers: expect.arrayContaining([ + 'real bundled cc-connect v1.4.1 engine process', + 'public Bridge preview_start and update_message packets observed in runtime diagnostics', + 'GUI execution graph and final assistant reply', + ]), + }), + expect.objectContaining({ + id: 'bridge-rich-card-action-real-bundle', + status: 'pass', + evidence: 'pnpm run test:e2e -- tests/e2e/cc-connect-real-bundle-smoke.spec.ts', + covers: expect.arrayContaining([ + 'real bundled cc-connect Bridge card packet from /cron list', + 'real card action values emitted by cc-connect', + 'card_action disable callback observed through Host API', + 'card_action enable callback observed through Host API', + 'card_action delete callback observed through Host API', + ]), + }), + expect.objectContaining({ + id: 'bridge-runtime-choice-real-bundle', + status: 'pass', + evidence: 'pnpm run test:e2e -- tests/e2e/cc-connect-real-bundle-smoke.spec.ts', + covers: expect.arrayContaining([ + 'real bundled cc-connect /lang card rendered as a shared runtime choice', + 'GUI action returned through the public card_action packet', + 'live language state verified through the public Management project API', + ]), + }), + expect.objectContaining({ + id: 'channel-lifecycle-local-bundle', + status: 'pass', + evidence: 'pnpm run test:e2e -- tests/e2e/cc-connect-real-bundle-smoke.spec.ts', + covers: expect.arrayContaining([ + 'Host API channels.connect through cc-connect runtime', + 'Host API channels.disconnect through cc-connect runtime', + 'Feishu/Lark local config projection', + 'Feishu/Lark agent binding and workspace projection', + 'real user channel credential removal', + ]), + }), + expect.objectContaining({ + id: 'channel-cron-command-local-diagnostics', + status: 'pass', + evidence: 'pnpm run test:e2e -- tests/e2e/cc-connect-real-bundle-smoke.spec.ts', + covers: expect.arrayContaining([ + 'managed admin identity for Channel Cron mutation commands', + 'Channel /cron add observed through Host API Cron list', + 'GUI announce Cron observed through Channel /cron list for the same Feishu target', + 'Channel /cron disable and enable observed through Host API', + 'Channel /cron delete observed through Host API', + 'single native cc-connect scheduler and unchanged runtime PID', + 'real cc-connect /cron card packet and actionable disable/enable/delete callbacks', + 'usable text fallback for the /cron add acknowledgement', + ]), + }), + expect.objectContaining({ + id: 'cron-lifecycle-local-bundle', + status: 'pass', + evidence: 'pnpm run test:e2e -- tests/e2e/cc-connect-real-bundle-smoke.spec.ts', + covers: expect.arrayContaining([ + 'Management API cron create/list/update/toggle/delete', + 'non-main agent project routing', + 'exec cron field mapping', + 'non-cron schedule unsupported/error semantics', + 'manual exec run unsupported/error semantics', + ]), + }), + expect.objectContaining({ + id: 'oauth-core-runtime-parity', + status: 'pass', + }), + expect.objectContaining({ + id: 'generated-file-card-real-oauth', + status: 'pass', + evidence: 'pnpm run test:e2e:cc-connect:real-comprehensive', + covers: expect.arrayContaining([ + 'real Codex apply_patch tool turn', + 'run-correlated cc-connect Bridge tool lifecycle', + 'generated-file card rendered in GUI chat', + ]), + }), + expect.objectContaining({ + id: 'scheduled-cron-delivery-local-bundle', + status: 'pass', + evidence: 'pnpm run test:e2e:cc-connect:real-scheduled-cron', + covers: expect.arrayContaining([ + 'real cc-connect scheduler tick', + 'enabled exec cron delivery', + ]), + }), + expect.objectContaining({ + id: 'scheduled-prompt-cron-delivery-local-bundle', + status: 'pass', + evidence: 'pnpm run test:e2e:cc-connect:real-scheduled-cron', + covers: expect.arrayContaining([ + 'real cc-connect prompt cron creation', + 'ClawX fallback delivery through cc-connect BridgePlatform', + 'cc-connect session history after scheduled prompt delivery', + ]), + }), + expect.objectContaining({ + id: 'local-openai-compatible-api-key-chat', + status: 'pass', + evidence: 'pnpm run test:e2e -- tests/e2e/cc-connect-real-openai-api-key.spec.ts tests/e2e/cc-connect-real-feishu-channel.spec.ts', + covers: expect.arrayContaining([ + 'OpenAI API-key provider with custom baseUrl', + 'chat through real cc-connect and bundled Codex', + ]), + }), + expect.objectContaining({ + id: 'chat-abort-local-openai-compatible', + status: 'pass', + evidence: 'pnpm run test:e2e -- tests/e2e/cc-connect-real-openai-api-key.spec.ts tests/e2e/cc-connect-real-feishu-channel.spec.ts', + covers: expect.arrayContaining([ + 'GUI Stop button through Host API chat.abort', + 'session-scoped cc-connect BridgePlatform /stop cancellation', + 'upstream stream closure before completion release', + 'late assistant output suppression', + 'unchanged cc-connect PID and runtime recovery to running state', + ]), + }), + expect.objectContaining({ + id: 'openai-api-key-provider-model-chat', + status: 'skipped', + reason: 'OPENAI_API_KEY is not configured.', + }), + expect.objectContaining({ + id: 'feishu-live-channel-lifecycle', + status: 'not-run', + reason: 'Command was not requested.', + }), + expect.objectContaining({ + id: 'feishu-live-inbound-delivery', + status: 'not-run', + reason: 'Command was not requested.', + covers: expect.arrayContaining([ + 'sanitized Feishu/Lark inbound marker handoff artifact', + 'real Feishu/Lark tenant message sent by a sandbox chat', + ]), + }), + expect.objectContaining({ + id: 'packaged-oauth-runtime-smoke', + status: 'pass', + evidence: 'pnpm run smoke:cc-connect:packaged -- --app=/tmp/ClawX.app --real-oauth=1', + covers: expect.arrayContaining([ + 'packaged cc-connect manifest and source sha256 integrity', + 'packaged Codex manifest and source sha256 integrity', + 'packaged signed executable version checks', + 'packaged Codex ripgrep helper executable', + 'packaged GUI Chat through cc-connect and the managed Codex OAuth launcher', + ]), + }), + ])); + }); + + it('backfills newly inferred coverage rows when reading older reports', () => { + const report = { + coverage: [ + { + id: 'oauth-core-runtime-parity', + status: 'pass', + covers: ['chat'], + evidence: 'previous report evidence', + reason: '', + }, + ], + checks: [ + { + id: 'local-validation-commands', + status: 'pass', + details: { + commands: [ + { + command: 'pnpm exec vitest run tests/unit/cc-connect-provider-profile.test.ts tests/unit/cc-connect-runtime-provider.test.ts tests/unit/cc-connect-bridge-adapter.test.ts', + status: 'pass', + }, + { + command: 'pnpm run test:e2e:cc-connect:real-comprehensive', + status: 'pass', + }, + ], + }, + }, + ], + }; + + expect(coverageRecords(report)).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: 'oauth-core-runtime-parity', + evidence: 'previous report evidence', + }), + expect.objectContaining({ + id: 'generated-file-card-real-oauth', + status: 'pass', + evidence: 'pnpm run test:e2e:cc-connect:real-comprehensive', + }), + expect.objectContaining({ + id: 'bridge-media-packets-local-diagnostics', + status: 'pass', + evidence: 'pnpm exec vitest run tests/unit/cc-connect-provider-profile.test.ts tests/unit/cc-connect-runtime-provider.test.ts tests/unit/cc-connect-bridge-adapter.test.ts', + }), + expect.objectContaining({ + id: 'bridge-media-send-real-bundle', + status: 'not-run', + evidence: 'Command was not requested.', + }), + expect.objectContaining({ + id: 'bridge-rich-packets-local-diagnostics', + status: 'pass', + evidence: 'pnpm exec vitest run tests/unit/cc-connect-provider-profile.test.ts tests/unit/cc-connect-runtime-provider.test.ts tests/unit/cc-connect-bridge-adapter.test.ts', + }), + expect.objectContaining({ + id: 'bridge-rich-progress-real-bundle', + status: 'not-run', + evidence: 'Command was not requested.', + }), + ])); + }); + + it('marks credential-gated coverage skipped when preconditions are missing even if commands were not requested', () => { + const report = { + checks: [ + { + id: 'current-runtime-bundles', + status: 'pass', + message: 'bundles ready', + }, + ], + missingPreconditions: [ + { + id: 'openai-api-key-env', + note: 'Set CLAWX_REAL_OPENAI_API_KEY before running the real OpenAI API-key smoke.', + }, + { + id: 'feishu-env', + note: 'Set Feishu/Lark credentials before running the real channel smoke.', + }, + { + id: 'feishu-inbound-fixture', + note: 'Set CLAWX_REAL_FEISHU_INBOUND_E2E=1 before running the real inbound smoke.', + }, + ], + }; + + expect(buildCoverage(report)).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: 'openai-api-key-provider-model-chat', + status: 'skipped', + reason: 'Set CLAWX_REAL_OPENAI_API_KEY before running the real OpenAI API-key smoke.', + }), + expect.objectContaining({ + id: 'feishu-live-channel-lifecycle', + status: 'skipped', + reason: 'Set Feishu/Lark credentials before running the real channel smoke.', + }), + expect.objectContaining({ + id: 'feishu-live-inbound-delivery', + status: 'skipped', + reason: 'Set Feishu/Lark credentials before running the real channel smoke.', + covers: expect.arrayContaining([ + 'sanitized Feishu/Lark inbound marker handoff artifact', + 'managed cc-connect session store records the inbound marker', + ]), + }), + ])); + }); + + it('keeps credential-gated coverage not-run when credentials are present but the command was not requested', () => { + expect(buildCoverage({ + checks: [ + { + id: 'current-runtime-bundles', + status: 'pass', + message: 'bundles ready', + }, + ], + missingPreconditions: [], + })).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: 'openai-api-key-provider-model-chat', + status: 'not-run', + reason: 'Command was not requested.', + }), + expect.objectContaining({ + id: 'feishu-live-channel-lifecycle', + status: 'not-run', + reason: 'Command was not requested.', + }), + expect.objectContaining({ + id: 'feishu-live-inbound-delivery', + status: 'not-run', + reason: 'Command was not requested.', + }), + ])); + }); + + it('classifies local verifier env files without exposing absolute paths', async () => { + expect(isPathInsideRoot(resolve('.env.cc-connect.local'))).toBe(true); + expect(isPathInsideRoot(join(tmpdir(), 'clawx-real-env.local'))).toBe(false); + + await expect(localEnvFileSafety(resolve('.env.cc-connect.local'))).resolves.toEqual({ + location: 'repo', + gitignored: true, + tracked: false, + safe: true, + }); + await expect(localEnvFileSafety(resolve('package.json'))).resolves.toEqual({ + location: 'repo', + gitignored: false, + tracked: true, + safe: false, + }); + await expect(localEnvFileSafety(join(tmpdir(), 'clawx-real-env.local'))).resolves.toEqual({ + location: 'outside-repo', + gitignored: true, + tracked: false, + safe: true, + }); + }); + + it('loads explicit verifier env files from CLAWX_REAL_ENV_FILE variables without overriding process env', async () => { + const firstDir = await mkdtemp(join(tmpdir(), 'clawx-verifier-env-first-')); + const secondDir = await mkdtemp(join(tmpdir(), 'clawx-verifier-env-second-')); + const originalSingle = process.env.CLAWX_REAL_ENV_FILE; + const originalMultiple = process.env.CLAWX_REAL_ENV_FILES; + const originalKey = process.env.CLAWX_REAL_OPENAI_API_KEY; + const originalFeishuDomain = process.env.CLAWX_REAL_FEISHU_DOMAIN; + const originalFeishuAppId = process.env.CLAWX_REAL_FEISHU_APP_ID; + try { + const firstFile = join(firstDir, 'real-one.env'); + const secondFile = join(secondDir, 'real-two.env'); + await writeFile(firstFile, [ + 'CLAWX_REAL_OPENAI_API_KEY=from-file', + 'CLAWX_REAL_FEISHU_DOMAIN=lark', + ].join('\n'), 'utf8'); + await writeFile(secondFile, [ + 'CLAWX_REAL_FEISHU_APP_ID=app-from-second', + ].join('\n'), 'utf8'); + + process.env.CLAWX_REAL_ENV_FILE = firstFile; + process.env.CLAWX_REAL_ENV_FILES = `${delimiter}${secondFile}${delimiter}${firstFile}`; + process.env.CLAWX_REAL_OPENAI_API_KEY = 'from-process'; + delete process.env.CLAWX_REAL_FEISHU_DOMAIN; + delete process.env.CLAWX_REAL_FEISHU_APP_ID; + + expect(extraLocalRealEnvFiles(process.env)).toEqual([firstFile, secondFile]); + const { env, summaries } = await loadLocalEnvFiles({ envFiles: [] }); + + expect(env).toMatchObject({ + CLAWX_REAL_FEISHU_DOMAIN: 'lark', + CLAWX_REAL_FEISHU_APP_ID: 'app-from-second', + }); + expect(env.CLAWX_REAL_OPENAI_API_KEY).toBeUndefined(); + expect(summaries).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'real-one.env', + loaded: true, + variableNames: ['CLAWX_REAL_FEISHU_DOMAIN', 'CLAWX_REAL_OPENAI_API_KEY'], + safety: expect.objectContaining({ location: 'outside-repo', safe: true }), + }), + expect.objectContaining({ + name: 'real-two.env', + loaded: true, + variableNames: ['CLAWX_REAL_FEISHU_APP_ID'], + safety: expect.objectContaining({ location: 'outside-repo', safe: true }), + }), + ])); + } finally { + if (originalSingle === undefined) delete process.env.CLAWX_REAL_ENV_FILE; + else process.env.CLAWX_REAL_ENV_FILE = originalSingle; + if (originalMultiple === undefined) delete process.env.CLAWX_REAL_ENV_FILES; + else process.env.CLAWX_REAL_ENV_FILES = originalMultiple; + if (originalKey === undefined) delete process.env.CLAWX_REAL_OPENAI_API_KEY; + else process.env.CLAWX_REAL_OPENAI_API_KEY = originalKey; + if (originalFeishuDomain === undefined) delete process.env.CLAWX_REAL_FEISHU_DOMAIN; + else process.env.CLAWX_REAL_FEISHU_DOMAIN = originalFeishuDomain; + if (originalFeishuAppId === undefined) delete process.env.CLAWX_REAL_FEISHU_APP_ID; + else process.env.CLAWX_REAL_FEISHU_APP_ID = originalFeishuAppId; + await Promise.all([ + rm(firstDir, { recursive: true, force: true }), + rm(secondDir, { recursive: true, force: true }), + ]); + } + }); + + it('does not load unsafe repo-local verifier env files', async () => { + const unsafeEnvFile = 'clawx-unsafe-real-env.tmp'; + try { + await writeFile(unsafeEnvFile, [ + 'CLAWX_REAL_OPENAI_API_KEY=unsafe-secret-value', + 'CLAWX_REAL_FEISHU_APP_ID=unsafe-app-id', + ].join('\n'), 'utf8'); + + const result = await loadLocalEnvFiles({ + envFiles: [unsafeEnvFile], + }); + + expect(result.env).not.toHaveProperty('CLAWX_REAL_OPENAI_API_KEY'); + expect(result.env).not.toHaveProperty('CLAWX_REAL_FEISHU_APP_ID'); + expect(result.summaries).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: unsafeEnvFile, + exists: true, + loaded: false, + variableNames: [], + safety: expect.objectContaining({ + location: 'repo', + safe: false, + }), + skippedReason: expect.stringContaining('untracked and gitignored'), + }), + ])); + } finally { + await rm(unsafeEnvFile, { force: true }); + } + }); + + it('resolves ClawX-specific OpenAI API key env before the standard name', () => { + expect(resolveOpenAiApiKeyEnv({ + CLAWX_REAL_OPENAI_API_KEY: ' clawx-key ', + OPENAI_API_KEY: 'standard-key', + })).toEqual({ + source: 'CLAWX_REAL_OPENAI_API_KEY', + value: 'clawx-key', + childEnv: { + OPENAI_API_KEY: 'clawx-key', + }, + }); + + expect(resolveOpenAiApiKeyEnv({ + OPENAI_API_KEY: ' standard-key ', + })).toEqual({ + source: 'OPENAI_API_KEY', + value: 'standard-key', + childEnv: { + OPENAI_API_KEY: 'standard-key', + }, + }); + + expect(resolveOpenAiApiKeyEnv({})).toEqual({ + source: '', + value: '', + childEnv: {}, + }); + }); + + it('can resolve OpenAI API key from Codex auth only after explicit env keys', () => { + expect(resolveOpenAiApiKeyEnv({ + OPENAI_API_KEY: ' standard-key ', + }, { + source: 'default Codex auth.json OPENAI_API_KEY', + value: ' auth-key ', + })).toEqual({ + source: 'OPENAI_API_KEY', + value: 'standard-key', + childEnv: { + OPENAI_API_KEY: 'standard-key', + }, + }); + + expect(resolveOpenAiApiKeyEnv({}, { + source: 'default Codex auth.json OPENAI_API_KEY', + value: ' auth-key ', + })).toEqual({ + source: 'default Codex auth.json OPENAI_API_KEY', + value: 'auth-key', + childEnv: { + OPENAI_API_KEY: 'auth-key', + }, + }); + + expect(resolveOpenAiApiKeyEnv({}, { + source: 'default Codex auth.json OPENAI_API_KEY', + value: null, + })).toEqual({ + source: '', + value: '', + childEnv: {}, + }); + }); + + it('summarizes Codex auth OpenAI API key candidates without exposing values', () => { + expect(openAiApiKeyCandidateSummary(' sk-redacted ')).toEqual({ + present: true, + usable: true, + valueType: 'string', + length: 'sk-redacted'.length, + reason: 'non-empty string', + }); + expect(openAiApiKeyCandidateSummary(null)).toEqual({ + present: true, + usable: false, + valueType: 'null', + length: 0, + reason: 'must be a non-empty string', + }); + expect(openAiApiKeyCandidateSummary(undefined)).toEqual({ + present: false, + usable: false, + valueType: 'undefined', + length: 0, + reason: 'missing', + }); + }); + + it('explains OpenAI API key precondition state without implying unusable metadata is a real key', () => { + expect(openAiApiKeyPreconditionMessage(true, true, openAiApiKeyCandidateSummary(undefined))) + .toBe('OpenAI API key real E2E environment is configured and selected for this run.'); + expect(openAiApiKeyPreconditionMessage(true, false, openAiApiKeyCandidateSummary(undefined))) + .toBe('OpenAI API key real E2E environment is configured; the real API-key E2E was not requested in this run.'); + expect(openAiApiKeyPreconditionMessage(false, true, openAiApiKeyCandidateSummary(undefined))) + .toBe('OpenAI API key real E2E environment is not configured.'); + expect(openAiApiKeyPreconditionMessage(false, true, openAiApiKeyCandidateSummary(' '))) + .toBe('Codex auth metadata contains an empty OPENAI_API_KEY; configure CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY for real API-key validation.'); + expect(openAiApiKeyPreconditionMessage(false, true, openAiApiKeyCandidateSummary(null))) + .toBe('Codex auth metadata contains OPENAI_API_KEY as null; configure CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY with a non-empty string for real API-key validation.'); + }); + + it('separates missing credentials from coverage paths that were not requested', () => { + const authSummary = { + exists: true, + hasTokens: true, + completeTokens: true, + tokenKeys: ['access_token', 'account_id', 'id_token', 'refresh_token'], + expired: false, + }; + + expect(missingPreconditions({ + authSummary, + authImportExplicit: true, + openAiConfigured: true, + feishuConfigured: true, + feishuInboundConfigured: true, + appPath: '/tmp/ClawX.app', + packagedExecutableExists: true, + })).toEqual([]); + + expect(missingPreconditions({ + authSummary, + authImportExplicit: true, + openAiConfigured: false, + feishuConfigured: false, + feishuInboundConfigured: false, + appPath: '/tmp/ClawX.app', + packagedExecutableExists: true, + })).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: 'openai-api-key-env', + required: ['CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY'], + optional: ['CLAWX_REAL_OPENAI_MODEL'], + nextCommand: 'pnpm run verify:cc-connect:local-real:api-key', + note: expect.stringContaining('CLAWX_REAL_OPENAI_MODEL'), + }), + expect.objectContaining({ + id: 'feishu-env', + required: [ + 'CLAWX_REAL_FEISHU_APP_ID', + 'CLAWX_REAL_FEISHU_APP_SECRET', + 'CLAWX_REAL_FEISHU_ADMIN_FROM', + ], + nextCommand: 'pnpm run verify:cc-connect:local-real:feishu', + }), + ])); + }); + + it('requires explicit Codex auth import before real OAuth paths can run', () => { + expect(missingPreconditions({ + authSummary: { + exists: true, + hasTokens: true, + completeTokens: true, + tokenKeys: ['access_token', 'refresh_token'], + expired: false, + }, + authImportExplicit: false, + openAiConfigured: true, + feishuConfigured: true, + feishuInboundConfigured: true, + appPath: '/tmp/ClawX.app', + packagedExecutableExists: true, + })).toEqual([ + expect.objectContaining({ + id: 'codex-oauth-auth-json', + required: ['CLAWX_REAL_CODEX_AUTH_JSON with a complete refresh-token set'], + nextCommand: 'pnpm run verify:cc-connect:local-real:oauth', + }), + ]); + }); + + it('summarizes Codex auth expiry without exposing token values', () => { + const now = Date.parse('2026-06-21T12:00:00.000Z'); + const jwt = (expiresAt: string) => [ + Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'), + Buffer.from(JSON.stringify({ exp: Math.floor(Date.parse(expiresAt) / 1000) })).toString('base64url'), + 'signature', + ].join('.'); + + expect(codexAuthExpirySummary({ + tokens: { + access_token: 'redacted', + expires_at: '2026-06-21T13:00:00.000Z', + }, + }, now)).toEqual({ + expiryStatus: 'valid', + expiresAt: '2026-06-21T13:00:00.000Z', + expired: false, + }); + + expect(codexAuthExpirySummary({ + tokens: { + access_token: 'redacted', + refresh_token: 'redacted', + nested: { expiry: Math.floor(Date.parse('2026-06-21T11:00:00.000Z') / 1000) }, + }, + }, now)).toEqual({ + expiryStatus: 'expired', + expiresAt: '2026-06-21T11:00:00.000Z', + expired: true, + }); + + expect(codexAuthExpirySummary({ + tokens: { access_token: 'redacted' }, + }, now)).toEqual({ + expiryStatus: 'unknown', + expiresAt: null, + expired: false, + }); + + const encodedToken = jwt('2026-06-21T10:00:00.000Z'); + const jwtSummary = codexAuthExpirySummary({ + tokens: { + access_token: encodedToken, + id_token: 'malformed-token', + refresh_token: 'must-not-appear', + }, + }, now); + expect(jwtSummary).toEqual({ + expiryStatus: 'expired', + expiresAt: '2026-06-21T10:00:00.000Z', + expired: true, + }); + expect(JSON.stringify(jwtSummary)).not.toContain(encodedToken); + expect(JSON.stringify(jwtSummary)).not.toContain('must-not-appear'); + }); + + it('allows a complete expired Codex auth set so real execution can prove refresh', () => { + expect(missingPreconditions({ + authSummary: { + exists: true, + hasTokens: true, + completeTokens: true, + tokenKeys: ['access_token', 'refresh_token'], + expired: true, + }, + authImportExplicit: true, + openAiConfigured: true, + feishuConfigured: true, + feishuInboundConfigured: true, + appPath: '/tmp/ClawX.app', + packagedExecutableExists: true, + })).toEqual([]); + }); + + it('treats incomplete Codex auth tokens as a missing real OAuth precondition', () => { + expect(missingPreconditions({ + authSummary: { + exists: true, + hasTokens: true, + completeTokens: false, + tokenKeys: ['access_token', 'refresh_token'], + missingTokenKeys: ['account_id', 'id_token'], + expired: false, + }, + authImportExplicit: true, + openAiConfigured: true, + feishuConfigured: true, + feishuInboundConfigured: true, + appPath: '/tmp/ClawX.app', + packagedExecutableExists: true, + })).toEqual([ + expect.objectContaining({ + id: 'codex-oauth-auth-json', + required: ['CLAWX_REAL_CODEX_AUTH_JSON with a complete refresh-token set'], + note: expect.stringContaining('account_id, id_token'), + }), + ]); + }); + + it('records cc-connect upstream CLI surface and unsupported primitives', () => { + const surface = analyzeCcConnectCliSurface({ + topHelp: [ + 'Commands:', + ' send', + ' cron', + ' sessions', + ' provider', + ' feishu', + ' config', + ].join('\n'), + cronAddHelp: [ + 'Usage: cc-connect cron add', + '--prompt ', + '--exec ', + '--session-mode ', + '--timeout-mins ', + ].join('\n'), + feishuHelp: [ + 'Commands:', + ' setup', + ' new', + ' bind', + '--platform-type ', + '--app-id ', + '--app-secret ', + ].join('\n'), + providerHelp: [ + 'cc-connect provider add', + 'cc-connect provider list', + 'cc-connect provider remove', + 'cc-connect provider import', + 'cc-connect provider presets', + 'cc-connect provider global list', + ].join('\n'), + sessionsHelp: [ + 'cc-connect sessions list', + 'cc-connect sessions show "#1"', + ].join('\n'), + configExample: [ + 'cc-connect doctor user-isolation', + '[[projects.platforms]]', + ].join('\n'), + }); + + expect(surface).toMatchObject({ + commands: { + send: true, + cron: true, + sessions: true, + provider: true, + feishu: true, + config: true, + doctorUserIsolation: true, + }, + cron: { + promptJobs: true, + execJobs: true, + sessionMode: true, + timeoutMins: true, + }, + feishu: { + setup: true, + bind: true, + new: true, + platformType: true, + appIdSecret: true, + }, + channelLifecycle: { + documentedConnectDisconnect: false, + documentedReloadStatus: true, + }, + missingPrimitives: [ + 'documented per-platform channel connect/disconnect', + ], + }); + }); + + it('summarizes replacement readiness from required runtime parity coverage', () => { + const ready = buildReplacementReadiness(coverageRows({})); + expect(ready).toMatchObject({ + status: 'ready', + replacementReady: true, + requiredCoverageIds: REPLACEMENT_REQUIRED_COVERAGE_IDS, + passedCoverageIds: REPLACEMENT_REQUIRED_COVERAGE_IDS, + missingCoverage: [], + nextCommands: [], + }); + expect(replacementReadinessCheck(ready)).toMatchObject({ + id: 'replacement-readiness', + status: 'pass', + }); + expect(runtimeMatrixStatus(coverageRows({}), ready)).toBe('pass'); + + const readiness = buildReplacementReadiness(coverageRows({ + 'openai-api-key-provider-model-chat': 'skipped', + 'feishu-live-channel-lifecycle': 'not-run', + 'feishu-live-inbound-delivery': 'not-run', + }), [ + { id: 'openai-api-key-env' }, + { id: 'feishu-env' }, + { id: 'feishu-inbound-fixture' }, + ]); + + expect(readiness).toMatchObject({ + status: 'partial', + replacementReady: false, + missingPreconditions: [ + { id: 'openai-api-key-env' }, + { id: 'feishu-env' }, + { id: 'feishu-inbound-fixture' }, + ], + missingCoverage: [ + expect.objectContaining({ + id: 'openai-api-key-provider-model-chat', + status: 'skipped', + nextCommand: 'pnpm run verify:cc-connect:local-real:api-key', + }), + expect.objectContaining({ + id: 'feishu-live-channel-lifecycle', + status: 'not-run', + nextCommand: 'pnpm run verify:cc-connect:local-real:feishu', + }), + expect.objectContaining({ + id: 'feishu-live-inbound-delivery', + status: 'not-run', + nextCommand: 'pnpm run verify:cc-connect:local-real:feishu-inbound', + }), + ], + nextCommands: [ + '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', + ], + }); + expect(replacementReadinessCheck(readiness)).toMatchObject({ + id: 'replacement-readiness', + status: 'partial', + details: { + missingCoverage: [ + expect.objectContaining({ id: 'openai-api-key-provider-model-chat' }), + expect.objectContaining({ id: 'feishu-live-channel-lifecycle' }), + expect.objectContaining({ id: 'feishu-live-inbound-delivery' }), + ], + nextCommands: [ + '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', + ], + }, + }); + expect(replacementReadinessCheck(readiness, { hardGate: true })).toMatchObject({ + id: 'replacement-readiness', + status: 'fail', + details: { + missingCoverage: [ + expect.objectContaining({ id: 'openai-api-key-provider-model-chat' }), + expect.objectContaining({ id: 'feishu-live-channel-lifecycle' }), + expect.objectContaining({ id: 'feishu-live-inbound-delivery' }), + ], + nextCommands: [ + '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', + ], + }, + }); + expect(runtimeMatrixStatus(coverageRows({ + 'openai-api-key-provider-model-chat': 'skipped', + 'feishu-live-channel-lifecycle': 'not-run', + 'feishu-live-inbound-delivery': 'not-run', + }), readiness)).toBe('partial'); + }); + + it('distinguishes runtime matrix status from hard-gate status', () => { + const partialCoverage = coverageRows({ + 'openai-api-key-provider-model-chat': 'skipped', + 'feishu-live-channel-lifecycle': 'not-run', + 'feishu-live-inbound-delivery': 'not-run', + }); + const partialReadiness = buildReplacementReadiness(partialCoverage); + expect(replacementReadinessCheck(partialReadiness)).toMatchObject({ + status: 'partial', + }); + expect(replacementReadinessCheck(partialReadiness, { hardGate: true })).toMatchObject({ + status: 'fail', + }); + expect(runtimeMatrixStatus(partialCoverage, partialReadiness)).toBe('partial'); + + const failedCoverage = coverageRows({ + 'oauth-core-runtime-parity': 'fail', + }); + expect(runtimeMatrixStatus(failedCoverage, buildReplacementReadiness(failedCoverage))).toBe('fail'); + }); + + it('builds a replacement contract checklist from the user-stated parity constraints', () => { + const coverage = coverageRows({ + 'openai-api-key-provider-model-chat': 'skipped', + 'feishu-live-channel-lifecycle': 'not-run', + 'feishu-live-inbound-delivery': 'not-run', + }); + const readiness = buildReplacementReadiness(coverage); + const gaps = buildValidationGaps(readiness, [], null, coverage); + const checklist = buildReplacementContract(coverage, readiness, gaps); + + expect(checklist).toHaveLength(REPLACEMENT_CONTRACT_ITEMS.length); + expect(checklist).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: 'developer-mode-gate', + status: 'pass', + requiredForLocalReplacementGate: false, + }), + expect.objectContaining({ + id: 'doctor-fix-non-parity', + status: 'pass', + requiredForLocalReplacementGate: false, + }), + expect.objectContaining({ + id: 'runtime-boundary-bridgeplatform-only', + status: 'pass', + requiredForLocalReplacementGate: true, + evidence: expect.stringContaining('Runtime boundary diagnostics: pass'), + }), + expect.objectContaining({ + id: 'codex-oauth-and-openai-api-key', + status: 'partial', + requiredForLocalReplacementGate: true, + nextAction: 'pnpm run verify:cc-connect:local-real:api-key', + }), + expect.objectContaining({ + id: 'feishu-channel-lifecycle', + status: 'partial', + requiredForLocalReplacementGate: true, + nextAction: 'pnpm run verify:cc-connect:local-real:feishu-inbound', + evidence: expect.stringContaining('live inbound delivery: not-run'), + }), + expect.objectContaining({ + id: 'cron-main-path', + status: 'pass', + requiredForLocalReplacementGate: false, + }), + expect.objectContaining({ + id: 'session-history-parity', + status: 'pass', + requiredForLocalReplacementGate: true, + evidence: expect.stringContaining('Local session/history diagnostics: pass'), + }), + expect.objectContaining({ + id: 'token-usage-contract', + status: 'partial', + requiredForLocalReplacementGate: true, + }), + expect.objectContaining({ + id: 'real-validation-opt-in', + status: 'pass', + requiredForLocalReplacementGate: false, + }), + expect.objectContaining({ + id: 'packaging-platform-smoke', + status: 'partial', + requiredForLocalReplacementGate: false, + }), + ])); + }); + + it('does not let local diagnostics satisfy live provider or public usage readiness', () => { + const readiness = buildReplacementReadiness(coverageRows({ + 'provider-model-profile-local-diagnostics': 'pass', + 'token-usage-contract-local-diagnostics': 'partial', + 'runtime-management-bundle-local-diagnostics': 'pass', + 'channel-lifecycle-local-bundle': 'pass', + 'cron-lifecycle-local-bundle': 'pass', + 'openai-api-key-provider-model-chat': 'skipped', + })); + + expect(readiness).toMatchObject({ + status: 'partial', + replacementReady: false, + requiredCoverageIds: REPLACEMENT_REQUIRED_COVERAGE_IDS, + missingCoverage: expect.arrayContaining([ + expect.objectContaining({ + id: 'openai-api-key-provider-model-chat', + status: 'skipped', + nextCommand: 'pnpm run verify:cc-connect:local-real:api-key', + }), + expect.objectContaining({ + id: 'token-usage-contract-local-diagnostics', + status: 'partial', + }), + ]), + }); + expect(readiness.missingCoverage.map((item) => item.id)) + .not.toContain('provider-model-profile-local-diagnostics'); + expect(readiness.missingCoverage.map((item) => item.id)) + .toContain('token-usage-contract-local-diagnostics'); + expect(readiness.missingCoverage.map((item) => item.id)) + .not.toContain('runtime-management-bundle-local-diagnostics'); + expect(readiness.missingCoverage.map((item) => item.id)) + .not.toContain('channel-lifecycle-local-bundle'); + expect(readiness.missingCoverage.map((item) => item.id)) + .not.toContain('cron-lifecycle-local-bundle'); + }); + + it('builds sanitized next actions for missing credentials, coverage, and upstream gaps', () => { + const readiness = buildReplacementReadiness(coverageRows({ + 'openai-api-key-provider-model-chat': 'skipped', + }), [ + { + id: 'openai-api-key-env', + required: ['CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY'], + optional: ['CLAWX_REAL_OPENAI_MODEL'], + nextCommand: 'pnpm run verify:cc-connect:local-real:api-key', + note: 'Set an OpenAI API key in an untracked and gitignored local env file.', + }, + ]); + const surface = analyzeCcConnectCliSurface({ + topHelp: 'send\ncron\nsessions\nprovider\nfeishu\nconfig', + cronAddHelp: '--prompt\n--exec\n--session-mode\n--timeout-mins', + feishuHelp: 'setup\nbind\nnew\n--platform-type\n--app-id\n--app-secret', + providerHelp: 'provider add\nprovider list\nprovider remove\nprovider import\nprovider presets\nprovider global', + sessionsHelp: 'sessions list\nsessions show', + configExample: 'doctor user-isolation\n[[projects.platforms]]', + }); + + expect(buildNextActions(readiness, readiness.missingPreconditions, surface)).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: 'configure-openai-api-key-env', + type: 'precondition', + priority: 'required', + command: 'pnpm run verify:cc-connect:local-real:api-key', + required: ['CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY'], + optional: ['CLAWX_REAL_OPENAI_MODEL'], + }), + expect.objectContaining({ + id: 'verify-openai-api-key-provider-model-chat', + type: 'coverage', + priority: 'required', + command: 'pnpm run verify:cc-connect:local-real:api-key', + }), + expect.objectContaining({ + id: 'upstream-documented-per-platform-channel-connect-disconnect', + type: 'upstream-gap', + priority: 'follow-up', + reason: 'documented per-platform channel connect/disconnect', + }), + ])); + }); + + it('builds structured validation gaps for required and residual replacement evidence', () => { + const readiness = buildReplacementReadiness(coverageRows({ + 'openai-api-key-provider-model-chat': 'skipped', + 'feishu-live-channel-lifecycle': 'not-run', + 'feishu-live-inbound-delivery': 'not-run', + }), [ + { + id: 'openai-api-key-env', + status: 'missing', + required: ['CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY'], + nextCommand: 'pnpm run verify:cc-connect:local-real:api-key', + note: 'Set an OpenAI API key in an untracked and gitignored local env file.', + }, + { + id: 'feishu-inbound-fixture', + status: 'missing', + required: ['CLAWX_REAL_FEISHU_INBOUND_E2E=1'], + nextCommand: 'pnpm run verify:cc-connect:local-real:feishu-inbound', + note: 'Set CLAWX_REAL_FEISHU_INBOUND_E2E=1 before running the real inbound smoke.', + }, + ]); + const surface = analyzeCcConnectCliSurface({ + topHelp: 'send\ncron\nsessions\nprovider\nfeishu\nconfig', + cronAddHelp: '--prompt\n--exec\n--session-mode\n--timeout-mins', + feishuHelp: 'setup\nbind\nnew\n--platform-type\n--app-id\n--app-secret', + providerHelp: 'provider add\nprovider list\nprovider remove\nprovider import\nprovider presets\nprovider global', + sessionsHelp: 'sessions list\nsessions show', + configExample: 'doctor user-isolation\n[[projects.platforms]]', + }); + + const gaps = buildValidationGaps(readiness, readiness.missingPreconditions, surface, coverageRows({ + 'openai-api-key-provider-model-chat': 'skipped', + 'feishu-live-channel-lifecycle': 'not-run', + 'feishu-live-inbound-delivery': 'not-run', + 'scheduled-cron-delivery-local-bundle': 'not-run', + 'scheduled-prompt-cron-delivery-local-bundle': 'not-run', + })); + + expect(gaps).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: 'precondition-openai-api-key-env', + priority: 'required', + status: 'missing', + requiredForLocalReplacementGate: true, + }), + expect.objectContaining({ + id: 'coverage-openai-api-key-provider-model-chat', + priority: 'required', + status: 'skipped', + requiredForLocalReplacementGate: true, + }), + expect.objectContaining({ + id: 'coverage-feishu-live-channel-lifecycle', + priority: 'required', + status: 'not-run', + requiredForLocalReplacementGate: true, + }), + expect.objectContaining({ + id: 'upstream-documented-per-platform-channel-connect-disconnect', + priority: 'follow-up', + status: 'missing-upstream-primitive', + requiredForLocalReplacementGate: false, + }), + expect.objectContaining({ + id: 'coverage-feishu-live-inbound-delivery', + area: 'feishu-live-inbound-delivery', + priority: 'required', + status: 'not-run', + requiredForLocalReplacementGate: true, + }), + expect.objectContaining({ + id: 'real-scheduled-cron-delivery', + area: 'cron', + priority: 'follow-up', + status: 'unverified', + requiredForLocalReplacementGate: false, + }), + expect.objectContaining({ + id: 'real-scheduled-prompt-channel-cron-delivery', + area: 'cron', + priority: 'follow-up', + status: 'unverified', + requiredForLocalReplacementGate: false, + }), + expect.objectContaining({ + id: 'upstream-public-token-usage', + area: 'usage', + priority: 'required', + status: 'upstream-blocked', + requiredForLocalReplacementGate: true, + }), + ])); + expect(gaps.filter((gap) => gap.requiredForLocalReplacementGate).map((gap) => gap.id)) + .toEqual([ + 'precondition-openai-api-key-env', + 'precondition-feishu-inbound-fixture', + 'coverage-openai-api-key-provider-model-chat', + 'coverage-feishu-live-channel-lifecycle', + 'coverage-feishu-live-inbound-delivery', + 'upstream-public-token-usage', + ]); + const publicUsageGap = gaps.find((gap) => gap.id === 'upstream-public-token-usage'); + expect(publicUsageGap?.reason).toContain('v1.5.0-beta.1'); + expect(publicUsageGap?.reason).toContain('PR #1428'); + expect(publicUsageGap?.reason).toContain('project/provider/model'); + expect(publicUsageGap?.reason).toContain('reconnect/replay'); + expect(gaps.filter((gap) => gap.requiredForLocalReplacementGate === false).map((gap) => gap.id)) + .toEqual(expect.arrayContaining(RESIDUAL_VALIDATION_GAPS + .filter((gap) => !gap.requiredForLocalReplacementGate) + .map((gap) => gap.id))); + + const gapsAfterScheduledCron = buildValidationGaps(readiness, readiness.missingPreconditions, surface, coverageRows({ + 'openai-api-key-provider-model-chat': 'skipped', + 'feishu-live-channel-lifecycle': 'not-run', + 'feishu-live-inbound-delivery': 'not-run', + 'scheduled-cron-delivery-local-bundle': 'pass', + 'scheduled-prompt-cron-delivery-local-bundle': 'not-run', + })); + expect(gapsAfterScheduledCron.map((gap) => gap.id)).not.toContain('real-scheduled-cron-delivery'); + expect(gapsAfterScheduledCron.map((gap) => gap.id)).toContain('real-scheduled-prompt-channel-cron-delivery'); + + const gapsAfterPromptDelivery = buildValidationGaps(readiness, readiness.missingPreconditions, surface, coverageRows({ + 'openai-api-key-provider-model-chat': 'skipped', + 'feishu-live-channel-lifecycle': 'not-run', + 'feishu-live-inbound-delivery': 'not-run', + 'scheduled-cron-delivery-local-bundle': 'pass', + 'scheduled-prompt-cron-delivery-local-bundle': 'pass', + })); + expect(gapsAfterPromptDelivery).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: 'real-scheduled-prompt-channel-cron-delivery', + status: 'unverified-channel-delivery', + nextCommand: 'Run a live tenant-channel scheduled prompt cron smoke once a safe channel fixture is available.', + reason: expect.stringContaining('Local BridgePlatform prompt delivery passed'), + }), + ])); + }); + + it('renders required and optional credential fields in markdown action tables', () => { + const report = { + generatedAt: '2026-06-28T00:00:00.000Z', + status: 'partial', + runtimeMatrixStatus: 'partial', + checks: [], + missingPreconditions: [ + { + id: 'openai-api-key-env', + required: ['CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY'], + optional: ['CLAWX_REAL_OPENAI_MODEL'], + nextCommand: 'pnpm run verify:cc-connect:local-real:api-key', + note: 'Set an API key.', + }, + ], + nextActions: [ + { + id: 'configure-openai-api-key-env', + type: 'precondition', + priority: 'required', + command: 'pnpm run verify:cc-connect:local-real:api-key', + reason: 'Set an API key.', + required: ['CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY'], + optional: ['CLAWX_REAL_OPENAI_MODEL'], + }, + ], + validationGaps: [ + { + id: 'precondition-openai-api-key-env', + area: 'preconditions', + priority: 'required', + status: 'missing', + requiredForLocalReplacementGate: true, + nextCommand: 'pnpm run verify:cc-connect:local-real:api-key', + reason: 'Set an API key.', + required: ['CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY'], + optional: ['CLAWX_REAL_OPENAI_MODEL'], + }, + ], + replacementContract: [ + { + id: 'codex-oauth-and-openai-api-key', + area: 'providers', + status: 'partial', + requiredForLocalReplacementGate: true, + expectedState: 'oauth-and-api-key-verifiable', + requirement: 'Codex OAuth and OpenAI API-key modes are supported and explicitly verifiable.', + evidence: 'Real OpenAI API-key row is skipped.', + nextAction: 'pnpm run verify:cc-connect:local-real:api-key', + }, + ], + }; + + const markdown = toMarkdown(report); + + expect(markdown).toContain('| ID | Required | Optional | Next Command | Note |'); + expect(markdown).toContain('## Replacement Contract Checklist'); + expect(markdown).toContain('| ID | Area | Status | Required For Local Gate | Expected State | Requirement | Evidence | Next Action |'); + expect(markdown).toContain('| ID | Type | Priority | Required | Optional | Command or Action | Reason |'); + expect(markdown).toContain('| ID | Area | Priority | Status | Blocks Local Gate | Required | Optional | Next Command or Action | Reason |'); + expect(markdown).toContain('codex-oauth-and-openai-api-key | providers | PARTIAL | yes | oauth-and-api-key-verifiable'); + expect(markdown).toContain('CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY | CLAWX_REAL_OPENAI_MODEL |'); + }); +}); diff --git a/tests/unit/cc-connect-paths.test.ts b/tests/unit/cc-connect-paths.test.ts new file mode 100644 index 000000000..1710a6814 --- /dev/null +++ b/tests/unit/cc-connect-paths.test.ts @@ -0,0 +1,76 @@ +// @vitest-environment node +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: vi.fn(() => tmpdir()), + }, +})); + +describe('cc-connect path resolver', () => { + const originalCwd = process.cwd(); + const originalOverride = process.env.CLAWX_CC_CONNECT_PATH; + let tempDir: string; + + beforeEach(async () => { + vi.resetModules(); + delete process.env.CLAWX_CC_CONNECT_PATH; + tempDir = await mkdtemp(join(tmpdir(), 'clawx-cc-paths-')); + process.chdir(tempDir); + }); + + afterEach(async () => { + process.chdir(originalCwd); + if (originalOverride === undefined) { + delete process.env.CLAWX_CC_CONNECT_PATH; + } else { + process.env.CLAWX_CC_CONNECT_PATH = originalOverride; + } + await rm(tempDir, { recursive: true, force: true }); + }); + + it('uses the dev bundled cc-connect binary when node_modules postinstall did not create one', async () => { + const binaryName = process.platform === 'win32' ? 'cc-connect.exe' : 'cc-connect'; + const bundledPath = join(process.cwd(), 'build', 'cc-connect', `${process.platform}-${process.arch}`, binaryName); + await mkdir(join(bundledPath, '..'), { recursive: true }); + await writeFile(bundledPath, 'mock cc-connect', 'utf8'); + await chmod(bundledPath, 0o755); + + const { getCcConnectBinaryPath, assertCcConnectBinaryPath } = await import('@electron/runtime/cc-connect-paths'); + + expect(getCcConnectBinaryPath()).toBe(bundledPath); + expect(assertCcConnectBinaryPath()).toBe(bundledPath); + }); + + it('uses an explicit dev cc-connect binary override', async () => { + const binaryName = process.platform === 'win32' ? 'cc-connect.exe' : 'cc-connect'; + const overridePath = join(tempDir, 'mock-runtime', binaryName); + await mkdir(join(overridePath, '..'), { recursive: true }); + await writeFile(overridePath, 'mock cc-connect override', 'utf8'); + await chmod(overridePath, 0o755); + process.env.CLAWX_CC_CONNECT_PATH = overridePath; + + const { getCcConnectBinaryPath, assertCcConnectBinaryPath } = await import('@electron/runtime/cc-connect-paths'); + + expect(getCcConnectBinaryPath()).toBe(overridePath); + expect(assertCcConnectBinaryPath()).toBe(overridePath); + }); + + it('does not use the npm postinstall binary as a runtime fallback', async () => { + const binaryName = process.platform === 'win32' ? 'cc-connect.exe' : 'cc-connect'; + const nodeModulesBinary = join(process.cwd(), 'node_modules', 'cc-connect', 'bin', binaryName); + await mkdir(join(nodeModulesBinary, '..'), { recursive: true }); + await writeFile(nodeModulesBinary, 'mock cc-connect from node_modules', 'utf8'); + await chmod(nodeModulesBinary, 0o755); + + const { getCcConnectBinaryPath, assertCcConnectBinaryPath } = await import('@electron/runtime/cc-connect-paths'); + + expect(getCcConnectBinaryPath()).toContain(join('build', 'cc-connect', `${process.platform}-${process.arch}`)); + expect(getCcConnectBinaryPath()).not.toBe(nodeModulesBinary); + expect(() => assertCcConnectBinaryPath()).toThrow('pnpm run bundle:cc-connect:current'); + }); +}); diff --git a/tests/unit/cc-connect-provider-profile.test.ts b/tests/unit/cc-connect-provider-profile.test.ts new file mode 100644 index 000000000..d2fdc5e07 --- /dev/null +++ b/tests/unit/cc-connect-provider-profile.test.ts @@ -0,0 +1,939 @@ +// @vitest-environment node +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const appPath = new Map(); +const getDefaultProviderAccountIdMock = vi.fn(); +const getProviderAccountMock = vi.fn(); +const getProviderSecretMock = vi.fn(); +const deleteProviderSecretMock = vi.fn(); +const bdRouteSegment = ['model', 'hub'].join(''); +const bdProviderKey = `${bdRouteSegment}_openapi`; +const bdHost = ['aidp', 'bytedance', 'net'].join('.'); +const bdApiPath = ['api', bdRouteSegment, 'online'].join('/'); +const bdBaseUrl = `https://${bdHost}/${bdApiPath}`; +const bdApiKeyEnv = 'CLAWX_CODEX_BD_RESPONSES_API_KEY'; +const bdExtraHeaderEnv = 'CLAWX_CODEX_BD_RESPONSES_EXTRA_HEADER'; +const bdStickySessionEnv = 'CLAWX_CODEX_BD_RESPONSES_STICKY_SESSION_ID'; +const bdTtEnvHeader = 'CLAWX_CODEX_BD_RESPONSES_HEADER_X_TT_ENV'; + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: vi.fn((name: string) => appPath.get(name) ?? tmpdir()), + }, +})); + +vi.mock('@electron/services/providers/provider-store', () => ({ + getDefaultProviderAccountId: (...args: unknown[]) => getDefaultProviderAccountIdMock(...args), + getProviderAccount: (...args: unknown[]) => getProviderAccountMock(...args), +})); + +vi.mock('@electron/services/secrets/secret-store', () => ({ + getProviderSecret: (...args: unknown[]) => getProviderSecretMock(...args), + getSecretStore: () => ({ + delete: (...args: unknown[]) => deleteProviderSecretMock(...args), + }), +})); + +describe('cc-connect provider profile sync', () => { + let tempDir: string; + const accountCodexHome = (accountId: string) => join(tempDir, 'credentials', 'oauth', accountId, 'codex-home'); + + beforeEach(async () => { + vi.resetModules(); + getDefaultProviderAccountIdMock.mockReset(); + getProviderAccountMock.mockReset(); + getProviderSecretMock.mockReset(); + deleteProviderSecretMock.mockReset(); + tempDir = await mkdtemp(join(tmpdir(), 'clawx-cc-provider-profile-')); + appPath.set('userData', tempDir); + appPath.set('home', tempDir); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('converts OpenAI provider accounts to Codex model args without writing secrets to disk', async () => { + getDefaultProviderAccountIdMock.mockResolvedValue('openai-main'); + getProviderAccountMock.mockResolvedValue({ + id: 'openai-main', + vendorId: 'openai', + label: 'OpenAI', + authMode: 'api_key', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-06-07T00:00:00.000Z', + }); + getProviderSecretMock.mockResolvedValue({ + type: 'api_key', + accountId: 'openai-main', + apiKey: 'sk-secret-value', + }); + const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile'); + + const profile = await syncCcConnectProviderProfile({ reason: 'set-default' }); + + expect(profile).toMatchObject({ + providerId: 'openai-main', + vendorId: 'openai', + model: 'gpt-5.5', + codexArgs: ['--model', 'gpt-5.5'], + env: { + CLAWX_CODEX_OPENAI_MAIN_API_KEY: 'sk-secret-value', + CODEX_HOME: accountCodexHome('openai-main'), + }, + launcherEnv: { OPENAI_API_KEY: 'CLAWX_CODEX_OPENAI_MAIN_API_KEY' }, + secretAvailable: true, + supported: true, + }); + const profileFile = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8'); + expect(profileFile).toContain('"envKeys"'); + expect(profileFile).toContain('CLAWX_CODEX_OPENAI_MAIN_API_KEY'); + expect(profileFile).not.toContain('sk-secret-value'); + }); + + it('marks OpenAI API-key providers unsupported when the API key is missing', async () => { + getDefaultProviderAccountIdMock.mockResolvedValue('openai-missing-key'); + getProviderAccountMock.mockResolvedValue({ + id: 'openai-missing-key', + vendorId: 'openai', + label: 'OpenAI Missing Key', + authMode: 'api_key', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-06-07T00:00:00.000Z', + }); + getProviderSecretMock.mockResolvedValue(null); + const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile'); + + const profile = await syncCcConnectProviderProfile({ reason: 'runtime-start' }); + + expect(profile).toMatchObject({ + providerId: 'openai-missing-key', + vendorId: 'openai', + model: 'gpt-5.5', + supported: false, + unsupportedReason: expect.stringContaining('OpenAI API key credentials are missing'), + codexArgs: [], + secretAvailable: false, + }); + expect(profile.env).toBeUndefined(); + expect(profile.ccConnectProvider).toBeUndefined(); + const profileFile = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8'); + expect(profileFile).toContain('OpenAI API key credentials are missing'); + expect(profileFile).not.toContain('OPENAI_API_KEY'); + expect(profileFile).not.toContain('api_key ='); + }); + + it('converts OpenAI API-key providers with custom baseUrl to managed Responses config', async () => { + getDefaultProviderAccountIdMock.mockResolvedValue('openai-local'); + getProviderAccountMock.mockResolvedValue({ + id: 'openai-local', + vendorId: 'openai', + label: 'OpenAI Local', + authMode: 'api_key', + baseUrl: 'http://127.0.0.1:45123/v1/responses', + model: 'gpt-local', + enabled: true, + isDefault: true, + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-06-07T00:00:00.000Z', + }); + getProviderSecretMock.mockResolvedValue({ + type: 'api_key', + accountId: 'openai-local', + apiKey: 'sk-local-secret-value', + }); + const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile'); + + const profile = await syncCcConnectProviderProfile({ reason: 'local-api-key' }); + + expect(profile).toMatchObject({ + providerId: 'openai-local', + vendorId: 'openai', + model: 'gpt-local', + codexHomeDir: accountCodexHome('openai-local'), + codexArgs: [ + '-c', + 'model_provider="clawx-openai"', + '-c', + 'model_providers.clawx-openai.name="OpenAI"', + '-c', + 'model_providers.clawx-openai.base_url="http://127.0.0.1:45123/v1"', + '-c', + 'model_providers.clawx-openai.env_key="CLAWX_CODEX_OPENAI_LOCAL_API_KEY"', + '-c', + 'model_providers.clawx-openai.wire_api="responses"', + '--model', + 'gpt-local', + ], + env: { + CLAWX_CODEX_OPENAI_LOCAL_API_KEY: 'sk-local-secret-value', + CODEX_HOME: accountCodexHome('openai-local'), + }, + launcherEnv: { OPENAI_API_KEY: 'CLAWX_CODEX_OPENAI_LOCAL_API_KEY' }, + ccConnectProvider: { + name: 'clawx-openai', + apiKeyEnvKey: 'CLAWX_CODEX_OPENAI_LOCAL_API_KEY', + baseUrl: 'http://127.0.0.1:45123/v1', + model: 'gpt-local', + wireApi: 'responses', + }, + secretAvailable: true, + supported: true, + }); + + const codexConfig = await readFile(join(accountCodexHome('openai-local'), 'config.toml'), 'utf8'); + expect(codexConfig).toContain('model = "gpt-local"'); + expect(codexConfig).toContain('model_provider = "clawx-openai"'); + expect(codexConfig).toContain('[model_providers.clawx-openai]'); + expect(codexConfig).toContain('base_url = "http://127.0.0.1:45123/v1"'); + expect(codexConfig).toContain('env_key = "CLAWX_CODEX_OPENAI_LOCAL_API_KEY"'); + expect(codexConfig).toContain('wire_api = "responses"'); + expect(codexConfig).not.toContain('sk-local-secret-value'); + + const profileFile = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8'); + expect(profileFile).toContain('CLAWX_CODEX_OPENAI_LOCAL_API_KEY'); + expect(profileFile).toContain('http://127.0.0.1:45123/v1'); + expect(profileFile).not.toContain('sk-local-secret-value'); + }); + + it('converts OpenAI OAuth accounts to a managed Codex auth home without writing tokens to the public profile', async () => { + getDefaultProviderAccountIdMock.mockResolvedValue('openai-oauth'); + getProviderAccountMock.mockResolvedValue({ + id: 'openai-oauth', + vendorId: 'openai', + label: 'OpenAI OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + metadata: { email: 'user@example.com', resourceUrl: 'openai-codex' }, + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-06-07T00:00:00.000Z', + }); + getProviderSecretMock.mockResolvedValue({ + type: 'oauth', + accountId: 'openai-oauth', + accessToken: 'oauth-access-token', + refreshToken: 'oauth-refresh-token', + idToken: 'oauth-id-token', + expiresAt: 1_780_000_000_000, + email: 'user@example.com', + subject: 'acct_123', + }); + const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile'); + + const profile = await syncCcConnectProviderProfile({ reason: 'oauth-login' }); + + const codexHome = accountCodexHome('openai-oauth'); + expect(profile).toMatchObject({ + providerId: 'openai-oauth', + vendorId: 'openai', + authMode: 'oauth_browser', + model: 'gpt-5.5', + codexArgs: ['--model', 'gpt-5.5'], + env: { CODEX_HOME: codexHome }, + secretAvailable: true, + supported: true, + }); + + const authFile = JSON.parse(await readFile(join(codexHome, 'auth.json'), 'utf8')) as { + auth_mode?: string; + OPENAI_API_KEY?: string | null; + tokens?: Record; + }; + expect(authFile).toEqual({ + auth_mode: 'chatgpt', + OPENAI_API_KEY: null, + tokens: { + id_token: 'oauth-id-token', + access_token: 'oauth-access-token', + refresh_token: 'oauth-refresh-token', + account_id: 'acct_123', + }, + last_refresh: expect.any(String), + }); + + const profileFile = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8'); + expect(profileFile).toContain('CODEX_HOME'); + expect(profileFile).not.toContain('oauth-access-token'); + expect(profileFile).not.toContain('oauth-refresh-token'); + expect(profileFile).not.toContain('oauth-id-token'); + }); + + it('replaces stale managed Codex auth with the new browser OAuth secret', async () => { + const codexHome = accountCodexHome('openai-oauth'); + await mkdir(codexHome, { recursive: true }); + await writeFile(join(codexHome, 'auth.json'), JSON.stringify({ + auth_mode: 'chatgpt', + OPENAI_API_KEY: null, + tokens: { + id_token: 'stale-managed-id-token', + access_token: 'stale-managed-access-token', + refresh_token: 'stale-managed-refresh-token', + account_id: 'acct_123', + }, + last_refresh: '2026-06-07T00:00:00.000Z', + }, null, 2), 'utf8'); + getDefaultProviderAccountIdMock.mockResolvedValue('openai-oauth'); + getProviderAccountMock.mockResolvedValue({ + id: 'openai-oauth', + vendorId: 'openai', + label: 'OpenAI OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-07-12T00:00:00.000Z', + }); + getProviderSecretMock.mockResolvedValue({ + type: 'oauth', + accountId: 'openai-oauth', + accessToken: 'new-browser-access-token', + refreshToken: 'new-browser-refresh-token', + idToken: 'new-browser-id-token', + expiresAt: 1_820_000_000_000, + subject: 'acct_123', + }); + const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile'); + + await expect(syncCcConnectProviderProfile({ + providerId: 'openai-oauth', + reason: 'oauth', + })).resolves.toMatchObject({ + providerId: 'openai-oauth', + supported: true, + codexHomeDir: codexHome, + }); + + const authFile = await readFile(join(codexHome, 'auth.json'), 'utf8'); + expect(authFile).toContain('new-browser-access-token'); + expect(authFile).toContain('new-browser-refresh-token'); + expect(authFile).toContain('new-browser-id-token'); + expect(authFile).not.toContain('stale-managed-access-token'); + const profileFile = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8'); + expect(profileFile).not.toContain('new-browser-access-token'); + expect(profileFile).not.toContain('new-browser-refresh-token'); + expect(profileFile).not.toContain('new-browser-id-token'); + }); + + it('preserves Codex-refreshed managed auth during ordinary runtime start', async () => { + const codexHome = accountCodexHome('openai-oauth'); + await mkdir(codexHome, { recursive: true }); + await writeFile(join(codexHome, 'auth.json'), JSON.stringify({ + auth_mode: 'chatgpt', + OPENAI_API_KEY: null, + tokens: { + id_token: 'refreshed-managed-id-token', + access_token: 'refreshed-managed-access-token', + refresh_token: 'refreshed-managed-refresh-token', + account_id: 'acct_123', + }, + last_refresh: '2026-07-12T00:00:00.000Z', + }, null, 2), 'utf8'); + getDefaultProviderAccountIdMock.mockResolvedValue('openai-oauth'); + getProviderAccountMock.mockResolvedValue({ + id: 'openai-oauth', + vendorId: 'openai', + label: 'OpenAI OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-06-07T00:00:00.000Z', + }); + getProviderSecretMock.mockResolvedValue({ + type: 'oauth', + accountId: 'openai-oauth', + accessToken: 'stale-vault-access-token', + refreshToken: 'stale-vault-refresh-token', + idToken: 'stale-vault-id-token', + expiresAt: 1_780_000_000_000, + subject: 'acct_123', + }); + const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile'); + + await syncCcConnectProviderProfile({ reason: 'runtime-start' }); + + const authFile = await readFile(join(codexHome, 'auth.json'), 'utf8'); + expect(authFile).toContain('refreshed-managed-access-token'); + expect(authFile).toContain('refreshed-managed-refresh-token'); + expect(authFile).not.toContain('stale-vault-access-token'); + }); + + it('does not replace complete managed auth when browser OAuth lacks an id token', async () => { + const codexHome = accountCodexHome('openai-oauth'); + await mkdir(codexHome, { recursive: true }); + await writeFile(join(codexHome, 'auth.json'), JSON.stringify({ + auth_mode: 'chatgpt', + OPENAI_API_KEY: null, + tokens: { + id_token: 'managed-id-token', + access_token: 'managed-access-token', + refresh_token: 'managed-refresh-token', + account_id: 'acct_123', + }, + last_refresh: '2026-07-12T00:00:00.000Z', + }, null, 2), 'utf8'); + getDefaultProviderAccountIdMock.mockResolvedValue('openai-oauth'); + getProviderAccountMock.mockResolvedValue({ + id: 'openai-oauth', + vendorId: 'openai', + label: 'OpenAI OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-07-12T00:00:00.000Z', + }); + getProviderSecretMock.mockResolvedValue({ + type: 'oauth', + accountId: 'openai-oauth', + accessToken: 'incomplete-browser-access-token', + refreshToken: 'incomplete-browser-refresh-token', + expiresAt: 1_820_000_000_000, + subject: 'acct_123', + }); + const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile'); + + await syncCcConnectProviderProfile({ reason: 'oauth' }); + + const authFile = await readFile(join(codexHome, 'auth.json'), 'utf8'); + expect(authFile).toContain('managed-access-token'); + expect(authFile).not.toContain('incomplete-browser-access-token'); + }); + + it('uses an existing managed Codex OAuth home without requiring a ClawX OAuth secret', async () => { + const legacyCodexHome = join(tempDir, 'runtimes', 'cc-connect', 'codex-home'); + const codexHome = accountCodexHome('openai-oauth'); + await mkdir(legacyCodexHome, { recursive: true }); + await writeFile(join(legacyCodexHome, 'auth.json'), JSON.stringify({ + auth_mode: 'chatgpt', + OPENAI_API_KEY: null, + tokens: { + id_token: 'managed-id-token', + access_token: 'managed-access-token', + refresh_token: 'managed-refresh-token', + account_id: 'acct_managed', + }, + last_refresh: '2026-06-07T00:00:00.000Z', + }, null, 2), 'utf8'); + getDefaultProviderAccountIdMock.mockResolvedValue('openai-oauth'); + getProviderAccountMock.mockResolvedValue({ + id: 'openai-oauth', + vendorId: 'openai', + label: 'OpenAI Codex OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + metadata: { resourceUrl: 'openai-codex' }, + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-06-07T00:00:00.000Z', + }); + getProviderSecretMock.mockResolvedValue(null); + const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile'); + + const profile = await syncCcConnectProviderProfile({ reason: 'runtime-start' }); + + expect(profile).toMatchObject({ + providerId: 'openai-oauth', + vendorId: 'openai', + authMode: 'oauth_browser', + supported: true, + secretAvailable: true, + env: { CODEX_HOME: codexHome }, + codexHomeDir: codexHome, + }); + const authFile = JSON.parse(await readFile(join(codexHome, 'auth.json'), 'utf8')) as { + last_refresh?: string; + tokens?: Record; + }; + expect(authFile).toMatchObject({ + last_refresh: '2026-06-07T00:00:00.000Z', + tokens: { + id_token: 'managed-id-token', + access_token: 'managed-access-token', + refresh_token: 'managed-refresh-token', + }, + }); + const profileFile = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8'); + expect(profileFile).toContain('CODEX_HOME'); + expect(profileFile).not.toContain('managed-id-token'); + expect(profileFile).not.toContain('managed-access-token'); + expect(profileFile).not.toContain('managed-refresh-token'); + await expect(access(legacyCodexHome)).rejects.toThrow(); + + getDefaultProviderAccountIdMock.mockResolvedValue('openai-oauth-second'); + getProviderAccountMock.mockResolvedValue({ + id: 'openai-oauth-second', + vendorId: 'openai', + label: 'Second OpenAI Codex OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-06-07T00:00:00.000Z', + }); + await expect(syncCcConnectProviderProfile({ reason: 'second-account' })).resolves.toMatchObject({ + providerId: 'openai-oauth-second', + supported: false, + }); + await expect(readFile(join(accountCodexHome('openai-oauth-second'), 'auth.json'), 'utf8')).rejects.toThrow(); + }); + + it('does not implicitly import global Codex auth for OAuth secrets that lack an id token', async () => { + await mkdir(join(tempDir, '.codex'), { recursive: true }); + await writeFile(join(tempDir, '.codex', 'auth.json'), JSON.stringify({ + auth_mode: 'chatgpt', + OPENAI_API_KEY: null, + tokens: { + id_token: 'imported-id-token', + access_token: 'imported-access-token', + refresh_token: 'imported-refresh-token', + account_id: 'acct_123', + }, + last_refresh: '2026-06-07T00:00:00.000Z', + }, null, 2), 'utf8'); + getDefaultProviderAccountIdMock.mockResolvedValue('openai-oauth'); + getProviderAccountMock.mockResolvedValue({ + id: 'openai-oauth', + vendorId: 'openai', + label: 'OpenAI OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-06-07T00:00:00.000Z', + }); + getProviderSecretMock.mockResolvedValue({ + type: 'oauth', + accountId: 'openai-oauth', + accessToken: 'oauth-access-token', + refreshToken: 'oauth-refresh-token', + expiresAt: 1_780_000_000_000, + subject: 'acct_123', + }); + const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile'); + + const profile = await syncCcConnectProviderProfile({ reason: 'runtime-start' }); + + expect(profile).toMatchObject({ + supported: false, + unsupportedReason: expect.stringContaining('credentials are missing'), + }); + await expect(readFile(join(accountCodexHome('openai-oauth'), 'auth.json'), 'utf8')).rejects.toThrow(); + const profileFile = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8'); + expect(profileFile).not.toContain('imported-id-token'); + expect(profileFile).not.toContain('imported-access-token'); + expect(profileFile).not.toContain('imported-refresh-token'); + }); + + it('reports managed and user Codex OAuth status without exposing token values', async () => { + const managedHome = accountCodexHome('openai-oauth'); + await mkdir(managedHome, { recursive: true }); + await writeFile(join(managedHome, 'auth.json'), JSON.stringify({ + auth_mode: 'chatgpt', + OPENAI_API_KEY: null, + tokens: { + id_token: 'managed-id-token', + access_token: 'managed-access-token', + refresh_token: 'managed-refresh-token', + account_id: 'acct_123', + }, + last_refresh: '2026-06-07T00:00:00.000Z', + }, null, 2), 'utf8'); + await mkdir(join(tempDir, '.codex'), { recursive: true }); + await writeFile(join(tempDir, '.codex', 'auth.json'), JSON.stringify({ + auth_mode: 'chatgpt', + tokens: { + id_token: 'user-id-token', + access_token: 'user-access-token', + refresh_token: 'user-refresh-token', + account_id: 'acct_123', + }, + }, null, 2), 'utf8'); + getDefaultProviderAccountIdMock.mockResolvedValue('openai-oauth'); + getProviderAccountMock.mockResolvedValue({ + id: 'openai-oauth', + vendorId: 'openai', + label: 'OpenAI OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-06-07T00:00:00.000Z', + }); + getProviderSecretMock.mockResolvedValue({ + type: 'oauth', + accountId: 'openai-oauth', + accessToken: 'managed-access-token', + refreshToken: 'managed-refresh-token', + idToken: 'managed-id-token', + subject: 'acct_123', + }); + const { getCcConnectCodexOAuthStatus } = await import('@electron/runtime/cc-connect-provider-profile'); + + const status = await getCcConnectCodexOAuthStatus(); + + expect(status).toMatchObject({ + success: true, + managedCodexHome: managedHome, + authPath: join(managedHome, 'auth.json'), + managed: { + exists: true, + complete: true, + accountId: 'acct_123', + authMode: 'chatgpt', + lastRefresh: '2026-06-07T00:00:00.000Z', + }, + user: { + exists: true, + complete: true, + accountId: 'acct_123', + }, + provider: { + accountId: 'openai-oauth', + vendorId: 'openai', + authMode: 'oauth_browser', + hasOAuthSecret: true, + managedMatchesAccount: true, + userMatchesAccount: true, + }, + }); + expect(JSON.stringify(status)).not.toContain('managed-access-token'); + expect(JSON.stringify(status)).not.toContain('user-refresh-token'); + }); + + it('imports the local Codex OAuth auth file into the managed cc-connect Codex home', async () => { + await mkdir(join(tempDir, '.codex'), { recursive: true }); + await writeFile(join(tempDir, '.codex', 'auth.json'), JSON.stringify({ + auth_mode: 'chatgpt', + tokens: { + id_token: 'local-id-token', + access_token: 'local-access-token', + refresh_token: 'local-refresh-token', + account_id: 'acct_123', + }, + }, null, 2), 'utf8'); + getDefaultProviderAccountIdMock.mockResolvedValue('openai-oauth'); + getProviderAccountMock.mockResolvedValue({ + id: 'openai-oauth', + vendorId: 'openai', + label: 'OpenAI OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-06-07T00:00:00.000Z', + }); + getProviderSecretMock.mockResolvedValue({ + type: 'oauth', + accountId: 'openai-oauth', + accessToken: 'different-access-token', + refreshToken: 'different-refresh-token', + subject: 'acct_123', + }); + const { importUserCodexOAuthToManagedHome } = await import('@electron/runtime/cc-connect-provider-profile'); + + const status = await importUserCodexOAuthToManagedHome(); + + expect(status.managed).toMatchObject({ + exists: true, + complete: true, + accountId: 'acct_123', + }); + const managedAuth = await readFile(join(accountCodexHome('openai-oauth'), 'auth.json'), 'utf8'); + expect(managedAuth).toContain('local-id-token'); + expect(JSON.stringify(status)).not.toContain('local-id-token'); + expect(JSON.stringify(status)).not.toContain('local-access-token'); + }); + + it('logs out Codex OAuth by clearing the managed auth file and provider OAuth secret', async () => { + const managedHome = accountCodexHome('openai-oauth'); + await mkdir(managedHome, { recursive: true }); + await writeFile(join(managedHome, 'auth.json'), JSON.stringify({ + auth_mode: 'chatgpt', + tokens: { + id_token: 'managed-id-token', + access_token: 'managed-access-token', + refresh_token: 'managed-refresh-token', + account_id: 'acct_123', + }, + }, null, 2), 'utf8'); + getDefaultProviderAccountIdMock.mockResolvedValue('openai-oauth'); + getProviderAccountMock.mockResolvedValue({ + id: 'openai-oauth', + vendorId: 'openai', + label: 'OpenAI OAuth', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + isDefault: true, + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-06-07T00:00:00.000Z', + }); + getProviderSecretMock.mockResolvedValue({ + type: 'oauth', + accountId: 'openai-oauth', + accessToken: 'managed-access-token', + refreshToken: 'managed-refresh-token', + subject: 'acct_123', + }); + const { logoutCcConnectCodexOAuth } = await import('@electron/runtime/cc-connect-provider-profile'); + + const status = await logoutCcConnectCodexOAuth(); + + expect(status.managed).toMatchObject({ exists: false, complete: false }); + expect(deleteProviderSecretMock).toHaveBeenCalledWith('openai-oauth'); + await expect(readFile(join(managedHome, 'auth.json'), 'utf8')).rejects.toThrow(); + }); + + it('converts Ollama provider accounts to Codex OSS local-provider args', async () => { + getDefaultProviderAccountIdMock.mockResolvedValue('ollama-local'); + getProviderAccountMock.mockResolvedValue({ + id: 'ollama-local', + vendorId: 'ollama', + label: 'Ollama', + authMode: 'local', + model: 'qwen3:latest', + enabled: true, + isDefault: true, + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-06-07T00:00:00.000Z', + }); + getProviderSecretMock.mockResolvedValue(null); + const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile'); + + await expect(syncCcConnectProviderProfile()).resolves.toMatchObject({ + providerId: 'ollama-local', + vendorId: 'ollama', + model: 'qwen3:latest', + codexArgs: ['--oss', '--local-provider', 'ollama', '--model', 'qwen3:latest'], + supported: true, + }); + }); + + it('converts OpenAI-compatible custom responses providers to Codex provider config args', async () => { + getDefaultProviderAccountIdMock.mockResolvedValue('custom-responses'); + getProviderAccountMock.mockResolvedValue({ + id: 'custom-responses', + vendorId: 'custom', + label: 'Custom Responses', + authMode: 'api_key', + baseUrl: 'https://gateway.example/openai/responses', + apiProtocol: 'openai-responses', + headers: { + 'X-Route': 'route-secret', + 'X-Trace-Id': 'trace-visible-but-secret', + }, + model: 'gpt-custom', + enabled: true, + isDefault: true, + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-06-07T00:00:00.000Z', + }); + getProviderSecretMock.mockResolvedValue({ + type: 'api_key', + accountId: 'custom-responses', + apiKey: 'custom-secret-value', + }); + const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile'); + + const profile = await syncCcConnectProviderProfile(); + + expect(profile).toMatchObject({ + providerId: 'custom-responses', + vendorId: 'custom', + model: 'gpt-custom', + supported: true, + env: { + CLAWX_CODEX_CUSTOM_RESPONSES_API_KEY: 'custom-secret-value', + CLAWX_CODEX_CUSTOM_RESPONSES_HEADER_X_ROUTE: 'route-secret', + CLAWX_CODEX_CUSTOM_RESPONSES_HEADER_X_TRACE_ID: 'trace-visible-but-secret', + CODEX_HOME: accountCodexHome('custom-responses'), + }, + codexHomeDir: accountCodexHome('custom-responses'), + ccConnectProvider: { + name: 'clawx-custom', + apiKeyEnvKey: 'CLAWX_CODEX_CUSTOM_RESPONSES_API_KEY', + baseUrl: 'https://gateway.example/openai', + model: 'gpt-custom', + wireApi: 'responses', + }, + secretAvailable: true, + }); + expect(profile.codexArgs).toEqual([ + '-c', + 'model_provider="clawx-custom"', + '-c', + 'model_providers.clawx-custom.name="Custom Responses"', + '-c', + 'model_providers.clawx-custom.base_url="https://gateway.example/openai"', + '-c', + 'model_providers.clawx-custom.env_key="CLAWX_CODEX_CUSTOM_RESPONSES_API_KEY"', + '-c', + 'model_providers.clawx-custom.wire_api="responses"', + '-c', + 'model_providers.clawx-custom.env_http_headers={ "X-Route" = "CLAWX_CODEX_CUSTOM_RESPONSES_HEADER_X_ROUTE", "X-Trace-Id" = "CLAWX_CODEX_CUSTOM_RESPONSES_HEADER_X_TRACE_ID" }', + '--model', + 'gpt-custom', + ]); + const profileFile = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8'); + expect(profileFile).toContain('CLAWX_CODEX_CUSTOM_RESPONSES_API_KEY'); + expect(profileFile).toContain('CLAWX_CODEX_CUSTOM_RESPONSES_HEADER_X_ROUTE'); + expect(profileFile).not.toContain('custom-secret-value'); + expect(profileFile).not.toContain('route-secret'); + expect(profileFile).not.toContain('trace-visible-but-secret'); + const codexConfig = await readFile(join(accountCodexHome('custom-responses'), 'config.toml'), 'utf8'); + expect(codexConfig).toContain('model_provider = "clawx-custom"'); + expect(codexConfig).toContain('base_url = "https://gateway.example/openai"'); + expect(codexConfig).toContain('env_http_headers = { "X-Route" = "CLAWX_CODEX_CUSTOM_RESPONSES_HEADER_X_ROUTE", "X-Trace-Id" = "CLAWX_CODEX_CUSTOM_RESPONSES_HEADER_X_TRACE_ID" }'); + expect(codexConfig).not.toContain('custom-secret-value'); + expect(codexConfig).not.toContain('route-secret'); + expect(codexConfig).not.toContain('trace-visible-but-secret'); + }); + + it('maps ByteDance compatible custom providers to the Codex Responses endpoint with env header refs', async () => { + getDefaultProviderAccountIdMock.mockResolvedValue('bd-responses'); + getProviderAccountMock.mockResolvedValue({ + id: 'bd-responses', + vendorId: 'custom', + label: 'bd-openai', + authMode: 'api_key', + baseUrl: `${bdBaseUrl}/v2/crawl`, + apiProtocol: 'openai-responses', + headers: { + extra: '{"session_id":"custom-sticky-session"}', + 'X-TT-Env': 'boe-secret', + }, + model: 'gpt-5.5-2026-04-24', + enabled: true, + isDefault: true, + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-06-07T00:00:00.000Z', + }); + getProviderSecretMock.mockResolvedValue({ + type: 'api_key', + accountId: 'bd-responses', + apiKey: 'bd-secret-value', + }); + const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile'); + + const profile = await syncCcConnectProviderProfile(); + + expect(profile).toMatchObject({ + providerId: 'bd-responses', + vendorId: 'custom', + supported: true, + model: 'gpt-5.5-2026-04-24', + env: { + [bdApiKeyEnv]: 'bd-secret-value', + [bdExtraHeaderEnv]: '{"session_id":"custom-sticky-session"}', + [bdStickySessionEnv]: 'custom-sticky-session', + [bdTtEnvHeader]: 'boe-secret', + CODEX_HOME: accountCodexHome('bd-responses'), + }, + ccConnectProvider: { + name: bdProviderKey, + apiKeyEnvKey: bdApiKeyEnv, + baseUrl: bdBaseUrl, + model: 'gpt-5.5-2026-04-24', + wireApi: 'responses', + }, + }); + expect(profile.codexArgs).toContain(`model_provider="${bdProviderKey}"`); + expect(profile.codexArgs).toContain(`model_providers.${bdProviderKey}.base_url="${bdBaseUrl}"`); + expect(profile.codexArgs).toContain('model_reasoning_effort="none"'); + expect(profile.codexArgs).toContain(`model_providers.${bdProviderKey}.env_http_headers={ "X-TT-Env" = "${bdTtEnvHeader}", "Api-Key" = "${bdApiKeyEnv}", "extra" = "${bdExtraHeaderEnv}" }`); + + const codexConfig = await readFile(join(accountCodexHome('bd-responses'), 'config.toml'), 'utf8'); + expect(codexConfig).toContain(`model_provider = "${bdProviderKey}"`); + expect(codexConfig).toContain('model_reasoning_effort = "none"'); + expect(codexConfig).toContain(`base_url = "${bdBaseUrl}"`); + expect(codexConfig).toContain(`env_http_headers = { "X-TT-Env" = "${bdTtEnvHeader}", "Api-Key" = "${bdApiKeyEnv}", "extra" = "${bdExtraHeaderEnv}" }`); + expect(codexConfig).not.toContain('bd-secret-value'); + expect(codexConfig).not.toContain('custom-sticky-session'); + expect(codexConfig).not.toContain('boe-secret'); + + const profileFile = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'provider-profile.json'), 'utf8'); + expect(profileFile).toContain(bdApiKeyEnv); + expect(profileFile).toContain(bdExtraHeaderEnv); + expect(profileFile).toContain(bdStickySessionEnv); + expect(profileFile).toContain(bdTtEnvHeader); + expect(profileFile).not.toContain('bd-secret-value'); + expect(profileFile).not.toContain('custom-sticky-session'); + expect(profileFile).not.toContain('boe-secret'); + }); + + it('marks custom chat completions providers unsupported because Codex only accepts responses wire api', async () => { + getDefaultProviderAccountIdMock.mockResolvedValue('custom-chat'); + getProviderAccountMock.mockResolvedValue({ + id: 'custom-chat', + vendorId: 'custom', + label: 'Custom Chat', + authMode: 'api_key', + baseUrl: 'https://gateway.example/openai', + apiProtocol: 'openai-completions', + model: 'gpt-custom', + enabled: true, + isDefault: true, + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-06-07T00:00:00.000Z', + }); + getProviderSecretMock.mockResolvedValue({ + type: 'api_key', + accountId: 'custom-chat', + apiKey: 'custom-secret-value', + }); + const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile'); + + await expect(syncCcConnectProviderProfile()).resolves.toMatchObject({ + providerId: 'custom-chat', + vendorId: 'custom', + supported: false, + unsupportedReason: expect.stringContaining('Chat Completions'), + codexArgs: [], + }); + }); + + it('marks non-Codex-compatible providers unsupported without mutating OpenClaw', async () => { + getDefaultProviderAccountIdMock.mockResolvedValue('anthropic-main'); + getProviderAccountMock.mockResolvedValue({ + id: 'anthropic-main', + vendorId: 'anthropic', + label: 'Anthropic', + authMode: 'api_key', + model: 'claude-opus-4-6', + enabled: true, + isDefault: true, + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-06-07T00:00:00.000Z', + }); + getProviderSecretMock.mockResolvedValue({ type: 'api_key', accountId: 'anthropic-main', apiKey: 'sk-ant' }); + const { syncCcConnectProviderProfile } = await import('@electron/runtime/cc-connect-provider-profile'); + + await expect(syncCcConnectProviderProfile()).resolves.toMatchObject({ + providerId: 'anthropic-main', + vendorId: 'anthropic', + supported: false, + unsupportedReason: expect.stringContaining('not supported yet'), + }); + }); +}); diff --git a/tests/unit/cc-connect-real-gate-handoff.test.ts b/tests/unit/cc-connect-real-gate-handoff.test.ts new file mode 100644 index 000000000..273443619 --- /dev/null +++ b/tests/unit/cc-connect-real-gate-handoff.test.ts @@ -0,0 +1,183 @@ +// @vitest-environment node +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + buildExternalGateHandoff, + parseArgs, + toJson, + toJsonPayload, + toMarkdown, + writeHandoff, +} from '../../scripts/cc-connect-real-gate-handoff.mjs'; + +const partialReport = { + generatedAt: '2026-06-27T23:54:28.368Z', + status: 'partial', + runtimeMatrixStatus: 'partial', + replacementReadiness: { + replacementReady: false, + }, + missingPreconditions: [ + { id: 'openai-api-key-env' }, + { id: 'feishu-env' }, + { id: 'feishu-inbound-fixture' }, + ], + checks: [ + { + id: 'codex-oauth-auth-json', + status: 'pass', + details: { + tokenPath: '/tmp/fixture/auth.json', + access_token: 'must-not-leak', + refresh_token: 'must-not-leak', + }, + }, + ], + coverage: [ + { + id: 'openai-api-key-provider-model-chat', + status: 'skipped', + reason: 'OPENAI_API_KEY is not configured.', + }, + { + id: 'feishu-live-channel-lifecycle', + status: 'skipped', + reason: 'Feishu/Lark credentials are not configured.', + }, + { + id: 'feishu-live-inbound-delivery', + status: 'skipped', + reason: 'Feishu/Lark inbound fixture is not enabled.', + }, + ], +}; + +describe('cc-connect real gate handoff', () => { + it('parses default and explicit report/output paths', () => { + expect(parseArgs([])).toMatchObject({ + reportPath: expect.stringContaining('artifacts/cc-connect/local-real-validation-report.json'), + outputPath: expect.stringContaining('artifacts/cc-connect/local-real-external-gates.md'), + jsonOutputPath: expect.stringContaining('artifacts/cc-connect/local-real-external-gates.json'), + }); + expect(parseArgs(['--report=/tmp/report.json', '--output=/tmp/handoff.md'])).toMatchObject({ + reportPath: '/tmp/report.json', + outputPath: '/tmp/handoff.md', + jsonOutputPath: '/tmp/handoff.json', + }); + expect(parseArgs(['--output=/tmp/handoff.md', '--json-output=/tmp/machine.json'])).toMatchObject({ + outputPath: '/tmp/handoff.md', + jsonOutputPath: '/tmp/machine.json', + }); + expect(parseArgs(['--json-output=/tmp/machine.json', '--output=/tmp/handoff.md'])).toMatchObject({ + outputPath: '/tmp/handoff.md', + jsonOutputPath: '/tmp/machine.json', + }); + expect(parseArgs(['--help'])).toMatchObject({ help: true }); + }); + + it('builds external gate handoff rows from a partial report', () => { + expect(buildExternalGateHandoff(partialReport)).toEqual([ + expect.objectContaining({ + id: 'openai-api-key-provider-model-chat', + currentStatus: 'skipped', + missingPreconditions: ['openai-api-key-env'], + command: 'pnpm run verify:cc-connect:local-real:api-key', + }), + expect.objectContaining({ + id: 'feishu-live-channel-lifecycle', + currentStatus: 'skipped', + missingPreconditions: ['feishu-env'], + command: 'pnpm run verify:cc-connect:local-real:feishu', + }), + expect.objectContaining({ + id: 'feishu-live-inbound-delivery', + currentStatus: 'skipped', + missingPreconditions: ['feishu-env', 'feishu-inbound-fixture'], + command: 'pnpm run verify:cc-connect:local-real:feishu-inbound', + }), + ]); + }); + + it('renders a credential-free markdown handoff', () => { + const markdown = toMarkdown(partialReport); + + expect(markdown).toContain('# cc-connect External Gate Handoff'); + expect(markdown).toContain('pnpm run verify:cc-connect:local-real:external-gates:check'); + expect(markdown).toContain('pnpm run verify:cc-connect:local-real:external-gates'); + expect(markdown).toContain('| Gate | Current Status | Missing Preconditions | Command | Required Inputs | Optional Inputs |'); + expect(markdown).toContain('CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY'); + expect(markdown).toContain('CLAWX_REAL_FEISHU_APP_SECRET'); + expect(markdown).toContain('artifacts/cc-connect/feishu-inbound-marker.json'); + expect(markdown).toContain('Do not add real API keys, OAuth tokens, app secrets'); + expect(markdown).not.toContain('must-not-leak'); + expect(markdown).not.toContain('access_token'); + expect(markdown).not.toContain('refresh_token'); + }); + + it('renders a credential-free machine-readable JSON handoff', () => { + const payload = toJsonPayload(partialReport); + const serialized = toJson(partialReport); + + expect(payload).toMatchObject({ + schemaVersion: 1, + sourceReport: { + generatedAt: '2026-06-27T23:54:28.368Z', + status: 'partial', + runtimeMatrixStatus: 'partial', + replacementReady: false, + }, + commands: { + nonDestructiveCheck: 'pnpm run verify:cc-connect:local-real:external-gates:check', + reportWritingRerun: 'pnpm run verify:cc-connect:local-real:external-gates', + }, + requiredExternalGates: [ + expect.objectContaining({ + id: 'openai-api-key-provider-model-chat', + requiredInputs: ['CLAWX_REAL_OPENAI_API_KEY or OPENAI_API_KEY'], + }), + expect.objectContaining({ + id: 'feishu-live-channel-lifecycle', + missingPreconditions: ['feishu-env'], + }), + expect.objectContaining({ + id: 'feishu-live-inbound-delivery', + missingPreconditions: ['feishu-env', 'feishu-inbound-fixture'], + }), + ], + safety: { + sanitized: true, + }, + }); + expect(serialized).toContain('verify:cc-connect:local-real:external-gates:check'); + expect(serialized).not.toContain('must-not-leak'); + expect(serialized).not.toContain('access_token'); + expect(serialized).not.toContain('refresh_token'); + }); + + it('writes the handoff artifact next to the sanitized validation report', async () => { + const root = await mkdtemp(join(tmpdir(), 'clawx-cc-connect-handoff-')); + try { + const reportPath = join(root, 'report.json'); + const outputPath = join(root, 'handoff.md'); + const jsonOutputPath = join(root, 'handoff.json'); + await writeFile(reportPath, JSON.stringify(partialReport), 'utf8'); + + const result = await writeHandoff(reportPath, outputPath); + const written = await readFile(outputPath, 'utf8'); + const writtenJson = await readFile(jsonOutputPath, 'utf8'); + + expect(result.outputPath).toBe(outputPath); + expect(result.jsonOutputPath).toBe(jsonOutputPath); + expect(written).toBe(result.markdown); + expect(writtenJson).toBe(result.json); + expect(written).toContain('Replacement ready: no'); + expect(written).not.toContain('must-not-leak'); + expect(writtenJson).toContain('"schemaVersion": 1'); + expect(writtenJson).not.toContain('must-not-leak'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/cc-connect-runtime-provider.test.ts b/tests/unit/cc-connect-runtime-provider.test.ts new file mode 100644 index 000000000..11e23469e --- /dev/null +++ b/tests/unit/cc-connect-runtime-provider.test.ts @@ -0,0 +1,2944 @@ +// @vitest-environment node +import { createServer as createHttpServer, type Server as HttpServer } from 'node:http'; +import { createServer as createTcpServer, type Server as TcpServer } from 'node:net'; +import { access, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const forkMock = vi.fn(); +const appPath = new Map(); +const originalCodexWorkDir = process.env.CLAWX_CODEX_WORKDIR; +const { readOpenClawConfigMock, execFileMock, getProviderAccountMock, getProviderSecretMock } = vi.hoisted(() => ({ + readOpenClawConfigMock: vi.fn(), + getProviderAccountMock: vi.fn(), + getProviderSecretMock: vi.fn(), + execFileMock: vi.fn((_file: string, _args: string[], _options: unknown, callback: (error: Error | null, stdout: string, stderr: string) => void) => { + callback(null, '', ''); + }), +})); + +vi.mock('node:child_process', () => ({ + spawn: forkMock, + execFile: execFileMock, +})); + +vi.mock('@electron/utils/channel-config', () => ({ + readOpenClawConfig: (...args: unknown[]) => readOpenClawConfigMock(...args), +})); + +vi.mock('@electron/services/providers/provider-store', () => ({ + getProviderAccount: (...args: unknown[]) => getProviderAccountMock(...args), + getDefaultProviderAccountId: vi.fn(async () => 'oauth-a'), +})); + +vi.mock('@electron/services/secrets/secret-store', () => ({ + getProviderSecret: (...args: unknown[]) => getProviderSecretMock(...args), + getSecretStore: () => ({ delete: vi.fn() }), +})); + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: vi.fn((name: string) => appPath.get(name) ?? tmpdir()), + }, +})); + +function createChild() { + const handlers = new Map void>>(); + const stdoutHandlers: Array<(data: Buffer) => void> = []; + const stderrHandlers: Array<(data: Buffer) => void> = []; + return { + pid: 4242, + killed: false, + exitCode: null, + stdout: { on: vi.fn((_event: string, handler: (data: Buffer) => void) => stdoutHandlers.push(handler)) }, + stderr: { on: vi.fn((_event: string, handler: (data: Buffer) => void) => stderrHandlers.push(handler)) }, + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + handlers.set(event, [...(handlers.get(event) ?? []), handler]); + if (event === 'spawn') queueMicrotask(handler); + return undefined; + }), + once: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + handlers.set(event, [...(handlers.get(event) ?? []), handler]); + if (event === 'spawn') queueMicrotask(handler); + return undefined; + }), + kill: vi.fn(), + writeStdout: (data: string) => { + for (const handler of stdoutHandlers) handler(Buffer.from(data)); + }, + writeStderr: (data: string) => { + for (const handler of stderrHandlers) handler(Buffer.from(data)); + }, + emit: (event: string, ...args: unknown[]) => { + for (const handler of handlers.get(event) ?? []) handler(...args); + }, + }; +} + +async function occupyTcpPort(port: number): Promise { + return await new Promise((resolve) => { + const server = createTcpServer(); + server.once('error', () => resolve(null)); + server.listen(port, '127.0.0.1', () => resolve(server)); + }); +} + +async function listenHttp(server: HttpServer, port: number): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => resolve()); + }); +} + +async function closeServer(server: TcpServer | HttpServer | null | undefined): Promise { + if (!server) return; + await new Promise((resolve) => server.close(() => resolve())); +} + +describe('CcConnectRuntimeProvider', () => { + let tempDir: string; + + beforeEach(async () => { + vi.resetModules(); + forkMock.mockReset(); + execFileMock.mockReset(); + getProviderAccountMock.mockReset(); + getProviderSecretMock.mockReset(); + execFileMock.mockImplementation((_file: string, _args: string[], _options: unknown, callback: (error: Error | null, stdout: string, stderr: string) => void) => { + callback(null, '', ''); + }); + tempDir = await mkdtemp(join(tmpdir(), 'clawx-cc-connect-')); + appPath.set('userData', tempDir); + delete process.env.CLAWX_CODEX_WORKDIR; + readOpenClawConfigMock.mockResolvedValue({}); + }); + + it('scopes ClawX local cron sessions by agent project', async () => { + const { ccConnectSessionLogicalKey } = await import('@electron/runtime/cc-connect-provider'); + + expect(ccConnectSessionLogicalKey( + 'clawx-research', + 'line:clawx-scheduled-cron', + 'cron-session-1', + true, + )).toBe('agent:research:cron:scheduled'); + expect(ccConnectSessionLogicalKey( + 'clawx-main', + 'line:clawx-scheduled-cron', + 'cron-session-2', + false, + )).toBe('agent:main:cron:scheduled:cron-session-2'); + expect(ccConnectSessionLogicalKey( + 'clawx-research', + 'feishu:chat-1:user-1', + 'channel-session', + true, + )).toBe('feishu:chat-1:user-1'); + }); + + afterEach(async () => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + if (originalCodexWorkDir === undefined) { + delete process.env.CLAWX_CODEX_WORKDIR; + } else { + process.env.CLAWX_CODEX_WORKDIR = originalCodexWorkDir; + } + await rm(tempDir, { recursive: true, force: true }); + }); + + function createBridgeAdapterMock(overrides: Record = {}) { + return { + connect: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + send: vi.fn(async () => ({ runId: 'cc-connect-run-1' })), + abort: vi.fn(async () => ({ + success: true, + abortedRuns: [], + stoppedSessions: [], + upstreamStopRequested: true, + })), + respondApproval: vi.fn(async ({ runId, action }: { runId: string; action: string }) => ({ + success: true, + runId, + action, + status: action === 'perm:deny' ? 'denied' : 'approved', + })), + forgetSession: vi.fn(), + isConnected: vi.fn(() => true), + listSessions: vi.fn(async () => [{ key: 'agent:main:main', displayName: 'main', updatedAt: 2 }]), + loadHistory: vi.fn(async () => [{ role: 'assistant', content: 'assistant ok', timestamp: 2 }]), + deleteSession: vi.fn(async () => undefined), + renameSession: vi.fn(async () => undefined), + getSessionLabel: vi.fn(async () => undefined), + summarizeSessions: vi.fn(async (sessionKeys: string[]) => sessionKeys.map((sessionKey) => ({ + sessionKey, + firstUserText: 'hello', + lastTimestamp: 2, + }))), + ...overrides, + }; + } + + it('routes approval responses exclusively through the cc-connect Bridge adapter', async () => { + const bridgeAdapter = createBridgeAdapterMock(); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + bridgeAdapter: bridgeAdapter as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile()) as never, + }); + + await expect(provider.rpc('chat.approval.respond', { + runId: 'cc-connect-run-1', + action: 'perm:allow', + })).resolves.toMatchObject({ + success: true, + status: 'approved', + }); + expect(bridgeAdapter.respondApproval).toHaveBeenCalledWith({ + runId: 'cc-connect-run-1', + action: 'perm:allow', + }); + }); + + function createApiSession( + logicalKey: string, + overrides: Record = {}, + ) { + const [, agentId = 'main', ...suffix] = logicalKey.split(':'); + return { + projectName: `clawx-${agentId}`, + agentId, + id: suffix.join(':') || 'main', + sessionKey: `clawx:${agentId}:${suffix.join(':') || 'main'}`, + logicalKey, + active: true, + createdAt: 1, + updatedAt: 2, + ...overrides, + }; + } + + function createSessionApiMock(options: { + sessions?: () => unknown[]; + histories?: Record; + } = {}) { + const sessions = options.sessions ?? (() => [createApiSession('agent:main:main', { name: 'main' })]); + const histories = options.histories ?? { + 'agent:main:main': [{ role: 'assistant', content: 'assistant ok', timestamp: 2 }], + }; + return { + listSessions: vi.fn(async () => sessions()), + loadHistory: vi.fn(async (session: { logicalKey: string }) => histories[session.logicalKey] ?? []), + deleteSession: vi.fn(async () => undefined), + }; + } + + function createSessionMetadataStoreMock(labels: Record = {}) { + return { + getLabel: vi.fn(async (sessionKey: string) => labels[sessionKey]), + setLabel: vi.fn(async (sessionKey: string, label: string) => { + labels[sessionKey] = label; + }), + deleteLabel: vi.fn(async (sessionKey: string) => { + delete labels[sessionKey]; + }), + }; + } + + function createProviderProfile(overrides: Record = {}) { + return { + providerId: 'ollama-local', + vendorId: 'ollama', + model: 'qwen3:latest', + modelRef: 'ollama/qwen3:latest', + supported: true, + codexArgs: ['--oss', '--local-provider', 'ollama', '--model', 'qwen3:latest'], + secretAvailable: false, + updatedAt: '2026-06-07T00:00:00.000Z', + ...overrides, + }; + } + + it('does not require a bundled Codex binary while the cc-connect runtime is inactive', async () => { + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + + const provider = new CcConnectRuntimeProvider({ + codexBundle: { + baseDir: join(tempDir, 'missing-codex'), + binaryPath: join(tempDir, 'missing-codex', 'bin', 'codex'), + pathDir: join(tempDir, 'missing-codex', 'codex-path'), + targetTriple: 'test-target', + }, + }); + + expect(provider.getStatus()).toMatchObject({ + runtimeKind: 'cc-connect', + state: 'stopped', + }); + expect(provider.listCapabilities()).toMatchObject({ + chat: true, + providers: true, + }); + }); + + it('creates managed config, starts cc-connect, and connects the ClawX bridge adapter', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const bridgeAdapter = createBridgeAdapterMock(); + const providerProfileLoader = vi.fn(async () => createProviderProfile()); + + const skillSyncer = vi.fn(async () => ({ skills: [] })); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + skillSyncer, + providerProfileLoader: providerProfileLoader as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce()); + child.emit('spawn'); + await startPromise; + const configPath = join(tempDir, 'runtimes', 'cc-connect', 'config.toml'); + const config = await readFile(configPath, 'utf8'); + expect(config).toContain('[management]'); + expect(config).toContain('enabled = true'); + expect(config).toContain('[bridge]'); + expect(config).toContain('path = "/bridge/ws"'); + expect(config).toContain('name = "clawx-main"'); + expect(config).toContain('reply_footer = false'); + expect(config).toContain('admin_from = "clawx-desktop"'); + expect(config).toContain('type = "codex"'); + expect(config).toContain('mode = "full-auto"'); + expect(config).toContain('backend = "app_server"'); + expect(config).toContain('app_server_url = "stdio://"'); + expect(config).toContain('[[projects.platforms]]'); + expect(config).toContain('type = "line"'); + expect(config).toContain('channel_secret = "clawx-local-placeholder"'); + expect(config).toContain('channel_token = "clawx-local-placeholder"'); + expect(config).toContain('port = "0"'); + expect(config).toContain(`cmd = "${join(tempDir, 'codex').replace(/\\/g, '\\\\')}"`); + expect(forkMock).toHaveBeenCalledWith(binaryPath, [ + '-config', + configPath, + ], expect.objectContaining({ + cwd: join(tempDir, 'runtimes', 'cc-connect'), + stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + env: expect.objectContaining({ + CODEX_HOME: join(tempDir, 'runtimes', 'cc-connect', 'codex-home'), + }), + })); + expect(bridgeAdapter.connect).toHaveBeenCalledOnce(); + expect(providerProfileLoader).toHaveBeenCalledWith({ reason: 'runtime-start' }); + expect(provider.getStatus()).toMatchObject({ + state: 'running', + pid: 4242, + runtimeKind: 'cc-connect', + capabilities: expect.objectContaining({ chat: true, doctor: true, providers: true, models: true, skills: true, cron: true }), + }); + }); + + it('probes the live process, Bridge, and Management project before reporting healthy', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const bridgeAdapter = createBridgeAdapterMock(); + let managementAvailable = true; + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + if (String(input).endsWith('/api/v1/projects/clawx-main') && managementAvailable) { + return new Response(JSON.stringify({ ok: true, data: { name: 'clawx-main' } }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: false, error: 'management unavailable' }), { status: 503 }); + })); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile()) as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce()); + child.emit('spawn'); + await startPromise; + + await expect(provider.checkHealth({ probe: true })).resolves.toMatchObject({ ok: true }); + expect(bridgeAdapter.isConnected).toHaveBeenCalled(); + + bridgeAdapter.isConnected.mockReturnValue(false); + managementAvailable = false; + await expect(provider.checkHealth()).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining('Bridge is disconnected'), + }); + await expect(provider.checkHealth()).resolves.toMatchObject({ + error: expect.stringContaining('Management API probe failed'), + }); + }); + + it('reads provider and model profiles from every live cc-connect project without restarting', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + readOpenClawConfigMock.mockResolvedValue({ + agents: { + list: [ + { id: 'main', default: true }, + { id: 'research' }, + ], + }, + }); + const bridgeAdapter = createBridgeAdapterMock(); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + expect(init?.method).toBe('GET'); + const projectName = url.includes('clawx-research') ? 'clawx-research' : 'clawx-main'; + if (url.endsWith('/providers')) { + return new Response(JSON.stringify({ + ok: true, + data: { + providers: [{ + name: `provider-${projectName}`, + active: true, + model: `model-${projectName}`, + api_key: 'must-not-cross-host-api', + }], + active_provider: `provider-${projectName}`, + token: 'must-not-cross-host-api', + }, + }), { status: 200 }); + } + if (url.endsWith('/models')) { + return new Response(JSON.stringify({ + ok: true, + data: { models: [`model-${projectName}`], current: `model-${projectName}` }, + }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: false, error: `unexpected ${url}` }), { status: 500 }); + }); + vi.stubGlobal('fetch', fetchMock); + + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile()) as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce()); + child.emit('spawn'); + await startPromise; + + await expect(provider.rpc('providers.profile')).resolves.toMatchObject({ + success: true, + runtimeState: 'running', + profile: expect.objectContaining({ providerId: 'ollama-local' }), + projects: [ + expect.objectContaining({ + projectName: 'clawx-main', + providers: expect.objectContaining({ activeProvider: 'provider-clawx-main' }), + models: expect.objectContaining({ current: 'model-clawx-main' }), + }), + expect.objectContaining({ + projectName: 'clawx-research', + providers: expect.objectContaining({ activeProvider: 'provider-clawx-research' }), + models: expect.objectContaining({ current: 'model-clawx-research' }), + }), + ], + }); + const publicResult = await provider.rpc('models.profile'); + expect(JSON.stringify(publicResult)).not.toContain('must-not-cross-host-api'); + expect(forkMock).toHaveBeenCalledOnce(); + expect(child.kill).not.toHaveBeenCalled(); + }); + + it('configures different cc-connect projects with isolated OAuth account launchers', async () => { + readOpenClawConfigMock.mockResolvedValue({ + agents: { + list: [ + { id: 'main', default: true, model: { primary: 'openai/gpt-main-override' } }, + { id: 'reviewer', model: { primary: 'openai/gpt-reviewer-override' } }, + ], + }, + }); + await mkdir(join(tempDir, 'app'), { recursive: true }); + await writeFile(join(tempDir, 'app', 'agent-bindings.json'), JSON.stringify({ + schema: 'clawx-agent-bindings', + version: 1, + agents: { + reviewer: { + providerAccountId: 'oauth-b', + permissionMode: 'suggest', + updatedAt: '2026-07-11T00:00:00.000Z', + }, + }, + }), 'utf8'); + getProviderAccountMock.mockImplementation(async (accountId: string) => accountId === 'oauth-b' ? { + id: 'oauth-b', + vendorId: 'openai', + label: 'OAuth B', + authMode: 'oauth_browser', + model: 'gpt-b', + enabled: true, + isDefault: false, + createdAt: '2026-07-11T00:00:00.000Z', + updatedAt: '2026-07-11T00:00:00.000Z', + } : null); + getProviderSecretMock.mockResolvedValue({ + type: 'oauth', + accountId: 'oauth-b', + accessToken: 'access-b', + refreshToken: 'refresh-b', + idToken: 'id-b', + subject: 'acct-b', + expiresAt: Date.now() + 60_000, + }); + const binaryPath = join(tempDir, 'cc-connect'); + const codexPath = join(tempDir, 'codex'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const skillSyncer = vi.fn(async () => ({ skills: [] })); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath, + bridgeAdapter: createBridgeAdapterMock() as never, + skillSyncer, + providerProfileLoader: vi.fn(async () => createProviderProfile({ + providerId: 'oauth-a', + vendorId: 'openai', + model: 'gpt-a', + env: { CODEX_HOME: join(tempDir, 'credentials', 'oauth', 'oauth-a', 'codex-home') }, + codexHomeDir: join(tempDir, 'credentials', 'oauth', 'oauth-a', 'codex-home'), + })) as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + + await provider.start(); + + const config = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'config.toml'), 'utf8'); + const mainBlock = config.slice(config.indexOf('name = "clawx-main"'), config.indexOf('name = "clawx-reviewer"')); + const reviewerBlock = config.slice(config.indexOf('name = "clawx-reviewer"')); + expect(mainBlock).toContain('model = "gpt-main-override"'); + expect(mainBlock).toContain('codex-oauth-a'); + expect(mainBlock).toContain('mode = "full-auto"'); + expect(reviewerBlock).toContain('model = "gpt-reviewer-override"'); + expect(reviewerBlock).toContain('codex-oauth-b'); + expect(reviewerBlock).toContain('mode = "suggest"'); + await expect(readFile(join(tempDir, 'credentials', 'oauth', 'oauth-b', 'codex-home', 'auth.json'), 'utf8')) + .resolves.toContain('"account_id": "acct-b"'); + expect(skillSyncer).toHaveBeenCalledWith(join(tempDir, 'credentials', 'oauth', 'oauth-a', 'codex-home')); + expect(skillSyncer).toHaveBeenCalledWith(join(tempDir, 'credentials', 'oauth', 'oauth-b', 'codex-home')); + skillSyncer.mockClear(); + await provider.rpc('skills.update'); + expect(skillSyncer).toHaveBeenCalledTimes(2); + expect(skillSyncer).toHaveBeenCalledWith(join(tempDir, 'credentials', 'oauth', 'oauth-a', 'codex-home')); + expect(skillSyncer).toHaveBeenCalledWith(join(tempDir, 'credentials', 'oauth', 'oauth-b', 'codex-home')); + }); + + it('configures different cc-connect projects with isolated API-key env and launchers', async () => { + readOpenClawConfigMock.mockResolvedValue({ + agents: { + list: [ + { id: 'main', default: true }, + { id: 'reviewer' }, + ], + }, + }); + await mkdir(join(tempDir, 'app'), { recursive: true }); + await writeFile(join(tempDir, 'app', 'agent-bindings.json'), JSON.stringify({ + schema: 'clawx-agent-bindings', + version: 1, + agents: { + reviewer: { providerAccountId: 'openai-b', updatedAt: '2026-07-11T00:00:00.000Z' }, + }, + }), 'utf8'); + getProviderAccountMock.mockImplementation(async (accountId: string) => accountId === 'openai-b' ? { + id: 'openai-b', + vendorId: 'openai', + label: 'OpenAI B', + authMode: 'api_key', + model: 'gpt-b', + enabled: true, + isDefault: false, + createdAt: '2026-07-11T00:00:00.000Z', + updatedAt: '2026-07-11T00:00:00.000Z', + } : null); + getProviderSecretMock.mockResolvedValue({ + type: 'api_key', + accountId: 'openai-b', + apiKey: 'sk-account-b', + }); + const binaryPath = join(tempDir, 'cc-connect'); + const codexPath = join(tempDir, 'codex'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const mainCodexHome = join(tempDir, 'credentials', 'oauth', 'openai-a', 'codex-home'); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath, + bridgeAdapter: createBridgeAdapterMock() as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile({ + providerId: 'openai-a', + vendorId: 'openai', + model: 'gpt-a', + env: { + CLAWX_CODEX_OPENAI_A_API_KEY: 'sk-account-a', + CODEX_HOME: mainCodexHome, + }, + codexHomeDir: mainCodexHome, + launcherEnv: { OPENAI_API_KEY: 'CLAWX_CODEX_OPENAI_A_API_KEY' }, + ccConnectProvider: { + name: 'openai', + apiKeyEnvKey: 'CLAWX_CODEX_OPENAI_A_API_KEY', + model: 'gpt-a', + }, + })) as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + + await provider.start(); + + const config = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'config.toml'), 'utf8'); + const mainBlock = config.slice(config.indexOf('name = "clawx-main"'), config.indexOf('name = "clawx-reviewer"')); + const reviewerBlock = config.slice(config.indexOf('name = "clawx-reviewer"')); + expect(mainBlock).toContain('api_key = "${CLAWX_CODEX_OPENAI_A_API_KEY}"'); + expect(mainBlock).toContain('codex-openai-a'); + expect(reviewerBlock).toContain('api_key = "${CLAWX_CODEX_OPENAI_B_API_KEY}"'); + expect(reviewerBlock).toContain('codex-openai-b'); + expect(forkMock).toHaveBeenCalledWith(binaryPath, expect.any(Array), expect.objectContaining({ + env: expect.objectContaining({ + CLAWX_CODEX_OPENAI_A_API_KEY: 'sk-account-a', + CLAWX_CODEX_OPENAI_B_API_KEY: 'sk-account-b', + }), + })); + await expect(readFile(join(tempDir, 'runtimes', 'cc-connect', 'config', 'launchers', 'codex-openai-a'), 'utf8')) + .resolves.toContain('export OPENAI_API_KEY="${CLAWX_CODEX_OPENAI_A_API_KEY}"'); + await expect(readFile(join(tempDir, 'runtimes', 'cc-connect', 'config', 'launchers', 'codex-openai-b'), 'utf8')) + .resolves.toContain('export OPENAI_API_KEY="${CLAWX_CODEX_OPENAI_B_API_KEY}"'); + }); + + it('validates provider support against the target Agent project instead of the default profile', async () => { + readOpenClawConfigMock.mockResolvedValue({ + agents: { + list: [ + { id: 'main', default: true }, + { id: 'reviewer' }, + ], + }, + }); + await mkdir(join(tempDir, 'app'), { recursive: true }); + await writeFile(join(tempDir, 'app', 'agent-bindings.json'), JSON.stringify({ + schema: 'clawx-agent-bindings', + version: 1, + agents: { + reviewer: { providerAccountId: 'missing-reviewer-account', updatedAt: '2026-07-12T00:00:00.000Z' }, + }, + }), 'utf8'); + getProviderAccountMock.mockResolvedValue(null); + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const bridgeAdapter = createBridgeAdapterMock(); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile({ + providerId: 'healthy-default', + supported: true, + })) as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + await provider.start(); + + await expect(provider.sendMessageWithMedia({ + sessionKey: 'agent:reviewer:main', + message: 'review', + idempotencyKey: 'reviewer-send', + })).rejects.toThrow('missing-reviewer-account'); + await expect(provider.sendMessageWithMedia({ + sessionKey: 'agent:main:main', + message: 'main', + idempotencyKey: 'main-send', + })).resolves.toMatchObject({ runId: 'cc-connect-run-1' }); + expect(bridgeAdapter.send).toHaveBeenCalledOnce(); + }); + + it('allows an Agent with a valid bound account when the default profile is unsupported', async () => { + readOpenClawConfigMock.mockResolvedValue({ + agents: { + list: [ + { id: 'main', default: true }, + { id: 'reviewer' }, + ], + }, + }); + await mkdir(join(tempDir, 'app'), { recursive: true }); + await writeFile(join(tempDir, 'app', 'agent-bindings.json'), JSON.stringify({ + schema: 'clawx-agent-bindings', + version: 1, + agents: { + reviewer: { providerAccountId: 'reviewer-account', updatedAt: '2026-07-12T00:00:00.000Z' }, + }, + }), 'utf8'); + getProviderAccountMock.mockResolvedValue({ + id: 'reviewer-account', + vendorId: 'openai', + label: 'Reviewer', + authMode: 'api_key', + model: 'gpt-reviewer', + enabled: true, + isDefault: false, + createdAt: '2026-07-12T00:00:00.000Z', + updatedAt: '2026-07-12T00:00:00.000Z', + }); + getProviderSecretMock.mockResolvedValue({ + type: 'api_key', + accountId: 'reviewer-account', + apiKey: 'sk-reviewer', + }); + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const bridgeAdapter = createBridgeAdapterMock(); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile({ + providerId: 'missing-default-account', + supported: false, + unsupportedReason: 'Default account is unavailable', + })) as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + await provider.start(); + + await expect(provider.sendMessageWithMedia({ + sessionKey: 'agent:reviewer:main', + message: 'review', + idempotencyKey: 'reviewer-send', + })).resolves.toMatchObject({ runId: 'cc-connect-run-1' }); + await expect(provider.sendMessageWithMedia({ + sessionKey: 'agent:main:main', + message: 'main', + idempotencyKey: 'main-send', + })).rejects.toThrow('Default account is unavailable'); + expect(bridgeAdapter.send).toHaveBeenCalledOnce(); + }); + + it('falls back to available cc-connect ports when the defaults are occupied', async () => { + const occupiedBridge = await occupyTcpPort(9810); + const occupiedManagement = await occupyTcpPort(9820); + const managementApi = createHttpServer((req, res) => { + expect(req.url).toBe('/api/v1/cron?project=clawx-main'); + expect(req.headers.authorization).toMatch(/^Bearer /); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, data: { jobs: [] } })); + }); + try { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const bridgeAdapter = createBridgeAdapterMock(); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile()) as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce()); + child.emit('spawn'); + await startPromise; + + const config = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'config.toml'), 'utf8'); + const managementPort = Number(config.match(/\[management\][\s\S]*?port = (\d+)/)?.[1]); + const bridgePort = Number(config.match(/\[bridge\][\s\S]*?port = (\d+)/)?.[1]); + expect(managementPort).toBeGreaterThan(0); + expect(bridgePort).toBeGreaterThan(0); + if (occupiedManagement) expect(managementPort).not.toBe(9820); + if (occupiedBridge) expect(bridgePort).not.toBe(9810); + expect(provider.getStatus().port).toBe(managementPort); + await expect(provider.rpc('runtime.controlUi')).resolves.toMatchObject({ + success: true, + url: `http://127.0.0.1:${managementPort}/`, + port: managementPort, + }); + + await listenHttp(managementApi, managementPort); + await expect(provider.rpc('cron.list')).resolves.toEqual([]); + await provider.stop(); + } finally { + await closeServer(managementApi); + await closeServer(occupiedBridge); + await closeServer(occupiedManagement); + } + }); + + it('emits session update events when cc-connect public API channel sessions change', async () => { + vi.useFakeTimers(); + try { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + let sessions = [createApiSession('agent:main:main', { name: 'main', updatedAt: 1000 })]; + const bridgeAdapter = createBridgeAdapterMock(); + const sessionApi = createSessionApiMock({ sessions: () => sessions }); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + sessionApi: sessionApi as never, + sessionMetadataStore: createSessionMetadataStoreMock(), + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile()) as never, + }); + const events: unknown[] = []; + provider.on('chat:runtime-event', (event) => events.push(event)); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce()); + child.emit('spawn'); + await startPromise; + await Promise.resolve(); + + expect(events).toEqual([]); + + sessions = [ + createApiSession('agent:main:main', { name: 'main', updatedAt: 1000 }), + createApiSession('feishu:chat-1:user-1', { + projectName: 'clawx-main', + agentId: 'main', + sessionKey: 'feishu:chat-1:user-1', + chatName: 'Channel', + updatedAt: 2000, + }), + ]; + await vi.advanceTimersByTimeAsync(2_000); + + expect(events).toEqual([ + expect.objectContaining({ + type: 'session.updated', + sessionKey: 'feishu:chat-1:user-1', + updatedAt: 2000, + reason: 'cc-connect-session-api', + }), + ]); + await provider.stop(); + } finally { + vi.useRealTimers(); + } + }); + + it('emits session update events when sessions.list observes new cc-connect sessions', async () => { + vi.useFakeTimers(); + try { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + let sessions = [createApiSession('agent:main:main', { name: 'main', updatedAt: 1000 })]; + const bridgeAdapter = createBridgeAdapterMock(); + const sessionApi = createSessionApiMock({ sessions: () => sessions }); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + sessionApi: sessionApi as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile()) as never, + }); + const events: unknown[] = []; + provider.on('chat:runtime-event', (event) => events.push(event)); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce()); + child.emit('spawn'); + await startPromise; + await Promise.resolve(); + expect(events).toEqual([]); + + sessions = [ + createApiSession('agent:main:main', { name: 'main', updatedAt: 1000 }), + createApiSession('agent:support:member-1', { name: 'Support', updatedAt: 2000 }), + ]; + + await expect(provider.listSessions()).resolves.toMatchObject({ + success: true, + sessions: expect.arrayContaining([ + expect.objectContaining({ key: 'agent:support:member-1' }), + ]), + }); + + expect(events).toEqual([ + expect.objectContaining({ + type: 'session.updated', + sessionKey: 'agent:support:member-1', + updatedAt: 2000, + reason: 'cc-connect-session-api', + }), + ]); + await provider.stop(); + } finally { + vi.useRealTimers(); + } + }); + + it('writes cc-connect provider config for custom Responses providers without persisting secrets', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const providerProfileLoader = vi.fn(async () => createProviderProfile({ + providerId: 'custom-responses', + vendorId: 'custom', + label: 'Custom Responses', + model: 'gpt-5.5', + codexArgs: [], + env: { CLAWX_CODEX_CUSTOM_API_KEY: 'secret-value' }, + ccConnectProvider: { + name: 'clawx-custom', + apiKeyEnvKey: 'CLAWX_CODEX_CUSTOM_API_KEY', + baseUrl: 'https://gateway.example/openai', + model: 'gpt-5.5', + wireApi: 'responses', + }, + secretAvailable: true, + })); + const bridgeAdapter = createBridgeAdapterMock(); + + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: providerProfileLoader as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce()); + child.emit('spawn'); + await startPromise; + + const config = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'config.toml'), 'utf8'); + expect(config).toContain('provider = "clawx-custom"'); + expect(config).toContain('[[projects.agent.providers]]'); + expect(config).toContain('name = "clawx-custom"'); + expect(config).toContain('api_key = "${CLAWX_CODEX_CUSTOM_API_KEY}"'); + expect(config).toContain('base_url = "https://gateway.example/openai"'); + expect(config).toContain('model = "gpt-5.5"'); + expect(config).toContain('wire_api = "responses"'); + expect(config).not.toContain('secret-value'); + expect(forkMock).toHaveBeenCalledWith(binaryPath, [ + '-config', + join(tempDir, 'runtimes', 'cc-connect', 'config.toml'), + ], expect.objectContaining({ + env: expect.objectContaining({ + CLAWX_CODEX_CUSTOM_API_KEY: 'secret-value', + }), + })); + }); + + it('terminates cc-connect and reports an error when bridge registration fails', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const bridgeAdapter = createBridgeAdapterMock({ + connect: vi.fn(async () => { + throw new Error('bridge registration rejected'); + }), + }); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile()) as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + + await expect(provider.start()).rejects.toThrow('bridge registration rejected'); + + expect(child.kill).toHaveBeenCalledOnce(); + expect(bridgeAdapter.close).toHaveBeenCalledOnce(); + expect(provider.getStatus()).toMatchObject({ + state: 'error', + pid: undefined, + gatewayReady: false, + error: 'bridge registration rejected', + }); + }); + + it('does not mutate cc-connect private sessions for ByteDance compatible providers', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const dataDir = join(tempDir, 'runtimes', 'cc-connect', 'data'); + const sessionsDir = join(dataDir, 'sessions'); + await mkdir(sessionsDir, { recursive: true }); + const sessionStorePath = join(sessionsDir, 'clawx-main_abc.json'); + await writeFile(sessionStorePath, JSON.stringify({ + sessions: { + s1: { + id: 's1', + agent_type: 'codex', + agent_session_id: 'stale-agent-session', + history: [{ role: 'user', content: 'hello' }], + }, + }, + active_session: { + 'feishu:oc_chat:ou_user': 's1', + }, + }, null, 2), 'utf8'); + const providerToken = ['model', 'hub'].join(''); + const stickyEnvKey = `CODEX_${providerToken.toUpperCase()}_STICKY_SESSION_ID`; + const extraEnvKey = `CODEX_${providerToken.toUpperCase()}_EXTRA_HEADER`; + const providerProfileLoader = vi.fn(async () => createProviderProfile({ + providerId: 'bd-responses', + vendorId: 'custom', + model: 'gpt-5.5', + env: { + [stickyEnvKey]: 'sticky-session', + [extraEnvKey]: '{"session_id":"sticky-session"}', + }, + ccConnectProvider: { + name: `${providerToken}_openapi`, + apiKeyEnvKey: 'BYTEDANCE_OPENAI_API_KEY', + baseUrl: 'https://gateway.example/openai', + model: 'gpt-5.5', + wireApi: 'responses', + }, + })); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: createBridgeAdapterMock() as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: providerProfileLoader as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce()); + child.emit('spawn'); + await startPromise; + + const stored = JSON.parse(await readFile(sessionStorePath, 'utf8')) as { + sessions: { s1: { agent_session_id?: unknown; history?: unknown[] } }; + }; + expect(stored.sessions.s1.agent_session_id).toBe('stale-agent-session'); + expect(stored.sessions.s1.history).toEqual([{ role: 'user', content: 'hello' }]); + await expect(access(join(dataDir, 'codex-agent-session-reset-v1.json'))).rejects.toThrow(); + }); + + it('returns cc-connect skills status instead of rejecting skill RPC', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + skillSyncer: vi.fn(async () => ({ skills: [] })), + }); + + await expect(provider.rpc('skills.status')).resolves.toMatchObject({ + skills: [], + }); + }); + + it('creates and lists cc-connect cron jobs in the selected agent project', async () => { + readOpenClawConfigMock.mockResolvedValue({ + agents: { + list: [ + { id: 'main', name: 'Main', default: true }, + { id: 'research', name: 'Research' }, + ], + }, + }); + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/api/v1/cron') && init?.method === 'POST') { + expect(init?.body).toBe(JSON.stringify({ + project: 'clawx-research', + session_key: 'line:clawx-scheduled-cron', + cron_expr: '0 9 * * *', + prompt: 'research daily', + description: 'Research daily', + silent: true, + enabled: true, + })); + return new Response(JSON.stringify({ + ok: true, + data: { + id: 'cron-research', + project: 'clawx-research', + session_key: 'line:clawx-scheduled-cron', + cron_expr: '0 9 * * *', + prompt: 'research daily', + description: 'Research daily', + enabled: true, + silent: true, + }, + }), { status: 200 }); + } + if (url.endsWith('/api/v1/cron?project=clawx-main') && init?.method === 'GET') { + return new Response(JSON.stringify({ ok: true, data: { jobs: [] } }), { status: 200 }); + } + if (url.endsWith('/api/v1/cron?project=clawx-research') && init?.method === 'GET') { + return new Response(JSON.stringify({ + ok: true, + data: { + jobs: [{ + id: 'cron-research', + project: 'clawx-research', + session_key: 'line:clawx-scheduled-cron', + cron_expr: '0 9 * * *', + prompt: 'research daily', + description: 'Research daily', + enabled: true, + silent: true, + }], + }, + }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: false, error: `unexpected ${init?.method} ${url}` }), { status: 500 }); + }); + vi.stubGlobal('fetch', fetchMock); + + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + skillSyncer: vi.fn(async () => ({ skills: [] })), + }); + + await expect(provider.rpc('cron.create', { + name: 'Research daily', + message: 'research daily', + schedule: { kind: 'cron', expr: '0 9 * * *' }, + agentId: 'research', + })).resolves.toMatchObject({ + id: 'cron-research', + agentId: 'research', + }); + await expect(provider.rpc('cron.list')).resolves.toEqual([ + expect.objectContaining({ + id: 'cron-research', + agentId: 'research', + }), + ]); + }); + + it('maps official cc-connect cron completion fields without inventing a run for Go zero time', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes('/api/v1/cron?project=') && init?.method === 'GET') { + return new Response(JSON.stringify({ + ok: true, + data: { + jobs: url.endsWith('clawx-main') ? [ + { + id: 'cron-success', + project: 'clawx-main', + cron_expr: '0 9 * * *', + prompt: 'success', + last_run: '2026-07-13T08:01:02Z', + last_error: '', + }, + { + id: 'cron-error', + project: 'clawx-main', + cron_expr: '0 10 * * *', + prompt: 'failure', + last_run: '2026-07-13T08:03:04Z', + last_error: 'provider request failed', + }, + { + id: 'cron-never-run', + project: 'clawx-main', + cron_expr: '0 11 * * *', + prompt: 'pending', + last_run: '0001-01-01T00:00:00Z', + last_error: '', + }, + ] : [], + }, + }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: false, error: `unexpected ${init?.method} ${url}` }), { status: 500 }); + }); + vi.stubGlobal('fetch', fetchMock); + + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + skillSyncer: vi.fn(async () => ({ skills: [] })), + }); + + const jobs = await provider.rpc>('cron.list'); + expect(jobs.find((job) => job.id === 'cron-success')?.lastRun).toEqual({ + time: '2026-07-13T08:01:02.000Z', + success: true, + }); + expect(jobs.find((job) => job.id === 'cron-error')?.lastRun).toEqual({ + time: '2026-07-13T08:03:04.000Z', + success: false, + error: 'provider request failed', + }); + expect(jobs.find((job) => job.id === 'cron-never-run')?.lastRun).toBeUndefined(); + }); + + it('rejects non-cron schedules for cc-connect cron create and update', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + skillSyncer: vi.fn(async () => ({ skills: [] })), + }); + + await expect(provider.rpc('cron.create', { + name: 'At schedule', + message: 'run once', + schedule: { kind: 'at', at: '2026-06-22T09:00:00.000Z' }, + })).rejects.toThrow('supports only cron expression schedules'); + await expect(provider.rpc('cron.update', { + id: 'cron-1', + input: { schedule: { kind: 'every', everyMs: 60_000 } }, + })).rejects.toThrow('supports only cron expression schedules'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('runs cron jobs only through the native cc-connect exec endpoint', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const bridgeAdapter = createBridgeAdapterMock(); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/api/v1/cron/cron-1/exec') && init?.method === 'POST') { + return new Response(JSON.stringify({ ok: true, data: { id: 'cron-1', status: 'triggered' } }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: false, error: `unexpected ${init?.method} ${url}` }), { status: 500 }); + }); + vi.stubGlobal('fetch', fetchMock); + + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + bridgeAdapter: bridgeAdapter as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + }); + + await expect(provider.rpc('cron.run', { id: 'cron-1' })).resolves.toEqual({ success: true }); + expect(bridgeAdapter.send).not.toHaveBeenCalled(); + }); + + it('returns the cc-connect Web Admin URL for runtime control UI', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + skillSyncer: vi.fn(async () => ({ skills: [] })), + }); + + await expect(provider.rpc('runtime.controlUi')).resolves.toMatchObject({ + success: true, + url: 'http://127.0.0.1:9820/', + port: 9820, + }); + }); + + it('does not route OpenClaw Dreams control UI requests to cc-connect Web Admin', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + skillSyncer: vi.fn(async () => ({ skills: [] })), + }); + + await expect(provider.rpc('runtime.controlUi', { view: 'dreams' })).resolves.toMatchObject({ + success: false, + error: expect.stringContaining('Dreams'), + }); + }); + + it('returns a stable channel status snapshot for cc-connect channel-capable runtime', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + skillSyncer: vi.fn(async () => ({ skills: [] })), + }); + + await expect(provider.rpc('channels.status', { probe: true })).resolves.toEqual({ + channels: {}, + channelAccounts: {}, + channelDefaultAccountId: {}, + }); + }); + + it('maps cc-connect channel lifecycle RPCs to runtime config refresh', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + skillSyncer: vi.fn(async () => ({ skills: [] })), + }); + const refreshSpy = vi.spyOn(provider, 'refreshConfig'); + + await expect(provider.rpc('channels.disconnect', { channelId: 'feishu-ops_bot' })).resolves.toEqual({ success: true }); + + expect(refreshSpy).toHaveBeenCalledWith({ + scope: 'channels', + reason: 'runtime:channels.disconnect:feishu:ops_bot', + channelType: 'feishu', + accountId: 'ops_bot', + forceRestart: true, + }); + }); + + it('reloads cc-connect channel config through the Management API without restarting when possible', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const bridgeAdapter = createBridgeAdapterMock(); + const providerProfileLoader = vi.fn(async () => createProviderProfile()); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/api/v1/reload') && init?.method === 'POST') { + return new Response(JSON.stringify({ + ok: true, + data: { message: 'config reloaded', projects_updated: ['clawx-main'] }, + }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: false, error: `unexpected ${init?.method} ${url}` }), { status: 500 }); + }); + vi.stubGlobal('fetch', fetchMock); + + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: providerProfileLoader as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce()); + child.emit('spawn'); + await startPromise; + + await expect(provider.rpc('channels.connect', { channelType: 'feishu' })).resolves.toEqual({ success: true }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/reload'), + expect.objectContaining({ method: 'POST' }), + ); + expect(child.kill).not.toHaveBeenCalled(); + expect(forkMock).toHaveBeenCalledOnce(); + }); + + it('toggles cc-connect cron jobs through the native cron.toggle RPC', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toContain('/api/v1/cron/cron-1'); + expect(init?.method).toBe('PATCH'); + expect(init?.body).toBe(JSON.stringify({ enabled: false })); + return new Response(JSON.stringify({ + ok: true, + data: { + id: 'cron-1', + description: 'Toggle me', + prompt: 'ping', + cron_expr: '0 9 * * *', + enabled: false, + silent: true, + }, + }), { status: 200 }); + }); + vi.stubGlobal('fetch', fetchMock); + + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + skillSyncer: vi.fn(async () => ({ skills: [] })), + }); + + await expect(provider.rpc('cron.toggle', { id: 'cron-1', enabled: false })).resolves.toMatchObject({ + id: 'cron-1', + name: 'Toggle me', + enabled: false, + }); + }); + + it('passes cc-connect exec cron fields through the Management API', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const workDir = join(tempDir, 'cron-workdir'); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/api/v1/cron') && init?.method === 'POST') { + expect(JSON.parse(String(init?.body))).toEqual({ + project: 'clawx-main', + session_key: 'line:clawx-scheduled-cron', + cron_expr: '0 10 * * *', + description: 'Exec smoke', + silent: false, + enabled: true, + exec: 'pnpm run report', + work_dir: workDir, + session_mode: 'new_per_run', + timeout_mins: 12, + }); + return new Response(JSON.stringify({ + ok: true, + data: { + id: 'cron-exec', + project: 'clawx-main', + session_key: 'line:clawx-scheduled-cron', + cron_expr: '0 10 * * *', + description: 'Exec smoke', + silent: false, + enabled: true, + exec: 'pnpm run report', + work_dir: workDir, + session_mode: 'new_per_run', + timeout_mins: 12, + }, + }), { status: 200 }); + } + if (url.endsWith('/api/v1/cron/cron-exec') && init?.method === 'PATCH') { + expect(JSON.parse(String(init?.body))).toEqual({ mute: true }); + return new Response(JSON.stringify({ + ok: true, + data: { + id: 'cron-exec', + project: 'clawx-main', + session_key: 'line:clawx-scheduled-cron', + cron_expr: '0 10 * * *', + description: 'Exec smoke', + silent: false, + enabled: true, + exec: 'pnpm run report', + work_dir: workDir, + session_mode: 'new_per_run', + timeout_mins: 12, + mute: true, + }, + }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: false, error: `unexpected ${init?.method} ${url}` }), { status: 500 }); + }); + vi.stubGlobal('fetch', fetchMock); + + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + skillSyncer: vi.fn(async () => ({ skills: [] })), + }); + + await expect(provider.rpc('cron.create', { + name: 'Exec smoke', + exec: 'pnpm run report', + workDir, + schedule: { kind: 'cron', expr: '0 10 * * *' }, + delivery: { mode: 'announce' }, + sessionMode: 'new_per_run', + timeoutMins: 12, + mute: true, + })).resolves.toMatchObject({ + id: 'cron-exec', + name: 'Exec smoke', + message: 'pnpm run report', + delivery: { mode: 'announce' }, + agentId: 'main', + exec: 'pnpm run report', + workDir, + sessionMode: 'new_per_run', + timeoutMins: 12, + mute: true, + }); + }); + + it('preserves cc-connect cron external delivery fields when explicitly configured', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/api/v1/cron') && init?.method === 'POST') { + expect(JSON.parse(String(init?.body))).toEqual({ + project: 'clawx-main', + session_key: 'feishu:chat:oc_123', + cron_expr: '0 9 * * *', + prompt: 'send daily summary', + delivery: { + mode: 'announce', + channel: 'feishu', + to: 'chat:oc_123', + account_id: 'ops_bot', + }, + description: 'Daily channel summary', + silent: false, + enabled: true, + }); + return new Response(JSON.stringify({ + ok: true, + data: { + id: 'cron-delivery', + project: 'clawx-main', + session_key: 'feishu:chat:oc_123', + cron_expr: '0 9 * * *', + prompt: 'send daily summary', + description: 'Daily channel summary', + silent: false, + enabled: true, + delivery: { + mode: 'announce', + channel: 'feishu', + to: 'chat:oc_123', + account_id: 'ops_bot', + }, + }, + }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: false, error: `unexpected ${init?.method} ${url}` }), { status: 500 }); + }); + vi.stubGlobal('fetch', fetchMock); + + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + skillSyncer: vi.fn(async () => ({ skills: [] })), + }); + + await expect(provider.rpc('cron.create', { + name: 'Daily channel summary', + message: 'send daily summary', + schedule: { kind: 'cron', expr: '0 9 * * *' }, + delivery: { + mode: 'announce', + channel: 'feishu', + accountId: 'ops_bot', + to: 'chat:oc_123', + }, + })).resolves.toMatchObject({ + id: 'cron-delivery', + name: 'Daily channel summary', + delivery: { + mode: 'announce', + channel: 'feishu', + accountId: 'ops_bot', + to: 'chat:oc_123', + }, + target: { + channelType: 'feishu', + channelId: 'ops_bot', + channelName: 'feishu', + recipient: 'chat:oc_123', + }, + }); + }); + + it('hydrates cc-connect exec cron updates with the existing job baseline', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const workDir = join(tempDir, 'cron-workdir'); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/api/v1/cron?project=clawx-main') && init?.method === 'GET') { + return new Response(JSON.stringify({ + ok: true, + data: { + jobs: [{ + id: 'cron-exec', + project: 'clawx-main', + session_key: 'clawx:main:main', + cron_expr: '0 10 * * *', + description: 'Exec smoke', + enabled: true, + silent: false, + mute: true, + exec: 'pnpm run report', + work_dir: workDir, + session_mode: 'new_per_run', + timeout_mins: 12, + }], + }, + }), { status: 200 }); + } + if (url.endsWith('/api/v1/cron/cron-exec') && init?.method === 'PATCH') { + expect(JSON.parse(String(init?.body))).toEqual({ + project: 'clawx-main', + session_key: 'line:clawx-scheduled-cron', + cron_expr: '0 10 * * *', + description: 'Exec smoke', + enabled: true, + silent: false, + mute: false, + exec: 'pnpm run updated-report', + work_dir: workDir, + session_mode: 'reuse', + timeout_mins: 15, + }); + return new Response(JSON.stringify({ + ok: true, + data: { + id: 'cron-exec', + project: 'clawx-main', + session_key: 'line:clawx-scheduled-cron', + cron_expr: '0 10 * * *', + description: 'Exec smoke', + enabled: true, + silent: false, + mute: false, + exec: 'pnpm run updated-report', + work_dir: workDir, + session_mode: 'reuse', + timeout_mins: 15, + }, + }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: false, error: `unexpected ${init?.method} ${url}` }), { status: 500 }); + }); + vi.stubGlobal('fetch', fetchMock); + + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + skillSyncer: vi.fn(async () => ({ skills: [] })), + }); + + await expect(provider.rpc('cron.update', { + id: 'cron-exec', + input: { + exec: 'pnpm run updated-report', + workDir, + sessionMode: 'continue', + timeoutMins: 15, + mute: false, + }, + })).resolves.toMatchObject({ + id: 'cron-exec', + name: 'Exec smoke', + delivery: { mode: 'announce' }, + agentId: 'main', + exec: 'pnpm run updated-report', + workDir, + sessionMode: 'continue', + timeoutMins: 15, + mute: false, + }); + }); + + it('mirrors configured OpenClaw channel accounts into cc-connect platform blocks', async () => { + readOpenClawConfigMock.mockResolvedValue({ + channels: { + telegram: { + defaultAccount: 'ops_bot', + accounts: { + ops_bot: { + token: 'telegram-secret-token', + allowFrom: ['12345', '67890'], + shareSessionInChannel: true, + }, + }, + }, + feishu: { + defaultAccount: 'lark_bot', + accounts: { + lark_bot: { + appId: 'cli_lark', + appSecret: 'lark-secret', + domain: 'global', + adminFrom: ['ou_cron_admin'], + enableFeishuCard: false, + groupReplyAll: true, + threadIsolation: true, + replyInThread: true, + reactionEmoji: 'OnIt', + doneEmoji: 'Done', + progressStyle: 'compact', + port: '8080', + callbackPath: '/feishu/webhook', + encryptKey: 'encrypt-key', + }, + }, + }, + }, + }); + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: createBridgeAdapterMock() as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile()) as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/api/v1/projects/clawx-main') && init?.method === 'GET') { + return new Response(JSON.stringify({ + ok: true, + data: { + name: 'clawx-main', + platforms: [ + { type: 'telegram', connected: true }, + { type: 'lark', connected: true }, + ], + }, + }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: false, error: `unexpected ${init?.method} ${url}` }), { status: 500 }); + }); + vi.stubGlobal('fetch', fetchMock); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce()); + child.emit('spawn'); + await startPromise; + + child.writeStdout('channel token=telegram-secret-token ready\n'); + child.writeStderr('Authorization: Bearer runtime-secret-bearer diagnostics\n'); + + const configPath = join(tempDir, 'runtimes', 'cc-connect', 'config.toml'); + const config = await readFile(configPath, 'utf8'); + expect(config).toContain('type = "telegram"'); + expect(config).toContain('token = "${CLAWX_CHANNEL_TELEGRAM_OPS_BOT_TOKEN}"'); + expect(config).toContain('allow_from = "12345,67890"'); + expect(config).toContain('share_session_in_channel = true'); + expect(config).toContain('type = "lark"'); + expect(config).toContain('app_id = "cli_lark"'); + expect(config).toContain('app_secret = "${CLAWX_CHANNEL_FEISHU_LARK_BOT_APP_SECRET}"'); + expect(config).toContain('domain = "https://open.larksuite.com"'); + expect(config).toContain('admin_from = "clawx-desktop,ou_cron_admin"'); + expect(config).not.toContain('[projects.platforms.options]\nadmin_from'); + expect(config).toContain('enable_feishu_card = false'); + expect(config).toContain('group_reply_all = true'); + expect(config).toContain('thread_isolation = true'); + expect(config).toContain('reply_in_thread = true'); + expect(config).toContain('reaction_emoji = "OnIt"'); + expect(config).toContain('done_emoji = "Done"'); + expect(config).toContain('progress_style = "compact"'); + expect(config).toContain('port = "8080"'); + expect(config).toContain('callback_path = "/feishu/webhook"'); + expect(config).toContain('encrypt_key = "${CLAWX_CHANNEL_FEISHU_LARK_BOT_ENCRYPT_KEY}"'); + expect(config).not.toContain('telegram-secret-token'); + expect(config).not.toContain('lark-secret'); + expect(config).not.toContain('encrypt-key'); + if (process.platform !== 'win32') { + expect((await stat(configPath)).mode & 0o777).toBe(0o600); + } + expect(forkMock).toHaveBeenCalledWith(binaryPath, expect.any(Array), expect.objectContaining({ + env: expect.objectContaining({ + CLAWX_CHANNEL_TELEGRAM_OPS_BOT_TOKEN: 'telegram-secret-token', + CLAWX_CHANNEL_FEISHU_LARK_BOT_APP_SECRET: 'lark-secret', + CLAWX_CHANNEL_FEISHU_LARK_BOT_ENCRYPT_KEY: 'encrypt-key', + }), + })); + + const logs = await provider.listLogs(); + expect(logs.content).not.toContain('telegram-secret-token'); + expect(logs.content).not.toContain('lark-secret'); + expect(logs.content).not.toContain('encrypt-key'); + expect(logs.content).not.toContain('runtime-secret-bearer'); + expect(logs.content).toContain('channel token= ready'); + expect(logs.content).toContain('Authorization: diagnostics'); + expect(logs.content).toContain('token = ""'); + expect(logs.content).toContain('app_secret = ""'); + const runtimeLogPath = join(tempDir, 'runtimes', 'cc-connect', 'logs', 'runtime.log'); + await vi.waitFor(async () => { + const runtimeLog = await readFile(runtimeLogPath, 'utf8'); + expect(runtimeLog).toContain('channel token= ready'); + expect(runtimeLog).not.toContain('telegram-secret-token'); + expect(runtimeLog).not.toContain('runtime-secret-bearer'); + }); + if (process.platform !== 'win32') { + expect((await stat(runtimeLogPath)).mode & 0o777).toBe(0o600); + } + const reloadedProvider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: createBridgeAdapterMock() as never, + }); + await expect(reloadedProvider.listLogs()).resolves.toMatchObject({ + content: expect.stringContaining('channel token= ready'), + }); + + await expect(provider.rpc('channels.status')).resolves.toMatchObject({ + channelAccounts: { + telegram: [{ + accountId: 'ops_bot', + configured: true, + connected: true, + running: true, + linked: true, + }], + feishu: [{ + accountId: 'lark_bot', + configured: true, + connected: true, + running: true, + linked: true, + name: 'lark', + }], + }, + channelDefaultAccountId: { + telegram: 'ops_bot', + feishu: 'lark_bot', + }, + }); + }); + + it('maps Feishu China accounts to the cc-connect feishu platform block', async () => { + readOpenClawConfigMock.mockResolvedValue({ + channels: { + feishu: { + defaultAccount: 'cn_bot', + accounts: { + cn_bot: { + appId: 'cli_feishu_cn', + appSecret: 'feishu-secret', + domain: 'feishu', + adminFrom: ['ou_cron_admin_cn'], + shareSessionInChannel: true, + }, + }, + }, + }, + }); + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: createBridgeAdapterMock() as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile()) as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/api/v1/projects/clawx-main') && init?.method === 'GET') { + return new Response(JSON.stringify({ + ok: true, + data: { + name: 'clawx-main', + platforms: [ + { type: 'feishu', connected: true }, + ], + }, + }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: false, error: `unexpected ${init?.method} ${url}` }), { status: 500 }); + }); + vi.stubGlobal('fetch', fetchMock); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce()); + child.emit('spawn'); + await startPromise; + + const config = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'config.toml'), 'utf8'); + expect(config).toContain('type = "feishu"'); + expect(config).toContain('app_id = "cli_feishu_cn"'); + expect(config).toContain('app_secret = "${CLAWX_CHANNEL_FEISHU_CN_BOT_APP_SECRET}"'); + expect(config).toContain('domain = "https://open.feishu.cn"'); + expect(config).toContain('admin_from = "clawx-desktop,ou_cron_admin_cn"'); + expect(config).not.toContain('[projects.platforms.options]\nadmin_from'); + expect(config).toContain('share_session_in_channel = true'); + expect(config).not.toContain('feishu-secret'); + expect(forkMock).toHaveBeenCalledWith(binaryPath, expect.any(Array), expect.objectContaining({ + env: expect.objectContaining({ + CLAWX_CHANNEL_FEISHU_CN_BOT_APP_SECRET: 'feishu-secret', + }), + })); + + await expect(provider.rpc('channels.status')).resolves.toMatchObject({ + channelAccounts: { + feishu: [{ + accountId: 'cn_bot', + configured: true, + connected: true, + running: true, + linked: true, + name: 'feishu', + }], + }, + channelDefaultAccountId: { + feishu: 'cn_bot', + }, + }); + }); + + it('keeps live cc-connect Feishu status account-scoped when one project has multiple Feishu platforms', async () => { + readOpenClawConfigMock.mockResolvedValue({ + channels: { + feishu: { + defaultAccount: 'cn_bot', + accounts: { + cn_bot: { + appId: 'cli_feishu_cn', + appSecret: 'feishu-secret-cn', + domain: 'feishu', + }, + ops_bot: { + appId: 'cli_feishu_ops', + appSecret: 'feishu-secret-ops', + domain: 'feishu', + }, + }, + }, + }, + }); + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/api/v1/projects/clawx-main') && init?.method === 'GET') { + return new Response(JSON.stringify({ + ok: true, + data: { + name: 'clawx-main', + platforms: [ + { type: 'feishu', connected: true, running: true }, + { type: 'feishu', connected: false, running: false, last_error: 'invalid tenant token' }, + ], + }, + }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: false, error: `unexpected ${init?.method} ${url}` }), { status: 500 }); + }); + vi.stubGlobal('fetch', fetchMock); + + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: createBridgeAdapterMock() as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile()) as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce()); + child.emit('spawn'); + await startPromise; + + await expect(provider.rpc('channels.status')).resolves.toMatchObject({ + channelAccounts: { + feishu: [ + { + accountId: 'cn_bot', + configured: true, + connected: true, + running: true, + linked: true, + }, + { + accountId: 'ops_bot', + configured: true, + connected: false, + running: false, + linked: true, + lastError: 'invalid tenant token', + }, + ], + }, + }); + }); + + it('reuses existing OpenClaw agent workspaces and assigns channel accounts to the bound agent project', async () => { + const openClawMainWorkspace = join(tempDir, 'workspace-main'); + const openClawResearchWorkspace = join(tempDir, 'workspace-research'); + await mkdir(openClawMainWorkspace, { recursive: true }); + await mkdir(openClawResearchWorkspace, { recursive: true }); + readOpenClawConfigMock.mockResolvedValue({ + agents: { + defaults: { workspace: openClawMainWorkspace }, + list: [ + { id: 'main', name: 'Main Agent', default: true, workspace: openClawMainWorkspace }, + { id: 'research', name: 'Research Agent', workspace: openClawResearchWorkspace }, + ], + }, + bindings: [ + { agentId: 'research', match: { channel: 'telegram', accountId: 'ops_bot' } }, + ], + channels: { + telegram: { + defaultAccount: 'ops_bot', + accounts: { + ops_bot: { + token: 'telegram-secret-token', + shareSessionInChannel: true, + }, + }, + }, + }, + }); + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: createBridgeAdapterMock() as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile()) as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce()); + child.emit('spawn'); + await startPromise; + + const config = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'config.toml'), 'utf8'); + const mainProjectIndex = config.indexOf('name = "clawx-main"'); + const researchProjectIndex = config.indexOf('name = "clawx-research"'); + const telegramIndex = config.indexOf('type = "telegram"'); + expect(mainProjectIndex).toBeGreaterThanOrEqual(0); + expect(researchProjectIndex).toBeGreaterThan(mainProjectIndex); + expect(config).toContain(`work_dir = "${openClawMainWorkspace.replace(/\\/g, '\\\\')}"`); + expect(config).toContain(`work_dir = "${openClawResearchWorkspace.replace(/\\/g, '\\\\')}"`); + expect(telegramIndex).toBeGreaterThan(researchProjectIndex); + expect(config.slice(mainProjectIndex, researchProjectIndex)).not.toContain('type = "telegram"'); + expect(config.slice(researchProjectIndex)).toContain('token = "${CLAWX_CHANNEL_TELEGRAM_OPS_BOT_TOKEN}"'); + expect(config).not.toContain('telegram-secret-token'); + }); + + it('falls back to managed workspaces when configured OpenClaw workspace paths do not exist', async () => { + const missingMainWorkspace = join(tempDir, 'missing-main-workspace'); + const missingResearchWorkspace = join(tempDir, 'missing-research-workspace'); + readOpenClawConfigMock.mockResolvedValue({ + agents: { + defaults: { workspace: missingMainWorkspace }, + list: [ + { id: 'main', name: 'Main Agent', default: true, workspace: missingMainWorkspace }, + { id: 'research', name: 'Research Agent', workspace: missingResearchWorkspace }, + ], + }, + }); + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: createBridgeAdapterMock() as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile()) as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce()); + child.emit('spawn'); + await startPromise; + + const mainWorkspace = join(tempDir, 'workspaces', 'agents', 'main'); + const researchWorkspace = join(tempDir, 'workspaces', 'agents', 'research'); + const config = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'config.toml'), 'utf8'); + expect(config).toContain(`work_dir = "${mainWorkspace.replace(/\\/g, '\\\\')}"`); + expect(config).toContain(`work_dir = "${researchWorkspace.replace(/\\/g, '\\\\')}"`); + expect(config).not.toContain(missingMainWorkspace); + expect(config).not.toContain(missingResearchWorkspace); + }); + + it('uses a managed cc-connect workspace when no agent workspace is configured', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: createBridgeAdapterMock() as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile()) as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce()); + child.emit('spawn'); + await startPromise; + + const managedWorkspace = join(tempDir, 'workspaces', 'agents', 'main'); + const config = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'config.toml'), 'utf8'); + expect(config).toContain(`work_dir = "${managedWorkspace.replace(/\\/g, '\\\\')}"`); + expect(config).not.toContain(process.cwd()); + await expect(access(managedWorkspace)).resolves.toBeUndefined(); + }); + + it('uses managed cc-connect workspaces for agents without explicit workspace paths', async () => { + readOpenClawConfigMock.mockResolvedValue({ + agents: { + list: [ + { id: 'no-workspace-agent', name: 'No Workspace Agent' }, + ], + }, + }); + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: createBridgeAdapterMock() as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile()) as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce()); + child.emit('spawn'); + await startPromise; + + const mainWorkspace = join(tempDir, 'workspaces', 'agents', 'main'); + const agentWorkspace = join(tempDir, 'workspaces', 'agents', 'no-workspace-agent'); + const config = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'config.toml'), 'utf8'); + expect(config).toContain(`work_dir = "${mainWorkspace.replace(/\\/g, '\\\\')}"`); + expect(config).toContain(`work_dir = "${agentWorkspace.replace(/\\/g, '\\\\')}"`); + expect(config).not.toContain('.openclaw'); + await expect(access(mainWorkspace)).resolves.toBeUndefined(); + await expect(access(agentWorkspace)).resolves.toBeUndefined(); + }); + + it('does not expose the managed local placeholder as a user channel', async () => { + const configPath = join(tempDir, 'runtimes', 'cc-connect', 'config.toml'); + await mkdir(join(tempDir, 'runtimes', 'cc-connect'), { recursive: true }); + await writeFile(configPath, [ + '[[projects]]', + 'name = "clawx-main"', + '', + '[[projects.platforms]]', + 'type = "line"', + '', + '[projects.platforms.options]', + 'channel_secret = "clawx-local-placeholder"', + 'channel_token = "clawx-local-placeholder"', + 'port = "0"', + '', + '[[projects.platforms]]', + 'type = "feishu"', + ].join('\n'), 'utf8'); + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + skillSyncer: vi.fn(async () => ({ skills: [] })), + }); + + await expect(provider.rpc('channels.status')).resolves.toEqual({ + channels: {}, + channelAccounts: {}, + channelDefaultAccountId: {}, + }); + }); + + it('rejects chat sends before bridge delivery when selected provider is unsupported', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const bridgeAdapter = createBridgeAdapterMock(); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile({ + providerId: 'custom-chat', + vendorId: 'custom', + supported: false, + unsupportedReason: 'Custom Chat Completions is not supported by Codex', + codexArgs: [], + })) as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce()); + child.emit('spawn'); + await startPromise; + + await expect(provider.sendMessageWithMedia({ + sessionKey: 'agent:main:main', + idempotencyKey: 'send-1', + message: 'hello', + })).rejects.toThrow('Custom Chat Completions is not supported by Codex'); + expect(bridgeAdapter.send).not.toHaveBeenCalled(); + }); + + it('routes GUI chat through cc-connect BridgePlatform and manages public API sessions', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const bridgeAdapter = createBridgeAdapterMock({ + send: vi.fn(async () => ({ runId: 'cc-connect-run-1' })), + listSessions: vi.fn(async () => [{ + key: 'agent:support:member-1', + displayName: 'Support', + derivedTitle: 'channel hello', + lastMessagePreview: 'channel ok', + updatedAt: 3, + }]), + summarizeSessions: vi.fn(async () => [{ sessionKey: 'agent:support:member-1', firstUserText: 'channel hello', lastTimestamp: 3 }]), + loadHistory: vi.fn(async (sessionKey: string) => sessionKey === 'agent:main:main' + ? [{ + id: 'bridge-patch', + role: 'assistant', + content: [{ + type: 'toolCall', + id: 'patch-1', + name: 'Patch', + arguments: [{ path: '/tmp/generated.md', diff: '# generated\n', kind: { type: 'add' } }], + }], + timestamp: 2.5, + }, { + id: 'bridge-file', + role: 'assistant', + content: 'report.pdf', + timestamp: 2.75, + _attachedFiles: [{ + fileName: 'report.pdf', + mimeType: 'application/pdf', + fileSize: 24, + filePath: '/tmp/report.pdf', + preview: null, + source: 'gateway-media', + }], + }] + : [{ role: 'assistant', content: 'channel ok', timestamp: 3 }]), + }); + const sessionApi = createSessionApiMock({ + sessions: () => [ + createApiSession('agent:main:main', { name: 'main', updatedAt: 3 }), + createApiSession('agent:research:main', { + name: 'default', + updatedAt: 4, + lastMessage: { content: 'CLAWX_REAL_RESEARCH_CHAT_OK' }, + }), + createApiSession('agent:support:member-1', { + name: 'channel hello', + chatName: 'Support', + updatedAt: 3, + lastMessage: { content: 'channel ok' }, + }), + ], + histories: { + 'agent:main:main': [{ role: 'assistant', content: 'bridge ok', timestamp: 3 }], + 'agent:support:member-1': [ + { role: 'user', content: 'channel hello', timestamp: 2 }, + { role: 'assistant', content: 'channel ok', timestamp: 3 }, + ], + }, + }); + const sessionMetadataStore = createSessionMetadataStoreMock(); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + sessionApi: sessionApi as never, + sessionMetadataStore, + }); + const chatEvents: unknown[] = []; + provider.on('chat:message', (event) => chatEvents.push(event)); + + await expect(provider.sendMessageWithMedia({ + sessionKey: 'agent:main:main', + message: 'hello', + idempotencyKey: 'idem-1', + })).resolves.toEqual({ + runId: 'cc-connect-run-1', + }); + await expect(provider.listSessions()).resolves.toMatchObject({ + success: true, + sessions: expect.arrayContaining([ + expect.objectContaining({ + key: 'agent:support:member-1', + displayName: 'Support', + derivedTitle: 'channel hello', + lastMessagePreview: 'channel ok', + }), + expect.objectContaining({ + key: 'agent:research:main', + agentId: 'research', + derivedTitle: 'CLAWX_REAL_RESEARCH_CHAT_OK', + lastMessagePreview: 'CLAWX_REAL_RESEARCH_CHAT_OK', + }), + ]), + }); + await expect(provider.listSessions({ sessionKeys: ['agent:main:main', 'agent:support:member-1'] })).resolves.toMatchObject({ + success: true, + summaries: expect.arrayContaining([ + { sessionKey: 'agent:support:member-1', firstUserText: 'channel hello', lastTimestamp: 3000 }, + ]), + }); + await expect(provider.loadHistory({ sessionKey: 'agent:main:main' })).resolves.toMatchObject({ + success: true, + messages: [ + expect.objectContaining({ + id: 'bridge-patch', + content: [expect.objectContaining({ name: 'Patch' })], + }), + expect.objectContaining({ + id: 'bridge-file', + _attachedFiles: [expect.objectContaining({ fileName: 'report.pdf' })], + }), + { role: 'assistant', content: 'bridge ok', timestamp: 3 }, + ], + }); + await expect(provider.loadHistory({ sessionKey: 'agent:support:member-1' })).resolves.toMatchObject({ + success: true, + messages: expect.arrayContaining([expect.objectContaining({ role: 'assistant', content: 'channel ok' })]), + }); + await expect(provider.renameSession({ sessionKey: 'agent:support:member-1', label: 'Renamed support' })).resolves.toEqual({ success: true }); + await expect(provider.deleteSession({ sessionKey: 'agent:main:main' })).resolves.toEqual({ success: true }); + expect(bridgeAdapter.send).toHaveBeenCalledWith(expect.objectContaining({ + sessionKey: 'agent:main:main', + message: 'hello', + idempotencyKey: 'idem-1', + })); + expect(sessionMetadataStore.setLabel).toHaveBeenCalledWith('agent:support:member-1', 'Renamed support'); + expect(sessionMetadataStore.deleteLabel).toHaveBeenCalledWith('agent:main:main'); + expect(bridgeAdapter.forgetSession).toHaveBeenCalledWith('agent:main:main'); + expect(sessionApi.deleteSession).toHaveBeenCalledWith(expect.objectContaining({ logicalKey: 'agent:main:main' })); + }); + + it('maps only public cc-connect history into the RuntimeProvider usage contract', async () => { + const sessionApi = createSessionApiMock({ + sessions: () => [createApiSession('agent:main:main', { id: 'runtime-session-1' })], + histories: { + 'agent:main:main': [{ + role: 'assistant', + content: 'metered public reply', + timestamp: 1_780_000_002_000, + provider: 'openai', + model: 'gpt-5.4', + usage: { + input_tokens: 100, + cached_input_tokens: 80, + output_tokens: 20, + reasoning_output_tokens: 5, + }, + }, { + role: 'assistant', + content: 'public reply without counters', + timestamp: 1_780_000_001_000, + }], + }, + }); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + bridgeAdapter: createBridgeAdapterMock() as never, + sessionApi: sessionApi as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile()) as never, + }); + + await expect(provider.listUsage({ limit: 10 })).resolves.toEqual({ + success: true, + records: [ + expect.objectContaining({ + runtimeKind: 'cc-connect', + logicalSessionId: 'agent:main:main', + runtimeSessionId: 'runtime-session-1', + agentId: 'main', + provider: 'openai', + model: 'gpt-5.4', + status: 'available', + inputTokens: 100, + cachedInputTokens: 80, + outputTokens: 20, + reasoningTokens: 5, + totalTokens: 120, + }), + expect.objectContaining({ + logicalSessionId: 'agent:main:main', + status: 'missing', + totalTokens: 0, + }), + ], + }); + expect(sessionApi.listSessions).toHaveBeenCalledOnce(); + expect(sessionApi.loadHistory).toHaveBeenCalledWith(expect.objectContaining({ + logicalKey: 'agent:main:main', + id: 'runtime-session-1', + })); + }); + + it('aborts active cc-connect chat runs through Bridge without restarting the runtime', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const bridgeAdapter = createBridgeAdapterMock({ + abort: vi.fn(async () => ({ + success: true, + abortedRuns: ['cc-connect-run-1'], + stoppedSessions: ['agent:main:main'], + upstreamStopRequested: true, + })), + }); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile()) as never, + }); + const firstChild = createChild(); + forkMock.mockReturnValueOnce(firstChild); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledTimes(1)); + firstChild.emit('spawn'); + await startPromise; + + await expect(provider.rpc('chat.abort', { sessionKey: 'agent:main:main' })).resolves.toEqual({ + success: true, + abortedRuns: ['cc-connect-run-1'], + stoppedSessions: ['agent:main:main'], + upstreamStopRequested: true, + }); + + expect(bridgeAdapter.abort).toHaveBeenCalledWith({ sessionKey: 'agent:main:main' }); + expect(firstChild.kill).not.toHaveBeenCalled(); + expect(bridgeAdapter.close).not.toHaveBeenCalled(); + expect(bridgeAdapter.connect).toHaveBeenCalledOnce(); + expect(forkMock).toHaveBeenCalledOnce(); + }); + + it('serializes a final stop behind the disconnected-Bridge abort restart fallback', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const bridgeAdapter = createBridgeAdapterMock({ + abort: vi.fn(async () => ({ + success: true, + abortedRuns: ['cc-connect-run-1'], + stoppedSessions: [], + upstreamStopRequested: false, + })), + }); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile()) as never, + }); + const firstChild = createChild(); + const secondChild = createChild(); + forkMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledTimes(1)); + firstChild.emit('spawn'); + await startPromise; + + const abortPromise = provider.rpc('chat.abort', { sessionKey: 'agent:main:main' }); + const finalStopPromise = provider.stop(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledTimes(2)); + secondChild.emit('spawn'); + + await abortPromise; + await finalStopPromise; + + expect(firstChild.kill).toHaveBeenCalledOnce(); + expect(secondChild.kill).toHaveBeenCalledOnce(); + expect(provider.getStatus()).toMatchObject({ state: 'stopped', pid: undefined }); + }); + + it('automatically restarts cc-connect after an unexpected crash', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const bridgeAdapter = createBridgeAdapterMock(); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile()) as never, + }); + const firstChild = createChild(); + const secondChild = createChild(); + forkMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledTimes(1)); + firstChild.emit('spawn'); + await startPromise; + + vi.useFakeTimers(); + firstChild.emit('exit', 1); + + expect(provider.getStatus()).toMatchObject({ + state: 'error', + error: 'cc-connect exited with code 1', + }); + expect(bridgeAdapter.close).toHaveBeenCalledOnce(); + + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledTimes(2)); + secondChild.emit('spawn'); + await vi.waitFor(() => expect(bridgeAdapter.connect).toHaveBeenCalledTimes(2)); + + expect(provider.getStatus()).toMatchObject({ + state: 'running', + pid: 4242, + error: undefined, + }); + }); + + it('does not restart cc-connect after an intentional stop', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const bridgeAdapter = createBridgeAdapterMock(); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile()) as never, + }); + const firstChild = createChild(); + forkMock.mockReturnValueOnce(firstChild); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledTimes(1)); + firstChild.emit('spawn'); + await startPromise; + + vi.useFakeTimers(); + await provider.stop(); + firstChild.emit('exit', 1); + await vi.advanceTimersByTimeAsync(1_000); + + expect(forkMock).toHaveBeenCalledTimes(1); + expect(firstChild.kill).toHaveBeenCalledOnce(); + expect(provider.getStatus()).toMatchObject({ + state: 'stopped', + pid: undefined, + }); + }); + + it('terminates orphaned subprocesses that still reference the managed cc-connect runtime dir on stop', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const bridgeAdapter = createBridgeAdapterMock(); + const managedDir = join(tempDir, 'runtimes', 'cc-connect'); + execFileMock + .mockImplementationOnce((_file: string, _args: string[], _options: unknown, callback: (error: Error | null, stdout: string, stderr: string) => void) => { + callback(null, [ + ` 5511 git clone --depth 1 https://github.com/openai/plugins.git ${managedDir}/codex-home/.tmp/plugins-clone-test`, + ' 5512 /usr/bin/other-process', + '', + ].join('\n'), ''); + }) + .mockImplementationOnce((_file: string, _args: string[], _options: unknown, callback: (error: Error | null, stdout: string, stderr: string) => void) => { + callback(null, ` 5511 git clone --depth 1 https://github.com/openai/plugins.git ${managedDir}/codex-home/.tmp/plugins-clone-test\n`, ''); + }); + const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as never); + + try { + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: vi.fn(async () => createProviderProfile()) as never, + }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce()); + child.emit('spawn'); + await startPromise; + await provider.stop(); + + expect(killSpy).toHaveBeenCalledWith(5511, 'SIGTERM'); + expect(killSpy).toHaveBeenCalledWith(5511, 'SIGKILL'); + expect(killSpy).not.toHaveBeenCalledWith(5512, expect.anything()); + } finally { + killSpy.mockRestore(); + } + }); + + it('keeps legacy Gateway RPC chat/session/history calls working for cc-connect', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const bridgeAdapter = createBridgeAdapterMock(); + const sessionApi = createSessionApiMock(); + const sessionMetadataStore = createSessionMetadataStoreMock(); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + sessionApi: sessionApi as never, + sessionMetadataStore, + }); + + await expect(provider.rpc('chat.send', { + sessionKey: 'agent:main:main', + message: 'hello via rpc', + idempotencyKey: 'idem-rpc', + })).resolves.toMatchObject({ runId: 'cc-connect-run-1' }); + await expect(provider.rpc('sessions.list', { includeDerivedTitles: true })).resolves.toMatchObject({ + success: true, + sessions: [{ key: 'agent:main:main', displayName: 'main' }], + }); + await expect(provider.rpc('chat.history', { sessionKey: 'agent:main:main', limit: 20 })).resolves.toMatchObject({ + success: true, + messages: [{ role: 'assistant', content: 'assistant ok' }], + }); + await expect(provider.rpc('sessions.rename', { sessionKey: 'agent:main:main', title: 'RPC title' })).resolves.toEqual({ success: true }); + await expect(provider.rpc('sessions.delete', { sessionKey: 'agent:main:main' })).resolves.toEqual({ success: true }); + + expect(bridgeAdapter.send).toHaveBeenCalledWith(expect.objectContaining({ + sessionKey: 'agent:main:main', + message: 'hello via rpc', + idempotencyKey: 'idem-rpc', + })); + expect(sessionMetadataStore.setLabel).toHaveBeenCalledWith('agent:main:main', 'RPC title'); + expect(sessionMetadataStore.deleteLabel).toHaveBeenCalledWith('agent:main:main'); + expect(bridgeAdapter.forgetSession).toHaveBeenCalledWith('agent:main:main'); + }); + + it('syncs provider and model profile through runtime RPC', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const providerProfileLoader = vi.fn(async () => createProviderProfile({ + providerId: 'openai-main', + vendorId: 'openai', + model: 'gpt-5.5', + codexArgs: ['--model', 'gpt-5.5'], + env: { OPENAI_API_KEY: 'sk-test' }, + secretAvailable: true, + })); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + providerProfileLoader: providerProfileLoader as never, + }); + + await expect(provider.rpc('providers.sync', { + providerId: 'openai-main', + reason: 'set-default', + })).resolves.toMatchObject({ + success: true, + profile: { + providerId: 'openai-main', + vendorId: 'openai', + model: 'gpt-5.5', + codexArgs: ['--model', 'gpt-5.5'], + envKeys: ['OPENAI_API_KEY'], + secretAvailable: true, + }, + }); + + expect(providerProfileLoader).toHaveBeenCalledWith({ + providerId: 'openai-main', + reason: 'set-default', + }); + }); + + it('restarts the running cc-connect process when provider sync changes launch config', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const bridgeAdapter = createBridgeAdapterMock(); + const providerProfileLoader = vi.fn(async () => createProviderProfile({ + providerId: 'openai-main', + vendorId: 'openai', + model: 'gpt-5.5', + codexArgs: ['--model', 'gpt-5.5'], + env: { OPENAI_API_KEY: 'sk-test' }, + ccConnectProvider: { + name: 'openai', + apiKeyEnvKey: 'OPENAI_API_KEY', + model: 'gpt-5.5', + }, + secretAvailable: true, + })); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: providerProfileLoader as never, + }); + const firstChild = createChild(); + const secondChild = createChild(); + forkMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild); + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledTimes(1)); + firstChild.emit('spawn'); + await startPromise; + + const syncPromise = provider.rpc('providers.sync', { + providerId: 'openai-main', + reason: 'set-default', + }); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledTimes(2)); + secondChild.emit('spawn'); + await syncPromise; + + expect(firstChild.kill).toHaveBeenCalledOnce(); + expect(bridgeAdapter.close).toHaveBeenCalledOnce(); + expect(bridgeAdapter.connect).toHaveBeenCalledTimes(2); + }); + + it('rewrites managed cc-connect config and env after provider/model sync restarts runtime', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const bridgeAdapter = createBridgeAdapterMock(); + const openAiProfile = createProviderProfile({ + providerId: 'openai-main', + vendorId: 'openai', + model: 'gpt-5.5', + codexArgs: ['--model', 'gpt-5.5'], + env: { OPENAI_API_KEY: 'sk-test-after-sync' }, + ccConnectProvider: { + name: 'openai', + apiKeyEnvKey: 'OPENAI_API_KEY', + model: 'gpt-5.5', + }, + secretAvailable: true, + }); + const providerProfileLoader = vi.fn() + .mockResolvedValueOnce(createProviderProfile({ + providerId: 'ollama-local', + vendorId: 'ollama', + model: 'qwen3:latest', + codexArgs: ['--oss', '--local-provider', 'ollama', '--model', 'qwen3:latest'], + secretAvailable: false, + })) + .mockResolvedValueOnce(openAiProfile) + .mockResolvedValueOnce(openAiProfile); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + bridgeAdapter: bridgeAdapter as never, + skillSyncer: vi.fn(async () => ({ skills: [] })), + providerProfileLoader: providerProfileLoader as never, + }); + const firstChild = createChild(); + const secondChild = createChild(); + forkMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild); + + const startPromise = provider.start(); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledTimes(1)); + firstChild.emit('spawn'); + await startPromise; + const configPath = join(tempDir, 'runtimes', 'cc-connect', 'config.toml'); + await expect(readFile(configPath, 'utf8')).resolves.toContain('model = "qwen3:latest"'); + + const syncPromise = provider.rpc('models.sync', { + providerId: 'openai-main', + reason: 'model-picker', + }); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledTimes(2)); + secondChild.emit('spawn'); + await expect(syncPromise).resolves.toMatchObject({ + success: true, + profile: { + providerId: 'openai-main', + vendorId: 'openai', + model: 'gpt-5.5', + envKeys: ['OPENAI_API_KEY'], + }, + }); + + const updatedConfig = await readFile(configPath, 'utf8'); + expect(updatedConfig).toContain('provider = "openai"'); + expect(updatedConfig).toContain('api_key = "${OPENAI_API_KEY}"'); + expect(updatedConfig).toContain('model = "gpt-5.5"'); + expect(updatedConfig).not.toContain('qwen3:latest'); + expect(updatedConfig).not.toContain('sk-test-after-sync'); + expect(forkMock).toHaveBeenLastCalledWith(binaryPath, [ + '-config', + configPath, + ], expect.objectContaining({ + env: expect.objectContaining({ + OPENAI_API_KEY: 'sk-test-after-sync', + }), + })); + expect(firstChild.kill).toHaveBeenCalledOnce(); + expect(bridgeAdapter.connect).toHaveBeenCalledTimes(2); + }); + + it('runs cc-connect doctor against the managed config', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + execFileMock.mockImplementation((_file, args, _options, callback) => { + if (args[0] === 'doctor') { + expect(args).toEqual(['doctor', '--json']); + callback(null, JSON.stringify({ status: 'ok', checks: [] }), ''); + return; + } + callback(null, '', ''); + }); + + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + }); + const resultPromise = provider.runDoctor('diagnose'); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce()); + child.writeStdout('doctor ok\n'); + child.emit('exit', 0); + + const result = await resultPromise; + expect(execFileMock).toHaveBeenCalledWith( + join(tempDir, 'codex'), + ['doctor', '--json'], + expect.objectContaining({ + cwd: join(tempDir, 'runtimes', 'cc-connect'), + }), + expect.any(Function), + ); + expect(result).toMatchObject({ + mode: 'diagnose', + success: true, + exitCode: 0, + stdout: expect.stringContaining('doctor ok\n'), + command: expect.stringContaining('doctor user-isolation'), + auditPath: expect.stringContaining(join('runtimes', 'cc-connect', 'audits', 'runtime-')), + audit: expect.objectContaining({ + schema: 'clawx-cc-connect-runtime-doctor', + runtimeKind: 'cc-connect', + ccConnect: expect.objectContaining({ success: true, auditGenerated: false }), + codex: expect.objectContaining({ + success: true, + report: { status: 'ok', checks: [] }, + }), + }), + }); + expect(forkMock).toHaveBeenCalledWith(binaryPath, expect.arrayContaining([ + 'doctor', + 'user-isolation', + '--config', + join(tempDir, 'runtimes', 'cc-connect', 'config.toml'), + '--out', + ]), expect.objectContaining({ + cwd: join(tempDir, 'runtimes', 'cc-connect'), + stdio: ['ignore', 'pipe', 'pipe'], + })); + await expect(readFile(result.auditPath!, 'utf8')).resolves.toContain('clawx-cc-connect-runtime-doctor'); + expect((await stat(result.auditPath!)).mode & 0o777).toBe(0o600); + }); + + it('fails Doctor when Codex returns JSON that is not an object', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const child = createChild(); + forkMock.mockReturnValueOnce(child); + execFileMock.mockImplementation((_file, args, _options, callback) => { + callback(null, args[0] === 'doctor' ? '[]' : '', ''); + }); + + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + }); + const resultPromise = provider.runDoctor('diagnose'); + await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce()); + child.emit('exit', 0); + + await expect(resultPromise).resolves.toMatchObject({ + success: false, + error: 'Codex doctor did not return a JSON object', + audit: expect.objectContaining({ + codex: expect.objectContaining({ + success: false, + error: 'Codex doctor did not return a JSON object', + }), + }), + }); + }); + + it('returns a stable unsupported result for cc-connect doctor fix', async () => { + const binaryPath = join(tempDir, 'cc-connect'); + await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 }); + const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider'); + const provider = new CcConnectRuntimeProvider({ + binaryPath, + codexPath: join(tempDir, 'codex'), + }); + + await expect(provider.runDoctor('fix')).resolves.toMatchObject({ + mode: 'fix', + success: false, + error: 'cc-connect Doctor does not support fix mode', + }); + }); +}); diff --git a/tests/unit/cc-connect-session-metadata.test.ts b/tests/unit/cc-connect-session-metadata.test.ts new file mode 100644 index 000000000..24b87b28d --- /dev/null +++ b/tests/unit/cc-connect-session-metadata.test.ts @@ -0,0 +1,60 @@ +// @vitest-environment node +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { FileCcConnectSessionMetadataStore } from '@electron/runtime/cc-connect-session-metadata'; + +describe('cc-connect session metadata store', () => { + let tempDir: string; + let metadataPath: string; + let legacyPath: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'clawx-cc-connect-session-metadata-')); + metadataPath = join(tempDir, 'app', 'cc-connect-session-metadata.json'); + legacyPath = join(tempDir, 'runtimes', 'cc-connect', 'data', 'sessions', '.clawx-supplemental-history.json'); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('persists concurrent labels atomically and deletes only the requested session', async () => { + const store = new FileCcConnectSessionMetadataStore(metadataPath, legacyPath); + await Promise.all([ + store.setLabel('agent:main:one', 'One'), + store.setLabel('agent:main:two', 'Two'), + ]); + + await expect(store.getLabel('agent:main:one')).resolves.toBe('One'); + await expect(store.getLabel('agent:main:two')).resolves.toBe('Two'); + await store.deleteLabel('agent:main:one'); + await expect(store.getLabel('agent:main:one')).resolves.toBeUndefined(); + await expect(store.getLabel('agent:main:two')).resolves.toBe('Two'); + + const document = JSON.parse(await readFile(metadataPath, 'utf8')) as { labels: Record }; + expect(document.labels).toEqual({ 'agent:main:two': 'Two' }); + if (process.platform !== 'win32') { + expect((await stat(metadataPath)).mode & 0o777).toBe(0o600); + } + }); + + it('migrates legacy ClawX labels without copying private runtime history', async () => { + await mkdir(join(legacyPath, '..'), { recursive: true }); + await writeFile(legacyPath, JSON.stringify({ + labels: { 'agent:research:main': 'Research title' }, + sessions: { + 'agent:research:main': [{ role: 'user', content: 'private history must not migrate' }], + }, + }), 'utf8'); + + const store = new FileCcConnectSessionMetadataStore(metadataPath, legacyPath); + await expect(store.getLabel('agent:research:main')).resolves.toBe('Research title'); + + const canonical = await readFile(metadataPath, 'utf8'); + expect(canonical).toContain('Research title'); + expect(canonical).not.toContain('private history must not migrate'); + expect(canonical).not.toContain('"sessions"'); + }); +}); diff --git a/tests/unit/cc-connect-skills.test.ts b/tests/unit/cc-connect-skills.test.ts new file mode 100644 index 000000000..fcdee7638 --- /dev/null +++ b/tests/unit/cc-connect-skills.test.ts @@ -0,0 +1,88 @@ +// @vitest-environment node +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const appPath = new Map(); + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: vi.fn((name: string) => appPath.get(name) ?? tmpdir()), + }, +})); + +describe('cc-connect skill sync', () => { + let tempDir: string; + + beforeEach(async () => { + vi.resetModules(); + tempDir = await mkdtemp(join(tmpdir(), 'clawx-cc-skills-')); + appPath.set('userData', tempDir); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('copies enabled local skills into the managed Codex home', async () => { + const sourceDir = join(tempDir, 'source-skill'); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, 'SKILL.md'), '---\nname: demo\n---\nDemo skill', 'utf8'); + await writeFile(join(sourceDir, 'helper.txt'), 'helper', 'utf8'); + + const { syncCcConnectSkillRecords } = await import('@electron/runtime/cc-connect-skills'); + const result = await syncCcConnectSkillRecords([ + { + id: 'demo/skill', + name: 'Demo Skill', + description: 'Demo', + enabled: true, + baseDir: sourceDir, + }, + { + id: 'disabled', + name: 'Disabled', + description: 'Disabled', + enabled: false, + baseDir: sourceDir, + }, + ]); + + const targetDir = join(tempDir, 'runtimes', 'cc-connect', 'codex-home', 'skills', 'demo-skill'); + await expect(readFile(join(targetDir, 'SKILL.md'), 'utf8')).resolves.toContain('Demo skill'); + await expect(readFile(join(targetDir, 'helper.txt'), 'utf8')).resolves.toBe('helper'); + await expect(readFile(join(tempDir, 'runtimes', 'cc-connect', 'codex-home', 'skills', 'manifest.json'), 'utf8')) + .resolves.toContain('demo/skill'); + expect(result.skills).toEqual([ + expect.objectContaining({ skillKey: 'demo/skill', name: 'Demo Skill', disabled: false }), + ]); + }); + + it('does not duplicate skills that Codex discovers natively from .agents', async () => { + const sourceDir = join(tempDir, '.agents', 'skills', 'native-skill'); + const codexHome = join(tempDir, 'codex-home'); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, 'SKILL.md'), '---\nname: native-skill\n---\nNative skill', 'utf8'); + await mkdir(join(codexHome, 'skills', 'native-skill'), { recursive: true }); + await writeFile(join(codexHome, 'skills', 'native-skill', 'SKILL.md'), 'stale duplicate', 'utf8'); + + const { syncCcConnectSkillRecords } = await import('@electron/runtime/cc-connect-skills'); + const result = await syncCcConnectSkillRecords([{ + id: 'native-skill', + name: 'Native Skill', + description: 'Native', + enabled: true, + source: 'agents-skills-personal', + baseDir: sourceDir, + }], codexHome); + + await expect(access(join(codexHome, 'skills', 'native-skill'))).rejects.toThrow(); + await expect(readFile(join(codexHome, 'skills', 'manifest.json'), 'utf8')) + .resolves.toContain('codex-native'); + expect(result.skills).toEqual([ + expect.objectContaining({ skillKey: 'native-skill', baseDir: sourceDir }), + ]); + }); +}); diff --git a/tests/unit/channel-config.test.ts b/tests/unit/channel-config.test.ts index 2d3bf3b85..2d19791bf 100644 --- a/tests/unit/channel-config.test.ts +++ b/tests/unit/channel-config.test.ts @@ -3,11 +3,12 @@ import { mkdir, readFile, rm, writeFile } from 'fs/promises'; import { join } from 'path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { testHome, testUserData, mockLoggerWarn, mockLoggerInfo, mockLoggerError } = vi.hoisted(() => { +const { testHome, testUserData, runtimeState, mockLoggerWarn, mockLoggerInfo, mockLoggerError } = vi.hoisted(() => { const suffix = Math.random().toString(36).slice(2); return { testHome: `/tmp/clawx-channel-config-${suffix}`, testUserData: `/tmp/clawx-channel-config-user-data-${suffix}`, + runtimeState: { kind: 'openclaw' as 'openclaw' | 'cc-connect' }, mockLoggerWarn: vi.fn(), mockLoggerInfo: vi.fn(), mockLoggerError: vi.fn(), @@ -33,6 +34,11 @@ vi.mock('electron', () => ({ getVersion: () => '0.0.0-test', getAppPath: () => '/tmp', }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8'), + }, })); vi.mock('@electron/utils/logger', () => ({ @@ -41,6 +47,15 @@ vi.mock('@electron/utils/logger', () => ({ error: mockLoggerError, })); +vi.mock('@electron/utils/store', () => ({ + getSetting: vi.fn(async (key: string) => key === 'runtimeKind' ? runtimeState.kind : undefined), + setSetting: vi.fn(async (key: string, value: unknown) => { + if (key === 'runtimeKind' && (value === 'openclaw' || value === 'cc-connect')) { + runtimeState.kind = value; + } + }), +})); + async function readOpenClawJson(): Promise> { const content = await readFile(join(testHome, '.openclaw', 'openclaw.json'), 'utf8'); return JSON.parse(content) as Record; @@ -50,6 +65,7 @@ describe('channel credential normalization and duplicate checks', () => { beforeEach(async () => { vi.resetAllMocks(); vi.resetModules(); + runtimeState.kind = 'openclaw'; await rm(testHome, { recursive: true, force: true }); await rm(testUserData, { recursive: true, force: true }); }); @@ -102,6 +118,99 @@ describe('channel credential normalization and duplicate checks', () => { expect.objectContaining({ channelType: 'feishu', accountId: 'agent-a', key: 'appId' }), ); }); + + it('stores Feishu cron administrators separately from chat allow-list users', async () => { + const { getChannelFormValues, saveChannelConfig } = await import('@electron/utils/channel-config'); + + await saveChannelConfig('feishu', { + appId: 'cli-admin-test', + appSecret: 'secret', + adminUsers: 'ou_cron_admin, ou_ops_admin', + }, 'ops'); + + const config = await readOpenClawJson(); + const channels = config.channels as Record; + }>; + expect(channels.feishu.accounts.ops.allowFrom).toEqual(['*']); + expect(channels.feishu.accounts.ops.adminFrom).toEqual(['ou_cron_admin', 'ou_ops_admin']); + expect(channels.feishu.accounts.ops.adminUsers).toBeUndefined(); + expect(channels.feishu.accounts.ops).toMatchObject({ appSecret: 'secret' }); + const canonical = await readFile(join(testUserData, 'app', 'runtime-config.json'), 'utf8'); + expect(canonical).toContain('cli-admin-test'); + expect(canonical).not.toContain('"appSecret"'); + expect(canonical).not.toContain('secret'); + await expect(getChannelFormValues('feishu', 'ops')).resolves.toMatchObject({ + adminUsers: 'ou_cron_admin, ou_ops_admin', + }); + }); + + it('preserves an explicit Feishu allow-list without widening it to wildcard access', async () => { + const { readOpenClawConfig, saveChannelConfig } = await import('@electron/utils/channel-config'); + + await saveChannelConfig('feishu', { + appId: 'cli-allow-list', + appSecret: 'secret', + allowFrom: ['ou_user_a', 'ou_user_b'], + }, 'ops'); + + const config = await readOpenClawConfig(); + expect(config.channels?.feishu.accounts?.ops).toMatchObject({ + dmPolicy: 'allowlist', + allowFrom: ['ou_user_a', 'ou_user_b'], + }); + }); + + it('keeps cc-connect saves canonical and projects them only when OpenClaw becomes active', async () => { + const { setSetting } = await import('@electron/utils/store'); + const { + saveChannelConfig, + writeOpenClawCompatibilityProjection, + } = await import('@electron/utils/channel-config'); + await setSetting('runtimeKind', 'cc-connect'); + + await saveChannelConfig('feishu', { + appId: 'cli-canonical-only', + appSecret: 'vault-only-secret', + }, 'ops'); + + await expect(readFile(join(testHome, '.openclaw', 'openclaw.json'), 'utf8')).rejects.toThrow(); + const canonical = await readFile(join(testUserData, 'app', 'runtime-config.json'), 'utf8'); + expect(canonical).toContain('cli-canonical-only'); + expect(canonical).not.toContain('vault-only-secret'); + + await setSetting('runtimeKind', 'openclaw'); + await writeOpenClawCompatibilityProjection(); + const projection = await readOpenClawJson(); + expect(projection).toMatchObject({ + channels: { + feishu: { + accounts: { + ops: { + appId: 'cli-canonical-only', + appSecret: 'vault-only-secret', + }, + }, + }, + }, + }); + }); + + it('does not create a ghost default account when deleting the current Feishu default', async () => { + const { deleteChannelAccountConfig, readOpenClawConfig, saveChannelConfig } = await import('@electron/utils/channel-config'); + await saveChannelConfig('feishu', { appId: 'cli-cn', appSecret: 'secret-cn' }, 'cn_bot'); + await saveChannelConfig('feishu', { appId: 'cli-global', appSecret: 'secret-global' }, 'global_bot'); + + await deleteChannelAccountConfig('feishu', 'cn_bot'); + + const config = await readOpenClawConfig(); + const section = config.channels?.feishu; + expect(section?.defaultAccount).toBe('global_bot'); + expect(section?.accounts).toEqual({ + global_bot: expect.objectContaining({ appId: 'cli-global', appSecret: 'secret-global' }), + }); + expect(section?.accounts?.default).toBeUndefined(); + }); }); describe('parseDoctorValidationOutput', () => { diff --git a/tests/unit/channel-store-operation-capabilities.test.ts b/tests/unit/channel-store-operation-capabilities.test.ts new file mode 100644 index 000000000..e9b77741d --- /dev/null +++ b/tests/unit/channel-store-operation-capabilities.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useChannelsStore } from '@/stores/channels'; +import { useGatewayStore } from '@/stores/gateway'; + +describe('channel store runtime operation capabilities', () => { + const rpc = vi.fn(); + + beforeEach(() => { + rpc.mockReset(); + useChannelsStore.setState({ channels: [], loading: false, error: null }); + useGatewayStore.setState({ + status: { + state: 'running', + port: 18789, + runtimeKind: 'cc-connect', + operationCapabilities: { + 'channels.add': { + capability: 'channels', + support: 'unsupported', + notes: 'Configure channel accounts through the Host API.', + }, + 'channels.requestQr': { + capability: 'channels', + support: 'unsupported', + notes: 'QR pairing is unavailable.', + }, + }, + }, + rpc, + }); + }); + + it('does not create a local channel when the runtime rejects channels.add', async () => { + await expect(useChannelsStore.getState().addChannel({ + type: 'feishu', + name: 'Feishu', + })).rejects.toThrow('Runtime operation channels.add is unavailable'); + + expect(rpc).not.toHaveBeenCalled(); + expect(useChannelsStore.getState().channels).toEqual([]); + }); + + it('does not invoke QR pairing when the runtime rejects channels.requestQr', async () => { + await expect(useChannelsStore.getState().requestQrCode('whatsapp')) + .rejects.toThrow('Runtime operation channels.requestQr is unavailable'); + + expect(rpc).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/chat-page-execution-graph.test.tsx b/tests/unit/chat-page-execution-graph.test.tsx index 021e93247..f3912e85f 100644 --- a/tests/unit/chat-page-execution-graph.test.tsx +++ b/tests/unit/chat-page-execution-graph.test.tsx @@ -95,7 +95,7 @@ vi.mock('@/pages/Chat/ChatMessage', () => ({ isStreaming, suppressAssistantText, }: { - message: { content?: unknown }; + message: { content?: unknown; _attachedFiles?: Array<{ fileName: string }> }; textOverride?: string; isStreaming?: boolean; suppressAssistantText?: boolean; @@ -114,6 +114,7 @@ vi.mock('@/pages/Chat/ChatMessage', () => ({ return (
{suppressAssistantText ? '' : text} + {(message._attachedFiles ?? []).map((file) => {file.fileName})}
); }, @@ -724,6 +725,63 @@ describe('Chat execution graph lifecycle', () => { expect(screen.queryByTestId('chat-activity-indicator')).not.toBeInTheDocument(); }); + it('keeps runtime media attachments visible when their narration is folded into the execution graph', async () => { + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + messages: [ + { role: 'user', content: 'Generate the media package' }, + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'media-1', name: 'exec', input: { command: 'generate' } }], + }, + ...[ + ['image.png', 'image/png'], + ['report.pdf', 'application/pdf'], + ['audio.wav', 'audio/wav'], + ['video.mp4', 'video/mp4'], + ].map(([fileName, mimeType]) => ({ + role: 'assistant' as const, + content: fileName, + _attachedFiles: [{ + fileName, + mimeType, + fileSize: 1, + preview: mimeType.startsWith('image/') ? 'data:image/png;base64,ok' : null, + filePath: `/tmp/${fileName}`, + source: 'gateway-media' as const, + }], + })), + ], + loading: false, + error: null, + runError: null, + sending: false, + activeRunId: null, + runtimeRuns: {}, + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: null, + pendingToolImages: [], + sessions: [{ key: 'agent:main:main' }], + currentSessionKey: 'agent:main:main', + currentAgentId: 'main', + sessionLabels: {}, + sessionLastActivity: {}, + thinkingLevel: null, + }); + + const { Chat } = await import('@/pages/Chat/index'); + render(); + + expect(screen.getByTestId('chat-execution-graph')).toBeInTheDocument(); + expect(screen.getAllByText('image.png').length).toBeGreaterThan(0); + expect(screen.getAllByText('report.pdf').length).toBeGreaterThan(0); + expect(screen.getAllByText('audio.wav').length).toBeGreaterThan(0); + expect(screen.getAllByText('video.mp4').length).toBeGreaterThan(0); + }); + it('stops trailing thinking when generated image media arrives but session wake is missed', async () => { const { useChatStore } = await import('@/stores/chat'); useChatStore.setState({ diff --git a/tests/unit/chat-target-routing.test.ts b/tests/unit/chat-target-routing.test.ts index 7202b342a..10cb3f9c3 100644 --- a/tests/unit/chat-target-routing.test.ts +++ b/tests/unit/chat-target-routing.test.ts @@ -164,6 +164,45 @@ describe('chat target routing', () => { expect(typeof (sendPayload as { idempotencyKey?: unknown }).idempotencyKey).toBe('string'); }); + it('keeps the selected channel session when sending from a cc-connect channel conversation', async () => { + const { useChatStore } = await import('@/stores/chat'); + const channelSessionKey = 'feishu:chat-1:user-1'; + + useChatStore.setState({ + currentSessionKey: channelSessionKey, + currentAgentId: 'main', + sessions: [{ key: channelSessionKey }], + messages: [{ role: 'assistant', content: 'Existing channel history' }], + sessionLabels: {}, + sessionLastActivity: {}, + sending: false, + activeRunId: null, + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: null, + pendingToolImages: [], + error: null, + loading: false, + thinkingLevel: null, + }); + + await useChatStore.getState().sendMessage('Reply from app', undefined, 'main'); + + const state = useChatStore.getState(); + expect(state.currentSessionKey).toBe(channelSessionKey); + expect(state.messages.at(-1)?.content).toBe('Reply from app'); + + const sendCall = gatewayRpcMock.mock.calls.find(([method]) => method === 'chat.send'); + const sendPayload = (sendCall?.[1] ?? {}) as Record; + expect(sendPayload).toMatchObject({ + sessionKey: channelSessionKey, + message: 'Reply from app', + deliver: false, + }); + }); + it('uses the selected agent main session for attachment sends', async () => { const { useChatStore } = await import('@/stores/chat'); diff --git a/tests/unit/clawx-data-layout.test.ts b/tests/unit/clawx-data-layout.test.ts new file mode 100644 index 000000000..f7fa7795a --- /dev/null +++ b/tests/unit/clawx-data-layout.test.ts @@ -0,0 +1,88 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + CLAWX_DATA_VERSION, + getClawXDataLayout, + initializeClawXDataLayout, + resolveClawXDataRoot, +} from '@electron/utils/clawx-data-layout'; + +const tempDirs: string[] = []; + +function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'clawx-data-layout-')); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('ClawX data layout', () => { + it('prefers CLAWX_DATA_HOME and isolates Electron userData', () => { + const root = createTempDir(); + const env = { CLAWX_DATA_HOME: root } as NodeJS.ProcessEnv; + const layout = getClawXDataLayout(resolveClawXDataRoot(env), env); + + expect(layout.root).toBe(root); + expect(layout.electronUserDataDir).toBe(join(root, 'system', 'electron')); + expect(layout.ccConnectRuntimeDir).toBe(join(root, 'runtimes', 'cc-connect')); + expect(layout.agentWorkspacesDir).toBe(join(root, 'workspaces', 'agents')); + expect(layout.writerLockPath).toBe(join(root, 'locks', 'writer.lock')); + }); + + it('preserves CLAWX_USER_DATA_DIR as a test-compatible shared root', () => { + const root = createTempDir(); + const env = { CLAWX_USER_DATA_DIR: root } as NodeJS.ProcessEnv; + const layout = getClawXDataLayout(resolveClawXDataRoot(env), env); + + expect(layout.root).toBe(root); + expect(layout.electronUserDataDir).toBe(root); + expect(layout.appDir).toBe(root); + expect(layout.ccConnectRuntimeDir).toBe(join(root, 'runtimes', 'cc-connect')); + }); + + it('derives the shared root from Electron userData compatibility paths', () => { + const root = createTempDir(); + + expect(resolveClawXDataRoot({} as NodeJS.ProcessEnv, root)).toBe(root); + expect(resolveClawXDataRoot( + {} as NodeJS.ProcessEnv, + join(root, 'system', 'electron'), + )).toBe(root); + }); + + it('creates the durable layout and version file', () => { + const root = createTempDir(); + const layout = getClawXDataLayout(root, {} as NodeJS.ProcessEnv); + const version = initializeClawXDataLayout(layout); + + expect(version.version).toBe(CLAWX_DATA_VERSION); + expect(existsSync(layout.credentialsDir)).toBe(true); + expect(existsSync(layout.agentWorkspacesDir)).toBe(true); + expect(existsSync(layout.ccConnectRuntimeDir)).toBe(true); + expect(JSON.parse(readFileSync(layout.dataVersionPath, 'utf8'))).toMatchObject({ + schema: 'clawx-data', + version: CLAWX_DATA_VERSION, + }); + }); + + it('refuses to write data created by a newer application', () => { + const root = createTempDir(); + const layout = getClawXDataLayout(root, {} as NodeJS.ProcessEnv); + initializeClawXDataLayout(layout); + writeFileSync(layout.dataVersionPath, JSON.stringify({ + schema: 'clawx-data', + version: CLAWX_DATA_VERSION + 1, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + })); + + expect(() => initializeClawXDataLayout(layout)).toThrow('newer than supported'); + }); +}); diff --git a/tests/unit/clawx-data-migration.test.ts b/tests/unit/clawx-data-migration.test.ts new file mode 100644 index 000000000..e6d2007af --- /dev/null +++ b/tests/unit/clawx-data-migration.test.ts @@ -0,0 +1,112 @@ +import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { getClawXDataLayout, initializeClawXDataLayout } from '@electron/utils/clawx-data-layout'; +import { migrateLegacyClawXData } from '@electron/utils/clawx-data-migration'; + +const roots: string[] = []; + +async function tempRoot(prefix: string): Promise { + const path = await mkdtemp(join(tmpdir(), prefix)); + roots.push(path); + return path; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +describe('legacy ClawX data migration', () => { + it('copies application state and cc-connect data without deleting the legacy source', async () => { + const source = await tempRoot('clawx-legacy-data-'); + const root = await tempRoot('clawx-new-data-'); + const layout = getClawXDataLayout(root, {} as NodeJS.ProcessEnv); + initializeClawXDataLayout(layout); + await mkdir(join(source, 'runtimes', 'cc-connect'), { recursive: true }); + await mkdir(join(source, 'logs'), { recursive: true }); + await writeFile(join(source, 'settings.json'), '{"runtimeKind":"cc-connect"}\n'); + await writeFile(join(source, 'clawx-providers.json'), '{"providerAccounts":{}}\n'); + await writeFile(join(source, 'runtimes', 'cc-connect', 'config.toml'), 'data_dir = "legacy"\n'); + await writeFile(join(source, 'logs', 'clawx.log'), 'legacy log\n'); + + const result = await migrateLegacyClawXData({ legacyElectronUserDataDir: source, layout }); + + expect(result.copied).toHaveLength(4); + await expect(readFile(join(layout.appDir, 'settings.json'), 'utf8')).resolves.toContain('cc-connect'); + await expect(readFile(join(layout.appDir, 'clawx-providers.json'), 'utf8')).resolves.toContain('providerAccounts'); + await expect(readFile(join(layout.ccConnectRuntimeDir, 'config.toml'), 'utf8')).resolves.toContain('legacy'); + await expect(readFile(join(layout.logsDir, 'clawx.log'), 'utf8')).resolves.toContain('legacy log'); + await expect(readFile(join(source, 'settings.json'), 'utf8')).resolves.toContain('cc-connect'); + await expect(readFile(layout.migrationJournalPath, 'utf8')).resolves.toContain('legacy-electron-user-data-import'); + }); + + it('does not overwrite state already created in the shared root', async () => { + const source = await tempRoot('clawx-legacy-data-'); + const root = await tempRoot('clawx-new-data-'); + const layout = getClawXDataLayout(root, {} as NodeJS.ProcessEnv); + initializeClawXDataLayout(layout); + await mkdir(layout.appDir, { recursive: true }); + await writeFile(join(source, 'settings.json'), '{"theme":"light"}\n'); + await writeFile(join(layout.appDir, 'settings.json'), '{"theme":"dark"}\n'); + + const result = await migrateLegacyClawXData({ legacyElectronUserDataDir: source, layout }); + + expect(result.copied).toEqual([]); + await expect(readFile(join(layout.appDir, 'settings.json'), 'utf8')).resolves.toContain('dark'); + }); + + it('skips migration when the old path is already inside the shared root', async () => { + const root = await tempRoot('clawx-new-data-'); + const layout = getClawXDataLayout(root, {} as NodeJS.ProcessEnv); + initializeClawXDataLayout(layout); + + await expect(migrateLegacyClawXData({ + legacyElectronUserDataDir: layout.electronUserDataDir, + layout, + })).resolves.toMatchObject({ skipped: true, copied: [] }); + }); + + it('skips migration when legacy and shared roots are filesystem aliases', async () => { + const parent = await tempRoot('clawx-data-alias-'); + const root = join(parent, 'root'); + const alias = join(parent, 'alias'); + await mkdir(root, { recursive: true }); + await symlink(root, alias, 'dir'); + const layout = getClawXDataLayout(root, {} as NodeJS.ProcessEnv); + initializeClawXDataLayout(layout); + + await expect(migrateLegacyClawXData({ + legacyElectronUserDataDir: alias, + layout, + })).resolves.toMatchObject({ skipped: true, copied: [] }); + }); + + it.skipIf(process.platform === 'win32')('ignores runtime sockets while importing legacy data', async () => { + const source = `/tmp/clawx-socket-${process.pid}-${Date.now()}`; + roots.push(source); + await mkdir(source, { recursive: true }); + const root = await tempRoot('clawx-new-socket-'); + const socketPath = join(source, 'runtimes', 'cc-connect', 'data', 'run', 'api.sock'); + await mkdir(join(source, 'runtimes', 'cc-connect', 'data', 'run'), { recursive: true }); + await writeFile(join(source, 'runtimes', 'cc-connect', 'config.toml'), 'data_dir = "legacy"\n'); + const server = createServer(); + await new Promise((resolveReady, reject) => { + server.once('error', reject); + server.listen(socketPath, resolveReady); + }); + const layout = getClawXDataLayout(root, {} as NodeJS.ProcessEnv); + initializeClawXDataLayout(layout); + + try { + await expect(migrateLegacyClawXData({ legacyElectronUserDataDir: source, layout })) + .resolves.toMatchObject({ skipped: false }); + await expect(readFile(join(layout.ccConnectRuntimeDir, 'config.toml'), 'utf8')) + .resolves.toContain('legacy'); + await expect(stat(join(layout.ccConnectRuntimeDir, 'data', 'run', 'api.sock'))).rejects.toThrow(); + } finally { + await new Promise((resolveClose) => server.close(() => resolveClose())); + } + }); +}); diff --git a/tests/unit/clawx-runtime-config.test.ts b/tests/unit/clawx-runtime-config.test.ts new file mode 100644 index 000000000..ebdac27d4 --- /dev/null +++ b/tests/unit/clawx-runtime-config.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment node +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +let root: string; + +vi.mock('electron', () => ({ + app: { + getPath: () => root, + }, +})); + +describe('ClawX canonical runtime config', () => { + beforeEach(async () => { + vi.resetModules(); + root = await mkdtemp(join(tmpdir(), 'clawx-runtime-config-')); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('imports OpenClaw compatibility config and then persists canonical updates atomically', async () => { + const openClawPath = join(root, '.openclaw', 'openclaw.json'); + await mkdir(join(root, '.openclaw'), { recursive: true }); + await writeFile(openClawPath, JSON.stringify({ agents: { list: [{ id: 'main' }] } }), 'utf8'); + const { getClawXRuntimeConfigPath, readClawXRuntimeConfig, writeClawXRuntimeConfig } = await import('@electron/utils/clawx-runtime-config'); + + const imported = await readClawXRuntimeConfig({ + openClawConfigPath: openClawPath, + readOpenClawCompatibility: async () => JSON.parse(await readFile(openClawPath, 'utf8')), + }); + expect(imported).toMatchObject({ agents: { list: [{ id: 'main' }] } }); + const canonicalPath = getClawXRuntimeConfigPath(); + await expect(readFile(canonicalPath, 'utf8')).resolves.toContain('clawx-runtime-config'); + + await writeClawXRuntimeConfig({ agents: { list: [{ id: 'ops' }] } }); + await expect(readFile(canonicalPath, 'utf8')).resolves.toContain('"id": "ops"'); + await writeFile(openClawPath, JSON.stringify({ agents: { list: [{ id: 'stale-external' }] } }), 'utf8'); + await expect(readClawXRuntimeConfig({ + openClawConfigPath: openClawPath, + readOpenClawCompatibility: async () => JSON.parse(await readFile(openClawPath, 'utf8')), + })).resolves.toMatchObject({ agents: { list: [{ id: 'ops' }] } }); + if (process.platform !== 'win32') { + expect((await stat(canonicalPath)).mode & 0o777).toBe(0o600); + } + }); +}); diff --git a/tests/unit/codex-bundle.test.ts b/tests/unit/codex-bundle.test.ts new file mode 100644 index 000000000..25a933fba --- /dev/null +++ b/tests/unit/codex-bundle.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { + buildCodexArchiveExtractionCommand, + buildCodexNativeTarballName, + buildCodexVersionCommand, + getCodexNativePackageName, + normalizeCodexTarget, +} from '../fixtures/codex-bundle-api'; + +describe('Codex bundle helpers', () => { + it('maps Node platform and arch to Codex native package metadata', () => { + expect(normalizeCodexTarget('darwin', 'arm64')).toEqual({ + nodePlatform: 'darwin', + nodeArch: 'arm64', + packageSuffix: 'darwin-arm64', + targetTriple: 'aarch64-apple-darwin', + binaryName: 'codex', + }); + expect(normalizeCodexTarget('linux', 'x64')).toMatchObject({ + packageSuffix: 'linux-x64', + targetTriple: 'x86_64-unknown-linux-musl', + }); + expect(normalizeCodexTarget('win32', 'x64')).toMatchObject({ + packageSuffix: 'win32-x64', + targetTriple: 'x86_64-pc-windows-msvc', + binaryName: 'codex.exe', + }); + }); + + it('builds npm tarball names for Codex native packages', () => { + expect(getCodexNativePackageName('darwin-arm64')).toBe('@openai/codex@0.137.0-darwin-arm64'); + expect(buildCodexNativeTarballName('0.137.0', 'linux-x64')).toBe('codex-0.137.0-linux-x64.tgz'); + }); + + it('passes Windows archive and executable paths as opaque process arguments', () => { + const archivePath = String.raw`D:\a\ClawX\build\codex\codex-win32-x64.tgz`; + const outputDir = String.raw`D:\a\ClawX\build\codex\win32-x64`; + const binaryPath = String.raw`D:\a\ClawX\build\codex\win32-x64\bin\codex.exe`; + + expect(buildCodexArchiveExtractionCommand(archivePath, outputDir)).toEqual({ + command: 'tar', + args: ['-xzf', archivePath, '-C', outputDir], + }); + expect(buildCodexVersionCommand(binaryPath)).toEqual({ + command: binaryPath, + args: ['--version'], + }); + }); +}); diff --git a/tests/unit/codex-paths.test.ts b/tests/unit/codex-paths.test.ts new file mode 100644 index 000000000..92cceb347 --- /dev/null +++ b/tests/unit/codex-paths.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment node +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: vi.fn(() => tmpdir()), + }, +})); + +describe('Codex bundle path resolver', () => { + const originalCwd = process.cwd(); + const originalOverride = process.env.CLAWX_CODEX_PATH; + let tempDir: string; + + beforeEach(async () => { + vi.resetModules(); + delete process.env.CLAWX_CODEX_PATH; + tempDir = await mkdtemp(join(tmpdir(), 'clawx-codex-paths-')); + process.chdir(tempDir); + }); + + afterEach(async () => { + process.chdir(originalCwd); + if (originalOverride === undefined) { + delete process.env.CLAWX_CODEX_PATH; + } else { + process.env.CLAWX_CODEX_PATH = originalOverride; + } + await rm(tempDir, { recursive: true, force: true }); + }); + + it('uses the dev bundled native Codex binary and helper path', async () => { + const binaryName = process.platform === 'win32' ? 'codex.exe' : 'codex'; + const bundledDir = join(process.cwd(), 'build', 'codex', `${process.platform}-${process.arch}`); + const binaryPath = join(bundledDir, 'bin', binaryName); + const pathDir = join(bundledDir, 'codex-path'); + await mkdir(join(binaryPath, '..'), { recursive: true }); + await mkdir(pathDir, { recursive: true }); + await writeFile(binaryPath, 'mock codex', 'utf8'); + await chmod(binaryPath, 0o755); + + const { getCodexBundle, assertCodexBundle } = await import('@electron/runtime/codex-paths'); + + expect(getCodexBundle()).toMatchObject({ + binaryPath, + pathDir, + baseDir: bundledDir, + }); + expect(assertCodexBundle()).toMatchObject({ binaryPath, pathDir }); + }); + + it('uses an explicit dev Codex binary override', async () => { + const binaryName = process.platform === 'win32' ? 'codex.exe' : 'codex'; + const bundledDir = join(tempDir, 'mock-runtime', 'codex'); + const binaryPath = join(bundledDir, 'bin', binaryName); + await mkdir(join(binaryPath, '..'), { recursive: true }); + await writeFile(binaryPath, 'mock codex override', 'utf8'); + await chmod(binaryPath, 0o755); + process.env.CLAWX_CODEX_PATH = binaryPath; + + const { getCodexBundle, assertCodexBundle } = await import('@electron/runtime/codex-paths'); + + expect(getCodexBundle()).toMatchObject({ + binaryPath, + baseDir: bundledDir, + pathDir: join(bundledDir, 'codex-path'), + }); + expect(assertCodexBundle()).toMatchObject({ binaryPath }); + }); + + it('fails without falling back to a PATH/global codex binary', async () => { + const { getCodexBundle, assertCodexBundle } = await import('@electron/runtime/codex-paths'); + + const candidate = getCodexBundle(); + expect(candidate.binaryPath).toContain(join('build', 'codex', `${process.platform}-${process.arch}`, 'bin')); + expect(candidate.binaryPath).not.toBe('codex'); + expect(() => assertCodexBundle()).toThrow('pnpm run bundle:codex:current'); + }); +}); diff --git a/tests/unit/credential-vault.test.ts b/tests/unit/credential-vault.test.ts new file mode 100644 index 000000000..0b2da5acb --- /dev/null +++ b/tests/unit/credential-vault.test.ts @@ -0,0 +1,96 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + deleteVaultSecret, + getChannelVaultSecrets, + getVaultSecret, + replaceChannelVaultSecrets, + setVaultSecret, + type CredentialCipher, +} from '@electron/services/secrets/credential-vault'; + +const cipher: CredentialCipher = { + isEncryptionAvailable: () => true, + encryptString: (value) => Buffer.from(value, 'utf8').map((byte) => byte ^ 0x5a), + decryptString: (value) => Buffer.from(value).map((byte) => byte ^ 0x5a).toString('utf8'), +}; + +let root: string; +let previousDataHome: string | undefined; + +beforeEach(async () => { + previousDataHome = process.env.CLAWX_DATA_HOME; + root = await mkdtemp(join(tmpdir(), 'clawx-credential-vault-')); + process.env.CLAWX_DATA_HOME = root; +}); + +afterEach(async () => { + if (previousDataHome === undefined) delete process.env.CLAWX_DATA_HOME; + else process.env.CLAWX_DATA_HOME = previousDataHome; + await rm(root, { recursive: true, force: true }); +}); + +describe('credential vault', () => { + it('persists provider secrets encrypted and exposes only account IDs in the index', async () => { + await setVaultSecret({ + type: 'api_key', + accountId: 'openai-primary', + apiKey: 'synthetic-api-key-must-not-be-plaintext', + }, cipher); + + await expect(getVaultSecret('openai-primary', cipher)).resolves.toEqual({ + type: 'api_key', + accountId: 'openai-primary', + apiKey: 'synthetic-api-key-must-not-be-plaintext', + }); + const encrypted = await readFile(join(root, 'credentials', 'secrets.enc')); + expect(encrypted.toString('utf8')).not.toContain('synthetic-api-key-must-not-be-plaintext'); + const index = await readFile(join(root, 'credentials', 'index.json'), 'utf8'); + expect(index).toContain('openai-primary'); + expect(index).not.toContain('synthetic-api-key'); + }); + + it('removes vault files after the final account is deleted', async () => { + await setVaultSecret({ + type: 'oauth', + accountId: 'oauth-account', + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + }, cipher); + await deleteVaultSecret('oauth-account', cipher); + + await expect(readFile(join(root, 'credentials', 'secrets.enc'))).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(getVaultSecret('oauth-account', cipher)).resolves.toBeNull(); + }); + + it('persists account-scoped channel credentials without exposing values in the index', async () => { + await replaceChannelVaultSecrets({ + 'feishu:ops': { + appSecret: 'feishu-secret-must-not-be-plaintext', + }, + }, cipher); + + await expect(getChannelVaultSecrets(cipher)).resolves.toEqual({ + 'feishu:ops': { + appSecret: 'feishu-secret-must-not-be-plaintext', + }, + }); + const encrypted = await readFile(join(root, 'credentials', 'secrets.enc')); + expect(encrypted.toString('utf8')).not.toContain('feishu-secret-must-not-be-plaintext'); + const index = await readFile(join(root, 'credentials', 'index.json'), 'utf8'); + expect(index).toContain('feishu:ops'); + expect(index).not.toContain('feishu-secret'); + }); + + it('refuses plaintext fallback when OS encryption is unavailable', async () => { + const unavailable = { ...cipher, isEncryptionAvailable: () => false }; + await expect(setVaultSecret({ + type: 'api_key', + accountId: 'blocked', + apiKey: 'secret', + }, unavailable)).rejects.toThrow('refusing to persist'); + }); +}); diff --git a/tests/unit/cron-store-fetch-dedupe.test.ts b/tests/unit/cron-store-fetch-dedupe.test.ts index 733dba0bf..b16c6a637 100644 --- a/tests/unit/cron-store-fetch-dedupe.test.ts +++ b/tests/unit/cron-store-fetch-dedupe.test.ts @@ -1,12 +1,16 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const hostApiFetchMock = vi.fn(); +const hostApiTriggerMock = vi.fn(); +const hostApiDeleteMock = vi.fn(); vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), hostApi: { cron: { list: () => hostApiFetchMock('/api/cron/jobs'), + trigger: (id: string) => hostApiTriggerMock(id), + delete: (id: string) => hostApiDeleteMock(id), }, }, })); @@ -35,6 +39,10 @@ describe('cron store fetchJobs dedupe', () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it('reuses in-flight fetchJobs request when called concurrently', async () => { const listDeferred = deferred>(); hostApiFetchMock.mockReturnValueOnce(listDeferred.promise); @@ -88,4 +96,189 @@ describe('cron store fetchJobs dedupe', () => { expect(useCronStore.getState().jobs.map((job) => job.id)).toEqual(['recurring-job', 'fresh-job']); }); + + it('does not invoke cron.run when the selected runtime marks it unsupported', async () => { + const { useGatewayStore } = await import('@/stores/gateway'); + const { useCronStore } = await import('@/stores/cron'); + useGatewayStore.setState({ + status: { + state: 'running', + port: 18789, + operationCapabilities: { + 'cron.run': { + capability: 'cron', + support: 'unsupported', + notes: 'manual cron execution is unavailable', + }, + }, + }, + }); + + await expect(useCronStore.getState().triggerJob('job-1')) + .rejects.toThrow('Runtime operation cron.run is unavailable'); + expect(hostApiFetchMock).not.toHaveBeenCalled(); + }); + + it('refreshes an asynchronously-triggered job until its last run changes', async () => { + vi.useFakeTimers(); + const pendingJob = { + id: 'job-1', + name: 'Async job', + message: 'run', + schedule: { kind: 'cron', expr: '0 9 * * *' }, + enabled: true, + createdAt: '2026-07-13T00:00:00.000Z', + updatedAt: '2026-07-13T00:00:00.000Z', + agentId: 'main', + timeoutMins: 3, + }; + const completedJob = { + ...pendingJob, + lastRun: { time: '2026-07-13T01:00:00.000Z', success: true }, + }; + hostApiTriggerMock.mockResolvedValueOnce({ success: true }); + hostApiFetchMock + .mockResolvedValueOnce([pendingJob]) + .mockResolvedValueOnce([completedJob]); + + const { useGatewayStore } = await import('@/stores/gateway'); + const { useCronStore } = await import('@/stores/cron'); + useGatewayStore.setState({ status: { state: 'running', port: 18789, runtimeKind: 'cc-connect' } }); + useCronStore.setState({ jobs: [pendingJob as never], loading: false, error: null }); + + await useCronStore.getState().triggerJob('job-1'); + expect(hostApiTriggerMock).toHaveBeenCalledWith('job-1'); + expect(hostApiFetchMock).toHaveBeenCalledTimes(1); + expect(useCronStore.getState().jobs[0]?.lastRun).toBeUndefined(); + + await vi.advanceTimersByTimeAsync(1_000); + expect(hostApiFetchMock).toHaveBeenCalledTimes(2); + expect(useCronStore.getState().jobs[0]?.lastRun).toEqual(completedJob.lastRun); + + await vi.advanceTimersByTimeAsync(30_000); + expect(hostApiFetchMock).toHaveBeenCalledTimes(2); + }); + + it('cancels completion observation when the job is deleted', async () => { + vi.useFakeTimers(); + const pendingJob = { + id: 'job-1', + name: 'Deleted job', + message: 'run', + schedule: { kind: 'cron', expr: '0 9 * * *' }, + enabled: true, + createdAt: '2026-07-13T00:00:00.000Z', + updatedAt: '2026-07-13T00:00:00.000Z', + agentId: 'main', + }; + hostApiTriggerMock.mockResolvedValueOnce({ success: true }); + hostApiDeleteMock.mockResolvedValueOnce({ success: true }); + hostApiFetchMock.mockResolvedValueOnce([pendingJob]); + + const { useGatewayStore } = await import('@/stores/gateway'); + const { useCronStore } = await import('@/stores/cron'); + useGatewayStore.setState({ status: { state: 'running', port: 18789, runtimeKind: 'cc-connect' } }); + useCronStore.setState({ jobs: [pendingJob as never], loading: false, error: null }); + + await useCronStore.getState().triggerJob('job-1'); + await useCronStore.getState().deleteJob('job-1'); + await vi.advanceTimersByTimeAsync(30_000); + + expect(hostApiDeleteMock).toHaveBeenCalledWith('job-1'); + expect(hostApiFetchMock).toHaveBeenCalledTimes(1); + expect(useCronStore.getState().jobs).toEqual([]); + }); + + it('supersedes the prior completion observation when a job is triggered again', async () => { + vi.useFakeTimers(); + const pendingJob = { + id: 'job-1', + name: 'Repeated job', + message: 'run', + schedule: { kind: 'cron', expr: '0 9 * * *' }, + enabled: true, + createdAt: '2026-07-13T00:00:00.000Z', + updatedAt: '2026-07-13T00:00:00.000Z', + agentId: 'main', + }; + const completedJob = { + ...pendingJob, + lastRun: { time: '2026-07-13T01:00:00.000Z', success: true }, + }; + hostApiTriggerMock.mockResolvedValue({ success: true }); + hostApiFetchMock + .mockResolvedValueOnce([pendingJob]) + .mockResolvedValueOnce([pendingJob]) + .mockResolvedValueOnce([completedJob]); + + const { useGatewayStore } = await import('@/stores/gateway'); + const { useCronStore } = await import('@/stores/cron'); + useGatewayStore.setState({ status: { state: 'running', port: 18789, runtimeKind: 'cc-connect' } }); + useCronStore.setState({ jobs: [pendingJob as never], loading: false, error: null }); + + await useCronStore.getState().triggerJob('job-1'); + await useCronStore.getState().triggerJob('job-1'); + await vi.advanceTimersByTimeAsync(1_000); + + expect(hostApiTriggerMock).toHaveBeenCalledTimes(2); + expect(hostApiFetchMock).toHaveBeenCalledTimes(3); + expect(useCronStore.getState().jobs[0]?.lastRun).toEqual(completedJob.lastRun); + }); + + it('stops completion observation when the selected runtime changes', async () => { + vi.useFakeTimers(); + const pendingJob = { + id: 'job-1', + name: 'Runtime switch job', + message: 'run', + schedule: { kind: 'cron', expr: '0 9 * * *' }, + enabled: true, + createdAt: '2026-07-13T00:00:00.000Z', + updatedAt: '2026-07-13T00:00:00.000Z', + agentId: 'main', + }; + hostApiTriggerMock.mockResolvedValueOnce({ success: true }); + hostApiFetchMock.mockResolvedValueOnce([pendingJob]); + + const { useGatewayStore } = await import('@/stores/gateway'); + const { useCronStore } = await import('@/stores/cron'); + useGatewayStore.setState({ status: { state: 'running', port: 18789, runtimeKind: 'cc-connect' } }); + useCronStore.setState({ jobs: [pendingJob as never], loading: false, error: null }); + + await useCronStore.getState().triggerJob('job-1'); + useGatewayStore.setState({ status: { state: 'running', port: 18789, runtimeKind: 'openclaw' } }); + await vi.advanceTimersByTimeAsync(30_000); + + expect(hostApiFetchMock).toHaveBeenCalledTimes(1); + expect(useCronStore.getState().jobs[0]?.lastRun).toBeUndefined(); + }); + + it('uses the runtime default timeout when timeoutMins is zero', async () => { + vi.useFakeTimers(); + const pendingJob = { + id: 'job-1', + name: 'Default timeout job', + message: 'run', + schedule: { kind: 'cron', expr: '0 9 * * *' }, + enabled: true, + createdAt: '2026-07-13T00:00:00.000Z', + updatedAt: '2026-07-13T00:00:00.000Z', + agentId: 'main', + timeoutMins: 0, + }; + hostApiTriggerMock.mockResolvedValueOnce({ success: true }); + hostApiDeleteMock.mockResolvedValueOnce({ success: true }); + hostApiFetchMock.mockResolvedValue([pendingJob]); + + const { useGatewayStore } = await import('@/stores/gateway'); + const { useCronStore } = await import('@/stores/cron'); + useGatewayStore.setState({ status: { state: 'running', port: 18789, runtimeKind: 'cc-connect' } }); + useCronStore.setState({ jobs: [pendingJob as never], loading: false, error: null }); + + await useCronStore.getState().triggerJob('job-1'); + await vi.advanceTimersByTimeAsync(90_000); + + expect(hostApiFetchMock.mock.calls.length).toBeGreaterThanOrEqual(10); + await useCronStore.getState().deleteJob('job-1'); + }); }); diff --git a/tests/unit/e2e-local-real-env.test.ts b/tests/unit/e2e-local-real-env.test.ts new file mode 100644 index 000000000..a2dfa2b3e --- /dev/null +++ b/tests/unit/e2e-local-real-env.test.ts @@ -0,0 +1,302 @@ +// @vitest-environment node +import { execFileSync } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { delimiter, join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + extraLocalRealEnvFiles, + loadLocalRealEnvFiles, + parseLocalRealEnvFile, +} from '../e2e/helpers/local-real-env'; +import { + buildFeishuInboundMarkerArtifact, + writeFeishuInboundMarkerArtifact, +} from '../e2e/helpers/feishu-inbound-marker'; + +describe('E2E local real env loader', () => { + it('builds a sanitized Feishu inbound marker handoff artifact', () => { + expect(buildFeishuInboundMarkerArtifact({ + marker: 'CLAWX_FEISHU_INBOUND_123', + accountId: 'real_feishu_bot', + domain: 'feishu', + timeoutMs: 180_000, + now: new Date('2026-06-28T00:00:00.000Z'), + })).toEqual({ + createdAt: '2026-06-28T00:00:00.000Z', + instruction: 'Send marker exactly as message text to the configured Feishu/Lark bot before timeout.', + marker: 'CLAWX_FEISHU_INBOUND_123', + accountId: 'real_feishu_bot', + domain: 'feishu', + timeoutMs: 180_000, + }); + }); + + it('writes the Feishu inbound marker artifact without credential fields', async () => { + const root = await mkdtemp(join(tmpdir(), 'clawx-feishu-marker-')); + try { + const path = await writeFeishuInboundMarkerArtifact(root, { + marker: 'CLAWX_FEISHU_INBOUND_456', + accountId: 'real_feishu_bot', + domain: 'lark', + timeoutMs: 120_000, + }); + const artifact = JSON.parse(await readFile(path, 'utf8')); + expect(artifact).toMatchObject({ + marker: 'CLAWX_FEISHU_INBOUND_456', + accountId: 'real_feishu_bot', + domain: 'lark', + timeoutMs: 120_000, + }); + expect(Object.keys(artifact).sort()).toEqual([ + 'accountId', + 'createdAt', + 'domain', + 'instruction', + 'marker', + 'timeoutMs', + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('parses shell-style local env files used by cc-connect real smokes', () => { + expect(parseLocalRealEnvFile([ + '# comment', + 'CLAWX_REAL_OPENAI_API_KEY="sk-e2e-placeholder"', + "export CLAWX_REAL_FEISHU_APP_ID='app-id'", + 'INVALID-NAME=value', + 'EMPTY=', + ].join('\n'))).toEqual({ + CLAWX_REAL_OPENAI_API_KEY: 'sk-e2e-placeholder', + CLAWX_REAL_FEISHU_APP_ID: 'app-id', + EMPTY: '', + }); + }); + + it('loads local env files without overriding explicit process env values', async () => { + const root = await mkdtemp(join(tmpdir(), 'clawx-e2e-env-')); + const env: NodeJS.ProcessEnv = { + CLAWX_REAL_OPENAI_API_KEY: 'already-set', + }; + try { + await writeFile(join(root, '.env.cc-connect.local'), [ + 'CLAWX_REAL_OPENAI_API_KEY=from-file', + 'CLAWX_REAL_FEISHU_APP_ID=from-file-app', + ].join('\n'), 'utf8'); + + expect(loadLocalRealEnvFiles({ root, env })).toEqual([ + { + name: '.env.cc-connect.local', + loaded: true, + variableNames: ['CLAWX_REAL_FEISHU_APP_ID', 'CLAWX_REAL_OPENAI_API_KEY'], + safety: { + location: 'outside-repo', + gitignored: true, + tracked: false, + safe: true, + }, + }, + { + name: '.env.local', + loaded: false, + variableNames: [], + safety: { + location: 'outside-repo', + gitignored: true, + tracked: false, + safe: true, + }, + }, + { + name: '.env', + loaded: false, + variableNames: [], + safety: { + location: 'outside-repo', + gitignored: true, + tracked: false, + safe: true, + }, + }, + ]); + expect(env).toMatchObject({ + CLAWX_REAL_OPENAI_API_KEY: 'already-set', + CLAWX_REAL_FEISHU_APP_ID: 'from-file-app', + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('resolves explicit direct E2E env files from environment variables', () => { + expect(extraLocalRealEnvFiles({ + CLAWX_REAL_ENV_FILE: ' /tmp/one.env ', + CLAWX_REAL_ENV_FILES: ` /tmp/two.env ${delimiter}/tmp/three.env `, + })).toEqual([ + '/tmp/one.env', + '/tmp/two.env', + '/tmp/three.env', + ]); + + expect(extraLocalRealEnvFiles({ + CLAWX_REAL_ENV_FILE: '/tmp/one.env', + CLAWX_REAL_ENV_FILES: `${delimiter}/tmp/one.env${delimiter}`, + })).toEqual(['/tmp/one.env']); + }); + + it('loads explicit outside-repo env files without exposing absolute paths in summaries', async () => { + const first = await mkdtemp(join(tmpdir(), 'clawx-e2e-env-explicit-first-')); + const second = await mkdtemp(join(tmpdir(), 'clawx-e2e-env-explicit-second-')); + const env: NodeJS.ProcessEnv = {}; + try { + const firstFile = join(first, 'real-one.env'); + const secondFile = join(second, 'real-two.env'); + await writeFile(firstFile, [ + 'CLAWX_REAL_OPENAI_MODEL=gpt-first', + 'CLAWX_REAL_FEISHU_DOMAIN=feishu', + ].join('\n'), 'utf8'); + await writeFile(secondFile, [ + 'CLAWX_REAL_OPENAI_MODEL=gpt-second', + 'CLAWX_REAL_FEISHU_APP_ID=app-id', + ].join('\n'), 'utf8'); + + expect(loadLocalRealEnvFiles({ root: first, env, files: [firstFile, secondFile] })).toEqual([ + { + name: 'real-one.env', + loaded: true, + variableNames: ['CLAWX_REAL_FEISHU_DOMAIN', 'CLAWX_REAL_OPENAI_MODEL'], + safety: { + location: 'outside-repo', + gitignored: true, + tracked: false, + safe: true, + }, + }, + { + name: 'real-two.env', + loaded: true, + variableNames: ['CLAWX_REAL_FEISHU_APP_ID', 'CLAWX_REAL_OPENAI_MODEL'], + safety: { + location: 'outside-repo', + gitignored: true, + tracked: false, + safe: true, + }, + }, + ]); + expect(env).toMatchObject({ + CLAWX_REAL_OPENAI_MODEL: 'gpt-first', + CLAWX_REAL_FEISHU_DOMAIN: 'feishu', + CLAWX_REAL_FEISHU_APP_ID: 'app-id', + }); + } finally { + await Promise.all([ + rm(first, { recursive: true, force: true }), + rm(second, { recursive: true, force: true }), + ]); + } + }); + + it('loads repo-local env files only when they are gitignored and untracked', async () => { + const root = await mkdtemp(join(tmpdir(), 'clawx-e2e-env-gitignored-')); + const env: NodeJS.ProcessEnv = {}; + try { + execFileSync('git', ['init'], { cwd: root, stdio: 'ignore' }); + await writeFile(join(root, '.gitignore'), '.env.cc-connect.local\n', 'utf8'); + await writeFile(join(root, '.env.cc-connect.local'), 'CLAWX_REAL_FEISHU_APP_ID=app-id\n', 'utf8'); + + expect(loadLocalRealEnvFiles({ root, env })).toEqual([ + { + name: '.env.cc-connect.local', + loaded: true, + variableNames: ['CLAWX_REAL_FEISHU_APP_ID'], + safety: { + location: 'repo', + gitignored: true, + tracked: false, + safe: true, + }, + }, + { + name: '.env.local', + loaded: false, + variableNames: [], + safety: { + location: 'repo', + gitignored: false, + tracked: false, + safe: false, + }, + }, + { + name: '.env', + loaded: false, + variableNames: [], + safety: { + location: 'repo', + gitignored: false, + tracked: false, + safe: false, + }, + }, + ]); + expect(env.CLAWX_REAL_FEISHU_APP_ID).toBe('app-id'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('skips repo-local env files that are not gitignored without parsing names or values', async () => { + const root = await mkdtemp(join(tmpdir(), 'clawx-e2e-env-unignored-')); + const env: NodeJS.ProcessEnv = {}; + try { + execFileSync('git', ['init'], { cwd: root, stdio: 'ignore' }); + await writeFile(join(root, '.env.cc-connect.local'), 'CLAWX_REAL_FEISHU_APP_ID=app-id\n', 'utf8'); + + expect(loadLocalRealEnvFiles({ root, env })[0]).toEqual({ + name: '.env.cc-connect.local', + loaded: false, + variableNames: [], + safety: { + location: 'repo', + gitignored: false, + tracked: false, + safe: false, + }, + skippedReason: expect.stringContaining('untracked and gitignored'), + }); + expect(env.CLAWX_REAL_FEISHU_APP_ID).toBeUndefined(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('skips repo-local env files that are tracked even when ignored later', async () => { + const root = await mkdtemp(join(tmpdir(), 'clawx-e2e-env-tracked-')); + const env: NodeJS.ProcessEnv = {}; + try { + execFileSync('git', ['init'], { cwd: root, stdio: 'ignore' }); + await writeFile(join(root, '.env.cc-connect.local'), 'CLAWX_REAL_FEISHU_APP_ID=app-id\n', 'utf8'); + execFileSync('git', ['add', '.env.cc-connect.local'], { cwd: root, stdio: 'ignore' }); + await writeFile(join(root, '.gitignore'), '.env.cc-connect.local\n', 'utf8'); + + expect(loadLocalRealEnvFiles({ root, env })[0]).toEqual({ + name: '.env.cc-connect.local', + loaded: false, + variableNames: [], + safety: { + location: 'repo', + gitignored: false, + tracked: true, + safe: false, + }, + skippedReason: expect.stringContaining('untracked and gitignored'), + }); + expect(env.CLAWX_REAL_FEISHU_APP_ID).toBeUndefined(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/execution-graph-card-badge.test.tsx b/tests/unit/execution-graph-card-badge.test.tsx index cd82a13b4..04cbb8f65 100644 --- a/tests/unit/execution-graph-card-badge.test.tsx +++ b/tests/unit/execution-graph-card-badge.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { ExecutionGraphCard } from '@/pages/Chat/ExecutionGraphCard'; import type { TaskStep } from '@/pages/Chat/task-visualization'; @@ -39,6 +39,21 @@ const branchStep: TaskStep = { }; describe('ExecutionGraphCard branch badge', () => { + it('renders the active empty-run thinking state as a visible graph node', () => { + render( + , + ); + + expect(screen.getByTestId('chat-execution-step-thinking-trailing')).toBeInTheDocument(); + const icon = screen.getByTestId('chat-execution-step-thinking-trailing-icon'); + expect(icon.querySelector('svg')).not.toBeNull(); + }); + it('renders the localized branch label without intra-badge wrapping', () => { render( { expect(pre!.textContent).toBe(subagentStep.detail); }); }); + +describe('ExecutionGraphCard approval actions', () => { + it('submits only the offered action with its correlated run id', async () => { + const onApprovalAction = vi.fn(async () => undefined); + const approvalStep: TaskStep = { + id: 'approval-1', + label: 'Codex approval', + kind: 'system', + status: 'running', + detail: 'Allow the command?', + depth: 1, + approval: { + runId: 'run-approval-1', + status: 'pending', + actions: [ + { action: 'perm:allow', label: 'Allow once' }, + { action: 'perm:deny', label: 'Deny' }, + ], + }, + }; + + render( + , + ); + + fireEvent.click(screen.getByText('Allow once')); + await waitFor(() => expect(onApprovalAction).toHaveBeenCalledWith('run-approval-1', 'perm:allow')); + expect(onApprovalAction).toHaveBeenCalledTimes(1); + }); + + it('does not render response controls after the approval resolves', () => { + const resolvedStep: TaskStep = { + id: 'approval-1', + label: 'Codex approval', + kind: 'system', + status: 'completed', + depth: 1, + approval: { + runId: 'run-approval-1', + status: 'approved', + actions: [{ action: 'perm:allow', label: 'Allow once' }], + }, + }; + + render( + undefined)} + />, + ); + + expect(screen.queryByTestId('chat-approval-actions')).toBeNull(); + }); +}); diff --git a/tests/unit/extension-host-api-contributions.test.ts b/tests/unit/extension-host-api-contributions.test.ts index 41459c534..fb9d619aa 100644 --- a/tests/unit/extension-host-api-contributions.test.ts +++ b/tests/unit/extension-host-api-contributions.test.ts @@ -28,6 +28,7 @@ describe('extension host API contributions', () => { }; const ctx = { gatewayManager: {} as ExtensionContext['gatewayManager'], + runtimeManager: {} as ExtensionContext['runtimeManager'], getMainWindow: () => null, hostApi: { register: hostApiRegister }, } satisfies ExtensionContext; diff --git a/tests/unit/files-api.test.ts b/tests/unit/files-api.test.ts new file mode 100644 index 000000000..ba3e07d21 --- /dev/null +++ b/tests/unit/files-api.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const appGetPathMock = vi.hoisted(() => vi.fn((name: string) => { + if (name === 'userData') return '/tmp/clawx-user-data'; + return '/tmp'; +})); + +vi.mock('electron', () => ({ + app: { + getPath: appGetPathMock, + isPackaged: false, + }, + nativeImage: { + createFromPath: vi.fn(() => ({ + isEmpty: () => true, + getSize: () => ({ width: 1, height: 1 }), + resize: vi.fn(), + toPNG: vi.fn(), + })), + }, +})); + +describe('files api', () => { + let testDir: string; + + beforeEach(async () => { + vi.resetModules(); + appGetPathMock.mockClear(); + testDir = await mkdtemp(join(tmpdir(), 'clawx-files-api-')); + appGetPathMock.mockImplementation((name: string) => { + if (name === 'userData') return join(testDir, 'userData'); + return testDir; + }); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it('stages pasted buffers under the cc-connect managed media root when cc-connect is active', async () => { + const { createFilesApi } = await import('../../electron/services/files-api'); + const filesApi = createFilesApi({ + runtimeManager: { + getStatus: () => ({ runtimeKind: 'cc-connect' }), + }, + }); + const payload = Buffer.from('hello cc-connect media').toString('base64'); + + const staged = await filesApi.stageBuffer({ + base64: payload, + fileName: 'note.txt', + mimeType: 'text/plain', + }); + + expect(staged.stagedPath).toContain(join('userData', 'runtimes', 'cc-connect', 'media', 'outbound')); + await expect(readFile(staged.stagedPath, 'utf8')).resolves.toBe('hello cc-connect media'); + }); +}); diff --git a/tests/unit/gateway-bisection-0d794cd.test.ts b/tests/unit/gateway-bisection-0d794cd.test.ts index 210fafd17..799503493 100644 --- a/tests/unit/gateway-bisection-0d794cd.test.ts +++ b/tests/unit/gateway-bisection-0d794cd.test.ts @@ -171,7 +171,7 @@ describe('bisection 0d794cd vs de3046a', () => { it('records whether gateway:sessions-changed is wired (de3046a=false, 0d794cd=true)', async () => { await initGatewayHandlers(); const wired = hasSessionsChangedWiring(); - + console.log(`[bisect] gateway:sessions-changed wired=${wired}`); expect([true, false]).toContain(wired); }); diff --git a/tests/unit/gateway-events.test.ts b/tests/unit/gateway-events.test.ts index 59b6050aa..071d06db4 100644 --- a/tests/unit/gateway-events.test.ts +++ b/tests/unit/gateway-events.test.ts @@ -541,6 +541,41 @@ describe('gateway store event wiring', () => { expect(useChatStore.getState().activeRunId).toBeNull(); }); + it('refreshes sessions and current history when the runtime reports a session update', async () => { + const handlers = new Map void>(); + hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + handlers.set(eventName, handler); + return () => {}; + }); + const { useChatStore } = await import('@/stores/chat'); + const loadSessions = vi.fn(async () => {}); + const loadHistory = vi.fn(async () => {}); + const handleRuntimeEvent = vi.fn(); + const channelSessionKey = 'feishu:chat-1:user-1'; + useChatStore.setState({ + currentSessionKey: channelSessionKey, + sessions: [{ key: channelSessionKey }], + loadSessions, + loadHistory, + handleRuntimeEvent, + }); + + const { useGatewayStore } = await import('@/stores/gateway'); + await useGatewayStore.getState().init(); + + handlers.get('chat:runtime-event')?.({ + type: 'session.updated', + runId: 'session-sync-1', + sessionKey: channelSessionKey, + updatedAt: 1773281731000, + }); + await flushAsyncImports(); + + expect(loadSessions).toHaveBeenCalledWith(true); + expect(loadHistory).toHaveBeenCalledTimes(1); + expect(handleRuntimeEvent).not.toHaveBeenCalled(); + }); + it('forwards normalized chat runtime events through the dedicated host event channel', async () => { const handlers = new Map void>(); hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { @@ -679,6 +714,64 @@ describe('gateway store event wiring', () => { expect(handleChatEvent).toHaveBeenCalledTimes(1); }); + it('keeps distinct proactive final attachments for one run and dedupes exact replays', async () => { + const handlers = new Map void>(); + hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + handlers.set(eventName, handler); + return () => {}; + }); + + const { useChatStore } = await import('@/stores/chat'); + const handleChatEvent = vi.fn(); + useChatStore.setState({ + currentSessionKey: 'agent:main:main', + sessions: [{ key: 'agent:main:main' }], + handleChatEvent, + }); + + const { useGatewayStore } = await import('@/stores/gateway'); + await useGatewayStore.getState().init(); + + const imageEvent = { + state: 'final', + runId: 'proactive-media-run', + sessionKey: 'agent:main:main', + message: { + id: 'proactive-media-run:image', + role: 'assistant', + content: 'image.png', + _attachedFiles: [{ fileName: 'image.png', mimeType: 'image/png' }], + }, + }; + const fileEvent = { + state: 'final', + runId: 'proactive-media-run', + sessionKey: 'agent:main:main', + message: { + id: 'proactive-media-run:file', + role: 'assistant', + content: 'report.pdf', + _attachedFiles: [{ fileName: 'report.pdf', mimeType: 'application/pdf' }], + }, + }; + handlers.get('gateway:chat-message')?.(imageEvent); + handlers.get('gateway:chat-message')?.(fileEvent); + handlers.get('gateway:chat-message')?.(imageEvent); + await flushAsyncImports(); + + expect(handleChatEvent).toHaveBeenCalledTimes(2); + expect(handleChatEvent).toHaveBeenNthCalledWith(1, expect.objectContaining({ + runId: 'proactive-media-run', + sessionKey: 'agent:main:main', + message: expect.objectContaining({ id: 'proactive-media-run:image' }), + })); + expect(handleChatEvent).toHaveBeenNthCalledWith(2, expect.objectContaining({ + runId: 'proactive-media-run', + sessionKey: 'agent:main:main', + message: expect.objectContaining({ id: 'proactive-media-run:file' }), + })); + }); + it('renders a cron run live when its run-scoped events bind to the base cron session in view', async () => { const baseKey = 'agent:product:cron:294717ee-6dde-45a8-8f67-900e2831cc4f'; const runKey = `${baseKey}:run:0bfbc08a-7582-4c88-9fd3-47c484e17660`; diff --git a/tests/unit/generated-files.test.ts b/tests/unit/generated-files.test.ts index ac5e2cb42..965b6f712 100644 --- a/tests/unit/generated-files.test.ts +++ b/tests/unit/generated-files.test.ts @@ -147,6 +147,143 @@ describe('generated-files utilities', () => { }); }); + it('extracts files created by Codex apply_patch tool calls', () => { + const messages: RawMessage[] = [ + { role: 'user', content: 'create file with apply_patch', timestamp: 1 }, + { + role: 'assistant', + content: [{ + type: 'toolCall', + id: 'call-apply-patch', + name: 'apply_patch', + arguments: [ + '*** Begin Patch', + '*** Add File: reports/summary.md', + '+# Summary', + '+', + '+CLAWX_REAL_TOOL_FILE_OK', + '*** End Patch', + '', + ].join('\n'), + }], + }, + ]; + + const files = extractGeneratedFiles(messages, 0, 1); + + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ + filePath: 'reports/summary.md', + fileName: 'summary.md', + action: 'created', + fullContent: '# Summary\n\nCLAWX_REAL_TOOL_FILE_OK\n', + baseline: { status: 'missing' }, + }); + expect(computeLineStats(files[0])).toEqual({ added: 3, removed: 0 }); + }); + + it('extracts files from Codex apply_patch line-array inputs', () => { + const messages = [ + { role: 'user', content: 'create file with apply_patch', timestamp: 1 }, + { + role: 'assistant', + content: [{ + type: 'toolCall', + id: 'call-apply-patch', + name: 'apply_patch', + arguments: [ + '*** Begin Patch', + '*** Add File: clawx-real-tool-smoke.txt', + '+CLAWX_REAL_TOOL_FILE_OK', + '*** End Patch', + '', + ], + }], + timestamp: 2, + }, + { role: 'assistant', content: 'done', timestamp: 3 }, + ] as RawMessage[]; + + const files = extractGeneratedFiles(messages, 0, messages.length - 1); + + expect(files).toEqual([ + expect.objectContaining({ + filePath: 'clawx-real-tool-smoke.txt', + fileName: 'clawx-real-tool-smoke.txt', + action: 'created', + fullContent: 'CLAWX_REAL_TOOL_FILE_OK\n', + }), + ]); + expect(computeLineStats(files[0])).toEqual({ added: 1, removed: 0 }); + }); + + it('extracts edited files from Codex apply_patch update hunks', () => { + const messages: RawMessage[] = [ + { role: 'user', content: 'update file with apply_patch', timestamp: 1 }, + { + role: 'assistant', + content: [{ + type: 'toolCall', + id: 'call-apply-patch', + name: 'apply_patch', + arguments: [ + '*** Begin Patch', + '*** Update File: src/example.ts', + '@@', + '-const value = 1', + '+const value = 2', + ' console.log(value)', + '*** End Patch', + '', + ].join('\n'), + }], + }, + ]; + + const files = extractGeneratedFiles(messages, 0, 1); + + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ + filePath: 'src/example.ts', + fileName: 'example.ts', + action: 'modified', + edits: [{ old: 'const value = 1\n', new: 'const value = 2\n' }], + }); + expect(computeLineStats(files[0])).toEqual({ added: 1, removed: 1 }); + }); + + it('extracts files from cc-connect structured Patch tool calls', () => { + const messages: RawMessage[] = [ + { role: 'user', content: 'create file through cc-connect', timestamp: 1 }, + { + role: 'assistant', + content: [{ + type: 'toolCall', + id: 'call-bridge-patch', + name: 'Patch', + arguments: [{ + diff: '# ClawX UI Artifact\nCLAWX_REAL_UI_ARTIFACT_OK\n', + kind: { type: 'add' }, + path: '/tmp/clawx-real-ui-artifact.md', + }], + }], + }, + ]; + + const files = extractGeneratedFiles(messages, 0, 1); + + expect(files).toEqual([ + expect.objectContaining({ + filePath: '/tmp/clawx-real-ui-artifact.md', + fileName: 'clawx-real-ui-artifact.md', + action: 'created', + fullContent: '# ClawX UI Artifact\nCLAWX_REAL_UI_ARTIFACT_OK\n', + baseline: { status: 'missing' }, + }), + ]); + expect(computeLineStats(files[0])).toEqual({ added: 2, removed: 0 }); + }); + it('computes edit snippet stats from joined edit hunks', () => { const stats = computeLineStats({ filePath: '/tmp/example.ts', diff --git a/tests/unit/host-api-facade.test.ts b/tests/unit/host-api-facade.test.ts index f5fcceb06..438aa8d98 100644 --- a/tests/unit/host-api-facade.test.ts +++ b/tests/unit/host-api-facade.test.ts @@ -239,6 +239,46 @@ describe('hostApi facade', () => { })); }); + it('passes cc-connect Codex OAuth lifecycle requests through hostInvoke', async () => { + hostInvoke + .mockResolvedValueOnce({ id: 'req-1', ok: true, data: { success: true, managed: { exists: true } } }) + .mockResolvedValueOnce({ id: 'req-2', ok: true, data: { success: true, managed: { exists: true } } }) + .mockResolvedValueOnce({ id: 'req-3', ok: true, data: { success: true, managed: { exists: false } } }); + const { hostApi } = await import('@/lib/host-api'); + + await expect(hostApi.providers.codexOAuthStatus({ accountId: 'openai-oauth' })).resolves.toEqual({ + success: true, + managed: { exists: true }, + }); + await expect(hostApi.providers.importCodexOAuth({ accountId: 'openai-oauth' })).resolves.toEqual({ + success: true, + managed: { exists: true }, + }); + await expect(hostApi.providers.logoutCodexOAuth({ + accountId: 'openai-oauth', + managedOnly: true, + })).resolves.toEqual({ + success: true, + managed: { exists: false }, + }); + + expect(hostInvoke).toHaveBeenNthCalledWith(1, expect.objectContaining({ + module: 'providers', + action: 'codexOAuthStatus', + payload: { accountId: 'openai-oauth' }, + })); + expect(hostInvoke).toHaveBeenNthCalledWith(2, expect.objectContaining({ + module: 'providers', + action: 'importCodexOAuth', + payload: { accountId: 'openai-oauth' }, + })); + expect(hostInvoke).toHaveBeenNthCalledWith(3, expect.objectContaining({ + module: 'providers', + action: 'logoutCodexOAuth', + payload: { accountId: 'openai-oauth', managedOnly: true }, + })); + }); + it('calls chat.sendWithMedia through hostInvoke', async () => { hostInvoke.mockResolvedValueOnce({ id: 'req', ok: true, data: { success: true } }); const { hostApi } = await import('@/lib/host-api'); @@ -283,6 +323,27 @@ describe('hostApi facade', () => { })); }); + it('calls skills.target through hostInvoke', async () => { + hostInvoke.mockResolvedValueOnce({ + id: 'req', + ok: true, + data: { + success: true, + runtimeKind: 'cc-connect', + sourceDir: '/tmp/.openclaw/skills', + openDir: '/tmp/clawx/runtimes/cc-connect/codex-home/skills', + mirrorMode: 'runtime-mirror', + }, + }); + const { hostApi } = await import('@/lib/host-api'); + + await hostApi.skills.target(); + expect(hostInvoke).toHaveBeenCalledWith(expect.objectContaining({ + module: 'skills', + action: 'target', + })); + }); + it('calls usage.recentTokenHistory through hostInvoke', async () => { hostInvoke.mockResolvedValueOnce({ id: 'req', ok: true, data: [] }); const { hostApi } = await import('@/lib/host-api'); diff --git a/tests/unit/host-services.test.ts b/tests/unit/host-services.test.ts index afa456404..fcd71cd87 100644 --- a/tests/unit/host-services.test.ts +++ b/tests/unit/host-services.test.ts @@ -1,14 +1,16 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { homedir, tmpdir } from 'node:os'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { applyProxySettingsMock, + browserOAuthSetSuccessHandlerMock, assignChannelAccountToAgentMock, assignChannelToAgentMock, clearChannelBindingMock, createAgentMock, + codexOAuthStatusMock, deleteAgentConfigMock, deleteChannelAccountConfigMock, deleteChannelConfigMock, @@ -16,6 +18,7 @@ const { getAllSettingsMock, getChannelFormValuesMock, getSettingMock, + importCodexOAuthMock, listLogFilesMock, logDir, listAgentsSnapshotFromConfigMock, @@ -27,13 +30,18 @@ const { providerServiceMock, readOpenClawConfigMock, readLogFileMock, + logoutCodexOAuthMock, removeAgentWorkspaceDirectoryMock, resetSettingsMock, saveChannelConfigMock, + deleteCcConnectAgentBindingMock, + setCcConnectAgentProviderBindingMock, + setCcConnectAgentPermissionModeMock, setSettingMock, syncDefaultProviderToRuntimeMock, syncDeletedProviderToRuntimeMock, syncSavedProviderToRuntimeMock, + syncUpdatedProviderToRuntimeMock, syncLaunchAtStartupSettingFromStoreMock, syncProxyConfigToOpenClawMock, testOpenClawConfigDir, @@ -41,10 +49,12 @@ const { validateApiKeyWithProviderMock, } = vi.hoisted(() => ({ applyProxySettingsMock: vi.fn(), + browserOAuthSetSuccessHandlerMock: vi.fn(), assignChannelAccountToAgentMock: vi.fn(), assignChannelToAgentMock: vi.fn(), clearChannelBindingMock: vi.fn(), createAgentMock: vi.fn(), + codexOAuthStatusMock: vi.fn(), deleteAgentConfigMock: vi.fn(), deleteChannelAccountConfigMock: vi.fn(), deleteChannelConfigMock: vi.fn(), @@ -52,6 +62,7 @@ const { getAllSettingsMock: vi.fn(), getChannelFormValuesMock: vi.fn(), getSettingMock: vi.fn(), + importCodexOAuthMock: vi.fn(), listLogFilesMock: vi.fn(), logDir: '/tmp/clawx-host-services-test-logs', listAgentsSnapshotFromConfigMock: vi.fn(), @@ -95,13 +106,18 @@ const { }, readOpenClawConfigMock: vi.fn(), readLogFileMock: vi.fn(), + logoutCodexOAuthMock: vi.fn(), removeAgentWorkspaceDirectoryMock: vi.fn(), resetSettingsMock: vi.fn(), - saveChannelConfigMock: vi.fn(), + saveChannelConfigMock: vi.fn(), + deleteCcConnectAgentBindingMock: vi.fn(), + setCcConnectAgentProviderBindingMock: vi.fn(), + setCcConnectAgentPermissionModeMock: vi.fn(), setSettingMock: vi.fn(), syncDefaultProviderToRuntimeMock: vi.fn(), syncDeletedProviderToRuntimeMock: vi.fn(), syncSavedProviderToRuntimeMock: vi.fn(), + syncUpdatedProviderToRuntimeMock: vi.fn(), syncLaunchAtStartupSettingFromStoreMock: vi.fn(), syncProxyConfigToOpenClawMock: vi.fn(), testOpenClawConfigDir: '/tmp/clawx-host-services-openclaw', @@ -116,6 +132,26 @@ vi.mock('@electron/utils/store', () => ({ setSetting: (...args: unknown[]) => setSettingMock(...args), })); +vi.mock('electron', () => ({ + app: { + isPackaged: false, + name: 'ClawX', + getPath: vi.fn(() => testOpenClawConfigDir), + getLocale: vi.fn(() => 'en'), + }, + BrowserWindow: { + getFocusedWindow: vi.fn(() => null), + getAllWindows: vi.fn(() => []), + }, + Menu: { + buildFromTemplate: vi.fn(() => ({})), + setApplicationMenu: vi.fn(), + }, + shell: { + openExternal: vi.fn(), + }, +})); + vi.mock('@electron/utils/openclaw-proxy', () => ({ syncProxyConfigToOpenClaw: (...args: unknown[]) => syncProxyConfigToOpenClawMock(...args), })); @@ -197,7 +233,7 @@ vi.mock('@electron/services/providers/provider-runtime-sync', () => ({ syncDeletedProviderToRuntime: (...args: unknown[]) => syncDeletedProviderToRuntimeMock(...args), syncProviderApiKeyToRuntime: vi.fn(), syncSavedProviderToRuntime: (...args: unknown[]) => syncSavedProviderToRuntimeMock(...args), - syncUpdatedProviderToRuntime: vi.fn(), + syncUpdatedProviderToRuntime: (...args: unknown[]) => syncUpdatedProviderToRuntimeMock(...args), getOpenClawProviderKey: vi.fn((type: string) => type), })); @@ -213,9 +249,22 @@ vi.mock('@electron/services/providers/provider-validation', () => ({ validateApiKeyWithProvider: (...args: unknown[]) => validateApiKeyWithProviderMock(...args), })); +vi.mock('@electron/runtime/cc-connect-provider-profile', () => ({ + getCcConnectCodexOAuthStatus: (...args: unknown[]) => codexOAuthStatusMock(...args), + importUserCodexOAuthToManagedHome: (...args: unknown[]) => importCodexOAuthMock(...args), + logoutCcConnectCodexOAuth: (...args: unknown[]) => logoutCodexOAuthMock(...args), +})); + +vi.mock('@electron/runtime/cc-connect-agent-bindings', () => ({ + deleteCcConnectAgentBinding: (...args: unknown[]) => deleteCcConnectAgentBindingMock(...args), + setCcConnectAgentProviderBinding: (...args: unknown[]) => setCcConnectAgentProviderBindingMock(...args), + setCcConnectAgentPermissionMode: (...args: unknown[]) => setCcConnectAgentPermissionModeMock(...args), +})); + vi.mock('@electron/utils/browser-oauth', () => ({ browserOAuthManager: { setWindow: vi.fn(), + setSuccessHandler: (...args: unknown[]) => browserOAuthSetSuccessHandlerMock(...args), startFlow: vi.fn(), stopFlow: vi.fn(), submitManualCode: vi.fn(), @@ -248,6 +297,7 @@ vi.mock('@electron/utils/paths', () => ({ getOpenClawConfigDir: () => testOpenClawConfigDir, getOpenClawDir: () => testOpenClawConfigDir, getOpenClawResolvedDir: () => testOpenClawConfigDir, + getOpenClawSkillsDir: () => join(homedir(), '.openclaw', 'skills'), })); vi.mock('@electron/utils/proxy-fetch', () => ({ @@ -311,6 +361,25 @@ describe('host services', () => { providerServiceMock.listVendors.mockResolvedValue([]); providerServiceMock.createAccount.mockImplementation(async (account: unknown) => account); providerServiceMock.setDefaultAccount.mockResolvedValue(undefined); + codexOAuthStatusMock.mockResolvedValue({ + success: true, + managedCodexHome: '/tmp/clawx/codex-home', + authPath: '/tmp/clawx/codex-home/auth.json', + managed: { path: '/tmp/clawx/codex-home/auth.json', exists: false, complete: false }, + user: { path: '/tmp/home/.codex/auth.json', exists: false, complete: false }, + }); + importCodexOAuthMock.mockResolvedValue({ + success: true, + provider: { accountId: 'openai-oauth' }, + managed: { exists: true, complete: true }, + user: { exists: true, complete: true }, + }); + logoutCodexOAuthMock.mockResolvedValue({ + success: true, + provider: { accountId: 'openai-oauth' }, + managed: { exists: false, complete: false }, + user: { exists: true, complete: true }, + }); validateApiKeyWithProviderMock.mockResolvedValue({ valid: true }); ensureFeishuPluginInstalledMock.mockResolvedValue({ installed: true }); rmSync(logDir, { recursive: true, force: true }); @@ -319,6 +388,10 @@ describe('host services', () => { mkdirSync(join(testOpenClawConfigDir, 'logs'), { recursive: true }); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it('runs proxy side effects and restarts a running gateway after settings.set', async () => { const gatewayManager = { getStatus: vi.fn(() => ({ state: 'running', port: 18789 })), @@ -339,6 +412,71 @@ describe('host services', () => { expect(gatewayManager.restart).toHaveBeenCalledTimes(1); }); + it('returns the OpenClaw skills source as the active runtime target by default', async () => { + const { createSkillsApi } = await import('@electron/services/skills-api'); + const skillsApi = createSkillsApi({ + clawHubService: {} as never, + gatewayManager: {} as never, + runtimeManager: { + getActiveKind: vi.fn(async () => 'openclaw'), + listCapabilities: vi.fn(() => ({ skills: true })), + } as never, + }); + + await expect(skillsApi.target()).resolves.toMatchObject({ + success: true, + runtimeKind: 'openclaw', + sourceDir: join(homedir(), '.openclaw', 'skills'), + openDir: join(homedir(), '.openclaw', 'skills'), + runtimeDir: join(homedir(), '.openclaw', 'skills'), + mirrorMode: 'source', + }); + }); + + it('returns the managed Codex skills mirror when cc-connect is active', async () => { + const { createSkillsApi } = await import('@electron/services/skills-api'); + const skillsApi = createSkillsApi({ + clawHubService: {} as never, + gatewayManager: {} as never, + runtimeManager: { + getActiveKind: vi.fn(async () => 'cc-connect'), + listCapabilities: vi.fn(() => ({ skills: true })), + } as never, + }); + + const runtimeDir = join(testOpenClawConfigDir, 'runtimes', 'cc-connect', 'codex-home', 'skills'); + await expect(skillsApi.target()).resolves.toMatchObject({ + success: true, + runtimeKind: 'cc-connect', + sourceDir: join(homedir(), '.openclaw', 'skills'), + openDir: runtimeDir, + runtimeDir, + manifestPath: join(runtimeDir, 'manifest.json'), + mirrorMode: 'runtime-mirror', + }); + }); + + it('refreshes every cc-connect skill mirror after a ClawHub install', async () => { + const install = vi.fn().mockResolvedValue(undefined); + const rpc = vi.fn().mockResolvedValue({ success: true }); + const { createSkillsApi } = await import('@electron/services/skills-api'); + const skillsApi = createSkillsApi({ + clawHubService: { install } as never, + gatewayManager: {} as never, + runtimeManager: { + getActiveProvider: vi.fn(() => ({ kind: 'cc-connect' })), + listCapabilities: vi.fn(() => ({ skills: true })), + rpc, + } as never, + }); + + await expect(skillsApi.clawhubInstall({ slug: 'shared-skill', version: '1.2.3' })) + .resolves.toEqual({ success: true }); + + expect(install).toHaveBeenCalledWith({ slug: 'shared-skill', version: '1.2.3' }); + expect(rpc).toHaveBeenCalledWith('skills.update', {}); + }); + it('runs launch-at-startup side effects after settings.setMany and reset', async () => { const gatewayManager = { getStatus: vi.fn(() => ({ state: 'stopped', port: 18789 })), @@ -381,6 +519,37 @@ describe('host services', () => { expect(gatewayManager.rpc).toHaveBeenCalledWith('chat.history', { limit: 1 }, 42); }); + it('opens the active runtime Control UI through provider capability', async () => { + const getControlUi = vi.fn(async () => ({ + success: true, + url: 'http://127.0.0.1:9820/', + token: 'runtime-management-token', + port: 9820, + })); + const runtimeManager = { + getStatus: vi.fn(() => ({ + state: 'running', + port: 9820, + runtimeKind: 'cc-connect', + capabilities: { controlUi: true }, + })), + getActiveProvider: vi.fn(() => ({ getControlUi })), + }; + const backpressure = { + run: vi.fn(), + }; + const { createGatewayApi } = await import('@electron/services/gateway-api'); + + await expect(createGatewayApi(runtimeManager as never, backpressure as never).controlUi()).resolves.toEqual({ + success: true, + url: 'http://127.0.0.1:9820/', + token: 'runtime-management-token', + port: 9820, + }); + + expect(getControlUi).toHaveBeenCalledWith({}); + }); + it('exposes provider account snapshot actions through the typed providers service', async () => { const account = { id: 'custom-local', @@ -469,6 +638,207 @@ describe('host services', () => { ); }); + it('syncs provider accounts through active runtime provider capability', async () => { + const account = { + id: 'ollama-local', + vendorId: 'ollama', + label: 'Ollama', + authMode: 'local', + model: 'qwen3:latest', + enabled: true, + createdAt: '2026-06-07T00:00:00.000Z', + updatedAt: '2026-06-07T00:00:00.000Z', + }; + providerServiceMock.createAccount.mockResolvedValue(account); + const syncProviderProfile = vi.fn(async () => ({ success: true })); + const runtimeManager = { + getActiveProvider: vi.fn(() => ({ syncProviderProfile })), + }; + const { createProvidersApi } = await import('@electron/services/providers-api'); + + await expect(createProvidersApi({ + gatewayManager: { debouncedReload: vi.fn() } as never, + runtimeManager: runtimeManager as never, + mainWindow: {} as never, + }).createAccount({ account })).resolves.toEqual({ + success: true, + account, + }); + + expect(providerServiceMock.createAccount).toHaveBeenCalledWith(account, undefined); + expect(syncProviderProfile).toHaveBeenCalledWith({ + providerId: 'ollama-local', + reason: 'save', + }); + expect(syncSavedProviderToRuntimeMock).not.toHaveBeenCalled(); + }); + + it('dispatches browser OAuth success through the active runtime provider', async () => { + const account = { + id: 'openai-oauth', + vendorId: 'openai', + label: 'OpenAI Codex', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + createdAt: '2026-07-12T00:00:00.000Z', + updatedAt: '2026-07-12T00:00:00.000Z', + }; + providerServiceMock.getAccount.mockResolvedValue(account); + const syncProviderProfile = vi.fn(async () => ({ success: true })); + const runtimeManager = { + getActiveProvider: vi.fn(() => ({ kind: 'cc-connect', syncProviderProfile })), + }; + const { createProvidersApi } = await import('@electron/services/providers-api'); + + createProvidersApi({ + gatewayManager: { debouncedReload: vi.fn() } as never, + runtimeManager: runtimeManager as never, + mainWindow: {} as never, + }); + const successHandler = browserOAuthSetSuccessHandlerMock.mock.calls.at(-1)?.[0]; + expect(successHandler).toBeTypeOf('function'); + await successHandler({ provider: 'openai', accountId: account.id }); + + expect(providerAccountToConfigMock).toHaveBeenCalledWith(account); + expect(syncProviderProfile).toHaveBeenCalledWith({ + providerId: account.id, + reason: 'oauth', + }); + expect(syncUpdatedProviderToRuntimeMock).not.toHaveBeenCalled(); + }); + + it('retains OpenClaw provider projection when browser OAuth has no runtime sync capability', async () => { + const account = { + id: 'openai-oauth', + vendorId: 'openai', + label: 'OpenAI Codex', + authMode: 'oauth_browser', + model: 'gpt-5.5', + enabled: true, + createdAt: '2026-07-12T00:00:00.000Z', + updatedAt: '2026-07-12T00:00:00.000Z', + }; + providerServiceMock.getAccount.mockResolvedValue(account); + const gatewayManager = { debouncedRestart: vi.fn() }; + const runtimeManager = { + getActiveProvider: vi.fn(() => ({ kind: 'openclaw' })), + }; + const { createProvidersApi } = await import('@electron/services/providers-api'); + + createProvidersApi({ + gatewayManager: gatewayManager as never, + runtimeManager: runtimeManager as never, + mainWindow: {} as never, + }); + const successHandler = browserOAuthSetSuccessHandlerMock.mock.calls.at(-1)?.[0]; + expect(successHandler).toBeTypeOf('function'); + await successHandler({ provider: 'openai', accountId: account.id }); + + expect(syncUpdatedProviderToRuntimeMock).toHaveBeenCalledWith( + expect.objectContaining({ id: account.id, type: 'openai' }), + undefined, + gatewayManager, + ); + }); + + it('exposes cc-connect Codex OAuth lifecycle through providers service and syncs active runtime', async () => { + const syncProviderProfile = vi.fn(async () => ({ success: true })); + const runtimeManager = { + getActiveProvider: vi.fn(() => ({ syncProviderProfile })), + }; + const { createProvidersApi } = await import('@electron/services/providers-api'); + const providersApi = createProvidersApi({ + gatewayManager: { debouncedReload: vi.fn() } as never, + runtimeManager: runtimeManager as never, + mainWindow: {} as never, + }); + + await expect(providersApi.codexOAuthStatus({ accountId: 'openai-oauth' })).resolves.toMatchObject({ + success: true, + managedCodexHome: '/tmp/clawx/codex-home', + }); + await expect(providersApi.importCodexOAuth({ accountId: 'openai-oauth' })).resolves.toMatchObject({ + success: true, + provider: { accountId: 'openai-oauth' }, + }); + await expect(providersApi.logoutCodexOAuth({ + accountId: 'openai-oauth', + managedOnly: true, + })).resolves.toMatchObject({ + success: true, + provider: { accountId: 'openai-oauth' }, + }); + + expect(codexOAuthStatusMock).toHaveBeenCalledWith({ accountId: 'openai-oauth' }); + expect(importCodexOAuthMock).toHaveBeenCalledWith({ accountId: 'openai-oauth' }); + expect(logoutCodexOAuthMock).toHaveBeenCalledWith({ + accountId: 'openai-oauth', + managedOnly: true, + }); + expect(syncProviderProfile).toHaveBeenCalledWith({ + providerId: 'openai-oauth', + reason: 'codex-oauth-import', + }); + expect(syncProviderProfile).toHaveBeenCalledWith({ + providerId: 'openai-oauth', + reason: 'codex-oauth-logout', + }); + }); + + it('routes cron API through active runtime cron capability', async () => { + const runtimeManager = { + listCapabilities: vi.fn(() => ({ cron: true })), + rpc: vi.fn(async () => [{ + id: 'cron-1', + name: 'Daily summary', + message: 'summarize', + schedule: { kind: 'cron', expr: '0 9 * * *' }, + enabled: true, + createdAt: '2026-06-08T00:00:00.000Z', + updatedAt: '2026-06-08T00:00:00.000Z', + }]), + }; + const gatewayManager = { + rpc: vi.fn(), + }; + const { createCronApi } = await import('@electron/services/cron-api'); + + await expect(createCronApi({ + gatewayManager: gatewayManager as never, + runtimeManager: runtimeManager as never, + }).list()).resolves.toMatchObject([ + { id: 'cron-1', name: 'Daily summary' }, + ]); + + expect(runtimeManager.rpc).toHaveBeenCalledWith('cron.list'); + expect(gatewayManager.rpc).not.toHaveBeenCalled(); + }); + + it('uses the canonical runtime cron RPC methods for mutations', async () => { + const runtimeManager = { + listCapabilities: vi.fn(() => ({ cron: true })), + rpc: vi.fn(async () => ({ success: true })), + }; + const gatewayManager = { + rpc: vi.fn(), + }; + const { createCronApi } = await import('@electron/services/cron-api'); + const cronApi = createCronApi({ + gatewayManager: gatewayManager as never, + runtimeManager: runtimeManager as never, + }); + + await expect(cronApi.delete({ id: 'cron-1' })).resolves.toEqual({ success: true }); + await expect(cronApi.toggle({ id: 'cron-1', enabled: false })).resolves.toEqual({ success: true }); + await expect(cronApi.trigger({ id: 'cron-1' })).resolves.toEqual({ success: true }); + + expect(runtimeManager.rpc).toHaveBeenCalledWith('cron.delete', { id: 'cron-1' }); + expect(runtimeManager.rpc).toHaveBeenCalledWith('cron.toggle', { id: 'cron-1', enabled: false }); + expect(runtimeManager.rpc).toHaveBeenCalledWith('cron.run', { id: 'cron-1', mode: 'force' }); + expect(gatewayManager.rpc).not.toHaveBeenCalled(); + }); + it('sets the default provider account and syncs runtime defaults', async () => { providerServiceMock.getDefaultAccountId.mockResolvedValue('old-default'); const gatewayManager = { debouncedReload: vi.fn() }; @@ -530,7 +900,7 @@ describe('host services', () => { }).deleteAccount({ accountId: deletedAccount.id })).resolves.toEqual({ success: true }); expect(providerServiceMock.setDefaultAccount).toHaveBeenCalledWith(newestEnabledAccount.id); - expect(syncDefaultProviderToRuntimeMock).toHaveBeenCalledWith(newestEnabledAccount.id); + expect(syncDefaultProviderToRuntimeMock).toHaveBeenCalledWith(newestEnabledAccount.id, gatewayManager); expect(syncDeletedProviderToRuntimeMock).toHaveBeenCalledWith( expect.objectContaining({ id: deletedAccount.id, type: deletedAccount.vendorId }), deletedAccount.id, @@ -651,6 +1021,71 @@ describe('host services', () => { expect(gatewayManager.rpc).not.toHaveBeenCalled(); }); + it('probes active runtime for channel status when runtime manager is provided', async () => { + readOpenClawConfigMock.mockResolvedValue({ + channels: { + feishu: { + accounts: { + team_bot: { enabled: true }, + }, + defaultAccount: 'team_bot', + }, + }, + }); + listConfiguredChannelsFromConfigMock.mockResolvedValue(['feishu']); + listConfiguredChannelAccountsFromConfigMock.mockReturnValue({ + feishu: { + defaultAccountId: 'team_bot', + accountIds: ['team_bot'], + }, + }); + const gatewayManager = { + rpc: vi.fn(), + getStatus: vi.fn(() => ({ state: 'stopped', port: 18789 })), + getDiagnostics: vi.fn(() => ({ consecutiveHeartbeatMisses: 0, consecutiveRpcFailures: 0 })), + }; + const runtimeManager = { + rpc: vi.fn(async () => ({ + channelAccounts: { + feishu: [{ + accountId: 'team_bot', + configured: true, + connected: true, + running: true, + linked: true, + }], + }, + })), + getStatus: vi.fn(() => ({ + state: 'running', + port: 9820, + runtimeKind: 'cc-connect', + capabilities: { channels: true }, + })), + }; + const { createChannelsApi } = await import('@electron/services/channels-api'); + + await expect(createChannelsApi({ + gatewayManager: gatewayManager as never, + runtimeManager: runtimeManager as never, + }).accounts({ mode: 'runtime', probe: true })).resolves.toMatchObject({ + success: true, + channels: [{ + channelType: 'feishu', + status: 'connected', + accounts: [{ + accountId: 'team_bot', + connected: true, + running: true, + linked: true, + }], + }], + }); + + expect(runtimeManager.rpc).toHaveBeenCalledWith('channels.status', { probe: true }, 5000); + expect(gatewayManager.rpc).not.toHaveBeenCalled(); + }); + it('lists channel targets from session history and validates channel type', async () => { const sessionsDir = join(testOpenClawConfigDir, 'agents', 'main', 'sessions'); mkdirSync(sessionsDir, { recursive: true }); @@ -752,6 +1187,46 @@ describe('host services', () => { expect(gatewayManager.debouncedRestart).toHaveBeenCalledWith(150); }); + it('refreshes active runtime config after saving channel config', async () => { + listAgentsSnapshotMock.mockResolvedValue({ + agents: [{ id: 'main', name: 'Main' }], + defaultAgentId: 'main', + defaultModelRef: null, + configuredChannelTypes: ['feishu'], + channelOwners: {}, + channelAccountOwners: {}, + }); + getChannelFormValuesMock.mockResolvedValue({ appId: 'old', appSecret: 'old-secret' }); + const gatewayManager = { + getStatus: vi.fn(() => ({ state: 'running', port: 18789 })), + debouncedRestart: vi.fn(), + debouncedReload: vi.fn(), + }; + const refreshConfig = vi.fn(async () => undefined); + const runtimeManager = { + getActiveProvider: vi.fn(() => ({ refreshConfig })), + }; + const { createChannelsApi } = await import('@electron/services/channels-api'); + + await expect(createChannelsApi({ + gatewayManager: gatewayManager as never, + runtimeManager: runtimeManager as never, + }).saveConfig({ + channelType: 'feishu', + accountId: 'default', + config: { appId: 'cli_new', appSecret: 'new-secret' }, + })).resolves.toEqual({ success: true }); + + expect(refreshConfig).toHaveBeenCalledWith({ + scope: 'channels', + reason: 'channel:saveConfig:feishu', + channelType: 'feishu', + forceRestart: true, + }); + expect(gatewayManager.debouncedRestart).not.toHaveBeenCalled(); + expect(gatewayManager.debouncedReload).not.toHaveBeenCalled(); + }); + it('deletes agents by restarting gateway, removing workspace, and returning snapshot', async () => { const snapshot = { agents: [], @@ -774,6 +1249,7 @@ describe('host services', () => { .resolves.toEqual({ success: true, ...snapshot }); expect(deleteAgentConfigMock).toHaveBeenCalledWith('code'); + expect(deleteCcConnectAgentBindingMock).toHaveBeenCalledWith('code'); expect(gatewayManager.restart).toHaveBeenCalledTimes(1); expect(removeAgentWorkspaceDirectoryMock).toHaveBeenCalledWith(removedEntry); }); @@ -809,6 +1285,46 @@ describe('host services', () => { expect(gatewayManager.debouncedReload).toHaveBeenCalledTimes(1); }); + it('updates a cc-connect Agent binding through the active runtime without OpenClaw projection', async () => { + const snapshot = { + agents: [{ id: 'reviewer', modelRef: 'openai/gpt-reviewer' }], + defaultAgentId: 'main', + defaultModelRef: null, + configuredChannelTypes: [], + channelOwners: {}, + channelAccountOwners: {}, + }; + const gatewayManager = { + getStatus: vi.fn(() => ({ state: 'running' })), + debouncedReload: vi.fn(), + }; + const refreshConfig = vi.fn().mockResolvedValue(undefined); + const runtimeManager = { + getActiveProvider: vi.fn(() => ({ kind: 'cc-connect', refreshConfig })), + }; + const { createAgentsApi } = await import('@electron/services/agents-api'); + const agentConfig = await import('@electron/utils/agent-config'); + const providerRuntimeSync = await import('@electron/services/providers/provider-runtime-sync'); + vi.mocked(agentConfig.updateAgentModel).mockResolvedValue(snapshot as never); + + await expect(createAgentsApi({ + gatewayManager: gatewayManager as never, + runtimeManager: runtimeManager as never, + }).updateModel({ + id: 'reviewer', + modelRef: 'openai/gpt-reviewer', + providerAccountId: 'reviewer-account', + })).resolves.toMatchObject({ success: true }); + + expect(providerRuntimeSync.syncAllProviderAuthToRuntime).not.toHaveBeenCalled(); + expect(providerRuntimeSync.syncAgentModelOverrideToRuntime).not.toHaveBeenCalled(); + expect(refreshConfig).toHaveBeenCalledWith({ + scope: 'runtime', + reason: 'update-agent-model', + }); + expect(gatewayManager.debouncedReload).not.toHaveBeenCalled(); + }); + it('assigns agent channels and schedules gateway reload', async () => { const snapshot = { agents: [{ id: 'main', channelTypes: ['feishu'] }], @@ -836,7 +1352,22 @@ describe('host services', () => { it('returns diagnostics snapshot with channel view and log tails', async () => { writeFileSync(join(testOpenClawConfigDir, 'logs', 'gateway.log'), 'gateway-one\ngateway-two\n'); + mkdirSync(join(testOpenClawConfigDir, 'runtimes', 'cc-connect'), { recursive: true }); + writeFileSync(join(testOpenClawConfigDir, 'runtimes', 'cc-connect', 'provider-profile.json'), JSON.stringify({ + providerId: 'openai-oauth', + vendorId: 'openai', + supported: true, + envKeys: ['CODEX_HOME'], + })); readLogFileMock.mockResolvedValue('clawx-log-tail'); + codexOAuthStatusMock.mockResolvedValue({ + success: true, + managedCodexHome: join(testOpenClawConfigDir, 'runtimes', 'cc-connect', 'codex-home'), + authPath: join(testOpenClawConfigDir, 'runtimes', 'cc-connect', 'codex-home', 'auth.json'), + managed: { exists: true, complete: true }, + user: { exists: true, complete: true }, + provider: { accountId: 'openai-oauth', vendorId: 'openai', hasOAuthSecret: true }, + }); readOpenClawConfigMock.mockResolvedValue({ channels: { feishu: { @@ -876,9 +1407,62 @@ describe('host services', () => { })), getCapabilitySnapshot: vi.fn(() => ({ rpc: true })), }; + const runtimeManager = { + getStatus: vi.fn(() => ({ + runtimeKind: 'cc-connect', + state: 'running', + configDir: join(testOpenClawConfigDir, 'runtimes', 'cc-connect'), + operationCapabilities: { + 'doctor.fix': { support: 'unsupported', reason: 'cc-connect v1.3.2' }, + }, + })), + getActiveProvider: vi.fn(() => ({ + kind: 'cc-connect', + rpc: vi.fn(async (method: string) => { + if (method === 'cron.list') { + return [{ + id: 'cron-1', + name: 'Daily build', + message: 'secret prompt should not be copied', + agentId: 'research', + enabled: true, + delivery: { mode: 'none' }, + createdAt: '2026-06-13T00:00:00.000Z', + updatedAt: '2026-06-13T00:00:00.000Z', + exec: 'node -e "secret command should not be copied"', + workDir: '/tmp/diagnostics-workdir', + sessionMode: 'continue', + timeoutMins: 5, + mute: true, + lastRun: { + time: '2026-06-13T00:01:00.000Z', + success: false, + error: 'secret runtime error should not be copied', + }, + }]; + } + return undefined; + }), + listOperationCapabilities: vi.fn(() => ({ + 'doctor.fix': { support: 'unsupported', reason: 'cc-connect v1.3.2' }, + })), + listLogs: vi.fn(async () => ({ content: 'cc-connect-log-tail' })), + getControlUi: vi.fn(async () => ({ + success: true, + url: 'http://127.0.0.1:9820/', + token: 'diagnostics-management-token', + port: 9820, + })), + })), + }; + const fetchMock = vi.fn(async () => new Response('{"ok":true}', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); const { createDiagnosticsApi } = await import('@electron/services/diagnostics-api'); - const snapshot = await createDiagnosticsApi({ gatewayManager: gatewayManager as never }).gatewaySnapshot(); + const snapshot = await createDiagnosticsApi({ + gatewayManager: gatewayManager as never, + runtimeManager: runtimeManager as never, + }).gatewaySnapshot(); expect(snapshot).toMatchObject({ platform: process.platform, @@ -890,10 +1474,83 @@ describe('host services', () => { ], clawxLogTail: 'clawx-log-tail', gateway: expect.objectContaining({ - state: 'healthy', capabilities: { rpc: true }, }), + runtime: { + activeKind: 'cc-connect', + status: expect.objectContaining({ + runtimeKind: 'cc-connect', + state: 'running', + }), + operationCapabilities: { + 'doctor.fix': { support: 'unsupported', reason: 'cc-connect v1.3.2' }, + }, + ccConnect: expect.objectContaining({ + managedDir: join(testOpenClawConfigDir, 'runtimes', 'cc-connect'), + configPath: join(testOpenClawConfigDir, 'runtimes', 'cc-connect', 'config.toml'), + codexHomeDir: join(testOpenClawConfigDir, 'runtimes', 'cc-connect', 'codex-home'), + providerProfilePath: join(testOpenClawConfigDir, 'runtimes', 'cc-connect', 'provider-profile.json'), + oauth: expect.objectContaining({ + success: true, + provider: expect.objectContaining({ accountId: 'openai-oauth' }), + }), + providerProfile: expect.objectContaining({ + providerId: 'openai-oauth', + envKeys: ['CODEX_HOME'], + }), + binaries: expect.objectContaining({ + ccConnect: expect.objectContaining({ + binaryPath: expect.stringContaining('cc-connect'), + manifestPath: expect.stringContaining('manifest.json'), + versionCommand: expect.objectContaining({ command: expect.stringContaining('--version') }), + }), + codex: expect.objectContaining({ + binaryPath: expect.stringContaining('codex'), + manifestPath: expect.stringContaining('manifest.json'), + versionCommand: expect.objectContaining({ command: expect.stringContaining('--version') }), + }), + }), + managementApi: expect.objectContaining({ + success: true, + port: 9820, + status: 200, + body: '{"ok":true}', + }), + cron: expect.objectContaining({ + success: true, + jobCount: 1, + jobs: [ + expect.objectContaining({ + id: 'cron-1', + name: 'Daily build', + agentId: 'research', + enabled: true, + deliveryMode: 'none', + hasExec: true, + hasPrompt: false, + sessionMode: 'continue', + timeoutMins: 5, + mute: true, + lastRun: expect.objectContaining({ + success: false, + hasError: true, + }), + }), + ], + knownGaps: expect.arrayContaining([ + 'scheduled-prompt-delivery-unproven', + ]), + }), + logTail: 'cc-connect-log-tail', + }), + }, + }); + expect(fetchMock).toHaveBeenCalledWith(new URL('http://127.0.0.1:9820/api/v1/status'), { + headers: { Authorization: 'Bearer diagnostics-management-token' }, }); + expect(JSON.stringify(snapshot)).not.toContain('secret prompt should not be copied'); + expect(JSON.stringify(snapshot)).not.toContain('secret command should not be copied'); + expect(JSON.stringify(snapshot)).not.toContain('secret runtime error should not be copied'); expect(snapshot.gatewayLogTail).toContain('gateway-one'); expect(snapshot.gatewayErrLogTail).toBe(''); }); @@ -994,4 +1651,27 @@ describe('host services', () => { ], }); }); + + it('routes session rename through the active runtime session RPC', async () => { + const rpc = vi.fn(async () => ({ success: true })); + const runtimeManager = { + getActiveProvider: vi.fn(() => ({ + kind: 'openclaw', + listCapabilities: vi.fn(() => ({ sessions: true })), + rpc, + })), + }; + const { createSessionsApi } = await import('@electron/services/sessions-api'); + const sessionsApi = createSessionsApi(runtimeManager as never); + + await expect(sessionsApi.rename({ + sessionKey: 'agent:research:named', + title: 'Renamed research', + })).resolves.toEqual({ success: true }); + + expect(rpc).toHaveBeenCalledWith('sessions.rename', { + sessionKey: 'agent:research:named', + label: 'Renamed research', + }); + }); }); diff --git a/tests/unit/logger-exit-handler.test.ts b/tests/unit/logger-exit-handler.test.ts new file mode 100644 index 000000000..1373be235 --- /dev/null +++ b/tests/unit/logger-exit-handler.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: vi.fn(() => '/tmp/clawx-logger-exit-test'), + getVersion: vi.fn(() => '0.0.0-test'), + }, +})); + +describe('logger exit handler', () => { + afterEach(() => { + vi.resetModules(); + }); + + it('registers at most one process exit listener across module reloads', async () => { + const before = process.listenerCount('exit'); + + await import('@electron/utils/logger'); + vi.resetModules(); + await import('@electron/utils/logger'); + vi.resetModules(); + await import('@electron/utils/logger'); + + const after = process.listenerCount('exit'); + expect(after - before).toBeLessThanOrEqual(1); + }); +}); diff --git a/tests/unit/media-api.test.ts b/tests/unit/media-api.test.ts index 9415d67f3..5792bd4c8 100644 --- a/tests/unit/media-api.test.ts +++ b/tests/unit/media-api.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -9,8 +9,16 @@ const createFromPathMock = vi.hoisted(() => vi.fn(() => ({ resize: vi.fn(), toPNG: vi.fn(), }))); +const appGetPathMock = vi.hoisted(() => vi.fn((name: string) => { + if (name === 'userData') return '/tmp/clawx-user-data'; + return '/tmp'; +})); vi.mock('electron', () => ({ + app: { + getPath: appGetPathMock, + isPackaged: false, + }, dialog: { showSaveDialog: vi.fn(), }, @@ -25,7 +33,12 @@ describe('media api', () => { beforeEach(async () => { vi.resetModules(); createFromPathMock.mockClear(); + appGetPathMock.mockClear(); testDir = await mkdtemp(join(tmpdir(), 'clawx-media-api-')); + appGetPathMock.mockImplementation((name: string) => { + if (name === 'userData') return join(testDir, 'userData'); + return testDir; + }); }); afterEach(async () => { @@ -50,4 +63,38 @@ describe('media api', () => { fileSize: Buffer.byteLength(svg), }); }); + + it('resolves outgoing gateway media through the active cc-connect media records', async () => { + const sourcePath = join(testDir, 'artifact.txt'); + await writeFile(sourcePath, 'generated artifact', 'utf8'); + const recordDir = join(testDir, 'userData', 'runtimes', 'cc-connect', 'media', 'outgoing', 'records'); + await mkdir(recordDir, { recursive: true }); + await writeFile( + join(recordDir, 'artifact-1.json'), + JSON.stringify({ + original: { + path: sourcePath, + contentType: 'text/plain', + }, + }), + 'utf8', + ); + + const { createMediaApi } = await import('../../electron/services/media-api'); + const mediaApi = createMediaApi({ + runtimeManager: { + getStatus: () => ({ runtimeKind: 'cc-connect' }), + }, + }); + const gatewayUrl = '/api/chat/media/outgoing/agent%3Amain%3As-1/artifact-1/full'; + + const result = await mediaApi.thumbnails({ + paths: [{ gatewayUrl, mimeType: 'text/plain' }], + }); + + expect(result[gatewayUrl]).toEqual({ + preview: null, + fileSize: Buffer.byteLength('generated artifact'), + }); + }); }); diff --git a/tests/unit/openclaw-auth.test.ts b/tests/unit/openclaw-auth.test.ts index f716cb15d..a666427f8 100644 --- a/tests/unit/openclaw-auth.test.ts +++ b/tests/unit/openclaw-auth.test.ts @@ -29,8 +29,17 @@ vi.mock('electron', () => ({ getPath: () => testUserData, getVersion: () => '0.0.0-test', }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8'), + }, })); +beforeEach(async () => { + await rm(testUserData, { recursive: true, force: true }); +}); + vi.mock('@electron/utils/store', () => ({ getSetting: getSettingMock, })); diff --git a/tests/unit/openclaw-image-generation.test.ts b/tests/unit/openclaw-image-generation.test.ts index 5de0014b0..a7ca2e2e2 100644 --- a/tests/unit/openclaw-image-generation.test.ts +++ b/tests/unit/openclaw-image-generation.test.ts @@ -28,6 +28,11 @@ vi.mock('electron', () => ({ getPath: () => testUserData, getVersion: () => '0.0.0-test', }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8'), + }, })); vi.mock('@electron/utils/paths', async () => { diff --git a/tests/unit/openclaw-runtime-provider.test.ts b/tests/unit/openclaw-runtime-provider.test.ts new file mode 100644 index 000000000..fa06e4d53 --- /dev/null +++ b/tests/unit/openclaw-runtime-provider.test.ts @@ -0,0 +1,210 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { renameSessionMock, tokenUsageHistoryMock, writeOpenClawCompatibilityProjectionMock } = vi.hoisted(() => ({ + renameSessionMock: vi.fn(), + tokenUsageHistoryMock: vi.fn(), + writeOpenClawCompatibilityProjectionMock: vi.fn(async () => undefined), +})); + +vi.mock('@electron/services/sessions-api', () => ({ + createSessionsApi: () => ({ + delete: vi.fn(async () => ({ success: true })), + history: vi.fn(async () => ({ success: true, messages: [] })), + rename: renameSessionMock, + summaries: vi.fn(async () => ({ success: true, summaries: [] })), + }), +})); + +vi.mock('@electron/utils/channel-config', () => ({ + writeOpenClawCompatibilityProjection: writeOpenClawCompatibilityProjectionMock, +})); + +vi.mock('@electron/utils/token-usage', () => ({ + getRecentTokenUsageHistory: (...args: unknown[]) => tokenUsageHistoryMock(...args), +})); + +function createGatewayManagerMock() { + return { + on: vi.fn(), + getStatus: vi.fn(() => ({ state: 'running', port: 18789 })), + checkHealth: vi.fn(async () => ({ ok: true })), + start: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + restart: vi.fn(async () => undefined), + debouncedReload: vi.fn(), + debouncedRestart: vi.fn(), + rpc: vi.fn(), + }; +} + +function gatewayCronJob(overrides: Record = {}) { + return { + id: 'cron-1', + name: 'Daily summary', + enabled: true, + createdAtMs: Date.parse('2026-06-09T00:00:00.000Z'), + updatedAtMs: Date.parse('2026-06-09T00:00:00.000Z'), + schedule: { kind: 'cron', expr: '0 9 * * *' }, + payload: { kind: 'agentTurn', message: 'summarize' }, + state: {}, + ...overrides, + }; +} + +describe('OpenClawRuntimeProvider runtime contract adapters', () => { + beforeEach(() => { + vi.clearAllMocks(); + tokenUsageHistoryMock.mockResolvedValue([]); + }); + + it('owns OpenClaw transcript usage behind the RuntimeProvider contract', async () => { + tokenUsageHistoryMock.mockResolvedValueOnce([{ + runtimeKind: 'openclaw', + timestamp: '2026-07-13T00:00:00.000Z', + sessionId: 'session-1', + agentId: 'main', + provider: 'openai', + model: 'gpt-5.4', + usageStatus: 'available', + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 80, + cacheWriteTokens: 0, + reasoningTokens: 5, + totalTokens: 120, + }]); + const gatewayManager = createGatewayManagerMock(); + const { OpenClawRuntimeProvider } = await import('@electron/runtime/openclaw-provider'); + const provider = new OpenClawRuntimeProvider(gatewayManager as never); + + await expect(provider.listUsage({ limit: 5 })).resolves.toMatchObject({ + success: true, + records: [expect.objectContaining({ + runtimeKind: 'openclaw', + logicalSessionId: 'session-1', + status: 'available', + cachedInputTokens: 80, + reasoningTokens: 5, + totalTokens: 120, + })], + }); + expect(tokenUsageHistoryMock).toHaveBeenCalledWith({ limit: 5, runtimeKind: 'openclaw' }); + }); + + it('routes session rename through the OpenClaw session API facade', async () => { + const gatewayManager = createGatewayManagerMock(); + renameSessionMock.mockResolvedValueOnce({ success: true }); + const { OpenClawRuntimeProvider } = await import('@electron/runtime/openclaw-provider'); + const provider = new OpenClawRuntimeProvider(gatewayManager as never); + + await expect(provider.rpc('sessions.rename', { + sessionKey: 'agent:main:abc123', + label: 'Renamed OpenClaw', + })).resolves.toEqual({ success: true }); + + expect(renameSessionMock).toHaveBeenCalledWith({ + sessionKey: 'agent:main:abc123', + label: 'Renamed OpenClaw', + }); + expect(gatewayManager.rpc).not.toHaveBeenCalled(); + }); + + it('refreshes the canonical OpenClaw projection before start and restart', async () => { + const order: string[] = []; + writeOpenClawCompatibilityProjectionMock.mockImplementation(async () => { + order.push('project'); + }); + const gatewayManager = createGatewayManagerMock(); + gatewayManager.start.mockImplementation(async () => { + order.push('start'); + }); + gatewayManager.restart.mockImplementation(async () => { + order.push('restart'); + }); + const { OpenClawRuntimeProvider } = await import('@electron/runtime/openclaw-provider'); + const provider = new OpenClawRuntimeProvider(gatewayManager as never); + + await provider.start(); + await provider.restart(); + + expect(order).toEqual(['project', 'start', 'project', 'restart']); + }); + + it('accepts Host cron.create payloads and adapts them to Gateway cron.add', async () => { + const gatewayManager = createGatewayManagerMock(); + gatewayManager.rpc.mockResolvedValueOnce(gatewayCronJob()); + const { OpenClawRuntimeProvider } = await import('@electron/runtime/openclaw-provider'); + const provider = new OpenClawRuntimeProvider(gatewayManager as never); + + await expect(provider.rpc('cron.create', { + name: 'Daily summary', + message: 'summarize', + schedule: '0 9 * * *', + enabled: true, + agentId: 'main', + delivery: { mode: 'none' }, + })).resolves.toMatchObject({ + id: 'cron-1', + name: 'Daily summary', + message: 'summarize', + schedule: { kind: 'cron', expr: '0 9 * * *' }, + agentId: 'main', + }); + + expect(gatewayManager.rpc).toHaveBeenCalledWith('cron.add', { + name: 'Daily summary', + schedule: { kind: 'cron', expr: '0 9 * * *' }, + payload: { kind: 'agentTurn', message: 'summarize' }, + enabled: true, + wakeMode: 'next-heartbeat', + sessionTarget: 'isolated', + agentId: 'main', + delivery: { mode: 'none' }, + }); + }); + + it('accepts Host cron.update payloads and adapts them to Gateway cron.update patches', async () => { + const gatewayManager = createGatewayManagerMock(); + gatewayManager.rpc.mockResolvedValueOnce(gatewayCronJob({ + payload: { kind: 'agentTurn', message: 'updated summary' }, + schedule: { kind: 'cron', expr: '0 10 * * *' }, + })); + const { OpenClawRuntimeProvider } = await import('@electron/runtime/openclaw-provider'); + const provider = new OpenClawRuntimeProvider(gatewayManager as never); + + await expect(provider.rpc('cron.update', { + id: 'cron-1', + input: { + message: 'updated summary', + schedule: '0 10 * * *', + enabled: false, + }, + })).resolves.toMatchObject({ + id: 'cron-1', + message: 'updated summary', + schedule: { kind: 'cron', expr: '0 10 * * *' }, + }); + + expect(gatewayManager.rpc).toHaveBeenCalledWith('cron.update', { + id: 'cron-1', + patch: { + schedule: { kind: 'cron', expr: '0 10 * * *' }, + enabled: false, + payload: { kind: 'agentTurn', message: 'updated summary' }, + }, + }); + }); + + it('delegates channel config refresh policy to the OpenClaw gateway lifecycle', async () => { + const gatewayManager = createGatewayManagerMock(); + const { OpenClawRuntimeProvider } = await import('@electron/runtime/openclaw-provider'); + const provider = new OpenClawRuntimeProvider(gatewayManager as never); + + await provider.refreshConfig?.({ scope: 'channels', reason: 'channel:saveConfig:feishu', forceRestart: false }); + await provider.refreshConfig?.({ scope: 'channels', reason: 'channel:setEnabled:feishu', forceRestart: true }); + + expect(gatewayManager.debouncedReload).toHaveBeenCalledWith(150); + expect(gatewayManager.debouncedRestart).toHaveBeenCalledWith(150); + }); +}); diff --git a/tests/unit/packaged-cc-connect-smoke.test.ts b/tests/unit/packaged-cc-connect-smoke.test.ts new file mode 100644 index 000000000..e03ac1376 --- /dev/null +++ b/tests/unit/packaged-cc-connect-smoke.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment node +import { join } from 'node:path'; +import { readFile } from 'node:fs/promises'; +import { describe, expect, it } from 'vitest'; +import { + defaultPackagedAppPath, + escapeTomlBasicString, + packagedExecutablePath, + packagedResourcesPath, + shouldVerifyPackagedCodeSignature, +} from '../../scripts/packaged-runtime-layout.mjs'; + +describe('packaged cc-connect smoke paths', () => { + const rootDir = join('repo', 'clawx'); + + it.each([ + ['darwin', 'arm64', join(rootDir, 'release', 'mac-arm64', 'ClawX.app')], + ['darwin', 'x64', join(rootDir, 'release', 'mac', 'ClawX.app')], + ['win32', 'x64', join(rootDir, 'release', 'win-unpacked')], + ['linux', 'x64', join(rootDir, 'release', 'linux-unpacked')], + ['linux', 'arm64', join(rootDir, 'release', 'linux-arm64-unpacked')], + ] as const)('resolves the %s-%s output', (platform, arch, expected) => { + expect(defaultPackagedAppPath({ platform, arch, rootDir })).toBe(expected); + }); + + it('resolves native executable and resource paths', () => { + expect(packagedExecutablePath('/app/ClawX.app', 'darwin')).toBe('/app/ClawX.app/Contents/MacOS/ClawX'); + expect(packagedResourcesPath('/app/ClawX.app', 'darwin')).toBe('/app/ClawX.app/Contents/Resources'); + expect(packagedExecutablePath('/app/win-unpacked', 'win32')).toBe('/app/win-unpacked/ClawX.exe'); + expect(packagedResourcesPath('/app/win-unpacked', 'win32')).toBe('/app/win-unpacked/resources'); + expect(packagedExecutablePath('/app/linux-unpacked', 'linux')).toBe('/app/linux-unpacked/clawx'); + expect(packagedResourcesPath('/app/linux-unpacked', 'linux')).toBe('/app/linux-unpacked/resources'); + }); + + it('rejects unsupported platforms', () => { + expect(() => defaultPackagedAppPath({ platform: 'freebsd', arch: 'x64', rootDir })).toThrow('Unsupported packaged smoke platform'); + expect(() => packagedExecutablePath('/app', 'freebsd')).toThrow('Unsupported packaged smoke platform'); + expect(() => packagedResourcesPath('/app', 'freebsd')).toThrow('Unsupported packaged smoke platform'); + }); + + it('requires macOS signatures unless unsigned smoke is explicit', () => { + expect(shouldVerifyPackagedCodeSignature('darwin', false)).toBe(true); + expect(shouldVerifyPackagedCodeSignature('darwin', true)).toBe(false); + expect(shouldVerifyPackagedCodeSignature('win32', false)).toBe(false); + expect(shouldVerifyPackagedCodeSignature('linux', false)).toBe(false); + }); + + it('escapes Windows paths for TOML config assertions', () => { + expect(escapeTomlBasicString(String.raw`C:\Users\runner\workspace`)) + .toBe(String.raw`C:\\Users\\runner\\workspace`); + }); + + it('keeps the Windows residual-process PowerShell command syntactically separated', async () => { + const source = await readFile(join(process.cwd(), 'scripts', 'smoke-packaged-cc-connect.mjs'), 'utf8'); + expect(source).toContain("'$needle = $env:CLAWX_SMOKE_PROCESS_NEEDLE;'"); + expect(source).toContain("execFileAsync('powershell.exe'"); + expect(source).toContain('CLAWX_E2E_CREDENTIAL_KEY: randomUUID()'); + expect(source).toContain("join(homeDir, 'AppData', 'Local')"); + expect(source).toContain("join(homeDir, 'AppData', 'Roaming')"); + }); +}); diff --git a/tests/unit/process-instance-lock.test.ts b/tests/unit/process-instance-lock.test.ts index 40edf91a7..47ddc39e7 100644 --- a/tests/unit/process-instance-lock.test.ts +++ b/tests/unit/process-instance-lock.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -190,4 +190,78 @@ describe('process instance file lock', () => { expect(readFileSync(lockPath, 'utf8')).toBe('7777'); lock.release(); }); + + it('writes structured owner metadata and uses an explicit shared-root lock path', () => { + const root = createTempDir(); + const lockPath = join(root, 'locks', 'writer.lock'); + const lock = acquireProcessInstanceFileLock({ + userDataDir: root, + lockName: 'writer', + lockPath, + pid: 4242, + metadata: { + appVersion: '1.2.3', + channel: 'beta', + executable: '/Applications/ClawX.app/Contents/MacOS/ClawX', + startedAt: '2026-07-11T10:00:00.000Z', + }, + }); + + expect(lock.acquired).toBe(true); + expect(JSON.parse(readFileSync(lockPath, 'utf8'))).toMatchObject({ + schema: 'clawx-instance-lock', + version: 2, + pid: 4242, + appVersion: '1.2.3', + channel: 'beta', + executable: '/Applications/ClawX.app/Contents/MacOS/ClawX', + startedAt: '2026-07-11T10:00:00.000Z', + heartbeatAt: '2026-07-11T10:00:00.000Z', + }); + lock.release(); + expect(existsSync(lockPath)).toBe(false); + }); + + it('requires both a dead pid and an expired heartbeat before recovery', () => { + const root = createTempDir(); + const lockPath = join(root, 'locks', 'writer.lock'); + mkdirSync(join(root, 'locks'), { recursive: true }); + const freshHeartbeat = new Date().toISOString(); + writeFileSync(lockPath, JSON.stringify({ + schema: 'clawx-instance-lock', + version: 2, + pid: 5151, + ownerToken: 'first-owner', + heartbeatAt: freshHeartbeat, + })); + + const held = acquireProcessInstanceFileLock({ + userDataDir: root, + lockName: 'writer', + lockPath, + pid: 6161, + isPidAlive: () => false, + heartbeatExpiryMs: 60_000, + }); + expect(held.acquired).toBe(false); + expect(held.ownerDetails?.ownerToken).toBe('first-owner'); + + writeFileSync(lockPath, JSON.stringify({ + schema: 'clawx-instance-lock', + version: 2, + pid: 5151, + ownerToken: 'first-owner', + heartbeatAt: '2020-01-01T00:00:00.000Z', + })); + const recovered = acquireProcessInstanceFileLock({ + userDataDir: root, + lockName: 'writer', + lockPath, + pid: 6161, + isPidAlive: () => false, + heartbeatExpiryMs: 60_000, + }); + expect(recovered.acquired).toBe(true); + recovered.release(); + }); }); diff --git a/tests/unit/runtime-manager.test.ts b/tests/unit/runtime-manager.test.ts new file mode 100644 index 000000000..60a486387 --- /dev/null +++ b/tests/unit/runtime-manager.test.ts @@ -0,0 +1,149 @@ +// @vitest-environment node +import { EventEmitter } from 'node:events'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RuntimeProvider } from '@electron/runtime/types'; + +const settings = new Map(); + +vi.mock('@electron/utils/store', () => ({ + getSetting: vi.fn(async (key: string) => settings.get(key)), + setSetting: vi.fn(async (key: string, value: unknown) => { + settings.set(key, value); + }), +})); + +function createProvider(kind: RuntimeProvider['kind']) { + const emitter = new EventEmitter(); + const provider: RuntimeProvider = { + kind, + on: emitter.on.bind(emitter) as RuntimeProvider['on'], + off: emitter.off.bind(emitter) as RuntimeProvider['off'], + start: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + restart: vi.fn(async () => undefined), + getStatus: vi.fn(() => ({ + state: 'stopped', + port: kind === 'openclaw' ? 18789 : 19876, + runtimeKind: kind, + capabilities: provider.listCapabilities(), + operationCapabilities: provider.listOperationCapabilities(), + })), + checkHealth: vi.fn(async () => ({ ok: true })), + rpc: vi.fn(async () => ({ ok: true })), + sendMessageWithMedia: vi.fn(async () => ({ runId: `${kind}-run` })), + listSessions: vi.fn(async () => ({ sessions: [] })), + loadHistory: vi.fn(async () => ({ messages: [] })), + deleteSession: vi.fn(async () => ({ success: true })), + listLogs: vi.fn(async () => ({ content: `${kind} logs` })), + runDoctor: vi.fn(async (mode) => ({ + mode, + success: true, + exitCode: 0, + stdout: '', + stderr: '', + command: `${kind} doctor`, + cwd: '/tmp', + durationMs: 1, + })), + listCapabilities: vi.fn(() => ({ + chat: true, + sessions: true, + history: true, + providers: kind === 'openclaw', + models: kind === 'openclaw', + channels: kind === 'openclaw', + cron: kind === 'openclaw', + logs: true, + skills: kind === 'openclaw', + doctor: true, + controlUi: true, + })), + listOperationCapabilities: vi.fn(() => ({ + 'chat.send': { capability: 'chat', support: kind === 'openclaw' ? 'proxy' : 'native', notes: `${kind} chat` }, + 'chat.abort': { capability: 'chat', support: kind === 'openclaw' ? 'proxy' : 'native', notes: `${kind} abort` }, + })), + }; + return { provider, emitter }; +} + +describe('RuntimeManager', () => { + beforeEach(() => { + settings.clear(); + vi.resetModules(); + }); + + it('defaults to OpenClaw when no runtime setting is stored', async () => { + const { RuntimeManager } = await import('@electron/runtime/manager'); + const openclaw = createProvider('openclaw').provider; + const ccConnect = createProvider('cc-connect').provider; + const manager = new RuntimeManager({ openclaw, ccConnect }); + + await expect(manager.getActiveKind()).resolves.toBe('openclaw'); + expect(manager.getActiveProvider()).toBe(openclaw); + expect(manager.getStatus().runtimeKind).toBe('openclaw'); + }); + + it('switches runtime setting and stops the previous provider', async () => { + settings.set('devModeUnlocked', true); + const { RuntimeManager } = await import('@electron/runtime/manager'); + const openclaw = createProvider('openclaw').provider; + const ccConnect = createProvider('cc-connect').provider; + const manager = new RuntimeManager({ openclaw, ccConnect }); + await manager.getActiveKind(); + + await manager.setActiveKind('cc-connect'); + + expect(openclaw.stop).toHaveBeenCalledOnce(); + expect(settings.get('runtimeKind')).toBe('cc-connect'); + expect(manager.getActiveProvider()).toBe(ccConnect); + expect(manager.listCapabilities().controlUi).toBe(true); + expect(manager.listOperationCapabilities()['chat.abort']?.support).toBe('native'); + }); + + it('falls back to OpenClaw when cc-connect is stored without developer mode', async () => { + settings.set('runtimeKind', 'cc-connect'); + settings.set('devModeUnlocked', false); + const { RuntimeManager } = await import('@electron/runtime/manager'); + const openclaw = createProvider('openclaw').provider; + const ccConnect = createProvider('cc-connect').provider; + const manager = new RuntimeManager({ openclaw, ccConnect }); + + await expect(manager.getActiveKind()).resolves.toBe('openclaw'); + expect(settings.get('runtimeKind')).toBe('openclaw'); + + await manager.setActiveKind('cc-connect'); + + expect(settings.get('runtimeKind')).toBe('openclaw'); + expect(manager.getActiveProvider()).toBe(openclaw); + }); + + it('marks cc-connect channels as supported because cc-connect owns messaging platforms', async () => { + const { CC_CONNECT_RUNTIME_CAPABILITIES } = await import('@electron/runtime/types'); + + expect(CC_CONNECT_RUNTIME_CAPABILITIES.channels).toBe(true); + }); + + it('forwards provider status events with runtimeKind preserved', async () => { + const { RuntimeManager } = await import('@electron/runtime/manager'); + const openclawFixture = createProvider('openclaw'); + const manager = new RuntimeManager({ + openclaw: openclawFixture.provider, + ccConnect: createProvider('cc-connect').provider, + }); + const statuses: unknown[] = []; + manager.on('status', (status) => statuses.push(status)); + + openclawFixture.emitter.emit('status', { state: 'running', port: 18789 }); + + expect(statuses).toEqual([ + expect.objectContaining({ + state: 'running', + port: 18789, + runtimeKind: 'openclaw', + operationCapabilities: expect.objectContaining({ + 'chat.abort': expect.objectContaining({ support: 'proxy' }), + }), + }), + ]); + }); +}); diff --git a/tests/unit/runtime-operation-capabilities.test.ts b/tests/unit/runtime-operation-capabilities.test.ts new file mode 100644 index 000000000..b754952b8 --- /dev/null +++ b/tests/unit/runtime-operation-capabilities.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { + assertRuntimeOperationSupported, + getRuntimeOperationCapability, + getUnsupportedRuntimeOperation, + isRuntimeOperationSupported, +} from '@/lib/runtime-operation-capabilities'; +import type { GatewayStatus } from '@/types/gateway'; + +describe('runtime operation capability helpers', () => { + it('allows operations when the runtime has not reported operation capabilities yet', () => { + expect(isRuntimeOperationSupported(undefined, 'cron.run')).toBe(true); + expect(() => assertRuntimeOperationSupported(undefined, 'cron.run')).not.toThrow(); + }); + + it('blocks undeclared operations after the runtime publishes its operation contract', () => { + const status: Pick = { + operationCapabilities: { + 'cron.run': { + capability: 'cron', + support: 'native', + notes: 'Uses runtime cron API.', + }, + }, + }; + + expect(isRuntimeOperationSupported(status, 'cron.create')).toBe(false); + expect(() => assertRuntimeOperationSupported(status, 'cron.create')) + .toThrow('Runtime operation cron.create is not declared'); + }); + + it('returns the declared operation support entry', () => { + const status: Pick = { + operationCapabilities: { + 'cron.run': { + capability: 'cron', + support: 'native', + notes: 'Uses runtime cron API.', + }, + }, + }; + + expect(getRuntimeOperationCapability(status, 'cron.run')).toMatchObject({ + capability: 'cron', + support: 'native', + }); + expect(getUnsupportedRuntimeOperation(status, 'cron.run')).toBeUndefined(); + }); + + it('blocks operations explicitly marked unsupported', () => { + const status: Pick = { + operationCapabilities: { + 'doctor.fix': { + capability: 'doctor', + support: 'unsupported', + notes: 'cc-connect does not support fix mode.', + }, + }, + }; + + expect(isRuntimeOperationSupported(status, 'doctor.fix')).toBe(false); + expect(getUnsupportedRuntimeOperation(status, 'doctor.fix')).toMatchObject({ + capability: 'doctor', + support: 'unsupported', + }); + expect(() => assertRuntimeOperationSupported(status, 'doctor.fix')) + .toThrow('Runtime operation doctor.fix is unavailable'); + }); +}); diff --git a/tests/unit/runtime-packaging.test.ts b/tests/unit/runtime-packaging.test.ts new file mode 100644 index 000000000..64ea23048 --- /dev/null +++ b/tests/unit/runtime-packaging.test.ts @@ -0,0 +1,245 @@ +// @vitest-environment node +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { parse } from 'yaml'; +import { describe, expect, it } from 'vitest'; +import { + archesForPlatform, + detectExecutableTarget, + parseArgs, + validateRuntimeBundleManifest, +} from '../../scripts/verify-runtime-bundles.mjs'; +import { + hashMachOSections, + parsePackagedResourceArgs, +} from '../../scripts/verify-packaged-runtime-resources.mjs'; +import { packagedSmokeChecks } from '../../scripts/smoke-packaged-cc-connect.mjs'; + +const repoRoot = process.cwd(); + +describe('runtime packaging guardrails', () => { + it('records real packaged OAuth chat only when the smoke executes it', () => { + expect(packagedSmokeChecks({ platform: 'darwin', realOAuthChatCompleted: true })) + .toContain('real-oauth-chat-through-managed-launcher'); + expect(packagedSmokeChecks({ platform: 'darwin', realOAuthChatCompleted: false })) + .not.toContain('real-oauth-chat-through-managed-launcher'); + }); + + it('keeps cc-connect and Codex bundle verification wired into packaging scripts', async () => { + const packageJson = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8')) as { + scripts: Record; + }; + + expect(packageJson.scripts['verify:runtime-bundles']).toBe('node scripts/verify-runtime-bundles.mjs'); + for (const scriptName of ['build', 'package', 'package:mac', 'package:win', 'package:linux', 'release']) { + expect(packageJson.scripts[scriptName], `${scriptName} should verify bundled runtimes`).toContain('verify:runtime-bundles'); + } + + const afterPack = await readFile(join(repoRoot, 'scripts/after-pack.cjs'), 'utf8'); + expect(afterPack).toContain("import('./verify-packaged-runtime-resources.mjs')"); + expect(afterPack).toContain('verifyPackagedRuntimeResources({ resources: resourcesDir, platform, arch })'); + + const releaseWorkflow = await readFile(join(repoRoot, '.github/workflows/release.yml'), 'utf8'); + for (const target of [ + 'release/mac/ClawX.app/Contents/Resources --platform=darwin --arch=x64', + 'release/mac-arm64/ClawX.app/Contents/Resources --platform=darwin --arch=arm64', + 'release/win-unpacked/resources --platform=win32 --arch=x64', + 'release/linux-unpacked/resources --platform=linux --arch=x64', + 'release/linux-arm64-unpacked/resources --platform=linux --arch=arm64', + ]) { + expect(releaseWorkflow).toContain(`verify:packaged-runtime-resources -- --resources=${target}`); + } + expect(releaseWorkflow).toContain('runs-on: macos-15-intel'); + expect(releaseWorkflow).toContain('runs-on: ubuntu-24.04-arm'); + expect(releaseWorkflow.match(/smoke:cc-connect:packaged/g)).toHaveLength(5); + expect(releaseWorkflow.match(/--allow-unsigned=\$\{\{ github\.event_name == 'workflow_dispatch'/g)).toHaveLength(2); + expect(releaseWorkflow).toContain('artifacts/cc-connect/packaged-smoke-*.json'); + expect(releaseWorkflow).toContain('artifacts/cc-connect/packaged-smoke-darwin-x64.json'); + expect(releaseWorkflow).toContain('artifacts/cc-connect/packaged-smoke-linux-arm64.json'); + expect(releaseWorkflow).toContain('needs: [release, runtime-smoke-macos-x64, runtime-smoke-linux-arm64]'); + + const workflow = parse(releaseWorkflow) as { + jobs?: Record; run?: string }>; + }>; + }; + expect(workflow.jobs?.release?.strategy?.['fail-fast']).toBe(false); + expect(workflow.jobs?.publish?.if).toBe("startsWith(github.ref, 'refs/tags/')"); + expect(workflow.jobs?.['upload-oss']?.if).toBe("startsWith(github.ref, 'refs/tags/')"); + const macBuild = workflow.jobs?.release?.steps?.find((step) => step.name === 'Build macOS'); + expect(macBuild?.env?.CSC_IDENTITY_AUTO_DISCOVERY) + .toBe("${{ github.event_name == 'workflow_dispatch' && 'false' || 'true' }}"); + expect(macBuild?.env?.CSC_LINK) + .toBe("${{ github.event_name == 'push' && secrets.MAC_CERTS || '' }}"); + expect(macBuild?.run).toContain('unset CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID'); + }); + + it('verifies every packaged arch when a platform preset is requested explicitly', () => { + expect(parseArgs([])).toEqual({ + platforms: [process.platform], + explicitPlatform: false, + }); + expect(archesForPlatform(process.platform, { explicitPlatform: false })).toEqual([process.arch]); + + expect(parseArgs(['--platform=mac'])).toEqual({ + platforms: ['darwin'], + explicitPlatform: true, + }); + expect(archesForPlatform('darwin', { explicitPlatform: true })).toEqual(['x64', 'arm64']); + expect(archesForPlatform('linux', { explicitPlatform: true })).toEqual(['x64', 'arm64']); + expect(archesForPlatform('win32', { explicitPlatform: true })).toEqual(['x64']); + }); + + it('packages cc-connect and Codex as platform resources on every Electron target', async () => { + const builderConfig = parse(await readFile(join(repoRoot, 'electron-builder.yml'), 'utf8')) as Record; + }>; + + for (const [target, platformDir] of [ + ['mac', 'darwin-${arch}'], + ['win', 'win32-${arch}'], + ['linux', 'linux-${arch}'], + ] as const) { + const extraResources = builderConfig[target]?.extraResources ?? []; + expect(extraResources, `${target} should package cc-connect`).toEqual(expect.arrayContaining([ + expect.objectContaining({ from: `build/cc-connect/${platformDir}/`, to: 'cc-connect/' }), + ])); + expect(extraResources, `${target} should package Codex`).toEqual(expect.arrayContaining([ + expect.objectContaining({ from: `build/codex/${platformDir}/`, to: 'codex/' }), + ])); + } + }); + + it('rejects stale, corrupted, and non-executable runtime bundle manifests', () => { + const required = { + name: 'cc-connect', + version: '1.4.1', + nodePlatform: 'linux', + nodeArch: 'arm64', + binaryName: 'cc-connect', + }; + const valid = { + ...required, + sha256: 'a'.repeat(64), + sourceUrl: 'https://example.test/cc-connect.tar.gz', + assetName: 'cc-connect.tar.gz', + verifiedWithVersionCommand: false, + }; + + expect(validateRuntimeBundleManifest(required, valid, 'a'.repeat(64), 0o100755)).toEqual([]); + expect(validateRuntimeBundleManifest(required, { + ...valid, + version: '1.3.2', + sha256: 'b'.repeat(64), + }, 'a'.repeat(64), 0o100644)).toEqual(expect.arrayContaining([ + expect.stringContaining('version must be'), + expect.stringContaining('sha256 mismatch'), + 'binary must have an executable bit', + ])); + }); + + it('requires Codex package target metadata in its manifest', () => { + const required = { + name: 'codex', + version: '0.137.0', + nodePlatform: 'win32', + nodeArch: 'x64', + binaryName: 'codex.exe', + }; + expect(validateRuntimeBundleManifest(required, { + ...required, + sha256: 'c'.repeat(64), + verifiedWithVersionCommand: false, + }, 'c'.repeat(64))).toEqual(expect.arrayContaining([ + 'packageSuffix must be present', + 'targetTriple must be present', + ])); + }); + + it('detects actual Mach-O, ELF, and PE executable targets', () => { + const macho = Buffer.alloc(8); + macho.writeUInt32LE(0xfeedfacf, 0); + macho.writeUInt32LE(0x0100000c, 4); + expect(detectExecutableTarget(macho)).toEqual({ platform: 'darwin', arch: 'arm64' }); + + const elf = Buffer.alloc(20); + elf.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); + elf.writeUInt16LE(62, 18); + expect(detectExecutableTarget(elf)).toEqual({ platform: 'linux', arch: 'x64' }); + + const pe = Buffer.alloc(72); + pe.set([0x4d, 0x5a]); + pe.writeUInt32LE(64, 0x3c); + pe.write('PE\0\0', 64, 'ascii'); + pe.writeUInt16LE(0x8664, 68); + expect(detectExecutableTarget(pe)).toEqual({ platform: 'win32', arch: 'x64' }); + expect(detectExecutableTarget(Buffer.from('not executable'))).toBeNull(); + }); + + it('rejects executable headers that disagree with the manifest target', () => { + const binary = Buffer.alloc(20); + binary.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); + binary.writeUInt16LE(62, 18); + const required = { + name: 'cc-connect', + version: '1.4.1', + nodePlatform: 'linux', + nodeArch: 'arm64', + binaryName: 'cc-connect', + }; + expect(validateRuntimeBundleManifest(required, { + ...required, + sha256: 'd'.repeat(64), + sourceUrl: 'https://example.test/cc-connect', + assetName: 'cc-connect', + verifiedWithVersionCommand: false, + }, 'd'.repeat(64), 0o100755, binary)).toContain('binary target must be linux-arm64, got linux-x64'); + }); + + it('requires an explicit packaged resources target for cross-platform smoke', () => { + expect(parsePackagedResourceArgs([ + '--resources=release/win-unpacked/resources', + '--platform=win32', + '--arch=x64', + ])).toEqual({ + resources: 'release/win-unpacked/resources', + platform: 'win32', + arch: 'x64', + }); + expect(() => parsePackagedResourceArgs(['--platform=win32'])).toThrow(/Usage:/); + expect(() => parsePackagedResourceArgs([ + '--resources=release/unknown', + '--platform=freebsd', + '--arch=x64', + ])).toThrow('Unsupported platform: freebsd'); + }); + + it('hashes Mach-O section payloads independently of signing load commands', () => { + const makeMachO = (loadCommandPadding: number, payload: string) => { + const commandSize = 72 + 80 + loadCommandPadding; + const sectionFileOffset = 32 + commandSize; + const payloadBuffer = Buffer.from(payload); + const buffer = Buffer.alloc(sectionFileOffset + payloadBuffer.length); + buffer.writeUInt32LE(0xfeedfacf, 0); + buffer.writeUInt32LE(1, 16); + buffer.writeUInt32LE(commandSize, 20); + buffer.writeUInt32LE(0x19, 32); + buffer.writeUInt32LE(commandSize, 36); + buffer.write('__TEXT', 40, 'ascii'); + buffer.writeUInt32LE(1, 32 + 64); + buffer.write('__text', 32 + 72, 'ascii'); + buffer.write('__TEXT', 32 + 72 + 16, 'ascii'); + buffer.writeBigUInt64LE(BigInt(payloadBuffer.length), 32 + 72 + 40); + buffer.writeUInt32LE(sectionFileOffset, 32 + 72 + 48); + payloadBuffer.copy(buffer, sectionFileOffset); + return buffer; + }; + + expect(hashMachOSections(makeMachO(0, 'runtime payload'))) + .toBe(hashMachOSections(makeMachO(16, 'runtime payload'))); + expect(hashMachOSections(makeMachO(0, 'runtime payload'))) + .not.toBe(hashMachOSections(makeMachO(0, 'tampered payload'))); + }); +}); diff --git a/tests/unit/runtime-rpc-contract.test.ts b/tests/unit/runtime-rpc-contract.test.ts new file mode 100644 index 000000000..16f81c1f4 --- /dev/null +++ b/tests/unit/runtime-rpc-contract.test.ts @@ -0,0 +1,117 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { + getRuntimeRpcCoverage, + getRuntimeOperationCapabilities, + RUNTIME_RPC_CONTRACT, + type RuntimeRpcSupport, +} from '@electron/runtime/rpc-contract'; +import { + CC_CONNECT_RUNTIME_CAPABILITIES, + OPENCLAW_RUNTIME_CAPABILITIES, +} from '@electron/runtime/types'; + +const expectedCcConnectNativeMethods = [ + 'chat.send', + 'sessions.list', + 'chat.history', + 'sessions.delete', + 'session.delete', + 'chat.session.delete', + 'sessions.rename', + 'session.rename', + 'providers.sync', + 'providers.profile', + 'skills.status', + 'skills.update', + 'channels.status', + 'channels.connect', + 'channels.disconnect', + 'channels.delete', + 'runtime.controlUi', + 'cron.list', + 'cron.create', + 'cron.add', + 'cron.update', + 'cron.delete', + 'cron.remove', + 'cron.toggle', + 'cron.run', + 'doctor.run', + 'logs.list', +]; + +describe('runtime RPC contract', () => { + it('documents every cc-connect primary runtime RPC as native or explicitly unsupported', () => { + const ccConnectCoverage = getRuntimeRpcCoverage('cc-connect'); + const byMethod = new Map(ccConnectCoverage.map((entry) => [entry.method, entry])); + + for (const method of expectedCcConnectNativeMethods) { + expect(byMethod.get(method), `${method} should be covered`).toMatchObject({ + runtime: 'cc-connect', + support: 'native' satisfies RuntimeRpcSupport, + }); + } + + expect(byMethod.get('doctor.memory.status')).toMatchObject({ + runtime: 'cc-connect', + support: 'unsupported', + }); + expect(byMethod.get('channels.add')).toMatchObject({ + runtime: 'cc-connect', + support: 'unsupported', + }); + expect(byMethod.get('channels.requestQr')).toMatchObject({ + runtime: 'cc-connect', + support: 'unsupported', + }); + }); + + it('keeps runtime capabilities backed by at least one declared RPC contract entry', () => { + for (const [runtime, capabilities] of [ + ['openclaw', OPENCLAW_RUNTIME_CAPABILITIES], + ['cc-connect', CC_CONNECT_RUNTIME_CAPABILITIES], + ] as const) { + for (const [capability, enabled] of Object.entries(capabilities)) { + if (!enabled) continue; + expect( + RUNTIME_RPC_CONTRACT.some((entry) => ( + entry.runtime === runtime + && entry.capability === capability + && entry.support !== 'unsupported' + )), + `${runtime}.${capability} should have a supported RPC contract entry`, + ).toBe(true); + } + } + }); + + it('exposes operation-level support so cc-connect degraded capabilities are visible', () => { + const operations = getRuntimeOperationCapabilities('cc-connect'); + + expect(operations['chat.send']).toMatchObject({ + capability: 'chat', + support: 'native', + }); + expect(operations['chat.approval.respond']).toMatchObject({ + capability: 'chat', + support: 'native', + }); + expect(operations['chat.abort']).toMatchObject({ + capability: 'chat', + support: 'native', + }); + expect(operations['doctor.fix']).toMatchObject({ + capability: 'doctor', + support: 'unsupported', + }); + expect(operations['channels.disconnect']).toMatchObject({ + capability: 'channels', + support: 'native', + }); + expect(operations['cron.toggle']).toMatchObject({ + capability: 'cron', + support: 'native', + }); + }); +}); diff --git a/tests/unit/runtime-usage.test.ts b/tests/unit/runtime-usage.test.ts new file mode 100644 index 000000000..9fa0d132b --- /dev/null +++ b/tests/unit/runtime-usage.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { runtimeUsageLimit, toRuntimeUsageRecords, toTokenUsageHistoryEntry } from '@electron/runtime/usage'; +import type { TokenUsageHistoryEntry } from '@electron/utils/token-usage-core'; + +function entry(overrides: Partial = {}): TokenUsageHistoryEntry { + return { + timestamp: '2026-07-13T00:00:00.000Z', + sessionId: 'agent:main:main', + agentId: 'main', + provider: 'openai', + model: 'gpt-5.4', + usageStatus: 'available', + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 80, + cacheWriteTokens: 0, + reasoningTokens: 5, + totalTokens: 120, + ...overrides, + }; +} + +describe('runtime usage contract mapping', () => { + it('preserves a public turn id and maps cache/reasoning subsets', () => { + const [record] = toRuntimeUsageRecords([entry({ turnId: 'public-turn-1' })], { + runtimeKind: 'cc-connect', + logicalSessionId: 'agent:main:main', + runtimeSessionId: 'runtime-session-1', + providerAccountId: 'openai-oauth-a', + }); + + expect(record).toMatchObject({ + id: 'cc-connect:runtime-session-1:public-turn-1', + turnId: 'public-turn-1', + providerAccountId: 'openai-oauth-a', + cachedInputTokens: 80, + reasoningTokens: 5, + totalTokens: 120, + }); + expect(toTokenUsageHistoryEntry(record)).toMatchObject({ + sessionId: 'agent:main:main', + runtimeSessionId: 'runtime-session-1', + turnId: 'public-turn-1', + providerAccountId: 'openai-oauth-a', + usageStatus: 'available', + cacheReadTokens: 80, + reasoningTokens: 5, + totalTokens: 120, + }); + }); + + it('uses a stable fallback turn id when older history receives a newer entry', () => { + const older = entry({ timestamp: '2026-07-13T00:00:00.000Z', content: 'older' }); + const newer = entry({ timestamp: '2026-07-13T00:01:00.000Z', content: 'newer' }); + const identity = { + runtimeKind: 'cc-connect' as const, + runtimeSessionId: 'runtime-session-1', + }; + + const [before] = toRuntimeUsageRecords([older], identity); + const after = toRuntimeUsageRecords([newer, older], identity).find((record) => record.content === 'older'); + + expect(after?.turnId).toBe(before.turnId); + expect(after?.id).toBe(before.id); + }); + + it('normalizes list limits at the runtime boundary', () => { + expect(runtimeUsageLimit({ limit: '5' })).toBe(5); + expect(runtimeUsageLimit(0)).toBe(1); + expect(runtimeUsageLimit({ limit: 'invalid' })).toBeUndefined(); + }); +}); diff --git a/tests/unit/skills-page-gateway-readiness.test.tsx b/tests/unit/skills-page-gateway-readiness.test.tsx index 37b8e0486..601371126 100644 --- a/tests/unit/skills-page-gateway-readiness.test.tsx +++ b/tests/unit/skills-page-gateway-readiness.test.tsx @@ -11,8 +11,9 @@ const installSkillMock = vi.fn(); const uninstallSkillMock = vi.fn(); const clawhubCapabilityMock = vi.fn(); const clawhubOpenSkillPathMock = vi.fn(); +const skillsTargetMock = vi.fn(); const openclawGetSkillsDirMock = vi.fn(); -const shellOpenExternalMock = vi.fn(); +const shellOpenPathMock = vi.fn(); const { gatewayState, skillsState } = vi.hoisted(() => ({ gatewayState: { @@ -56,9 +57,10 @@ vi.mock('@/lib/host-api', () => ({ getSkillsDir: () => openclawGetSkillsDirMock(), }, shell: { - openExternal: (...args: unknown[]) => shellOpenExternalMock(...args), + openPath: (...args: unknown[]) => shellOpenPathMock(...args), }, skills: { + target: () => skillsTargetMock(), clawhubCapability: () => clawhubCapabilityMock(), clawhubOpenSkillPath: (...args: unknown[]) => clawhubOpenSkillPathMock(...args), }, @@ -77,7 +79,10 @@ vi.mock('@/extensions/registry', () => ({ vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string) => key, + t: (key: string, options?: Record) => { + if (key === 'runtimeTarget.mirror') return `runtimeTarget.mirror ${String(options?.path || '')}`; + return key; + }, }), })); @@ -97,7 +102,15 @@ describe('Skills page gateway readiness', () => { gatewayState.status = { state: 'running', port: 18789, gatewayReady: true }; skillsState.skills = []; openclawGetSkillsDirMock.mockResolvedValue('/tmp/.openclaw/skills'); - shellOpenExternalMock.mockResolvedValue(undefined); + shellOpenPathMock.mockResolvedValue(undefined); + skillsTargetMock.mockResolvedValue({ + success: true, + runtimeKind: 'openclaw', + sourceDir: '/tmp/.openclaw/skills', + openDir: '/tmp/.openclaw/skills', + runtimeDir: '/tmp/.openclaw/skills', + mirrorMode: 'source', + }); clawhubCapabilityMock.mockResolvedValue({ success: true, capability: { canSearch: false, canInstall: false } }); clawhubOpenSkillPathMock.mockResolvedValue({ success: true }); fetchSkillsMock.mockResolvedValue(true); @@ -173,6 +186,33 @@ describe('Skills page gateway readiness', () => { expect(screen.getByText('XLSX')).toBeInTheDocument(); }); + it('shows and opens the cc-connect runtime skills mirror target', async () => { + gatewayState.status = { state: 'running', port: 9820, runtimeKind: 'cc-connect', gatewayReady: true }; + skillsTargetMock.mockResolvedValue({ + success: true, + runtimeKind: 'cc-connect', + sourceDir: '/tmp/.openclaw/skills', + openDir: '/tmp/clawx/runtimes/cc-connect/codex-home/skills', + runtimeDir: '/tmp/clawx/runtimes/cc-connect/codex-home/skills', + manifestPath: '/tmp/clawx/runtimes/cc-connect/codex-home/skills/manifest.json', + mirrorMode: 'runtime-mirror', + }); + skillsState.skills = [ + { id: 'pdf', name: 'PDF', description: 'enabled skill', enabled: true, source: 'openclaw-managed' }, + ]; + + render(); + + await act(async () => { + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(1_600); + }); + + expect(screen.getByTestId('skills-runtime-target')).toHaveTextContent('/tmp/clawx/runtimes/cc-connect/codex-home/skills'); + fireEvent.click(screen.getByText('openRuntimeFolder')); + expect(shellOpenPathMock).toHaveBeenCalledWith('/tmp/clawx/runtimes/cc-connect/codex-home/skills'); + }); + it('shows manifest versions but still hides slug badges and hash-only preinstalled versions', async () => { gatewayState.status = { state: 'stopped', port: 18789 }; skillsState.skills = [ diff --git a/tests/unit/stores.test.ts b/tests/unit/stores.test.ts index 00b5365f3..401e29376 100644 --- a/tests/unit/stores.test.ts +++ b/tests/unit/stores.test.ts @@ -44,6 +44,7 @@ describe('Settings Store', () => { sidebarCollapsed: false, sidebarWidth: 280, devModeUnlocked: false, + runtimeKind: 'openclaw', gatewayAutoStart: true, gatewayPort: 18789, autoCheckUpdate: true, @@ -95,6 +96,31 @@ describe('Settings Store', () => { expect(hostApiMock.settings.set).toHaveBeenCalledWith('devModeUnlocked', true); }); + it('should reset cc-connect runtime when developer mode is disabled', () => { + useSettingsStore.setState({ devModeUnlocked: true, runtimeKind: 'cc-connect' }); + + const { setDevModeUnlocked } = useSettingsStore.getState(); + setDevModeUnlocked(false); + + expect(useSettingsStore.getState().devModeUnlocked).toBe(false); + expect(useSettingsStore.getState().runtimeKind).toBe('openclaw'); + expect(hostApiMock.settings.set).toHaveBeenCalledWith('devModeUnlocked', false); + expect(hostApiMock.settings.set).toHaveBeenCalledWith('runtimeKind', 'openclaw'); + }); + + it('should normalize persisted cc-connect runtime when developer mode is locked', async () => { + hostApiMock.settings.getAll.mockResolvedValueOnce({ + devModeUnlocked: false, + runtimeKind: 'cc-connect', + language: 'en', + }); + + await useSettingsStore.getState().init(); + + expect(useSettingsStore.getState().runtimeKind).toBe('openclaw'); + expect(hostApiMock.settings.set).toHaveBeenCalledWith('runtimeKind', 'openclaw'); + }); + it('should persist launch-at-startup setting through host api', () => { hostApiMock.settings.set.mockResolvedValueOnce({ success: true }); diff --git a/tests/unit/task-visualization.test.ts b/tests/unit/task-visualization.test.ts index 858dc1fee..7f8611147 100644 --- a/tests/unit/task-visualization.test.ts +++ b/tests/unit/task-visualization.test.ts @@ -11,6 +11,35 @@ import { applyRuntimeEventToRuns } from '@/stores/chat/runtime-graph'; import type { RawMessage, ToolStatus } from '@/stores/chat'; describe('runtime graph state', () => { + it('keeps cc-connect approval actions attached to the pending graph step', () => { + const runs = applyRuntimeEventToRuns({}, { + type: 'approval.updated', + runId: 'run-approval', + itemId: 'approval-1', + title: 'Codex approval', + phase: 'requested', + status: 'pending', + message: 'Allow the command?', + actions: [ + { action: 'perm:allow', label: 'Allow once' }, + { action: 'perm:deny', label: 'Deny' }, + ], + }); + + expect(deriveRuntimeTaskSteps(runs['run-approval'])).toContainEqual(expect.objectContaining({ + id: 'approval-1', + status: 'running', + approval: { + runId: 'run-approval', + status: 'pending', + actions: [ + { action: 'perm:allow', label: 'Allow once' }, + { action: 'perm:deny', label: 'Deny' }, + ], + }, + })); + }); + it('keeps distinct runtime tool updates for the same tool call', () => { const started = applyRuntimeEventToRuns({}, { type: 'tool.started', @@ -58,6 +87,23 @@ describe('runtime graph state', () => { expect(second['run-1'].assistantText).toBe('corrected'); }); + + it('clears a transient assistant preview when the runtime replaces it with empty text', () => { + const preview = applyRuntimeEventToRuns({}, { + type: 'assistant.delta', + runId: 'run-preview', + text: 'working draft', + replace: true, + }); + const cleared = applyRuntimeEventToRuns(preview, { + type: 'assistant.delta', + runId: 'run-preview', + text: '', + replace: true, + }); + + expect(cleared['run-preview'].assistantText).toBe(''); + }); }); describe('deriveTaskSteps', () => { diff --git a/tests/unit/token-usage-scan.test.ts b/tests/unit/token-usage-scan.test.ts index be9d1b05b..b7cba5171 100644 --- a/tests/unit/token-usage-scan.test.ts +++ b/tests/unit/token-usage-scan.test.ts @@ -12,14 +12,8 @@ const { testHome, testUserData } = vi.hoisted(() => { vi.mock('os', async () => { const actual = await vi.importActual('os'); - const mocked = { - ...actual, - homedir: () => testHome, - }; - return { - ...mocked, - default: mocked, - }; + const mocked = { ...actual, homedir: () => testHome }; + return { ...mocked, default: mocked }; }); vi.mock('electron', () => ({ @@ -30,6 +24,34 @@ vi.mock('electron', () => ({ }, })); +async function seedOpenClawUsage( + agentId: string, + fileName: string, + timestamp = '2026-03-12T12:19:00.000Z', +): Promise { + const openclawDir = join(testHome, '.openclaw'); + const sessionsDir = join(openclawDir, 'agents', agentId, 'sessions'); + await mkdir(sessionsDir, { recursive: true }); + await writeFile(join(openclawDir, 'openclaw.json'), JSON.stringify({ + agents: { list: [{ id: 'main', name: 'Main', default: true }] }, + }), 'utf8'); + await writeFile(join(sessionsDir, fileName), JSON.stringify({ + type: 'message', + timestamp, + message: { + role: 'assistant', + model: 'gpt-5.2-2025-12-11', + provider: 'openai', + usage: { + input: 17_649, + output: 107, + total: 17_756, + cost: { total_usd: 0.0042 }, + }, + }, + }), 'utf8'); +} + describe('token usage session scan', () => { beforeEach(async () => { vi.resetModules(); @@ -38,52 +60,52 @@ describe('token usage session scan', () => { await rm(testUserData, { recursive: true, force: true }); }); - it('includes transcripts from agent directories that exist on disk but are not configured', async () => { - const openclawDir = join(testHome, '.openclaw'); - await mkdir(openclawDir, { recursive: true }); - await writeFile(join(openclawDir, 'openclaw.json'), JSON.stringify({ - agents: { - list: [ - { id: 'main', name: 'Main', default: true }, - ], - }, - }, null, 2), 'utf8'); + it('includes OpenClaw transcripts from agent directories that exist only on disk', async () => { + await seedOpenClawUsage('custom-custom25', 'f8e66f77-0125-4e2f-b750-9c4de01e8f5a.jsonl'); + + const { getRecentTokenUsageHistory } = await import('@electron/utils/token-usage'); + await expect(getRecentTokenUsageHistory()).resolves.toEqual([ + expect.objectContaining({ + runtimeKind: 'openclaw', + agentId: 'custom-custom25', + sessionId: 'f8e66f77-0125-4e2f-b750-9c4de01e8f5a', + model: 'gpt-5.2-2025-12-11', + totalTokens: 17_756, + costUsd: 0.0042, + }), + ]); + }); - const diskOnlySessionsDir = join(openclawDir, 'agents', 'custom-custom25', 'sessions'); - await mkdir(diskOnlySessionsDir, { recursive: true }); - await writeFile( - join(diskOnlySessionsDir, 'f8e66f77-0125-4e2f-b750-9c4de01e8f5a.jsonl'), - [ - JSON.stringify({ - type: 'message', - timestamp: '2026-03-12T12:19:00.000Z', - message: { - role: 'assistant', - model: 'gpt-5.2-2025-12-11', - provider: 'openai', - usage: { - input: 17649, - output: 107, - total: 17756, - }, - }, - }), - ].join('\n'), - 'utf8', - ); + it.each([ + 'session-a.deleted.jsonl', + 'session-b.jsonl.reset.2026-03-12T12-19-00.000Z', + ])('keeps OpenClaw history file %s in usage aggregation', async (fileName) => { + await seedOpenClawUsage('main', fileName); const { getRecentTokenUsageHistory } = await import('@electron/utils/token-usage'); - const entries = await getRecentTokenUsageHistory(); + const entries = await getRecentTokenUsageHistory({ runtimeKind: 'openclaw' }); - expect(entries).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - agentId: 'custom-custom25', - sessionId: 'f8e66f77-0125-4e2f-b750-9c4de01e8f5a', - model: 'gpt-5.2-2025-12-11', - totalTokens: 17756, - }), - ]), - ); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ runtimeKind: 'openclaw', agentId: 'main' }); + }); + + it('returns no usage for cc-connect without reading its private session or Codex files', async () => { + const privateSessionDir = join(testUserData, 'runtimes', 'cc-connect', 'data', 'sessions'); + const privateCodexDir = join(testUserData, 'runtimes', 'cc-connect', 'codex-home', 'sessions'); + await mkdir(privateSessionDir, { recursive: true }); + await mkdir(privateCodexDir, { recursive: true }); + await writeFile(join(privateSessionDir, 'private.json'), JSON.stringify({ usage: { total_tokens: 99 } }), 'utf8'); + await writeFile(join(privateCodexDir, 'private.jsonl'), JSON.stringify({ type: 'token_count', total_tokens: 99 }), 'utf8'); + + const { getRecentTokenUsageHistory } = await import('@electron/utils/token-usage'); + await expect(getRecentTokenUsageHistory({ runtimeKind: 'cc-connect' })).resolves.toEqual([]); + }); + + it('does not mix OpenClaw usage into an explicit cc-connect query', async () => { + await seedOpenClawUsage('main', 'openclaw-session.jsonl'); + + const { getRecentTokenUsageHistory } = await import('@electron/utils/token-usage'); + await expect(getRecentTokenUsageHistory({ runtimeKind: 'cc-connect' })).resolves.toEqual([]); + await expect(getRecentTokenUsageHistory({ runtimeKind: 'openclaw' })).resolves.toHaveLength(1); }); }); diff --git a/tests/unit/token-usage.test.ts b/tests/unit/token-usage.test.ts index b5ae0c1ee..5fe63453c 100644 --- a/tests/unit/token-usage.test.ts +++ b/tests/unit/token-usage.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { parseUsageEntriesFromJsonl } from '@electron/utils/token-usage-core'; +import { + parseUsageEntriesFromJsonl, + parseUsageEntriesFromMessages, +} from '@electron/utils/token-usage-core'; describe('parseUsageEntriesFromJsonl', () => { it('extracts assistant usage entries in reverse chronological order', () => { @@ -30,6 +33,7 @@ describe('parseUsageEntriesFromJsonl', () => { promptTokens: 200, completionTokens: 80, cacheRead: 25, + reasoningOutputTokens: 15, }, }, }), @@ -54,7 +58,8 @@ describe('parseUsageEntriesFromJsonl', () => { outputTokens: 80, cacheReadTokens: 25, cacheWriteTokens: 0, - totalTokens: 305, + reasoningTokens: 15, + totalTokens: 280, costUsd: undefined, }, { @@ -207,6 +212,48 @@ describe('parseUsageEntriesFromJsonl', () => { ]); }); + it('extracts assistant usage from cc-connect session history messages', () => { + expect(parseUsageEntriesFromMessages([ + { + id: 'u1', + role: 'user', + content: 'hello', + timestamp: 1_780_000_000_000, + }, + { + id: 'a1', + role: 'assistant', + content: 'cc-connect reply', + timestamp: 1_780_000_001_000, + model: 'gpt-5.1-codex', + provider: 'openai', + usage: { + input_tokens: 44, + output_tokens: 12, + cache_read_tokens: 3, + total_tokens: 59, + }, + }, + ], { sessionId: 'agent:main:main', agentId: 'main' })).toEqual([ + { + timestamp: '2026-05-28T20:26:41.000Z', + sessionId: 'agent:main:main', + agentId: 'main', + model: 'gpt-5.1-codex', + provider: 'openai', + content: 'cc-connect reply', + turnId: 'a1', + usageStatus: 'available', + inputTokens: 44, + outputTokens: 12, + cacheReadTokens: 3, + cacheWriteTokens: 0, + totalTokens: 59, + costUsd: undefined, + }, + ]); + }); + it('uses tool result usage when provided', () => { const jsonl = [ JSON.stringify({ diff --git a/tests/unit/usage-api.test.ts b/tests/unit/usage-api.test.ts new file mode 100644 index 000000000..8293e3727 --- /dev/null +++ b/tests/unit/usage-api.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { RuntimeManager } from '@electron/runtime/manager'; +import type { RuntimeKind, RuntimeUsageRecord } from '@electron/runtime/types'; + +function usageRecord(runtimeKind: RuntimeKind, overrides: Partial = {}): RuntimeUsageRecord { + return { + id: `${runtimeKind}:turn-1`, + runtimeKind, + logicalSessionId: 'agent:main:main', + runtimeSessionId: 'runtime-main', + turnId: 'turn-1', + agentId: 'main', + provider: 'openai', + model: 'gpt-5.4', + timestamp: '2026-05-28T20:26:41.000Z', + status: 'missing', + inputTokens: 0, + cachedInputTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + ...overrides, + }; +} + +function runtimeManager( + activeKind: RuntimeKind, + records: Partial> = {}, +): RuntimeManager { + const providers = Object.fromEntries((['openclaw', 'cc-connect'] as const).map((kind) => [kind, { + kind, + listUsage: vi.fn(async () => ({ success: true, records: records[kind] ?? [] })), + }])) as Record }>; + return { + getActiveProvider: () => providers[activeKind], + getProvider: (kind: RuntimeKind) => providers[kind], + } as unknown as RuntimeManager; +} + +describe('usage api runtime routing', () => { + it('reports missing usage supplied by the cc-connect RuntimeProvider', async () => { + const { createUsageApi } = await import('@electron/services/usage-api'); + const manager = runtimeManager('cc-connect', { + 'cc-connect': [usageRecord('cc-connect', { content: 'public cc-connect reply' })], + }); + const usage = createUsageApi(manager); + + await expect(usage.recentTokenHistory({ limit: 25 })).resolves.toEqual([ + expect.objectContaining({ + runtimeKind: 'cc-connect', + sessionId: 'agent:main:main', + agentId: 'main', + content: 'public cc-connect reply', + usageStatus: 'missing', + totalTokens: 0, + }), + ]); + + expect(manager.getProvider('cc-connect').listUsage).toHaveBeenCalledWith({ limit: 25 }); + }); + + it('preserves real usage when cc-connect exposes it in public history', async () => { + const { createUsageApi } = await import('@electron/services/usage-api'); + const usage = createUsageApi(runtimeManager('cc-connect', { + 'cc-connect': [usageRecord('cc-connect', { + status: 'available', + inputTokens: 12, + outputTokens: 3, + totalTokens: 15, + })], + })); + + await expect(usage.recentTokenHistory({ limit: 25 })).resolves.toEqual([ + expect.objectContaining({ + usageStatus: 'available', + inputTokens: 12, + outputTokens: 3, + totalTokens: 15, + }), + ]); + }); + + it('keeps explicit runtimeKind overrides for diagnostics', async () => { + const { getRecentTokenHistoryForRuntime } = await import('@electron/services/usage-api'); + const manager = runtimeManager('cc-connect'); + + await getRecentTokenHistoryForRuntime({ limit: 10, runtimeKind: 'openclaw' }, manager); + + expect(manager.getProvider('openclaw').listUsage).toHaveBeenCalledWith({ limit: 10 }); + }); + + it('supports legacy numeric payloads while applying active runtime', async () => { + const { getRecentTokenHistoryForRuntime } = await import('@electron/services/usage-api'); + + const manager = runtimeManager('openclaw'); + await getRecentTokenHistoryForRuntime(5, manager); + + expect(manager.getProvider('openclaw').listUsage).toHaveBeenCalledWith({ limit: 5 }); + }); +}); diff --git a/tests/unit/whatsapp-login.test.ts b/tests/unit/whatsapp-login.test.ts index e6a367a89..20cb551fc 100644 --- a/tests/unit/whatsapp-login.test.ts +++ b/tests/unit/whatsapp-login.test.ts @@ -47,6 +47,11 @@ vi.mock('electron', () => ({ getVersion: () => '0.0.0-test', getAppPath: () => '/tmp', }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value, 'utf8'), + decryptString: (value: Buffer) => value.toString('utf8'), + }, })); vi.mock('@electron/utils/logger', () => ({ @@ -185,4 +190,3 @@ describe('listConfiguredChannels WhatsApp detection', () => { expect(channelsAfter).not.toContain('whatsapp'); }); }); -