Compare commits

...
Author SHA1 Message Date
ashione 2bc9d2740e fix(cc-connect): preserve isolated reconnect state 2026-07-27 17:49:17 +08:00
ashione f058ccf28f fix(cc-connect): complete recovered tool lifecycles 2026-07-27 17:22:46 +08:00
ashione 2a965cb462 test(cc-connect): avoid session ordering race 2026-07-27 16:56:29 +08:00
ashione 9fe334dec2 fix(cc-connect): constrain channel history recovery 2026-07-27 16:52:16 +08:00
ashione 8afad35d2f fix(cc-connect): align bounded history contract 2026-07-27 16:38:51 +08:00
ashione 92f966424f fix(cc-connect): harden history recovery 2026-07-27 16:26:49 +08:00
ashione a378c31261 fix(cc-connect): restore channel tool history 2026-07-27 16:09:15 +08:00
Lingxuan Zuo 960f6b298d [codex] Add runtime abstraction and cc-connect provider (#1103) 2026-07-26 23:43:55 +08:00
paisley 8034c5ad31 fix: harden OpenClaw upgrade snapshot backup scope and cleanup (#1194) 2026-07-24 11:56:21 +08:00
paisley 1f26cd205d feat:upgrade openclaw to 7.1 (#1193) 2026-07-24 10:38:57 +08:00
paisley 9cdd501a80 release 0.5.1 (#1192) 2026-07-23 14:10:40 +08:00
paisley 26d20fdb35 fix: disable model ID editing for existing providers (#1191) 2026-07-23 13:43:53 +08:00
paisley 378e01ee1e feat(gateway): improve startup timing diagnostics (#1190) 2026-07-23 11:06:29 +08:00
ZHUO Xu 7cb6eb240c feat: enhance session status, file previews, open-with feature, web browser, and turn timing (#1189) 2026-07-23 11:06:01 +08:00
paisley cd0d95ad69 fix chat workspace menu and session fallback titles (#1187) 2026-07-22 15:20:52 +08:00
paisley d501bad05d fix(chat): inherit workspace when creating a new chat (#1186) 2026-07-22 14:03:02 +08:00
paisley 5d0099abaa feat: improve unavailable workspace recovery and cleanup (#1183) 2026-07-20 18:27:09 +08:00
paisley 951ca13db8 fix: keep @agent first send on target workspace Prevent reactive ACP loads from cancelling new-agent prompts by binding the target workspace on session switch and creating the target main session when missing. (#1184) 2026-07-20 17:54:31 +08:00
paisley c96b2336c6 fix(chat): preserve image generation thinking state across sessions (#1180) 2026-07-20 10:50:41 +08:00
paisley a42a7e4256 feat: support renaming imported workspaces (#1179) 2026-07-17 16:25:37 +08:00
paisley ff61f5dd72 fix: prevent model reload from interrupting ACP startup (#1178) 2026-07-17 15:16:44 +08:00
paisley 91836cd6bc fix chat follow-up auto-scroll (#1177) 2026-07-16 16:30:24 +08:00
paisley fb19cc61c5 fix markdown code highlighting (#1176) 2026-07-16 15:48:52 +08:00
paisley 0fa869f1cc release v0.5.0 (#1175) 2026-07-16 11:53:03 +08:00
paisley 33bcf654eb fix(chat): hide disabled workspace selector border (#1174) 2026-07-16 11:51:09 +08:00
Felix 7774d89c66 Revert "fix: raise default LLM idle timeout to 240 seconds to reduce false timeouts on slow providers" (#1172) 2026-07-16 11:33:04 +08:00
ZHUO Xu d741364c74 fix: resolve attachment unavailability, buffer unfinished streams, and show file paths in cards (#1171) 2026-07-16 10:10:50 +08:00
paisley 502d9d5367 fix: raise default LLM idle timeout to 240 seconds to reduce false timeouts on slow providers (#1170) 2026-07-15 18:58:43 +08:00
Haze 1e52b0048a refactor: update styling and remove unused status classes in AcpToolCallCard component (#1169) 2026-07-15 17:32:23 +08:00
fc87e00323 feat(providers): add Z.AI CN/Global with Coding Plan support (#1167)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-07-15 16:28:18 +08:00
greg dohertyandClaude Fable 5 a9d9376fe0 fix: preserve agent model fallbacks when switching models (#1160)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 16:14:49 +08:00
1a46e1f1d5 chore: update OpenRouter default model to openai/gpt-5.6-sol (#1168)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-07-15 16:04:34 +08:00
51655b91e1 chore: update Anthropic default model to Claude Opus 4.8 (#1166)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-07-15 15:12:19 +08:00
ZHUO Xu 702f03e055 fix: resolve a batch of problems after migrating to ACP (#1165) 2026-07-15 14:36:17 +08:00
Felix c7d4345b6e Revert "feat(auth): deny subagent tools for ClawX desktop" (#1164) 2026-07-15 13:21:36 +08:00
paisley b598e2482d fix: hide empty new chats from sidebar until first message (#1162) 2026-07-14 18:17:32 +08:00
paisley 3d134a563a fix: remove stale OAuth models from chat picker (#1161) 2026-07-14 17:04:45 +08:00
ZHUO Xuandpaisley ff9024fb5d feat: Use ACP to refactor chat UI and session management; add workspace feature (#1158)
Co-authored-by: paisley <8197966+su8su@users.noreply.github.com>
2026-07-13 11:03:37 +08:00
paisley 4a74b22796 release v0.4.16 (#1155) 2026-07-10 18:26:47 +08:00
paisley bcec47f600 fix: set default compaction reserveTokensFloor to 50000 (#1154) 2026-07-10 18:04:59 +08:00
paisley d7671a42e6 release 0.4.15 (#1151) 2026-07-07 18:56:31 +08:00
paisley f44c2250fb chore: remove unused preinstalled skills (#1150) 2026-07-07 18:09:40 +08:00
paisley d6c770e4a5 chore: bump version to 0.4.14 (#1149) 2026-07-07 17:26:49 +08:00
paisley e84f13c876 fix: seed memorySearch disabled when no user embedding config (#1148) 2026-07-07 16:41:00 +08:00
Felix a0509b9b54 feat(auth): deny subagent tools for ClawX desktop (#1147) 2026-07-07 15:44:45 +08:00
paisley e09dd289db fix: add contextWindow defaults and compaction safeguard for custom providers (#1146) 2026-07-07 15:25:46 +08:00
Felix 2a1e2adde3 docs: surface uv guidance in ClawX agents context (#1142) 2026-07-03 17:17:40 +08:00
paisley 6ea53bdeb1 chore: bump version to 0.4.13 (#1141) 2026-07-02 16:56:46 +08:00
paisley f3689894bf fix: persist trusted plugin install records to SQLite for packaged WhatsApp (#1140) 2026-07-02 11:25:09 +08:00
paisley 33694efe84 Fix whatsapp login (#1139) 2026-07-01 18:54:34 +08:00
paisley 22af7468d7 Upgrade openclaw to 6.10 (#1138) 2026-06-30 18:29:35 +08:00
Felix cb8c3cfd02 chore: bump version to 0.4.12 (#1135) 2026-06-26 13:48:35 +08:00
Felix e42307cbd3 feature: support multi-model provider accounts (#1133) 2026-06-25 14:07:52 +08:00
Felix ffe50ff8a5 fix: deny built-in skill workshop tool (#1131) 2026-06-23 13:30:10 +08:00
Felix d223af5747 chore: bump bundled Windows node to 22.19.0 (#1129) 2026-06-22 12:33:11 +08:00
Haze b0db926874 release: v0.4.11 (#1126) 2026-06-17 10:13:53 +08:00
Haze 05ea50a834 opt(cron): cron job status (#1125) 2026-06-15 19:46:40 +08:00
Haze 808f4a840d feat(cron): opt cron task job (#1124) 2026-06-15 17:54:06 +08:00
ZHUO Xu d9ffbca856 refactor: chat markdown list and heading styles (#1121) 2026-06-15 16:55:27 +08:00
paisley fcfb72b103 release 0.4.10 (#1117) 2026-06-11 18:48:31 +08:00
paisley ae91ba3c05 fix: clear stale agent model refs after deleting custom providers (#1116) 2026-06-11 18:45:26 +08:00
paisley dd92cf400b chore:show provider or custom name in chat model picker labels (#1115) 2026-06-11 18:27:07 +08:00
paisley d246860ef1 fix: migrate OpenAI Codex OAuth to canonical openai provider (#1114) 2026-06-11 18:00:09 +08:00
paisley 66692f1777 fix: use openai-chatgpt-responses for Codex OAuth and hide stale OpenAI API slot after logout (#1113) 2026-06-11 15:46:06 +08:00
paisley 1fd3c50c3e fix(chat): scroll-to-latest no longer cancels smooth scroll to bottom (#1112) 2026-06-11 13:20:47 +08:00
paisley bf038f5ca4 feat:upgrade OpenClaw to 2026.6.5 and fix OAuth provider detection for auth-profile-only configs. (#1109) 2026-06-10 17:52:05 +08:00
paisleyandCursor 07c5c4cfcc fix: reassign default provider when deleting the current default (#1108)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 18:05:56 +08:00
paisley 71ea96a881 fix: preserve custom provider model capabilities on switch (#1107) 2026-06-09 17:18:10 +08:00
paisley 1e2fc7e404 fix(chat): surface image generation replie (#1106) 2026-06-09 13:59:55 +08:00
547 changed files with 109415 additions and 8673 deletions
+38
View File
@@ -0,0 +1,38 @@
# Local real cc-connect validation template.
# Save real values in .env.cc-connect.local. That file is gitignored.
# Required for real OAuth, packaged OAuth, and Feishu E2E paths.
# Point at the auth.json that may be copied into an isolated managed CODEX_HOME.
# Use ~/.codex/auth.json only when that import is intentional.
CLAWX_REAL_CODEX_AUTH_JSON=
# Required for OpenAI API-key provider/model chat validation.
# The verifier maps this to child-process OPENAI_API_KEY without writing the value to reports.
CLAWX_REAL_OPENAI_API_KEY=
# Optional: override the model used by the real OpenAI API-key smoke.
# Leave empty to use the test default.
CLAWX_REAL_OPENAI_MODEL=
# Optional: set OPENAI_API_KEY directly instead when external tools need the standard name.
# OPENAI_API_KEY=
# Required for Feishu/Lark live channel lifecycle validation.
CLAWX_REAL_FEISHU_APP_ID=
CLAWX_REAL_FEISHU_APP_SECRET=
# A real user/open_id accepted by the bot. The lifecycle test verifies that
# cc-connect preserves this admin together with ClawX's local bridge admin.
CLAWX_REAL_FEISHU_ADMIN_FROM=
# Optional Feishu/Lark settings.
# Values for CLAWX_REAL_FEISHU_DOMAIN: feishu, lark, cn, global, or a full API base URL.
CLAWX_REAL_FEISHU_DOMAIN=feishu
CLAWX_REAL_FEISHU_ACCOUNT_ID=real_feishu_bot
CLAWX_REAL_FEISHU_ALLOW_FROM=
# Optional manual Feishu/Lark inbound delivery smoke.
# Set this only when a sandbox tenant chat can send the marker to the configured bot
# while the E2E test is waiting.
CLAWX_REAL_FEISHU_INBOUND_E2E=
CLAWX_REAL_FEISHU_INBOUND_MARKER=
CLAWX_REAL_FEISHU_INBOUND_TIMEOUT_MS=180000
+38
View File
@@ -33,6 +33,41 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
# The runtime compatibility test executes Electron to assert its embedded
# Node and SQLite versions, so this job cannot rely on the package alone.
# Use the same extraction path as Electron E2E because install.js can
# leave a partially extracted dist directory on GitHub-hosted runners.
- name: Install Electron binary for runtime compatibility test
shell: bash
env:
force_no_cache: 'true'
run: |
set -euo pipefail
unset ELECTRON_SKIP_BINARY_DOWNLOAD
ELECTRON_DIR="$(node -p "require('path').dirname(require.resolve('electron/package.json'))")"
echo "Electron package dir: $ELECTRON_DIR"
rm -rf "$ELECTRON_DIR/dist" "$ELECTRON_DIR/path.txt"
mkdir -p "$ELECTRON_DIR/dist"
ZIP="$(cd "$ELECTRON_DIR" && node -e "
const { downloadArtifact } = require('@electron/get');
const { version } = require('./package.json');
downloadArtifact({ version, artifactName: 'electron', force: true })
.then((z) => { process.stdout.write(z); process.exit(0); })
.catch((e) => { console.error(e); process.exit(1); });
")"
ZIP_SIZE="$(stat -c%s "$ZIP")"
echo "Downloaded zip: $ZIP ($ZIP_SIZE bytes)"
unzip -oq "$ZIP" -d "$ELECTRON_DIR/dist"
echo "Extracted top-level entries: $(ls -1 "$ELECTRON_DIR/dist" | wc -l | tr -d ' ')"
if [ -f "$ELECTRON_DIR/dist/electron.d.ts" ]; then
mv "$ELECTRON_DIR/dist/electron.d.ts" "$ELECTRON_DIR/electron.d.ts"
fi
test -f "$ELECTRON_DIR/dist/electron"
chmod +x "$ELECTRON_DIR/dist/electron"
printf '%s' 'electron' > "$ELECTRON_DIR/path.txt"
- name: Generate extension bridge
run: pnpm run ext:bridge
@@ -82,6 +117,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Test Windows attachment open-with bridge
run: pnpm exec vitest run tests/unit/attachment-open-with.test.ts tests/unit/attachment-open-with-native.test.ts
- name: Generate extension bridge
run: pnpm run ext:bridge
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
electron-e2e:
name: Electron E2E (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 20
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
+140 -8
View File
@@ -10,7 +10,7 @@ on:
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., 1.0.0)'
description: 'Version label for an unsigned smoke build (e.g., 1.0.0-beta.smoke)'
required: true
permissions:
@@ -31,6 +31,7 @@ jobs:
release:
needs: validate-release
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
@@ -114,21 +115,43 @@ jobs:
if: matrix.platform == 'mac'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CSC_IDENTITY_AUTO_DISCOVERY: ${{ github.event_name == 'workflow_dispatch' && 'false' || 'true' }}
CSC_LINK: ${{ github.event_name == 'push' && secrets.MAC_CERTS || '' }}
CSC_KEY_PASSWORD: ${{ github.event_name == 'push' && secrets.MAC_CERTS_PASSWORD || '' }}
APPLE_ID: ${{ github.event_name == 'push' && secrets.APPLE_ID || '' }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ github.event_name == 'push' && secrets.APPLE_APP_SPECIFIC_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ github.event_name == 'push' && secrets.APPLE_TEAM_ID || '' }}
run: |
ulimit -n 65536
echo "File descriptor limit: $(ulimit -n)"
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
unset CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID
fi
pnpm run package:mac
- name: Verify macOS packaged runtime resources
if: matrix.platform == 'mac'
run: |
pnpm run verify:packaged-runtime-resources -- --resources=release/mac/ClawX.app/Contents/Resources --platform=darwin --arch=x64
pnpm run verify:packaged-runtime-resources -- --resources=release/mac-arm64/ClawX.app/Contents/Resources --platform=darwin --arch=arm64
- name: Smoke native macOS packaged cc-connect runtime
if: matrix.platform == 'mac'
run: pnpm run smoke:cc-connect:packaged -- --allow-unsigned=${{ github.event_name == 'workflow_dispatch' && '1' || '0' }}
# Windows specific steps
- name: Build Windows
if: matrix.platform == 'win'
run: pnpm run package:win
- name: Verify Windows packaged runtime resources
if: matrix.platform == 'win'
run: pnpm run verify:packaged-runtime-resources -- --resources=release/win-unpacked/resources --platform=win32 --arch=x64
- name: Smoke native Windows packaged cc-connect runtime
if: matrix.platform == 'win'
run: pnpm run smoke:cc-connect:packaged
# Detect release channel from tag to skip code signing for alpha/beta builds
- name: Detect Windows release channel
if: matrix.platform == 'win'
@@ -276,6 +299,23 @@ jobs:
if: matrix.platform == 'linux'
run: pnpm run package:linux
- name: Verify Linux packaged runtime resources
if: matrix.platform == 'linux'
run: |
pnpm run verify:packaged-runtime-resources -- --resources=release/linux-unpacked/resources --platform=linux --arch=x64
pnpm run verify:packaged-runtime-resources -- --resources=release/linux-arm64-unpacked/resources --platform=linux --arch=arm64
- name: Smoke native Linux x64 packaged cc-connect runtime
if: matrix.platform == 'linux'
run: xvfb-run -a pnpm run smoke:cc-connect:packaged
- name: Upload native runtime smoke evidence
uses: actions/upload-artifact@v4
with:
name: runtime-smoke-${{ matrix.platform }}-native
path: artifacts/cc-connect/packaged-smoke-*.json
retention-days: 7
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
@@ -292,12 +332,103 @@ jobs:
!release/builder-debug.yml
retention-days: 7
runtime-smoke-macos-x64:
needs: validate-release
runs-on: macos-15-intel
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'pnpm'
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install
- name: Build native macOS x64 unpacked app
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
SKIP_PREINSTALLED_SKILLS: '1'
run: |
pnpm run package
node scripts/run-electron-builder.mjs --mac dir --x64 --publish never
- name: Verify and smoke native macOS x64 runtime
run: |
pnpm run verify:packaged-runtime-resources -- --resources=release/mac/ClawX.app/Contents/Resources --platform=darwin --arch=x64
pnpm run smoke:cc-connect:packaged -- --allow-unsigned=1
- name: Upload macOS x64 runtime smoke evidence
uses: actions/upload-artifact@v4
with:
name: runtime-smoke-macos-x64
path: artifacts/cc-connect/packaged-smoke-darwin-x64.json
retention-days: 7
runtime-smoke-linux-arm64:
needs: validate-release
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'pnpm'
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies and X virtual framebuffer
run: |
pnpm install
sudo apt-get update
sudo apt-get install -y xvfb
- name: Build native Linux arm64 unpacked app
env:
SKIP_PREINSTALLED_SKILLS: '1'
run: |
pnpm run package
node scripts/run-electron-builder.mjs --linux dir --arm64 --publish never
- name: Verify and smoke native Linux arm64 runtime
run: |
pnpm run verify:packaged-runtime-resources -- --resources=release/linux-arm64-unpacked/resources --platform=linux --arch=arm64
xvfb-run -a pnpm run smoke:cc-connect:packaged
- name: Upload Linux arm64 runtime smoke evidence
uses: actions/upload-artifact@v4
with:
name: runtime-smoke-linux-arm64
path: artifacts/cc-connect/packaged-smoke-linux-arm64.json
retention-days: 7
# ──────────────────────────────────────────────────────────────
# Job: Publish to GitHub Releases
# ──────────────────────────────────────────────────────────────
publish:
needs: release
needs: [release, runtime-smoke-macos-x64, runtime-smoke-linux-arm64]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download release artifacts only
@@ -389,8 +520,9 @@ jobs:
# releases/vX.Y.Z/ → permanent archive, never deleted
# ──────────────────────────────────────────────────────────────
upload-oss:
needs: release
needs: [release, runtime-smoke-macos-x64, runtime-smoke-linux-arm64]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download release artifacts only
+3
View File
@@ -84,3 +84,6 @@ playground/
# ClawX-biz bridge workspace artifacts
resources/enterprise-skills/
resources/openclaw-plugins/skillshub/
.opencode
.superpowers
+3 -3
View File
@@ -2,6 +2,6 @@
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}
"trailingComma": "all",
"printWidth": 120
}
+1
View File
@@ -48,3 +48,4 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
- **Spec-driven harness rule**: AI Coding tasks that touch backend communication must start from a task spec under `harness/specs/tasks/` and reference `gateway-backend-communication` when the change involves renderer/Main/host-api/api-client/Gateway/OpenClaw runtime paths. Run `pnpm harness validate --spec <task-spec>` before implementation review, and `pnpm harness run --spec <task-spec>` or `--dry-run` when checking the selected validation flow.
- **Spec/rule growth rule**: When adding a new feature, user-visible OpenClaw scenario, or recurring AI Coding constraint, add or update the relevant harness scenario spec and rule spec in the same PR so future AI work can validate the behavior instead of relying on tribal knowledge.
- **Harness CI/local parity**: Run `pnpm run harness:ci` to exercise the same baseline harness checks used by GitHub Actions. Real task specs should be validated without `--no-diff`; `--no-diff` is only for structural checks of checked-in examples.
- **Harness reference docs**: Keep durable, non-executable architecture and compatibility details under `harness/reference/`. Link them from the relevant scenario, rule, and task specs, but do not pass reference documents to `harness validate` or `harness run`.
+95 -16
View File
@@ -93,7 +93,17 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
私たちはアップストリームのOpenClawプロジェクトとの厳密な整合性を維持することにコミットしており、公式リリースが提供する最新の機能、安定性の改善、エコシステムの互換性に常にアクセスできることを保証します。
開発者モードを有効にすると、サイドバーにはネイティブの Dreams ページも表示され、ClawX 内で OpenClaw の記憶レビュー、夢日記、基本メンテナンス操作を扱えます。詳細な診断が必要な場合は、そのページから完全版の OpenClaw Dreams UI も開けます。
開発者モードを有効にし、OpenClaw が active runtime の場合、サイドバーにはネイティブの Dreams ページも表示され、ClawX 内で OpenClaw の記憶レビュー、夢日記、基本メンテナンス操作を扱えます。詳細な診断が必要な場合は、そのページから完全版の OpenClaw Dreams UI も開けます。
ClawX には runtime 抽象レイヤーもあります。OpenClaw は既定 runtime とロールバック経路のままで、**設定 → Gateway → Runtime** から任意の同梱 `cc-connect` runtime に切り替えられます。パッケージ版は cc-connect バイナリと OpenAI Codex ネイティブ CLI bundle の両方を app resources に含め、runtime 起動はグローバルインストール、PATH 上のバイナリ、起動時ダウンロードに依存しません。ClawX はアップグレード後も共有できる app config、credential、runtime data、skills、workspace を `~/.clawx`(または `CLAWX_DATA_HOME`)に保持し、`~/.cc-connect` を自動変更しません。GUI chat は cc-connect BridgePlatform 経由で Codex project agent に接続し、管理 project は cc-connect の Codex app-server stdio backend を使うため、リアルタイムの tool progress を共通 Chat execution graph へ直接反映できます。cc-connect の公開 history に channel session の tool packet がない場合、ClawX は所有する Agent の workspace に限定して一致するローカル Codex transcript から history を補完します。承認ボタンと cc-connect card の選択肢は実行グラフに表示され、応答はすべて cc-connect の公開 `card_action` プロトコルを通じて返されます。Runtime が生成した画像、ファイル、音声、動画の packet も BridgePlatform 経由で返り、Chat の添付として表示され続けます。各 Agent は既定でフルオートを使用し、Agent のモデル/runtime 設定で「承認を求める」(`suggest`)を個別に選択できます。新しい agent は `~/.clawx/workspaces/agents/<id>` を使い、既存の OpenClaw workspace は移動や所有権変更なしで元のパスを再利用できます。provider/model、native cron、enabled skills は管理された cc-connect/Codex runtime に同期されます。
Agent と channel の設定は `~/.clawx` を canonical source とします。cc-connect が active の間は保存しても `~/.openclaw/openclaw.json` を書き換えず、OpenClaw に戻すと Gateway 起動前に互換 projection を再生成します。
cc-connect mode では、Codex provider sync は OpenAI API key、OpenAI OAuth/Codex、Ollama、および Responses API を公開する OpenAI-compatible Custom provider をサポートします。Custom provider の header は環境変数参照として管理 config に書き込まれるため、secret や session header は永続化されません。Chat Completions として設定された Custom provider は、この経路が Codex の Responses wire API を使うため、chat 配信前に unsupported として報告されます。
OAuth provider account ごとに独立した管理 `CODEX_HOME` を持ちます。runtime 起動時にユーザーのグローバル Codex login を自動採用することはなく、選択した account に対する明示的な Codex OAuth import が必要です。
cc-connect はメッセージング platform bridge も担当します。cc-connect が active runtime の場合、channel status probe は OpenClaw Gateway に固定せず runtime abstraction 経由でルーティングされ、設定済み channel account はバインド先 agent を所有する cc-connect project にミラーされます。channel の保存や削除では cc-connect Management API で管理 config を reload し、可能な場合は完全な runtime restart なしで platform 変更を反映します。Developer Mode のサイドバーのページショートカットは cc-connect Web Admin を開き、OpenClaw Dreams ショートカットは OpenClaw runtime 専用のままです。
---
@@ -106,8 +116,20 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリング(GitHub 風テーブルや KaTeX による LaTeX 数式 `$インライン$``$$ブロック$$``\(インライン\)``\[ブロック\]` を含む)に加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
コンポーザーから挿入した Skill は `/skill-name` 形式のチップとして表示され、チップをクリックすると右側のプレビュー側欄でその Skill の `SKILL.md` を開けます。
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
セッション側欄はワークスペース優先で整理され、既定ワークスペースを先頭に固定し、その他のワークスペースは自然順に並べます。各ワークスペースは折りたたみや追加読み込みができます。AI の返信中は行にスピナーが表示され、未確認の返信が完了すると青い点に変わり、会話を開くと相対アクティビティ時刻に戻ります。ホバーすると引き続き操作ボタンが表示されます。インポートしたワークスペースは側欄の見出しから名前を変更でき、新しい名前はチャット入力欄の下にも反映されます。見出しにホバーすると引き続きファイルシステムのパスを確認できます。選択中の会話に有効なワークスペースがある場合、新しいチャットはそれを引き継ぎ、最初の送信までは変更できます。編集可能な新規または未バインドのチャットでは、コンポーザーのワークスペースチップから最近使用したワークスペースと既存セッションのワークスペースの一覧を開き、既定ワークスペースへ戻すか別フォルダーを選べます。保存済みのワークスペースフォルダーが移動または削除されている場合、Chat はセッション作成を一時停止し、無効なパスを繰り返し再試行せずに既存のフォルダーを選ぶよう案内します。利用できない既定以外のグループには側欄で印が付き、確認後に削除できます。この操作ではグループ内の全セッションが完全に削除されます。OpenClaw が生成する UUID と日付のフォールバックタイトルは、そのセッション ID と一致する場合に限って欠落タイトルとして扱い、セッション名として保存せず、会話の最初のユーザーメッセージに置き換えて表示します。
各 Agent は `provider/model` の実行時設定を個別に上書きできます。上書きしていない Agent は引き続きグローバルの既定モデルを継承します。
Chat の右パネルにあるワークスペースとプレビューの各タブでは、`.docx``.pptx` ファイルを読み取り専用でプレビューできます。従来形式の `.doc``.ppt` はアプリ内ではプレビューせず、引き続き OS 経由で開きます。DOCX のページ区切りは Microsoft Word と異なる場合があり、PPTX プレビューではアニメーション、画面切り替え、メディア再生をサポートしません。20 MB を超える Office ファイルはアプリ内でプレビューされません。
### シングルページ Web ブラウザ
Chat の右パネルには、ワークスペース、プレビュー、変更、ウェブブラウザの 4 タブがあります。ウェブブラウザは初回利用時に 1 つのライブページを遅延作成し、パネルを閉じる、別のパネルタブを選ぶ、チャットセッションを切り替える、または ClawX の別ルートへ移動しても、ページを非表示にするだけで実行を継続します。そのため、非表示中もスクリプト、ネットワーク通信、音声、リソース消費が続く場合があります。専用の永続セッションはアプリ再起動後も Cookie とサイトストレージを保持しますが、起動ごとに `about:blank` から始まり、以前の URL、ページ状態、ナビゲーション履歴は復元しません。ページが favicon を提供する場合はタイトルの左側に表示され、favicon がない間は同じサイズのプレースホルダーでタイトル位置を維持します。アドレス編集中はアイコン領域全体が非表示になります。追加のブラウザタブやウィンドウ、ブックマーク、履歴の永続化、パスワードマネージャー、自動入力管理はありません。
トップレベルナビゲーションでは HTTP、HTTPS、および明示的に入力した標準 `file:///` URL を利用できます。通常のファイルシステムパスとその他のプロトコルは拒否されます。ローカルファイルを開くと、通常の Chromium セキュリティ規則の範囲で、読み取り可能な内容が埋め込みページに公開されます。また、`file:` URL に **システムブラウザで開く**を使うと、ブラウザではなく OS の関連付け済みアプリが起動する場合があります。許可されたポップアップ先は子ウィンドウを作らず現在のページを置き換えます。この同一ページへのフォールバックでは、`window.opener`、返されたウィンドウハンドル、空白ページを後から書き換えるスクリプト型ポップアップ、POST 本文や referrer、名前付きウィンドウ、ウィンドウ機能の完全な動作を維持できません。
ダウンロードには Electron と OS の既定動作がそのまま使われます。プラットフォームによってはネイティブの保存ダイアログが表示され、ユーザー操作が必要です。ClawX はカスタム保存先を指定せず、ダウンロードの進捗、履歴、管理 UI も提供しません。カメラとマイクはリクエストごとにネイティブの許可/拒否ダイアログを表示し、選択を記憶しません。クリップボードアクセスは許可され、位置情報、画面キャプチャ、通知、その他の権限は拒否されます。
**Cookie を消去**はブラウザセッション内の全オリジンの Cookie のみを削除し、キャッシュとサイトストレージを保持します。**サイトデータを消去**は全オリジンの HTTP/Chromium キャッシュ、Cache Storage、Local Storage、IndexedDB、Service Worker を削除し、Cookie とダウンロード済みファイルを保持します。ブラウザ通信は Electron/Chromium のシステムプロキシ解決に従います。ClawX クライアントのプロキシ設定はこのブラウザセッションへ同期されず、設定を変更しても再構成されません。
### 📡 マルチチャネル管理
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。
現在は各チャンネルで複数アカウントを扱え、Channels ページでアカウントの Agent 紐付けやデフォルトアカウント切替を直接管理できます。
@@ -116,21 +138,23 @@ ClawX には Tencent 公式の個人 WeChat チャンネルプラグインも同
### ⏰ Cronベースの自動化
AIタスクを自動的に実行するようスケジュール設定できます。トリガーを定義し、間隔を設定することで、手動介入なしにAIエージェントを24時間稼働させることができます。
定期タスク画面では外部配信を「送信アカウント」と「受信先ターゲット」の 2 段階セレクターで設定できるようになりました。対応チャネルでは、受信先候補をチャネルのディレクトリ機能や既知セッション履歴から自動検出するため、`jobs.json` を手で編集する必要はありません。
定期タスク画面では外部配信を「送信アカウント」と「受信先ターゲット」の 2 段階セレクターで設定できるようになりました。対応チャネルでは、受信先候補をチャネルのディレクトリ機能や既知セッション履歴から自動検出するため、`jobs.json` を手で編集する必要はありません。タスクのメッセージ入力欄でも、メインのチャット入力と同じインライン `/skill` トークン記法でスキルを挿入できるようになりました(選択中のエージェントに応じて読み込み)。スケジュールされたプロンプトから直接スキルを起動できます。スケジュール選択は**繰り返し**と**1回のみ**のタブに分かれました。繰り返しは毎時・毎日・平日・毎週・カスタム(生の cron)の頻度を時刻/曜日コントロール付きで選べ、1回のみは選択した日付(曜日を表示)と時刻に一度だけ実行します。1回のみのタスクは未来の時刻を指定する必要があり、実行後はランタイムにより自動的に削除されます。
runtime が **今すぐ実行** を非同期で受け付ける場合、ClawX はトリガー確認をブロックせず、Cron カードに最新の完了結果が表示されるか、制限された停止条件に達するまで runtime 管理のジョブをバックグラウンド更新します。
### 🧩 拡張可能なスキルシステム
事前構築されたスキルでAIエージェントを拡張できます。統合 Skills ページはローカル優先で、管理ディレクトリや workspace のスキルをスキャンし、Gateway に依存せず有効/無効を切り替えられます。エンタープライズ拡張がある場合は、その拡張が提供する marketplace も表示できます。
ClawX はドキュメント処理スキル(`pdf``xlsx``docx``pptx`)もフル内容で同梱し、起動時に管理スキルディレクトリ(既定 `~/.openclaw/skills`)へ自動配備し、初回インストール時に既定で有効化します。追加の同梱スキル(`find-skills``self-improving-agent``tavily-search`)も既定で有効化されますが、必要な API キーが未設定の場合は OpenClaw が実行時に設定エラーを表示します。
ClawX はドキュメント処理スキル(`pdf``xlsx``docx``pptx`)もフル内容で同梱し、起動時に管理スキルディレクトリ(既定 `~/.openclaw/skills`)へ自動配備し、初回インストール時に既定で有効化します。
Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、workspace、追加スキルディレクトリ)から検出されたスキルを表示でき、各スキルの実際のパスを確認して実フォルダを直接開けます。OpenClaw 同梱の bundled skill については、コミュニティ版ではパッケージにも表示にも `skill-creator` のみを残し、dev 起動時と packaged 起動時の両方で他の bundled skill を物理的に削除します。さらに、削除済み bundled skill の古い `openclaw.json` エントリも一緒に掃除します。
主な検索スキルで必要な環境変数:
- `TAVILY_API_KEY`: `tavily-search` 用(上流ランタイムで OAuth 対応の場合あり)
cc-connect runtime が有効な場合、有効化されたローカル skills は app userData 配下の管理 Codex home にミラーされ、同梱 Codex agent がグローバル skill ディレクトリを読まずに同じ skill セットを使えます。
### 🔐 セキュアなプロバイダー統合
複数のAIプロバイダー(OpenAI、Anthropicなど)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuth(Codex サブスクリプション)の両方に対応しています。
複数のAIプロバイダー(OpenAI、Anthropic、Z.AI / GLMなど)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuth(Codex サブスクリプション)の両方に対応しています。
開発者モードでは、専用の Image Generation ページで、独立した OpenAI 互換の画像生成エンドポイント(Base URL、API キー、`gpt-image-2` などのモデル名)を設定でき、画像生成だけ専用の `/v1/images/generations` サービスを使い、チャットは通常の OpenAI Provider のまま継続できます。
OpenAI-compatible ゲートウェイを **Custom プロバイダー** で使う場合、**設定 → AI Providers → Provider 編集** でカスタム `User-Agent` を設定でき、互換性が必要なエンドポイントで有効です。
プロバイダーの編集や切り替え時、ClawX は `input: ["text", "image"]` など既存のモデル単位の能力メタデータを保持します。新しく選択した Custom プロバイダーのモデルには OpenClaw onboarding と同等の画像入力推論を適用し、不明なモデルはテキスト専用として扱います。
Custom プロバイダーのモデル行には明示的な `contextWindow` も書き込まれ(モデルファミリーから推定、例:`gpt-5.x` → 272k)、旧バージョンで保存された行は起動時に自動補完されます。これにより OpenClaw は長いセッションを "Context overflow" エラーになる前に圧縮できます。compaction 未設定の場合は `agents.defaults.compaction.mode = "safeguard"``reserveTokensFloor = 50000` が既定値として設定されますが、ユーザーが自分で設定したモデル行や圧縮設定が変更されることはありません(`reserveTokensFloor` が未設定の場合のみ補完されることがあります)。
Z.AICN / Global)は OpenClaw 組み込みの `zai` プロバイダー(`ZAI_API_KEY`)に対応し、既定モデルは `glm-5.2` です。Code Plan プリセットで Coding Plan エンドポイント(`…/api/coding/paas/v4`)へ切り替え、通常 API`…/api/paas/v4`)も利用できます。CN と Global は同じ OpenClaw ランタイムキーを共有するため同時追加できません。
互換ゲートウェイで `/models` が認証以外の理由で使えない場合、ClawX は API キー検証時に軽量な `/chat/completions` または `/responses` プローブへ自動フォールバックします。
### 🌙 アダプティブテーマ
@@ -184,7 +208,7 @@ ClawXを初めて起動すると、**セットアップウィザード**が以
### プロキシ設定
ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネルがローカルプロキシクライアントを介してインターネットにアクセスする必要がある環境向けに、組み込みのプロキシ設定が含まれています。
ClawXには、Electron、OpenClaw Gateway、任意の cc-connect/Codex runtime、またはTelegramなどのチャネルがローカルプロキシクライアントを介してインターネットにアクセスする必要がある環境向けに、組み込みのプロキシ設定が含まれています。
**設定 → ゲートウェイ → プロキシ**を開いて以下を設定します:
@@ -205,10 +229,11 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ
- `host:port`のみの値はHTTPとして扱われます。
- 高度なプロキシフィールドが空の場合、ClawXは`プロキシサーバー`にフォールバックします。
- プロキシ設定を保存すると、Electronのネットワーク設定が即座に再適用され、ゲートウェイが自動的に再起動されます。
- cc-connect runtime モードでは、Codex 子プロセスが同じ `HTTP_PROXY``HTTPS_PROXY``ALL_PROXY`、バイパス環境値を継承します。
- ClawXはTelegramが有効な場合、プロキシをOpenClawのTelegramチャネル設定にも同期します。
- ClawXのプロキシが無効な状態では、Gatewayの通常再起動時に既存のTelegramチャネルプロキシ設定を保持します。
- OpenClaw設定のTelegramプロキシを明示的に消したい場合は、プロキシ無効の状態で一度「保存」を実行してください。
- **設定 → 詳細 → 開発者** では **OpenClaw Doctor** を実行でき、`openclaw doctor --json` の診断出力をアプリ内で確認できます。
- **設定 → 詳細 → 開発者** の Runtime Doctor は、OpenClaw では `openclaw doctor --json` を実行します。cc-connect では同梱の `cc-connect doctor user-isolation``codex doctor --json` を組み合わせ、モード 0600 の監査レポートを ClawX 管理の runtime ディレクトリへ保存します。Doctor Fix は OpenClaw 専用です。
- Windows のパッケージ版では、同梱された `openclaw` CLI/TUI は端末入力を安定させるため、同梱の `node.exe` エントリーポイント経由で実行されます。
---
@@ -217,6 +242,26 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ
ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を採用しています。Renderer は単一クライアント抽象を呼び出し、プロトコル選択とライフサイクルは Main が管理します:
Chat transport は active runtime に応じて切り替わりますが、Renderer の境界は 1 つに保たれます。OpenClaw Chat は Electron Main が所有する ACP stdio bridge を使用し、Renderer は型付き host event を受け取ってメモリ上の ACP timeline を描画します。cc-connect Chat は `RuntimeManager` から cc-connect BridgePlatform 経由で dispatch され、session history、progress、approval、generated media も同じ経路を通ります。両モードで Renderer は同じ Host API facade を使い、Codex を直接呼び出しません。非 Chat 機能も runtime provider 経由で dispatch され、OpenClaw 固有操作は OpenClaw adapter 内に限定されます。
別の会話やページを開いても、未完了の ACP 応答はストリーミングを継続します。完了前に戻ると最新のメモリ内 timeline が復元され、ライブ応答の表示が続きます。完了後は通常の ACP 履歴リプレイが引き続き唯一の正となります。
ACP の assistant ターンにはターン全体の所要時間が表示されます。ライブ計時はクライアントが観測した prompt ライフサイクルに従い、アプリ内を移動しても継続します。履歴の所要時間は Electron Main が範囲を限定した OpenClaw transcript のタイムスタンプから算出し、ACP リプレイですでに復元されたターンだけに付与します。
ACP Chat は標準 ACP resource を添付ファイルとして表示します。ユーザーが選択した画像は、ホバー時のオーバーレイにファイル名を表示するサムネイルとして描画され、その他の利用可能な添付カードはファイル名に続いて、淡色で省略可能なソースパスを表示します。現在の OpenClaw ACP adapter が assistant のメディアを省略した場合も、明示的な assistant の `MEDIA:` ディレクティブを、元のディレクティブを表示せずに添付カードとして復元できます。現在の workspace 外を含む既存のローカルファイル参照は、プレビューまたはオープンのたびに Electron Main で正確な session と generation に対して再検証されます。AI が生成したプレビュー可能なローカル添付ファイル(20 MB 以下の `.docx``.pptx` を含む)は、読み取り専用のアプリ内プレビューを主要操作として維持し、対応アプリで開く操作と Finder、エクスプローラー、またはシステムのファイルマネージャーで表示する操作を副次メニューから利用できます。ローカル HTML 添付ファイルでは、そのメニューの先頭項目がファイル URL を右側のウェブブラウザで開きます。ここでも Office プレビューには同じ制限があります。`.doc``.ppt` はシステムアプリで開く形式のままで、DOCX のページ区切りは Microsoft Word と異なる場合があり、PPTX のアニメーション、画面切り替え、メディア再生はサポートされません。対応アプリの検出は macOS と Windows のみで利用でき、Linux または検出失敗時には通知せず、ファイルの場所を表示する操作だけに切り替わります。それ以外のローカルファイル(20 MB を超える Office ファイルを含む)はユーザーのクリック後にシステムアプリで開かれます。リモートの HTTP/HTTPS 添付ファイルはクリック後に外部で開かれます。通常の文章内にある単独またはインラインのパスは添付ファイルとして扱われません。
ACP Chat は、runtime が画像生成メディアを信頼できる構造化メディアとして配信した場合に、生成画像のプレビューも表示できます。信頼できる OpenClaw internal-UI 配信と画像生成タスクに関連付けられた最終返信では、テキストのみの失敗説明を含む元のユーザー向け完了テキストを保持し、汎用の画像キャプションへ置き換えません。OpenClaw の履歴リプレイ中は、同じセッションで画像生成タスク開始が記録されている場合に限り、assistant の画像 `MEDIA:` マーカーがインライン画像表示へ昇格されます。ClawX は Renderer から任意にファイルシステムへアクセスするのではなく、Electron Main のホストメディア処理を通じてプレビューを読み込みます。標準 ACP の画像と resource コンテンツは引き続き推奨パスであり、そのまま描画されます。
### ACP ファイルアクティビティのセマンティクス
- ファイルアクティビティは、成功して完了した OpenClaw の `write``edit``apply_patch` 呼び出しから投影されます。ツールの認識方法は公式 OpenClaw Chat UI に準拠し、完了した呼び出しだけに絞る処理は ClawX 固有です。
- 作成・変更されたアクティビティ行は、プレビュー可能な assistant 添付ファイルと同じファイルカードと**アプリで開く**メニューを使い、状態表示と利用可能な `+/-` 集計も保持します。HTML ファイルでは、メニューの先頭項目がローカルファイル URL を右側の**ウェブブラウザ**で開き、そのタブを有効にします。削除された行には **Changes** 操作だけを残します。アプリ一覧、選択アプリで開く操作、ファイル位置の表示は、workspace ルートと相対パスから Electron Main が毎回個別に再検証します。ツール由来のパスが添付ファイルに変換されたり、Renderer に正規化済みのネイティブパスが渡されたりすることはありません。
- `write` はツールが宣言したとおり、作成および全行追加の差分として表示されます。対象パスがすでに存在する可能性がある場合も同様です。
- **Changes** は、ツールが宣言したアクティビティを時系列に並べたセッション単位の記録です。Git の出力でも、検証済みソースベースラインに対する差分でもありません。
- 各ファイルについて、Changes はアシスタントの各ターンに最大 1 つの diff エディターを表示します。安全に連結できるフラグメントは合成し、独立したフラグメントは 1 つのエディターに連結しますが、完全なファイルベースラインとの差分であるとはみなしません。
- シェルコマンド、スクリプト、ユーザー、IDE による副作用は検出されません。
- 完全な ACP リプレイからは記録済みのファイルアクティビティを復元できます。リプレイが不完全な場合、ClawX はフォールバック推論で欠落したアクティビティを補いません。
```
┌────────────────────────────────────────────────────────────────────┐
│ ClawX デスクトップアプリ │
@@ -243,17 +288,17 @@ ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を
│ 型付き IPC リクエスト
┌─────────────────────────────────────────────────────────────────┐
│ Main Host Services と Gateway Manager │
│ Main Host Services と Runtime Manager │
│ │
│ • host:invoke 型付きサービスディスパッチ │
│ • 設定、ファイル、セッション、スキル、プロバイダー、診断サービス │
│ • Main が Gateway WebSocket とプロセス監視を所有 │
│ • Runtime 選択、transport、プロセス監視を所有
└──────────────────────────────┬──────────────────────────────────┘
│ Main 所有 WebSocket
┌─────────────────────────────────────────────────────────────────┐
│ OpenClaw ゲートウェイ
│ OpenClaw Gateway 経路(図示)
│ │
│ • AIエージェントランタイムとオーケストレーション │
│ • メッセージチャネル管理 │
@@ -265,7 +310,7 @@ ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を
- **プロセス分離**: AIランタイムは別プロセスで動作し、重い計算処理中でもUIの応答性を確保します
- **フロントエンド呼び出しの単一入口**: Renderer は host-api/api-client を通じて呼び出し、下位プロトコルに依存しません
- **Mainによるトランスポート制御**: Gateway WebSocket は Electron Main のみが所有し、Renderer は型付き IPC で Main と通信します
- **Mainによるトランスポート制御**: OpenClaw ACP/Gateway transport と cc-connect BridgePlatform dispatch は Electron Main が所有し、Renderer は型付き IPC で Main と通信します
- **拡張 IPC コントリビューション**: Main プロセス拡張は HTTP route ではなく、型付き IPC レジストリを通じて host-api action を提供します
- **グレースフルリカバリ**: 再接続・タイムアウト・バックオフで一時的障害を自動処理します
- **セキュアストレージ**: APIキーや機密データは、OSのネイティブセキュアストレージ機構を活用します
@@ -274,7 +319,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 として表示します。
@@ -305,7 +350,7 @@ AI を開発ワークフローに統合できます。エージェントを使
### 前提条件
- **Node.js**: 22以上(LTS推奨)
- **Node.js**: 対応するメジャー系列の 22.22.3以上、24.15.0以上、または25.9.0以上(Node 24 LTS推奨)
- **パッケージマネージャー**: pnpm 9以上(推奨)またはnpm
- **LinuxUbuntu/Debian**: Electron を実行する前に、必要なシステムライブラリをインストールしてください:
```bash
@@ -341,6 +386,8 @@ AI を開発ワークフローに統合できます。エージェントを使
```
### 利用可能なコマンド
cc-connect の実環境検証はローカル env ファイルを読み込めますが、リポジトリ内の認証情報ファイルは gitignore されている必要があります。リポジトリ外の `--env-file` パスは利用でき、レポートには書き込まれません。`.env.cc-connect.local.example` は `.env.cc-connect.local` のフィールドテンプレートです。
```bash
# 開発
pnpm run init # 依存関係のインストール + バンドルバイナリ(uv、agent-browser)のダウンロード
@@ -353,10 +400,39 @@ pnpm typecheck # TypeScriptの型チェック
# テスト
pnpm test # ユニットテストを実行
pnpm run test:e2e # Electron E2E スモークテストを実行
pnpm run test:e2e:cc-connect:codex-oauth-lifecycle # 実認証情報なしで cc-connect Codex OAuth Host API の status/import/logout を検証
CLAWX_REAL_OAUTH_E2E=1 CLAWX_REAL_CODEX_AUTH_JSON="$HOME/.codex/auth.json" pnpm run test:e2e:cc-connect:real-oauth # 実 OAuth tool execution と Chat execution graph を検証
pnpm run test:e2e:headed # 表示付きウィンドウで Electron E2E を実行
pnpm run comms:replay # 通信リプレイ指標を算出
pnpm run comms:baseline # 通信ベースラインを更新
pnpm run comms:compare # リプレイ指標をベースライン閾値と比較
pnpm run verify:cc-connect:local-real # ローカル cc-connect 実環境検証の事前レポートを書き出す
pnpm run verify:cc-connect:local-real:run # 安全なローカル cc-connect 実環境検証を実行してレポートを書き出す
pnpm run verify:cc-connect:local-real:oauth # CLAWX_REAL_CODEX_AUTH_JSON に完全な refresh token フィールドがある場合、開発版 cc-connect の実 OAuth 総合スモークも実行
pnpm run verify:cc-connect:local-real:oauth-all # CLAWX_REAL_CODEX_AUTH_JSON に完全な refresh token フィールドがある場合、開発版とパッケージ版 cc-connect の実 OAuth スモークも実行
pnpm run verify:cc-connect:local-real:api-key # ローカル OpenAI-compatible API-key chat/abort スモークを実行し、認証情報がある場合は実 OpenAI API-key スモークも実行
pnpm run verify:cc-connect:local-real:feishu # 認証情報と CLAWX_REAL_CODEX_AUTH_JSON がある場合に実 Feishu/Lark ライフサイクルスモークも実行
pnpm run verify:cc-connect:local-real:feishu-inbound # サンドボックス tenant fixture が有効な場合に実 Feishu/Lark inbound marker スモークも実行
pnpm run verify:cc-connect:local-real:scheduled-cron # 実 native exec cron を実行し、Codex auth がある場合は public cc-connect session history で native prompt scheduling も検証
pnpm run verify:cc-connect:local-real:all # 利用可能なローカル cc-connect 実環境検証をすべて実行し、外部 gate handoff を書き出す
pnpm run verify:cc-connect:local-real:all-strict # リリース候補検証では全実認証情報と runtime parity coverage の PASS を必須にし、失敗前にも handoff を書き出す
pnpm run verify:cc-connect:local-real:replacement-ready # replacement readiness を必須にし、不足認証情報を別の事前失敗にはしない。失敗前にも handoff を書き出す
pnpm run verify:cc-connect:local-real:replacement-ready:check # 同じ readiness gate を実行し、前回のレポート成果物は上書きしない
pnpm run verify:cc-connect:local-real:packaged-oauth # CLAWX_REAL_CODEX_AUTH_JSON に完全な refresh token フィールドがある場合、パッケージ版 cc-connect の実 OAuth スモークも実行
pnpm run verify:cc-connect:local-real:external-gates:check # 残りの required external gates を非破壊で確認し、レポート成果物は上書きしない
pnpm run verify:cc-connect:local-real:external-gates # 残りの required external gates のみを実行し、3件すべて PASS の場合だけ成功
pnpm run verify:cc-connect:local-real:handoff # 残りの外部 gate 向けに認証情報を含まない handoff checklist を生成
# レポートは artifacts/cc-connect/local-real-validation-report.{json,md} に出力されます。
# :all、:all-strict、:replacement-ready、:external-gates、または :handoff は artifacts/cc-connect/local-real-external-gates.{md,json} に外部 gate handoff を出力します。
# JSON handoff は machine-readable で、sanitize 済みの status、env var 名、command、安全メモのみを含みます。
# runtimeMatrixStatus は pass/partial/fail の coverage と hard gate の終了状態を分けて表示します。
# --no-write、replacement-ready:check、または external-gates:check は非破壊の gate check に使えます。不足 precondition と次の command は秘密値なしで表示されます。
# validationGaps はローカル hard gate の不足と full parity に必要な follow-up evidence gap を分けて記録します。
# partial レポートには秘密値を含まない後続コマンドの Next Actions が含まれます。
# 実認証情報は、未追跡かつ gitignore 済みの .env.cc-connect.local、--env-file=<path>、
# または CLAWX_REAL_ENV_FILE / CLAWX_REAL_ENV_FILES で渡せます。明示的な process env が優先されます。
# API-key スモークでは、デフォルトモデルが利用できない場合に CLAWX_REAL_OPENAI_MODEL を設定できます。
# ビルド&パッケージ
pnpm run build:vite # フロントエンドのみビルド
@@ -365,13 +441,16 @@ pnpm package # 現在のプラットフォーム向けにパッケ
pnpm package:mac # macOS向けにパッケージ化
pnpm package:win # Windows向けにパッケージ化
pnpm package:linux # Linux向けにパッケージ化
pnpm run verify:runtime-bundles # ダウンロード済み cc-connect/Codex bundle の manifest とバイナリを検証
pnpm run verify:packaged-runtime-resources -- --resources=<path> --platform=<darwin|win32|linux> --arch=<x64|arm64> # 最終 Electron runtime resources を検証
pnpm run smoke:cc-connect:packaged # ネイティブ unpacked app を起動し、cc-connect の起動/状態/Cron/Doctor/ロールバック/クリーンアップを検証
```
ヘッドレス Linux では Electron テストに表示サーバーが必要です。`xvfb-run -a pnpm run test:e2e` を利用してください。
### 通信回帰チェック
PR が通信経路(Gateway イベント、Chat 送受信フロー、Channel 配信、トランスポートのフォールバック)に触れる場合は、次を実行してください。
PR が通信経路(Gateway イベント、ACP Chat bridge の送受信フロー、Channel 配信、トランスポートのフォールバック)に触れる場合は、次を実行してください。
```bash
pnpm run comms:replay
+95 -17
View File
@@ -93,7 +93,17 @@ ClawX is built directly upon the official **OpenClaw** core. Instead of requirin
We are committed to maintaining strict alignment with the upstream OpenClaw project, ensuring that you always have access to the latest capabilities, stability improvements, and ecosystem compatibility provided by the official releases.
When Developer Mode is enabled, the sidebar also provides a native Dreams page for OpenClaw memory review, dream diary inspection, and basic maintenance actions. The full upstream OpenClaw Dreams UI remains available from that page when deeper diagnostics are needed.
When Developer Mode is enabled and OpenClaw is the active runtime, the sidebar also provides a native Dreams page for OpenClaw memory review, dream diary inspection, and basic maintenance actions. The full upstream OpenClaw Dreams UI remains available from that page when deeper diagnostics are needed.
ClawX also includes a runtime abstraction layer. OpenClaw remains the default runtime and rollback path, while **Settings → Gateway → Runtime** can switch to an optional bundled `cc-connect` runtime. Packaged builds include both the cc-connect binary and the native OpenAI Codex CLI bundle in app resources; runtime startup does not depend on global installs, PATH binaries, or app-time downloads. ClawX keeps upgrade-stable app config, credentials, runtime data, skills, and workspaces under `~/.clawx` (or `CLAWX_DATA_HOME`) instead of modifying `~/.cc-connect`. GUI chat connects through cc-connect BridgePlatform with Codex as the project agent; managed projects use cc-connect's Codex app-server backend over stdio so live tool progress can drive the shared Chat execution graph directly. When public cc-connect history omits tool packets for a channel-originated session, ClawX supplements that history from matching local Codex transcripts constrained to the owning Agent's workspace. Approval buttons and cc-connect card choices are rendered in that graph, and responses return through cc-connect's public `card_action` protocol. Runtime-generated image, file, audio, and video packets also return through BridgePlatform and remain visible as Chat attachments. Each Agent defaults to Full Auto and can independently select Ask for approval (`suggest`) in Agent model/runtime settings. New agents use `~/.clawx/workspaces/agents/<id>`; existing OpenClaw workspaces can be reused by reference without being moved or owned by ClawX. Provider/model selections, native cron tasks, and enabled skills are synchronized into the managed cc-connect/Codex runtime.
Agent and channel settings are canonical under `~/.clawx`. While cc-connect is active, saving them does not rewrite `~/.openclaw/openclaw.json`; switching back to OpenClaw rebuilds that compatibility projection before the Gateway starts.
In cc-connect mode, Codex provider sync supports OpenAI API key, OpenAI OAuth/Codex, Ollama, and Custom OpenAI-compatible providers that expose the Responses API. Custom provider headers are written as environment-variable references so secrets and session headers are not persisted in managed config files. Custom providers configured for Chat Completions are reported as unsupported before chat delivery because Codex accepts the Responses wire API for this path.
Each OAuth provider account has an isolated managed `CODEX_HOME`. An existing user-global Codex login is never adopted during runtime startup; importing it requires the explicit Codex OAuth import action for the selected account.
cc-connect also owns messaging platform bridges. When cc-connect is the active runtime, channel status probes are routed through the runtime abstraction instead of the OpenClaw Gateway, configured channel accounts are mirrored into the cc-connect project that owns their bound agent, and channel saves/deletes reload the managed cc-connect config through its Management API so platform changes take effect without a full runtime restart when possible. The Developer Mode sidebar page shortcut opens cc-connect Web Admin, while the OpenClaw Dreams shortcut remains OpenClaw-only.
---
@@ -106,8 +116,20 @@ Complete the entire setup—from installation to your first AI interaction—thr
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, rich content rendering with Markdown (including GitHub-flavored tables and KaTeX-powered LaTeX math: `$inline$`, `$$block$$`, `\(inline\)`, and `\[block\]`), and direct `@agent` routing in the main composer for multi-agent setups.
Skills you insert from the composer appear as `/skill-name` chips; click a chip to open the preview sidebar and read that skill's `SKILL.md`.
When you target another agent with `@agent`, ClawX switches into that agent's own conversation context directly instead of relaying through the default agent. Agent workspaces stay separate by default, and stronger isolation depends on OpenClaw sandbox settings.
The session sidebar is workspace-first: the default workspace stays at the top, other workspaces sort naturally, and each workspace can collapse or load more sessions. A row shows a spinner while the AI is replying, a blue dot when an unseen reply finishes, and its relative activity time after the conversation is opened; hovering still reveals row actions. Imported workspaces can be renamed from their sidebar header; the custom name is reflected in the chat composer while hovering the header still reveals the filesystem path. When available, a new chat inherits the selected conversation's workspace while remaining editable until first send. Editable new or unbound chats expose the composer workspace chip as a small menu that lists recent and known-session workspaces, returns to the default workspace, or chooses another folder. If a saved workspace folder was moved or deleted, Chat pauses session creation and prompts you to choose an existing folder instead of repeatedly retrying the missing path. Unavailable non-default groups are marked in the sidebar and can be removed after confirmation; this permanently deletes every session in that group. Synthetic OpenClaw UUID-date fallback titles are treated as missing only when they match the session ID, then replaced with the conversation's first user prompt instead of being persisted as the session name.
Each agent can also override its own `provider/model` runtime setting; agents without overrides continue inheriting the global default model.
The Workspace and Preview tabs in Chat's right panel provide read-only previews for `.docx` and `.pptx` files. Legacy `.doc` and `.ppt` files continue to open through the operating system instead of inline. DOCX pagination may differ from Microsoft Word, and PPTX previews do not support animations, transitions, or media playback. Office files larger than 20 MB are not previewed inline.
### Single-Page Web Browser
The Chat right panel has four tabs: Workspace, Preview, Changes, and Web Browser. Web Browser lazily creates one live page and keeps it running when you close the panel, select another panel tab, switch chat sessions, or visit another ClawX route; hidden pages may continue scripts, network activity, audio, and resource use. Its dedicated persistent session retains cookies and site storage across app restarts, but every new app run starts at `about:blank` without restoring the previous URL, page state, or navigation history. When a page provides a favicon, it appears beside the title; a same-size placeholder keeps the title aligned while no favicon is available, and the icon slot is hidden while editing the address. There are no additional browser tabs or windows, bookmarks, persisted history, password manager, or autofill management.
Top-level navigation accepts HTTP, HTTPS, and explicitly entered standard `file:///` URLs. Plain filesystem paths and other protocols are rejected. Opening a local file exposes its readable content to the embedded page under normal Chromium security rules, and using **Open in System Browser** for a `file:` URL may launch the OS-associated application instead of a browser. Allowed popup targets replace the current page rather than creating a child window; this same-page fallback cannot preserve `window.opener`, returned window handles, initially blank scripted popups, or full POST-body, referrer, named-window, and window-feature behavior.
Downloads keep Electron and the operating system defaults. Depending on the platform, this may present a native Save dialog and require user interaction; ClawX does not choose a custom path or provide download progress, history, or management UI. Camera and microphone access uses a native Allow/Deny prompt for every request and is never remembered. Clipboard access is allowed, while geolocation, display capture, notifications, and other permissions are denied.
**Clear Cookies** removes cookies for every origin in the browser session while preserving cache and site storage. **Clear Site Data** removes HTTP/Chromium cache, Cache Storage, Local Storage, IndexedDB, and Service Workers for every origin while preserving cookies and downloaded files. Browser traffic follows Electron/Chromium system-proxy resolution; ClawX client proxy settings are not synchronized to this browser session, and changing them does not reconfigure it.
### 📡 Multi-Channel Management
Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks.
Each channel now supports multiple accounts, per-account agent binding, and switching the channel default account directly from the Channels page.
@@ -116,22 +138,23 @@ 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 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. Additional bundled skills (`find-skills`, `self-improving-agent`, `tavily-search`) are also enabled by default; if required API keys are missing, OpenClaw will surface configuration errors in runtime.
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.
Environment variables for bundled search skills:
- `TAVILY_API_KEY` for `tavily-search` (OAuth may also be supported by upstream skill runtime)
- `find-skills` and `self-improving-agent` do not require API keys
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.
Connect to multiple AI providers (OpenAI, Anthropic, Z.AI / GLM, and more) with credentials stored securely in your system's native keychain. OpenAI supports both API key and browser OAuth (Codex subscription) sign-in.
In developer mode, the dedicated Image Generation page supports an independent OpenAI-compatible image-generation endpoint (Base URL, API key, and model name such as `gpt-image-2`) so image generation can use a dedicated `/v1/images/generations` service while chat continues using the normal OpenAI provider.
For **Custom** providers used with OpenAI-compatible gateways, you can set a custom `User-Agent` in **Settings → AI Providers → Edit Provider** for compatibility-sensitive endpoints.
When you edit or switch providers, ClawX preserves existing per-model capability metadata such as `input: ["text", "image"]`. Newly selected Custom-provider models use OpenClaw onboarding-compatible image-input inference, with unknown models defaulting to text-only.
Custom-provider model rows also receive an explicit `contextWindow` (inferred from the model family, e.g. `gpt-5.x` → 272k), and rows saved by older versions are backfilled on startup, so OpenClaw can compact long sessions before they fail with "Context overflow" errors. When you have no compaction config, ClawX seeds `agents.defaults.compaction.mode = "safeguard"` and `reserveTokensFloor = 50000`; rows or configs you authored yourself are never modified (except a missing `reserveTokensFloor` may be backfilled).
Z.AI (CN / Global) maps to OpenClaw's built-in `zai` provider (`ZAI_API_KEY`). Default model is `glm-5.2`. Use the Code Plan preset for Coding Plan endpoints (`…/api/coding/paas/v4`) or the normal API endpoints (`…/api/paas/v4`); CN and Global are mutually exclusive because they share one OpenClaw runtime key.
When a compatible gateway rejects `/models` for non-auth reasons, ClawX automatically falls back to a lightweight `/chat/completions` or `/responses` probe during API key validation.
### 🌙 Adaptive Theming
@@ -188,7 +211,7 @@ The wizard preselects your system language when it is supported, and falls back
### Proxy Settings
ClawX includes built-in proxy settings for environments where Electron, the OpenClaw Gateway, or channels such as Telegram need to reach the internet through a local proxy client.
ClawX includes built-in proxy settings for environments where Electron, the OpenClaw Gateway, the optional cc-connect/Codex runtime, or channels such as Telegram need to reach the internet through a local proxy client.
Open **Settings → Gateway → Proxy** and configure:
@@ -209,10 +232,11 @@ Notes:
- A bare `host:port` value is treated as HTTP.
- If advanced proxy fields are left empty, ClawX falls back to `Proxy Server`.
- Saving proxy settings reapplies Electron networking immediately and restarts the Gateway automatically.
- In cc-connect runtime mode, Codex child processes inherit the same `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and bypass environment values.
- ClawX also syncs the proxy to OpenClaw's Telegram channel config when Telegram is enabled.
- Gateway restarts preserve an existing Telegram channel proxy if ClawX proxy is currently disabled.
- To explicitly clear Telegram channel proxy from OpenClaw config, save proxy settings with proxy disabled.
- In **Settings → Advanced → Developer**, you can run **OpenClaw Doctor** to execute `openclaw doctor --json` and inspect the diagnostic output without leaving the app.
- In **Settings → Advanced → Developer**, Runtime Doctor runs `openclaw doctor --json` for OpenClaw. For cc-connect it combines bundled `cc-connect doctor user-isolation` with bundled `codex doctor --json` and stores a mode-0600 audit under the ClawX-managed runtime directory. Doctor Fix remains OpenClaw-only.
- On packaged Windows builds, the bundled `openclaw` CLI/TUI runs via the shipped `node.exe` entrypoint to keep terminal input behavior stable.
---
@@ -221,6 +245,26 @@ Notes:
ClawX employs a **dual-process architecture** with a unified host API layer. The renderer talks to a single client abstraction, while Electron Main owns protocol selection and process lifecycle:
Chat transport follows the active runtime while preserving one renderer boundary. OpenClaw Chat uses an ACP stdio bridge owned by Electron Main; the renderer receives typed host events and renders an in-memory ACP timeline. cc-connect Chat is dispatched by `RuntimeManager` through cc-connect BridgePlatform, including session history, progress, approvals, and generated media. The renderer uses the same Host API facade in both modes and never invokes Codex directly. Non-Chat capabilities are also dispatched through runtime providers; OpenClaw-specific operations remain behind the OpenClaw adapter.
An unfinished ACP response keeps streaming when you open another conversation or page. Returning before it finishes restores the latest in-memory timeline and continues the live response; once it finishes, normal ACP history replay remains the source of truth.
ACP assistant turns show whole-turn duration. Live timing follows the client-observed prompt lifecycle and survives in-app navigation; historical timing is derived in Electron Main from bounded OpenClaw transcript timestamps and only annotates a turn already restored by ACP replay.
ACP Chat renders standard ACP resources as attachments. User-selected images appear as thumbnails with a filename hover overlay, while other available attachment cards show the filename and a muted, truncating source path. When the current OpenClaw ACP adapter omits assistant media, explicit assistant `MEDIA:` directives can also be recovered as attachment cards without displaying the raw directive. Existing local file references, including paths outside the active workspace, are revalidated in Electron Main for the exact session and generation before every preview or open. Previewable local attachments produced by the AI, including `.docx` and `.pptx` files within the 20 MB inline-preview limit, keep their primary read-only in-app preview action and provide a secondary menu for opening with compatible applications or revealing the file in Finder, File Explorer, or the system file manager. For local HTML attachments, that menu starts with an action that opens the file URL in the right-side Web Browser. The same Office limitations apply here: `.doc` and `.ppt` remain system-open formats, DOCX pagination may differ from Microsoft Word, and PPTX animations, transitions, and media playback are unsupported. Compatible-application discovery is available only on macOS and Windows and silently degrades to reveal-only behavior on Linux or when discovery fails. Other local files, including Office files larger than 20 MB, open in the system application after a user click; remote HTTP and HTTPS attachments open externally after a user click. Bare or inline prose paths are not treated as attachments.
ACP Chat can also display generated image previews when image-generation media is delivered by the runtime as trusted structured media. Trusted OpenClaw internal-UI deliveries and task-correlated final replies preserve the original user-facing completion text, including text-only failure explanations, rather than replacing it with a generic image caption. During historical OpenClaw replay, assistant image `MEDIA:` markers are promoted to the inline image experience only when they follow a recorded image-generation task start for that session. ClawX loads previews through host media handling in Electron Main, not arbitrary Renderer filesystem access. Standard ACP image and resource content remains the preferred path and renders directly.
### ACP File Activity Semantics
- File activity is projected from successful, completed OpenClaw `write`, `edit`, and `apply_patch` calls. Tool recognition follows the official OpenClaw Chat UI; filtering to completed calls is specific to ClawX.
- Created and modified activity rows use the same file-card shell and **Open with** menu as previewable assistant attachments while retaining their status and optional `+/-` summary. For HTML files, the first menu item opens the local file URL in the right-side Web Browser and activates that tab. Deleted rows keep only the **Changes** action. Every application-list, selected-application, and reveal request is independently revalidated in Electron Main from the workspace root and relative path; tool-derived paths never become attachments or expose canonical native paths to Renderer.
- A `write` is shown as the tool declares it: a creation with an all-added diff, even if the path may already exist.
- **Changes** is a chronological, session-level record of tool-declared activity. It is not Git output or a verified diff against a source baseline.
- For each file, Changes renders at most one diff editor per assistant turn. Sequential fragments are composed when safe; independent fragments share one concatenated editor without claiming a complete-file baseline.
- Side effects made by shell commands, scripts, users, or IDEs are not detected.
- A full ACP replay can restore recorded file activity. If replay is incomplete, ClawX does not infer missing activity through fallback behavior.
```
┌──────────────────────────────────────────────────────────────────┐
│ ClawX Desktop App │
@@ -247,17 +291,17 @@ ClawX employs a **dual-process architecture** with a unified host API layer. The
│ Typed IPC requests
┌──────────────────────────────────────────────────────────────────┐
Main Host Services & Gateway Manager │
│ Main Host Services & Runtime Manager
│ │
│ • host:invoke typed service dispatcher │
│ • Settings, files, sessions, skills, providers, diagnostics │
│ • Main-owned Gateway WebSocket and process supervision
│ • Runtime selection, transport, and process supervision │
└──────────────────────────────┬───────────────────────────────────┘
│ Main-owned WebSocket
┌──────────────────────────────────────────────────────────────────┐
OpenClaw Gateway
│ OpenClaw Gateway path (shown)
│ │
│ • AI agent runtime and orchestration │
│ • Message channel management │
@@ -269,7 +313,7 @@ ClawX employs a **dual-process architecture** with a unified host API layer. The
- **Process Isolation**: The AI runtime operates in a separate process, ensuring UI responsiveness even during heavy computation
- **Single Entry for Frontend Calls**: Renderer requests go through host-api/api-client; protocol details are hidden behind a stable interface
- **Main-Process Transport Ownership**: Electron Main owns the Gateway WebSocket; the renderer talks to Main over typed IPC
- **Main-Process Transport Ownership**: Electron Main owns OpenClaw ACP/Gateway transports and cc-connect BridgePlatform dispatch; the renderer talks to Main over typed IPC
- **Extension IPC Contributions**: Main-process extensions contribute host-api actions through the typed IPC registry instead of HTTP routes
- **Graceful Recovery**: Built-in reconnect, timeout, and backoff logic handles transient failures automatically
- **Secure Storage**: API keys and sensitive data leverage the operating system's native secure storage mechanisms
@@ -278,7 +322,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.
@@ -309,7 +353,7 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro
### Prerequisites
- **Node.js**: 22+ (LTS recommended)
- **Node.js**: 22.22.3+, 24.15.0+, or 25.9.0+ within the corresponding supported major line (Node 24 LTS recommended)
- **Package Manager**: pnpm 9+ (recommended) or npm
- **Linux (Ubuntu/Debian)**: Install required system libraries before running Electron:
```bash
@@ -345,6 +389,8 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro
```
### Available Commands
Real cc-connect verification can load local env files, but repo-local credential files must be gitignored; external `--env-file` paths are allowed without being written to reports. Use `.env.cc-connect.local.example` as the field template for `.env.cc-connect.local`.
```bash
# Development
pnpm run init # Install dependencies + download bundled binaries (uv, agent-browser)
@@ -357,10 +403,39 @@ pnpm typecheck # TypeScript validation
# Testing
pnpm test # Run unit tests
pnpm run test:e2e # Run Electron E2E smoke tests with Playwright
pnpm run test:e2e:cc-connect:codex-oauth-lifecycle # Verify cc-connect Codex OAuth Host API status/import/logout without real credentials
CLAWX_REAL_OAUTH_E2E=1 CLAWX_REAL_CODEX_AUTH_JSON="$HOME/.codex/auth.json" pnpm run test:e2e:cc-connect:real-oauth # Verify real OAuth tool execution and the Chat execution graph
pnpm run test:e2e:headed # Run Electron E2E tests with a visible window
pnpm run comms:replay # Compute communication replay metrics
pnpm run comms:baseline # Refresh communication baseline snapshot
pnpm run comms:compare # Compare replay metrics against baseline thresholds
pnpm run verify:cc-connect:local-real # Write a local cc-connect real-validation preflight report
pnpm run verify:cc-connect:local-real:run # Run safe local cc-connect real-validation checks and write the report
pnpm run verify:cc-connect:local-real:oauth # Also run dev cc-connect real OAuth comprehensive smoke when CLAWX_REAL_CODEX_AUTH_JSON has a complete refresh-token set
pnpm run verify:cc-connect:local-real:oauth-all # Also run dev and packaged cc-connect real OAuth smokes when CLAWX_REAL_CODEX_AUTH_JSON has a complete refresh-token set
pnpm run verify:cc-connect:local-real:api-key # Run local OpenAI-compatible API-key chat/abort smokes; also run real OpenAI API-key smoke when credentials are available
pnpm run verify:cc-connect:local-real:feishu # Also run real Feishu/Lark lifecycle smoke when credentials and CLAWX_REAL_CODEX_AUTH_JSON are available
pnpm run verify:cc-connect:local-real:feishu-inbound # Also run the manual real Feishu/Lark inbound marker smoke when the sandbox tenant fixture is enabled
pnpm run verify:cc-connect:local-real:scheduled-cron # Also run real native exec cron; with Codex auth, verify native prompt scheduling through public cc-connect session history
pnpm run verify:cc-connect:local-real:all # Run every available local real cc-connect validation path and write the external gate handoff
pnpm run verify:cc-connect:local-real:all-strict # Require all real credentials and runtime parity coverage for release-candidate validation; writes the handoff before failing
pnpm run verify:cc-connect:local-real:replacement-ready # Require replacement readiness without making missing credentials a separate preflight failure; writes the handoff before failing
pnpm run verify:cc-connect:local-real:replacement-ready:check # Same readiness gate without overwriting the last report artifacts
pnpm run verify:cc-connect:local-real:packaged-oauth # Also run packaged cc-connect real OAuth smoke when CLAWX_REAL_CODEX_AUTH_JSON has a complete refresh-token set
pnpm run verify:cc-connect:local-real:external-gates:check # Check remaining required external gates without overwriting report artifacts
pnpm run verify:cc-connect:local-real:external-gates # Run only the remaining required external gates and fail unless all three pass
pnpm run verify:cc-connect:local-real:handoff # Generate a credential-free handoff checklist for remaining external gates
# The report is written to artifacts/cc-connect/local-real-validation-report.{json,md};
# The external gate handoff is written to artifacts/cc-connect/local-real-external-gates.{md,json} by :all, :all-strict, :replacement-ready, :external-gates, or :handoff.
# The JSON handoff is machine-readable and contains only sanitized status, env-var names, commands, and safety notes.
# runtimeMatrixStatus shows pass/partial/fail coverage separately from hard-gate exit status.
# Use --no-write, replacement-ready:check, or external-gates:check for non-destructive gate checks; missing preconditions and next commands are printed without secret values.
# validationGaps records required local gate gaps separately from follow-up full-parity evidence gaps.
# partial reports include Next Actions with follow-up commands and no secret values.
# Real credentials can be supplied through untracked/gitignored .env.cc-connect.local,
# --env-file=<path>, or CLAWX_REAL_ENV_FILE / CLAWX_REAL_ENV_FILES; process env values win.
# API-key smoke can set CLAWX_REAL_OPENAI_MODEL when the default model is not available.
# Build & Package
pnpm run build:vite # Build frontend only
@@ -369,13 +444,16 @@ pnpm package # Package for current platform (includes bundled prein
pnpm package:mac # Package for macOS
pnpm package:win # Package for Windows
pnpm package:linux # Package for Linux
pnpm run verify:runtime-bundles # Verify downloaded cc-connect/Codex bundle manifests and binaries
pnpm run verify:packaged-runtime-resources -- --resources=<path> --platform=<darwin|win32|linux> --arch=<x64|arm64> # Verify final Electron runtime resources
pnpm run smoke:cc-connect:packaged # Launch the native unpacked app and verify cc-connect start/status/Cron/Doctor/rollback/cleanup
```
On headless Linux, run Electron tests under a display server such as `xvfb-run -a pnpm run test:e2e`.
### Communication Regression Checks
When a PR changes communication paths (gateway events, chat runtime send/receive flow, channel delivery, or transport fallback), run:
When a PR changes communication paths (gateway events, ACP Chat bridge send/receive flow, channel delivery, or transport fallback), run:
```bash
pnpm run comms:replay
+14 -8
View File
@@ -106,6 +106,15 @@ ClawX построен непосредственно на официально
При выборе другого агента через `@agent` ClawX переключается непосредственно в контекст этого агента вместо ретрансляции через агента по умолчанию. Рабочие пространства агентов по умолчанию разделены, но более строгая изоляция зависит от настроек песочницы OpenClaw.
Каждый агент может переопределить свои настройки `provider/model`; агенты без переопределения продолжают наследовать глобальную модель по умолчанию.
### Одностраничный веб-браузер
На правой панели Chat находятся четыре вкладки: «Рабочая область», «Просмотр», «Изменения» и «Веб-браузер». При первом использовании веб-браузер лениво создаёт одну активную страницу и не останавливает её при закрытии панели, выборе другой вкладки панели, переключении сессии чата или переходе на другой маршрут ClawX; скрытая страница может продолжать выполнять скрипты, обращаться к сети, воспроизводить звук и расходовать ресурсы. Выделенная постоянная сессия сохраняет cookie и хранилища сайтов после перезапуска приложения, но каждый новый запуск начинается с `about:blank` без восстановления предыдущего URL, состояния страницы или истории переходов. Если страница предоставляет favicon, он отображается рядом с заголовком; пока favicon недоступен, заполнитель того же размера сохраняет положение заголовка, а при редактировании адреса вся область значка скрывается. Дополнительных вкладок или окон браузера, закладок, сохраняемой истории, менеджера паролей и управления автозаполнением нет.
Навигация верхнего уровня принимает HTTP, HTTPS и явно введённые стандартные URL `file:///`. Обычные пути файловой системы и другие протоколы отклоняются. Открытие локального файла предоставляет встроенной странице доступ к его читаемому содержимому в рамках обычных правил безопасности Chromium; команда **Открыть в системном браузере** для URL `file:` может запустить связанное с файлом приложение ОС, а не браузер. Разрешённая цель всплывающего окна заменяет текущую страницу, а не создаёт дочернее окно. Такой переход в той же странице не сохраняет `window.opener`, возвращаемые дескрипторы окон, сценарии с первоначально пустым окном, а также полную семантику тела POST, referrer, именованных окон и параметров окна.
Для загрузок сохраняется стандартное поведение Electron и операционной системы. В зависимости от платформы может появиться нативный диалог сохранения, требующий действий пользователя; ClawX не задаёт собственный путь и не предоставляет интерфейс прогресса, истории или управления загрузками. Для каждого запроса камеры или микрофона показывается нативный диалог разрешения или запрета, а решение не запоминается. Доступ к буферу обмена разрешён; геолокация, захват экрана, уведомления и остальные разрешения отклоняются.
**Очистить файлы cookie** удаляет только cookie всех источников в сессии браузера, сохраняя кэш и хранилища сайтов. **Очистить данные сайта** удаляет HTTP/Chromium-кэш, Cache Storage, Local Storage, IndexedDB и Service Workers всех источников, сохраняя cookie и загруженные файлы. Трафик браузера использует системное разрешение прокси Electron/Chromium; настройки клиентского прокси ClawX не синхронизируются с этой сессией браузера, и их изменение не перенастраивает её.
### 📡 Управление несколькими каналами
Настраивайте и отслеживайте несколько AI-каналов одновременно. Каждый канал работает независимо, позволяя запускать специализированных агентов для разных задач.
Каждый канал теперь поддерживает несколько учётных записей, привязку агента к учётной записи и переключение канала по умолчанию прямо на странице Каналы.
@@ -114,20 +123,17 @@ ClawX также включает официальный плагин лично
### ⏰ Автоматизация по расписанию
Планируйте автоматический запуск AI-задач. Определяйте триггеры, устанавливайте интервалы и позволяйте AI-агентам работать круглосуточно без ручного вмешательства.
На странице Cron теперь можно настроить внешнюю доставку непосредственно в форме задачи с отдельными селекторами учётной записи отправителя и цели получателя. Для поддерживаемых каналов цели получателей автоматически обнаруживаются из каталогов каналов или известной истории сессий, поэтому больше не нужно редактировать `jobs.json` вручную.
На странице Cron теперь можно настроить внешнюю доставку непосредственно в форме задачи с отдельными селекторами учётной записи отправителя и цели получателя. Для поддерживаемых каналов цели получателей автоматически обнаруживаются из каталогов каналов или известной истории сессий, поэтому больше не нужно редактировать `jobs.json` вручную. Поле сообщения задачи также поддерживает вставку навыков с помощью того же синтаксиса встроенных токенов `/skill`, что и в основном окне чата (с учётом выбранного агента), поэтому запланированные подсказки могут запускать навыки напрямую. Выбор расписания разделён на вкладки **Повтор** и **Однократно**: повтор предлагает частоты «Ежечасно», «Ежедневно», «По будням», «Еженедельно» и «Свой» (произвольный cron) со встроенными элементами выбора времени/дня недели, а однократно запускает задачу один раз в выбранную дату (с показом дня недели) и время. Однократные задачи должны быть запланированы на будущее и автоматически удаляются средой выполнения после завершения.
### 🧩 Расширяемая система навыков
Расширяйте возможности AI-агентов готовыми навыками. Просматривайте, устанавливайте и управляйте навыками через встроенную панель — менеджеры пакетов не нужны.
ClawX также предварительно упаковывает полные навыки обработки документов (`pdf`, `xlsx`, `docx`, `pptx`), автоматически развёртывает их в управляемый каталог навыков (по умолчанию `~/.openclaw/skills`) при запуске и включает по умолчанию при первой установке. Дополнительные упакованные навыки (`find-skills`, `self-improving-agent`, `tavily-search`) также включены по умолчанию; если необходимые API-ключи отсутствуют, OpenClaw покажет ошибки конфигурации во время выполнения.
ClawX также предварительно упаковывает полные навыки обработки документов (`pdf`, `xlsx`, `docx`, `pptx`), автоматически развёртывает их в управляемый каталог навыков (по умолчанию `~/.openclaw/skills`) при запуске и включает по умолчанию при первой установке.
На странице Навыки отображаются навыки из нескольких источников OpenClaw (управляемый каталог, workspace и дополнительные каталоги навыков), а также показывается фактическое расположение каждого навыка для прямого открытия папки.
Переменные окружения для встроенных поисковых навыков:
- `TAVILY_API_KEY` для `tavily-search` (OAuth также может поддерживаться вышестоящей средой выполнения)
- `find-skills` и `self-improving-agent` не требуют API-ключей
### 🔐 Безопасная интеграция провайдеров
Подключайтесь к нескольким AI-провайдерам (OpenAI, Anthropic и др.) с учётными данными, безопасно хранящимися в системной связке ключей. OpenAI поддерживает как API-ключи, так и OAuth через браузер (подписка Codex).
Подключайтесь к нескольким AI-провайдерам (OpenAI, Anthropic, Z.AI / GLM и др.) с учётными данными, безопасно хранящимися в системной связке ключей. OpenAI поддерживает как API-ключи, так и OAuth через браузер (подписка Codex).
Для провайдеров **Custom**, используемых с OpenAI-совместимыми шлюзами, вы можете установить пользовательский `User-Agent` в **Настройки → AI Провайдеры → Редактировать провайдера** для совместимости с чувствительными эндпоинтами.
Z.AI (CN / Global) соответствует встроенному провайдеру OpenClaw `zai` (`ZAI_API_KEY`). Модель по умолчанию — `glm-5.2`. Пресет Code Plan переключает на эндпоинты Coding Plan (`…/api/coding/paas/v4`); также доступны обычные API (`…/api/paas/v4`). CN и Global взаимоисключающие, так как используют один и тот же runtime-ключ OpenClaw.
Когда совместимый шлюз отклоняет `/models` по причинам, не связанным с аутентификацией, ClawX автоматически переключается на легковесный зонд `/chat/completions` или `/responses` при проверке API-ключа.
### 🌙 Адаптивные темы
@@ -300,7 +306,7 @@ ClawX использует **двухпроцессную архитектуру
### Требования
- **Node.js**: 22+ (рекомендуется LTS)
- **Node.js**: 22.22.3+, 24.15.0+ или 25.9.0+ в пределах соответствующей основной версии (рекомендуется Node 24 LTS)
- **Менеджер пакетов**: pnpm 9+ (рекомендуется) или npm
### Структура проекта
+95 -16
View File
@@ -94,7 +94,17 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
我们致力于与上游 OpenClaw 项目保持严格同步,确保你始终可以使用官方发布的最新功能、稳定性改进和生态兼容性。
打开开发者模式,侧边栏还会提供原生 Dreams 页面,可在 ClawX 内查看 OpenClaw 记忆回顾、梦境日记,并执行基础维护操作;需要更深诊断时仍可从该页面打开完整 OpenClaw Dreams UI。
打开开发者模式且当前 runtime 为 OpenClaw 时,侧边栏还会提供原生 Dreams 页面,可在 ClawX 内查看 OpenClaw 记忆回顾、梦境日记,并执行基础维护操作;需要更深诊断时仍可从该页面打开完整 OpenClaw Dreams UI。
ClawX 现在也包含 runtime 抽象层。OpenClaw 仍是默认 runtime 和回滚路径,你可以在 **设置 → 网关 → Runtime** 切换到可选的内置 `cc-connect` runtime。打包产物会同时内置 cc-connect 二进制和 OpenAI Codex 原生 CLI bundleruntime 启动不依赖全局安装、PATH 二进制或运行时下载。ClawX 会把可跨升级复用的 app 配置、凭据、runtime 数据、skills 和 workspace 放在 `~/.clawx`(或 `CLAWX_DATA_HOME`),不会自动修改 `~/.cc-connect`。GUI chat 会通过 cc-connect BridgePlatform 连接到 Codex project agent;托管 project 固定使用 cc-connect 的 Codex app-server stdio backend,让实时工具进度可以直接驱动共用的 Chat execution graph。当 cc-connect 公共历史缺少频道会话的工具数据包时,ClawX 会从匹配的本地 Codex transcript 补全历史,并将匹配范围限制在该会话所属 Agent 的 workspace。审批按钮和 cc-connect card 选项都会显示在执行图中,响应统一通过 cc-connect 公共 `card_action` 协议返回。Runtime 生成的图片、文件、音频和视频包也通过 BridgePlatform 返回,并持续显示为 Chat 附件。每个 Agent 默认使用全自动模式,也可以在 Agent 的模型/runtime 设置中独立选择“需要审批”(`suggest`)。新 agent 使用 `~/.clawx/workspaces/agents/<id>`;已有 OpenClaw workspace 可以按原路径复用,ClawX 不移动也不接管它。Provider/model、原生 cron 任务和已启用 skills 会同步到托管的 cc-connect/Codex runtime。
Agent 和频道设置以 `~/.clawx` 为唯一 canonical 数据源。cc-connect 处于启用状态时,保存设置不会改写 `~/.openclaw/openclaw.json`;切回 OpenClaw 后,Gateway 启动前会重新生成这份兼容投影。
在 cc-connect 模式下,Codex provider 同步支持 OpenAI API Key、OpenAI OAuth/Codex、Ollama,以及暴露 Responses API 的 OpenAI-compatible Custom provider。Custom provider header 会以环境变量引用写入托管配置,避免持久化密钥或 session header。配置为 Chat Completions 的 Custom provider 会在 chat 投递前被明确标记为不支持,因为这条路径使用 Codex 的 Responses wire API。
每个 OAuth provider account 都有独立的托管 `CODEX_HOME`。runtime 启动不会自动采用用户全局 Codex 登录;必须对选中的 account 显式执行 Codex OAuth 导入。
cc-connect 也负责消息平台桥接。当 cc-connect 是当前 runtime 时,频道状态探测会通过 runtime 抽象层路由,而不是继续固定查询 OpenClaw Gateway;已配置的频道账号会同步到其绑定 agent 所属的 cc-connect project,频道保存/删除会通过 cc-connect Management API reload 托管配置,在可行时无需完整重启 runtime 就让 platform 变更生效;开发者模式侧边栏的页面入口会打开 cc-connect Web AdminOpenClaw Dreams 入口仍只在 OpenClaw runtime 下显示。
---
@@ -107,8 +117,20 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、Markdown 富文本渲染(包括 GitHub 风格表格以及由 KaTeX 渲染的 LaTeX 数学公式:`$行内$``$$块级$$``\(行内\)``\[块级\]`),以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。
从输入框插入的技能会以 `/技能名` 卡片形式显示;点击卡片可在右侧预览栏打开并阅读该技能的 `SKILL.md`
当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
会话侧边栏现在以工作空间优先组织:默认工作空间固定在最上方,其它工作空间按自然顺序排列,每个工作空间都可折叠或继续加载更多会话。AI 回复期间,会话行显示加载指示器;未查看的回复完成后显示蓝点;打开会话后恢复显示相对活跃时间,悬停时仍会露出操作按钮。导入的工作空间可从侧边栏标题处重命名,新名称会同步显示在对话输入框下方,同时悬浮标题仍可查看文件系统路径。如果当前所选会话存在有效工作空间,新对话会继承该工作空间,并在首次发送前保持可编辑。对于可编辑的新对话或未绑定对话,输入框的工作空间卡片会打开一个小菜单,列出最近使用及现有会话中的工作空间,并可切回默认工作空间或选择其它目录。如果保存的工作空间文件夹已被移动或删除,Chat 会暂停创建会话并提示选择现有文件夹,而不会持续重试失效路径。不可用的非默认工作空间会在侧边栏显示标记,并可在确认后删除;该操作会永久删除分组中的全部会话。OpenClaw 生成的 UUID 加日期兜底标题只有在与该会话 ID 匹配时才会被视为缺失标题,随后改用会话的首条用户消息展示,而不会被持久化为会话名称。
每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。
Chat 右侧面板的工作空间和预览选项卡支持以只读方式预览 `.docx``.pptx` 文件。旧版 `.doc``.ppt` 文件不会在应用内预览,而是继续通过操作系统打开。DOCX 的分页效果可能与 Microsoft Word 不同;PPTX 预览不支持动画、切换效果或媒体播放。超过 20 MB 的 Office 文件不会在应用内预览。
### 单页面 Web 浏览器
Chat 右侧面板包含四个选项卡:工作空间、预览、变更和网页浏览器。网页浏览器会在首次使用时延迟创建一个实时页面;关闭面板、切换面板选项卡、切换聊天会话或前往 ClawX 的其它路由时,页面只会隐藏并继续运行,因此脚本、网络活动、音频和资源占用都可能持续。专用持久会话会在应用重启后保留 Cookie 和站点存储,但每次启动都从 `about:blank` 开始,不恢复上次的 URL、页面状态或导航历史。页面提供网站图标时,图标会显示在标题左侧;没有图标时,同尺寸占位图标会保持标题对齐,编辑地址时则隐藏整个图标位。该功能不提供额外浏览器标签页或窗口、书签、持久化历史、密码管理器或自动填充管理。
顶层导航支持 HTTP、HTTPS 和明确输入的标准 `file:///` URL;普通文件系统路径及其它协议会被拒绝。打开本地文件会在 Chromium 的常规安全规则下向嵌入页面暴露其中可读取的内容;对 `file:` URL 使用**在系统浏览器中打开**时,操作系统也可能改用文件关联应用,而不是浏览器。允许的弹窗目标会替换当前页面,不会创建子窗口;这种同页面回退无法保留 `window.opener`、返回的窗口句柄、先打开空白页再写入内容的脚本弹窗,也不能完整保持 POST 请求体、referrer、命名窗口和窗口特性行为。
下载完全沿用 Electron 和操作系统的默认行为。根据平台不同,系统可能显示原生“保存”对话框并需要用户操作;ClawX 不会指定自定义路径,也不提供下载进度、历史或管理界面。摄像头和麦克风权限会对每次请求显示原生“允许/拒绝”提示,且不会记住选择。剪贴板访问允许使用;地理位置、屏幕捕获、通知及其它权限均会被拒绝。
**清除 Cookie**会删除浏览器会话中所有来源的 Cookie,同时保留缓存和站点存储。**清除网站数据**会删除所有来源的 HTTP/Chromium 缓存、Cache Storage、Local Storage、IndexedDB 和 Service Worker,同时保留 Cookie 与已下载文件。浏览器流量使用 Electron/Chromium 的系统代理解析;ClawX 客户端代理设置不会同步到该浏览器会话,修改这些设置也不会重新配置它。
### 📡 多频道管理
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。
现在每个频道支持多个账号,并可在 Channels 页面直接完成账号绑定到 Agent 与默认账号切换。
@@ -117,21 +139,23 @@ ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Chan
### ⏰ 定时任务自动化
调度 AI 任务自动执行。定义触发器、设置时间间隔,让 AI 智能体 7×24 小时不间断工作。
现在定时任务页面已经可以直接配置外部投递,统一拆成“发送账号”和“接收目标”两个下拉选择。对于已支持的通道,接收目标会从通道目录能力或已知会话历史中自动发现,不需要再手动修改 `jobs.json`
现在定时任务页面已经可以直接配置外部投递,统一拆成“发送账号”和“接收目标”两个下拉选择。对于已支持的通道,接收目标会从通道目录能力或已知会话历史中自动发现,不需要再手动修改 `jobs.json`任务的消息输入框也支持像主对话框那样以内联 `/skill` 令牌的方式插入技能(按所选智能体范围加载),让定时提示词可以直接触发技能。调度选择器现在分为**周期**和**单次**两个选项卡:周期支持每小时、每天、工作日、每周、自定义(原始 cron)等频率,并内置时间/星期选择;单次则在所选日期(显示星期)和时间执行一次。单次任务必须设置为未来时间,并会在执行完成后由运行时自动清除。
当 runtime 异步接受**立即运行**时,ClawX 会保持触发确认非阻塞,并在后台刷新 runtime 自己管理的任务,直到 Cron 卡片显示最新完成结果或达到有界停止条件。
### 🧩 可扩展技能系统
通过预构建的技能扩展 AI 智能体的能力。集成的 Skills 页面采用“本地优先”方式:会扫描托管目录与 workspace 技能目录,并且无需依赖 Gateway 即可启用或停用技能;在企业扩展接管时,也可以显示扩展提供的 marketplace。
ClawX 还会内置预装完整的文档处理技能(`pdf``xlsx``docx``pptx`),在启动时自动部署到托管技能目录(默认 `~/.openclaw/skills`),并在首次安装时默认启用。额外预装技能(`find-skills``self-improving-agent``tavily-search`)也会默认启用;若缺少必需的 API Key,OpenClaw 会在运行时给出配置错误提示。
ClawX 还会内置预装完整的文档处理技能(`pdf``xlsx``docx``pptx`),在启动时自动部署到托管技能目录(默认 `~/.openclaw/skills`),并在首次安装时默认启用。
Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、workspace、额外技能目录),并显示每个技能的实际路径,便于直接打开真实安装位置。对于 OpenClaw 自带的 bundled skills,社区版现在在打包产物里只保留并展示 `skill-creator`;开发模式和打包版启动时都会直接清理其它 bundled skill,同时把这些已删除 bundled skill 在 `openclaw.json` 中残留的旧配置一并移除。
重点搜索技能所需环境变量:
- `TAVILY_API_KEY`:用于 `tavily-search`(上游运行时也可能支持 OAuth
当 cc-connect runtime 处于启用状态时,ClawX 会把已启用的本地 skills 镜像到 app userData 下托管的 Codex home 中,让内置 Codex agent 使用同一套技能,而不读取全局 skill 目录。
### 🔐 安全的供应商集成
连接多个 AI 供应商(OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuthCodex 订阅)登录。
连接多个 AI 供应商(OpenAI、Anthropic、Z.AI / GLM 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuthCodex 订阅)登录。
在开发者模式下,独立的“图像生成”页面支持配置 OpenAI 兼容生图端点(Base URL、API Key 和模型名,例如 `gpt-image-2`),生图请求会走专用的 `/v1/images/generations` 服务,聊天仍继续使用正常的 OpenAI Provider。
如果你通过 **自定义(CustomProvider** 对接 OpenAI-compatible 网关,可以在 **设置 → AI Providers → 编辑 Provider** 中配置自定义 `User-Agent`,以提高兼容性。
编辑或切换 Provider 时,ClawX 会保留已有的模型级能力元数据,例如 `input: ["text", "image"]`。新选择的自定义 Provider 模型会使用与 OpenClaw onboarding 一致的图片输入能力推断;未知模型默认按纯文本模型处理。
自定义 Provider 的模型行还会写入显式的 `contextWindow`(按模型系列推断,例如 `gpt-5.x` → 272k),旧版本保存的模型行会在启动时自动回填,使 OpenClaw 能在长会话超限前主动压缩上下文,避免出现 "Context overflow" 报错。当你没有配置 compaction 时,ClawX 会默认写入 `agents.defaults.compaction.mode = "safeguard"``reserveTokensFloor = 50000`;你手动配置过的模型行或压缩配置永远不会被修改(仅可能回填缺失的 `reserveTokensFloor`)。
Z.AI(国内站 / 国际站)会映射到 OpenClaw 内置的 `zai` 供应商(`ZAI_API_KEY`),默认模型为 `glm-5.2`。可通过 Code Plan 预设切换到编码套餐端点(`…/api/coding/paas/v4`),或使用普通 API 端点(`…/api/paas/v4`);国内站与国际站互斥,因为它们共享同一个 OpenClaw 运行时 key。
如果兼容网关的 `/models` 因非鉴权原因不可用,ClawX 会在校验 API Key 时自动降级为轻量的 `/chat/completions``/responses` 探测。
### 🌙 自适应主题
@@ -188,7 +212,7 @@ pnpm dev
### 代理设置
ClawX 内置了代理设置,适用于需要通过本地代理客户端访问外网的场景,包括 Electron 本身、OpenClaw Gateway,以及 Telegram 这类频道的联网请求。
ClawX 内置了代理设置,适用于需要通过本地代理客户端访问外网的场景,包括 Electron 本身、OpenClaw Gateway、可选的 cc-connect/Codex runtime,以及 Telegram 这类频道的联网请求。
打开 **设置 → 网关 → 代理**,配置以下内容:
@@ -209,10 +233,11 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问
- 只填写 `host:port` 时,会按 HTTP 代理处理。
- 高级代理项留空时,会自动回退到“代理服务器”。
- 保存代理设置后,Electron 网络层会立即重新应用代理,并自动重启 Gateway。
- 在 cc-connect runtime 模式下,Codex 子进程会继承同一组 `HTTP_PROXY``HTTPS_PROXY``ALL_PROXY` 和绕过规则环境变量。
- 如果启用了 TelegramClawX 还会把代理同步到 OpenClaw 的 Telegram 频道配置中。
- 当 ClawX 代理处于关闭状态时,Gateway 的常规重启会保留已有的 Telegram 频道代理配置。
- 如果你要明确清空 OpenClaw 中的 Telegram 代理,请在关闭代理后点一次“保存代理设置”。
-**设置 → 高级 → 开发者** 中,可以直接运行 **OpenClaw Doctor**,执行 `openclaw doctor --json` 并在应用内查看诊断输出
-**设置 → 高级 → 开发者** 中,Runtime Doctor 会在 OpenClaw 模式执行 `openclaw doctor --json`;在 cc-connect 模式组合执行内置的 `cc-connect doctor user-isolation``codex doctor --json`,并把权限为 0600 的审计报告写入 ClawX 托管 runtime 目录。Doctor Fix 仍只支持 OpenClaw
- 在 Windows 打包版本中,内置的 `openclaw` CLI/TUI 会通过随包分发的 `node.exe` 入口运行,以保证终端输入行为稳定。
---
@@ -221,6 +246,26 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问
ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用统一客户端抽象,协议选择与进程生命周期由 Electron 主进程统一管理:
Chat 传输会随当前 runtime 切换,但 Renderer 始终只经过同一个边界。OpenClaw Chat 使用由 Electron Main 持有的 ACP stdio bridgeRenderer 接收类型化 host events 并渲染内存中的 ACP timelinecc-connect Chat 则由 `RuntimeManager` 通过 cc-connect BridgePlatform 分派,包括 session history、progress、approval 与 generated media。两种模式都使用同一套 Host API facadeRenderer 不会直接调用 Codex。非 Chat 能力也通过 runtime provider 分派,OpenClaw 专属操作只保留在 OpenClaw adapter 内。
打开其它会话或页面时,尚未完成的 ACP 回复仍会继续流式接收。若在回复完成前返回,ClawX 会恢复最新的内存 timeline 并继续显示实时输出;回复完成后,普通 ACP 历史回放仍是唯一事实来源。
ACP assistant 回合会显示整轮耗时。Live 计时跟随客户端观测到的 prompt 生命周期,并在应用内导航后保持连续;历史耗时由 Electron Main 根据有界的 OpenClaw transcript 时间戳计算,而且只能标注 ACP 回放已经恢复出的回合。
ACP Chat 会将标准 ACP resource 渲染为附件。用户选择的图片会显示为缩略图,并在悬停蒙层中显示文件名;其它可用的附件卡片会显示文件名,以及灰色、可截断的来源路径。当前 OpenClaw ACP adapter 遗漏 assistant 媒体时,显式的 assistant `MEDIA:` 指令也可恢复为附件卡片,且不会显示原始指令。现有本地文件引用(包括当前 workspace 外的路径)在每次预览或打开前,都会由 Electron Main 按精确的 session 和 generation 重新验证。AI 生成且可预览的本地附件(包括不超过 20 MB 的 `.docx``.pptx` 文件)会保留主要的只读应用内预览操作,并提供次级菜单,可通过兼容应用打开,或在 Finder、文件资源管理器或系统文件管理器中显示。对于本地 HTML 附件,该菜单第一项会在右侧网页浏览器中打开文件 URL。Office 预览在此处也有相同限制:`.doc``.ppt` 仍通过系统应用打开,DOCX 的分页效果可能与 Microsoft Word 不同,PPTX 的动画、切换效果和媒体播放不受支持。兼容应用发现仅在 macOS 和 Windows 上可用;在 Linux 上或发现失败时,会静默降级为仅显示文件位置。其它本地文件(包括超过 20 MB 的 Office 文件)会在用户点击后通过系统应用打开;远程 HTTP 和 HTTPS 附件会在用户点击后从外部打开。普通文本中的裸路径或行内路径不会被当作附件。
ACP Chat 也可在 runtime 以可信结构化媒体投递图像生成结果时显示生成图片预览。对于可信的 OpenClaw internal-UI 投递和与生图任务关联的最终回复,ClawX 会保留原始的用户可见完成文案,包括只有文本的失败说明,而不会统一替换成通用图片文案。历史 OpenClaw 回放中,assistant 的图片 `MEDIA:` 标记只有在同一会话已记录图像生成任务启动后才会进入内联图片体验。ClawX 通过 Electron Main 的主机媒体处理加载预览,而不是让 Renderer 任意访问文件系统。标准 ACP 图片和 resource 内容仍是首选路径,并会直接渲染。
### ACP 文件活动语义
- 文件活动由成功且已完成的 OpenClaw `write``edit``apply_patch` 调用投影而来。工具识别方式与 OpenClaw 官方 Chat UI 保持一致;仅接收已完成调用的筛选规则是 ClawX 特有的。
- 已创建和已修改的活动行与可预览的 assistant 附件共用同一种文件卡片外壳和**打开方式**菜单,同时保留状态文字及可用的 `+/-` 统计。对于 HTML 文件,菜单第一项会在右侧**网页浏览器**中打开本地文件 URL 并激活该选项卡;已删除的活动行只保留 **Changes** 操作。应用列表、指定应用打开和显示文件位置都会由 Electron Main 根据 workspace 根目录与相对路径分别重新验证;工具路径不会因此变成附件,Renderer 也不会获得规范化系统路径。
- `write` 按工具声明的语义显示:视为创建,并展示为全部新增的差异,即使该路径可能已经存在。
- **Changes** 是按时间顺序记录工具声明活动的会话级记录,不是 Git 输出,也不是相对于已验证源码基线的差异。
- 对每个文件,Changes 在每轮助手回复中最多展示一个 diff 编辑器。可安全串联的片段会合并,独立片段会拼接到同一个编辑器中,但不会被描述为基于完整文件基线的差异。
- Shell 命令、脚本、用户或 IDE 产生的副作用不会被检测。
- 完整的 ACP 回放可以恢复已记录的文件活动;如果回放不完整,ClawX 不会通过回退推断来补造缺失活动。
```
┌───────────────────────────────────────────────────────────────────┐
│ ClawX 桌面应用 │
@@ -247,17 +292,17 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
│ 类型化 IPC 请求
┌─────────────────────────────────────────────────────────────────┐
│ 主进程 Host Services 与 Gateway Manager │
│ 主进程 Host Services 与 Runtime Manager │
│ │
│ • host:invoke 类型化服务分发 │
│ • 设置、文件、会话、技能、供应商、诊断服务 │
│ • 主进程持有 Gateway WebSocket 并负责进程监控
│ • Runtime 选择、传输与进程监控
└──────────────────────────────┬──────────────────────────────────┘
│ 主进程持有 WebSocket
┌─────────────────────────────────────────────────────────────────┐
│ OpenClaw 网关
│ OpenClaw 网关路径(图示)
│ │
│ • AI 智能体运行时与编排 │
│ • 消息频道管理 │
@@ -269,7 +314,7 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
- **进程隔离**:AI 运行时在独立进程中运行,确保即使在高负载计算期间 UI 也能保持响应
- **前端调用单一入口**:渲染层统一走 host-api/api-client,不感知底层协议细节
- **主进程掌控传输策略**Gateway WebSocket 只由 Electron Main 持有,渲染进程通过类型化 IPC 调用 Main
- **主进程掌控传输策略**OpenClaw ACP/Gateway 传输与 cc-connect BridgePlatform 分派都由 Electron Main 持有,渲染进程通过类型化 IPC 调用 Main
- **扩展 IPC 贡献点**:主进程扩展通过类型化 IPC 注册表贡献 host-api action,而不是挂载 HTTP route
- **优雅恢复**:内置重连、超时、退避逻辑,自动处理瞬时故障
- **安全存储**:API 密钥和敏感数据利用操作系统原生的安全存储机制
@@ -278,7 +323,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 故障。
@@ -309,7 +354,7 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
### 前置要求
- **Node.js**22+(推荐 LTS 版本
- **Node.js**对应主版本范围内的 22.22.3+、24.15.0+ 或 25.9.0+(推荐 Node 24 LTS
- **包管理器**pnpm 9+(推荐)或 npm
- **LinuxUbuntu/Debian**:运行 Electron 前,请先安装所需系统库:
```bash
@@ -345,6 +390,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
@@ -357,10 +404,39 @@ pnpm typecheck # TypeScript 类型检查
# 测试
pnpm test # 运行单元测试
pnpm run test:e2e # 运行 Electron E2E 冒烟测试
pnpm run test:e2e:cc-connect:codex-oauth-lifecycle # 无需真实凭证验证 cc-connect Codex OAuth Host API 状态/导入/登出
CLAWX_REAL_OAUTH_E2E=1 CLAWX_REAL_CODEX_AUTH_JSON="$HOME/.codex/auth.json" pnpm run test:e2e:cc-connect:real-oauth # 验证真实 OAuth 工具执行和 Chat execution graph
pnpm run test:e2e:headed # 以可见窗口运行 Electron E2E 测试
pnpm run comms:replay # 计算通信回放指标
pnpm run comms:baseline # 刷新通信基线快照
pnpm run comms:compare # 将回放指标与基线阈值对比
pnpm run verify:cc-connect:local-real # 写入本地 cc-connect 真实验证前置报告
pnpm run verify:cc-connect:local-real:run # 执行安全的本地 cc-connect 真实验证检查并写入报告
pnpm run verify:cc-connect:local-real:oauth # CLAWX_REAL_CODEX_AUTH_JSON 包含完整 refresh token 字段时额外执行开发版 cc-connect 真实 OAuth 综合冒烟
pnpm run verify:cc-connect:local-real:oauth-all # CLAWX_REAL_CODEX_AUTH_JSON 包含完整 refresh token 字段时额外执行开发版和打包版 cc-connect 真实 OAuth 冒烟
pnpm run verify:cc-connect:local-real:api-key # 执行本地 OpenAI-compatible API-key chat/abort 冒烟;有真实凭证时额外执行真实 OpenAI API-key 冒烟
pnpm run verify:cc-connect:local-real:feishu # 有凭证和 CLAWX_REAL_CODEX_AUTH_JSON 时额外执行真实飞书/Lark 生命周期冒烟
pnpm run verify:cc-connect:local-real:feishu-inbound # 沙箱租户入站 fixture 启用时额外执行真实飞书/Lark inbound marker 冒烟
pnpm run verify:cc-connect:local-real:scheduled-cron # 执行真实原生 exec cron;有 Codex auth 时通过 cc-connect public session history 验证原生 prompt 调度
pnpm run verify:cc-connect:local-real:all # 执行所有可用的本地 cc-connect 真实验证路径,并写入外部门禁交接清单
pnpm run verify:cc-connect:local-real:all-strict # 发布候选验证要求所有真实凭证和 runtime parity 覆盖都通过;失败前也会写入交接清单
pnpm run verify:cc-connect:local-real:replacement-ready # 要求 replacement readiness 通过,但不把缺失凭证单独作为前置失败;失败前也会写入交接清单
pnpm run verify:cc-connect:local-real:replacement-ready:check # 同样检查 readiness,但不覆盖上一次报告产物
pnpm run verify:cc-connect:local-real:packaged-oauth # CLAWX_REAL_CODEX_AUTH_JSON 包含完整 refresh token 字段时额外执行打包版 cc-connect 真实 OAuth 冒烟
pnpm run verify:cc-connect:local-real:external-gates:check # 非破坏性检查剩余 required external gates,不覆盖报告产物
pnpm run verify:cc-connect:local-real:external-gates # 只运行剩余 required external gates,三项全部通过才成功
pnpm run verify:cc-connect:local-real:handoff # 生成不含凭证的剩余外部门禁交接清单
# 报告写入 artifacts/cc-connect/local-real-validation-report.{json,md}
# :all、:all-strict、:replacement-ready、:external-gates 或 :handoff 会把外部门禁交接清单写入 artifacts/cc-connect/local-real-external-gates.{md,json}。
# JSON 交接清单可供机器读取,只包含清洗后的状态、环境变量名、命令和安全说明。
# runtimeMatrixStatus 会把 pass/partial/fail 覆盖状态和硬门禁退出状态分开展示。
# 使用 --no-write、replacement-ready:check 或 external-gates:check 做非破坏性门禁检查;缺失前置条件和下一步命令会以不含密钥值的形式打印。
# validationGaps 会区分本地硬门禁缺口和完整替代所需的 follow-up 证据缺口。
# partial 报告会包含 Next Actions,列出后续命令且不写入密钥值。
# 真实凭证可通过未跟踪且已 gitignore 的 .env.cc-connect.local、--env-file=<path>、
# 或 CLAWX_REAL_ENV_FILE / CLAWX_REAL_ENV_FILES 提供;显式进程环境变量优先。
# API-key 冒烟在默认模型不可用时可设置 CLAWX_REAL_OPENAI_MODEL。
# 构建与打包
pnpm run build:vite # 仅构建前端
@@ -369,13 +445,16 @@ pnpm package # 为当前平台打包(包含预装技能资源)
pnpm package:mac # 为 macOS 打包
pnpm package:win # 为 Windows 打包
pnpm package:linux # 为 Linux 打包
pnpm run verify:runtime-bundles # 校验下载的 cc-connect/Codex bundle manifest 与二进制
pnpm run verify:packaged-runtime-resources -- --resources=<路径> --platform=<darwin|win32|linux> --arch=<x64|arm64> # 校验最终 Electron runtime resources
pnpm run smoke:cc-connect:packaged # 启动当前平台 unpacked app,验证 cc-connect 启动/状态/Cron/Doctor/回滚/清理
```
在无头 Linux 环境下,Electron 测试需要显示服务;可使用 `xvfb-run -a pnpm run test:e2e`。
### 通信回归检查
当 PR 涉及通信链路(Gateway 事件、Chat 收发流程、Channel 投递、传输回退)时,建议执行:
当 PR 涉及通信链路(Gateway 事件、ACP Chat bridge 收发流程、Channel 投递、传输回退)时,建议执行:
```bash
pnpm run comms:replay
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+865
View File
@@ -0,0 +1,865 @@
# ClawX Runtime Abstraction and cc-connect Replacement Specification
Status: implementation contract
Updated: 2026-07-12
Default runtime: `openclaw`
Optional runtime: `cc-connect` behind Developer Mode
## 1. Objective
ClawX must expose one runtime layer whose OpenClaw and cc-connect providers
support the same product surfaces. OpenClaw remains the default and rollback
path. cc-connect is accepted as a replacement only when chat, sessions,
history, tools, provider credentials, Feishu/Lark, native cron, usage,
skills, diagnostics, and packaged startup are proven through the cc-connect
process rather than through ClawX-to-Codex shortcuts.
The non-negotiable execution boundary is:
```text
Renderer -> Host API -> RuntimeManager -> CcConnectRuntimeProvider
-> cc-connect Bridge/Management API -> cc-connect -> Codex
```
ClawX may supply the Codex binary path, provider environment, `CODEX_HOME`,
workspace, skills, and credentials to cc-connect. It must not spawn Codex for
chat, parse Codex files as the production real-time event transport, or invoke
Codex session commands directly.
## 2. Version and packaging decision
The original prototype pinned `cc-connect@1.3.2`. That package contains only a
CLI wrapper, install script, and documentation; its postinstall downloads a
release binary into `node_modules/cc-connect/bin`. Declaring the dependency is
therefore insufficient for Electron packaging.
The replacement implementation targets stable `cc-connect@1.4.1` because its
published runtime surface includes Bridge REST session management and a broader
Management API. The exact binary, not upstream `main`, is the release contract.
Every upgrade must run the contract probe before application code adopts a new
endpoint.
Packaging requirements:
- Pin `cc-connect` exactly in `devDependencies`.
- `scripts/bundle-cc-connect.mjs` downloads release assets for macOS x64/arm64,
Linux x64/arm64, and Windows x64.
- Verify `--version`, executable permission, SHA-256, platform, architecture,
source URL, and package version in `manifest.json`.
- Copy the verified binary to `process.resourcesPath/cc-connect/`; never run
postinstall or download a binary at application runtime.
- Bundle the pinned Codex CLI in `process.resourcesPath/codex/`; cc-connect is
the only process allowed to launch it for runtime work.
- `afterPack` must reject a target whose copied cc-connect or Codex resource is
missing, stale, corrupted, non-executable, or inconsistent with its manifest.
- Final unpacked artifacts must pass
`pnpm run verify:packaged-runtime-resources -- --resources=<resources> --platform=<platform> --arch=<arch>`.
Windows and Linux require exact packaged-binary SHA equality. macOS also
requires exact SHA before signing; when `codesign` rewrites Mach-O metadata,
the final verifier requires the source bundle SHA, all Mach-O section
payloads, architecture/version, and `codesign --verify --strict` to agree.
- macOS, Windows, and Linux packaged jobs must run a resource/startup/cleanup
smoke before release readiness can be claimed.
- `.github/workflows/release.yml` runs the final resource verifier for macOS
x64/arm64, Windows x64, and Linux x64/arm64 before uploading release
artifacts. The same release gate runs the full packaged smoke natively on
macOS arm64, Windows x64, and Linux x64, with dedicated `macos-15-intel` and
`ubuntu-24.04-arm` jobs for macOS x64 and Linux arm64. Publishing depends on
all five jobs. A local run cannot replace observed CI evidence. Runner labels
follow the [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners).
- A manual `Release` workflow dispatch is evidence-only: it disables macOS
signing discovery and never creates a GitHub Release, uploads to OSS, or runs
final promotion. Publishing remains tag-only. Use an alpha/beta version label
for manual smoke so Windows also skips SignPath. Manual macOS smoke explicitly
records that signature validation was skipped; tag builds still require strict
signature verification before publishing.
Primary upstream contracts:
- [cc-connect usage](https://github.com/chenhg5/cc-connect/blob/v1.4.1/docs/usage.md)
- [Management API](https://github.com/chenhg5/cc-connect/blob/v1.4.1/docs/management-api.md)
- [Bridge protocol](https://github.com/chenhg5/cc-connect/blob/v1.4.1/docs/bridge-protocol.md)
## 3. Durable data and locking
All ClawX-owned persistent state uses one upgrade-stable root. Stable, beta,
dev, and multiple installations may share it, but only one writer may run at a
time.
```text
~/.clawx/
state/
data-version.json
migration-journal.jsonl
locks/
writer.lock
app/
settings.json
clawx-providers.json
runtime-config.json
cc-connect-agent-bindings.json
cc-connect-session-metadata.json
credentials/
index.json
secrets.enc
oauth/<provider-account-id>/codex-home/
skills/
installed/
configs.json
workspaces/
agents/<agent-id>/
runtimes/
cc-connect/{config,data,media,events,logs}
openclaw/projection-state.json
system/electron/
logs/
backups/
cache/
```
`resolveClawXDataRoot()` and `getClawXDataLayout()` are the only path-building
entry points. Production defaults to `~/.clawx`; `CLAWX_DATA_HOME` is the
supported override. Electron `userData` becomes `~/.clawx/system/electron` and
application logs use `~/.clawx/logs`.
`writer.lock` is created atomically and contains pid, owner token, app version,
channel, executable, start time, and heartbeat time. A second installation
shows the current owner and exits before the data layout, migrations, runtime
manager, or scheduler can start. Failure to acquire or inspect the lock is
fail-closed; ClawX never falls back to an uncoordinated shared-root writer.
Stale lock recovery requires both a dead pid and an expired heartbeat;
`force: true` deletion is forbidden.
Migrations are version-gated, journaled, additive, backed up, and atomic. An
older application that cannot understand the current data version refuses to
write. Existing Electron data is imported into `~/.clawx`; existing
`~/.openclaw` remains external compatibility data and is never moved or
deleted.
`tests/e2e/clawx-data-layout-migration.spec.ts` exercises this production
startup order without the flat `CLAWX_USER_DATA_DIR` test compatibility
override. It supplies an isolated legacy `--user-data-dir` before Main startup,
uses an isolated `CLAWX_DATA_HOME`, launches Electron, and verifies `app/`,
`system/electron`, the data version, migration journal, and retained legacy
source on every CI platform without reading the developer's real userData. It
then changes the legacy settings and launches again to prove the canonical
`app/` state wins across upgrades and repeated migration attempts.
`tests/e2e/clawx-shared-root-single-writer.spec.ts` launches two real Electron
processes against the same root, proves the duplicate cannot replace the live
owner or create a window, captures first-writer UI evidence, then closes the
owner and proves a successor process acquires the released lock.
`app/runtime-config.json` is the canonical Agent, binding, channel-account, and
OpenClaw-compatible runtime metadata document. Sensitive channel fields are
removed before this file is written and are hydrated from
`credentials/secrets.enc` only in Main-process memory. `~/.openclaw/openclaw.json`
is an import/export compatibility projection, not the cc-connect state owner.
The compatibility file is imported only when canonical state does not yet
exist. Shared saves never use its mtime to overwrite canonical state. While
cc-connect is active they do not write the projection; the OpenClaw adapter
rebuilds it, including vault-backed channel secrets, immediately before
OpenClaw start or restart.
## 4. Runtime contracts
```ts
type RuntimeKind = 'openclaw' | 'cc-connect'
interface RuntimeProvider {
kind: RuntimeKind
start(): Promise<void>
stop(): Promise<void>
restart(): Promise<void>
getStatus(): RuntimeStatus
checkHealth(options?: RuntimeHealthOptions): Promise<RuntimeHealth>
rpc<T>(method: string, params?: unknown): Promise<T>
sendMessageWithMedia(payload: RuntimeSendPayload): Promise<RuntimeSendResult>
abortRun(payload: RuntimeAbortPayload): Promise<RuntimeAbortResult>
resolveApproval(payload: RuntimeApprovalResponse): Promise<void>
listSessions(query?: RuntimeSessionQuery): Promise<RuntimeSessionPage>
loadHistory(query: RuntimeHistoryQuery): Promise<RuntimeHistoryPage>
deleteSession(payload: RuntimeSessionMutation): Promise<void>
listUsage(query?: RuntimeUsageQuery): Promise<RuntimeUsagePage>
listLogs(query?: RuntimeLogQuery): Promise<RuntimeLogPage>
runDoctor(mode: 'diagnose' | 'fix'): Promise<RuntimeDoctorResult>
listCapabilities(): RuntimeCapabilities
listOperationCapabilities(): RuntimeOperationCapabilities
}
```
`RuntimeStatus` retains Gateway-compatible process states and adds
`runtimeKind`, version, config directory, capabilities, operation capabilities,
and scoped health. `gateway:*` IPC/event names remain compatibility aliases,
but their data is always supplied by the active provider.
Operation support is `native`, `proxy`, `degraded`, or `unsupported`.
`degraded` means the command remains callable but has a documented parity or
blast-radius limitation. For cc-connect v1.4.1, `chat.abort` is native: ClawX
sends the public `/stop` command over BridgePlatform for the selected session.
The whole runtime is restarted only as a disconnected-Bridge fallback when the
stop command cannot be delivered. Settings displays degraded and unsupported
operations separately from top-level capability availability.
Before a runtime status has published operation capabilities, renderer helpers
retain compatibility with legacy Gateway status. Once the operation map is
present, any undeclared method is treated as unsupported; this makes contract
drift visible instead of allowing an unreviewed runtime call to pass through.
OpenClaw-specific auth, proxy mutation, Doctor Fix, Skills implementation,
Dreams, memory repair, and Control UI remain inside the OpenClaw adapter.
Shared services must not call `GatewayManager` or write `~/.openclaw` when
cc-connect is active.
## 5. Agent, provider, model, and credential ownership
Provider Account is the stable credential identity. Agent bindings reference an
account explicitly instead of encoding identity in `provider/model` strings.
```ts
interface AgentRuntimeBinding {
agentId: string
providerAccountId: string
model: string
workspaceId: string
}
```
`agents.updateRuntimeBinding({ id, providerAccountId, model })` is the canonical
Host API. The old model-only method is a compatibility adapter and fails when
multiple accounts make the reference ambiguous.
Each cc-connect project resolves credential identity from the Agent's provider
account binding and resolves model independently from that Agent's explicit
`provider/model` override or the canonical default. Project model overrides
replace only cc-connect/Codex model arguments; they never replace or merge the
bound account's OAuth home or API-key environment.
Credential rules:
- Browser OAuth acquisition writes only the ClawX-owned provider account and
encrypted secret. Runtime projection is dispatched through the active
`RuntimeProvider`: cc-connect materializes its account-scoped managed
`CODEX_HOME`, while OpenClaw retains its existing auth/config projection. A
cc-connect OAuth success must never write OpenClaw config or schedule an
OpenClaw Gateway restart.
- A successful cc-connect browser re-login (`reason=oauth`) replaces that
account's managed Codex auth with the newly acquired vault secret. Ordinary
runtime startup keeps managed auth first so Codex refresh-token rotation is
not rolled back by an older vault snapshot.
- API keys and reusable OAuth recovery material are encrypted with Electron
`safeStorage` in `credentials/secrets.enc`.
- Channel account secrets share the encrypted vault under account-scoped IDs;
`credentials/index.json` contains IDs only, never secret values.
- Every OpenAI OAuth account owns a complete account-level `CODEX_HOME` under
`credentials/oauth/<account-id>/codex-home`; auth files are mode `0600`.
- OAuth homes are not symlinked or copied between accounts. Agents may share an
account by binding to the same account-level home.
- A pre-account shared managed Codex home is moved once to the selected default
OAuth account and then removed; it is never copied to a second account.
- Runtime profile construction never consumes user-global `~/.codex/auth.json`.
That file is inspected only for redacted status and copied only after the user
explicitly invokes `importCodexOAuth` for a matching account.
- API-key projects receive account-specific environment variables. Secrets are
never written literally to generated TOML or exposed to Renderer.
- Provider/model/account changes detach the old runtime session and create a
new cc-connect/Codex session on the next turn while preserving visible ClawX
history.
- Missing or incomplete credentials block only bound Agents. Access-token
expiry does not invalidate a complete managed OAuth home because
cc-connect/Codex owns refresh-token rotation there; a failed refresh is
surfaced on that Agent's runtime turn and can be recovered with browser
re-login, without changing another Agent's credentials.
- Validation may import a complete token set with an expired access or ID token
into an isolated managed `CODEX_HOME`. The verifier records only sanitized JWT
expiry metadata; only a successful real cc-connect -> Codex turn proves that
refresh-token rotation worked. Passing the static precondition alone is not
refresh evidence.
- Proxy variables are supplied to cc-connect and inherited by its children;
localhost, `127.0.0.1`, and `::1` are always added to `NO_PROXY`.
Initial verified matrix: OpenAI API key, OpenAI Codex OAuth, OpenAI-compatible
Responses, and Ollama. Unsupported providers return a stable capability error
without mutating OpenClaw config.
`providers.profile` and `models.profile` are read-only runtime operations. While
cc-connect is running they return the ClawX-managed public profile together
with each managed project's public Management API `/providers` and `/models`
state. They never reuse the sync path and therefore never restart cc-connect.
The adapter maps only provider name, active state, model, base URL, model list,
and current model; unknown Management fields and secret-like fields never cross
the Host API.
Provider/model writes remain ClawX-owned: ClawX updates the account-scoped
Codex profile and cc-connect project config, then reloads or restarts through
the runtime provider.
## 6. Workspace, skills, and plugins
New Agents use `~/.clawx/workspaces/agents/<agent-id>`. If an existing OpenClaw
Agent has a valid configured workspace, ClawX records that path as
`external-openclaw` and reuses it without copying or moving data.
Each cc-connect project receives exactly that Agent workspace as `work_dir`.
No code path may default to `process.cwd()`, the ClawX source checkout, or app
resources. Agent deletion removes only `clawx-managed` workspaces.
When a new Agent requests workspace inheritance, ClawX may read bootstrap files
from the existing OpenClaw main workspace, but writes the new Agent under the
ClawX-managed root. It never changes or assumes ownership of the source path.
ClawX owns one Skill Registry. OpenClaw receives its normal skills projection;
cc-connect receives the same enabled skills through its project/Codex skills
surface. The acceptance test must invoke a real installed skill through chat,
not only compare copied files.
Plugin reuse means shared ClawX capability, account, binding, and UI metadata.
OpenClaw JS plugins remain OpenClaw-specific. cc-connect channels are generated
as native `projects.platforms` entries and do not load OpenClaw plugins.
## 7. Chat, events, tools, approvals, and cancellation
GUI Chat registers as a cc-connect Bridge adapter. cc-connect invokes Codex and
emits all run activity over Bridge. The normalized envelope is:
OpenClaw and cc-connect intentionally use different provider-owned Chat
transports behind the same ClawX route. OpenClaw uses the Main-owned ACP
session transport introduced by the OpenClaw runtime. cc-connect renders the
Runtime Chat implementation and sends through `RuntimeManager` -> active
`RuntimeProvider` -> BridgePlatform. Renderer routing follows the active
runtime status, not only the pending Settings selection. As defense in depth,
Main rejects ACP load, prompt, cancel, and permission requests whenever
cc-connect is active; typed media sends remain dispatched through the active
runtime provider.
The adapter follows the pinned cc-connect Web Admin client lifecycle: after
`register_ack` it sends a JSON `ping` every 25 seconds, reconnects after 3
seconds when the socket drops, and stops both timers during an intentional
runtime stop. This is required for scheduler and long-running Agent replies
that cross cc-connect's approximately 90-second idle disconnect window.
```ts
interface RuntimeEventEnvelope {
schemaVersion: 1
eventId: string
runtimeKind: RuntimeKind
project: string
sessionKey: string
runtimeSessionId: string
runId: string
turnId: string
seq: number
timestamp: string
type: RuntimeEventType
payload: unknown
}
```
Required event types are `run.started`, `assistant.delta`,
`reasoning.summary.delta`, `tool.started`, `tool.updated`, `tool.completed`,
`command.output`, `patch.completed`, `approval.requested`,
`approval.resolved`, `usage.recorded`, and `run.ended`.
Pinned cc-connect v1.4.1 has two materially different Codex backends. Its
default `exec` backend does not map Codex 0.137 `custom_tool_call` records such
as `apply_patch` to `EventToolUse`; a real OAuth probe created the requested
file while cc-connect reported `tools=0`. ClawX therefore configures every
managed Codex project with `backend = "app_server"` and
`app_server_url = "stdio://"`. cc-connect remains the process owner and starts
the bundled Codex app-server inside the Agent workspace.
The Bridge adapter registers `progress_style = "card"` and
`supports_progress_card_payload = true`. cc-connect then sends the public
`__cc_connect_progress_card_v1__:` payload through `preview_start` and
`update_message`; ClawX maps typed `thinking`, `tool_use`, `tool_result`, and
`error` entries to the shared runtime graph. cc-connect v1.4.1 emits a
`fileChange` start but no corresponding result, so a successful or failed final
Bridge reply closes any still-open tool with
`meta.inferredFromRunCompletion = true`. Explicit tool results always win and
are never replaced by the inferred terminal event.
Plain-text previews use the same normalized `assistant.delta` contract. ClawX
emits the initial `preview_start` immediately, applies each `update_message` as
an in-place replacement, and clears only that transient assistant text when
cc-connect sends `delete_message`. Structured progress is intentionally kept as
semantic thinking/tool lifecycle in the execution graph; deleting cc-connect's
temporary platform message must not erase the completed tool relationship.
The opt-in real OAuth E2E proves the full path: GUI send -> RuntimeManager ->
cc-connect Bridge -> cc-connect-owned Codex app-server -> Patch -> progress
payload -> Main runtime event -> Renderer execution graph. It asserts
`transport=stdio`, cc-connect `tools=1`, the managed workspace file, both tool
lifecycle events, real approval request/resolution, and the visible graph. It
writes sanitized evidence under
`artifacts/cc-connect/real-oauth-tool-events.{png,json}` plus
`artifacts/cc-connect/real-oauth-approval-request.png`. These screenshots keep
the tool type, approval controls, lifecycle state, generated filename, and
assistant result visible while masking the isolated managed workspace path.
Reading Codex JSONL as a real-time event source, wrapping Codex stdout, or
spawning a second Codex bridge remains forbidden. Section 8 documents the sole
bounded historical exception for Channel tool packets omitted by public
cc-connect history.
The local-real verifier performs runtime checks with real filesystem paths but
replaces repository, home, and temporary roots with `<repo>`, `<home>`, and
`<tmp>` before persisting JSON or Markdown. A passing evidence row must not
publish a developer's worktree, credential-home, or isolated runtime path.
Only Codex-provided reasoning summaries are shown. Hidden chain-of-thought is
never requested or inferred. `eventId` deduplicates; `runId + seq` orders and
detects gaps. Bridge reconnect must replay missing events through
cc-connect-owned history once the upstream protocol exposes them. ClawX must
not scan Codex transcript files to reconstruct real-time tool activity.
The app-server backend surfaces approval requests as Bridge `buttons`. ClawX
stores the run-correlated `session_key`, `reply_ctx`, project, and only the
actions offered by cc-connect. `chat.approval.respond` validates the requested
action against that pending set and sends cc-connect's public `card_action`
packet; Renderer never talks to Codex and cannot inject an arbitrary action.
Deterministic Electron E2E proves request rendering, GUI click, Host API/runtime
RPC dispatch, the exact Bridge packet, and resumed assistant delivery. The
opt-in real OAuth E2E additionally runs the Main Agent in `suggest` mode and
proves the same flow through bundled cc-connect 1.4.1 and bundled Codex: a real
Patch approval is rendered, allowed, resolved by cc-connect, and followed by a
workspace write and final assistant response.
The same validated path handles non-approval runtime choices from cc-connect
cards. Action rows, list buttons, and select options are parsed from the public
card schema; only `perm:`, `askq:`, `cmd:`, `nav:`, and `act:` values are
eligible. Select options complete the current Chat run when cc-connect returns
the updated state card, while navigation/button cards can continue the same
interaction until cc-connect emits a reply or the user aborts. The real bundled
`/lang -> card -> card_action -> card` E2E verifies the live language through
the public Management project API and preserves the runtime PID. Pinned v1.4.1
does not persist manual `/lang` selections to `config.toml`: its save callback
is registered only for automatic language detection, so ClawX does not infer a
durable write that the runtime did not perform.
Permission mode is Agent-owned runtime metadata in
`~/.clawx/app/agent-bindings.json`, alongside but independent from the Agent's
provider-account binding. `full-auto` remains the default; `suggest` selects
cc-connect app-server's `on-request` approval policy and read-only sandbox.
Saving the mode refreshes the managed project config without writing OpenClaw
configuration. Only these two safe product modes are exposed; ClawX does not
offer cc-connect's sandbox-bypassing mode.
Pinned cc-connect v1.4.1 has no dedicated incoming Bridge cancellation packet
or per-run cancellation Management endpoint, but its public `/stop` command is
session-scoped. `chat.abort` immediately ends the correlated ClawX run, sends
`/stop` through BridgePlatform for that session, and suppresses replies correlated
to the aborted run. Codex app-server does not implement cc-connect's graceful
`CancelTurn` interface, so cc-connect closes only that session's Codex child
while preserving its stored AgentSessionID for resume; the cc-connect process
and other Agent sessions remain running. If Bridge is disconnected and `/stop`
cannot be delivered, ClawX restarts the owned runtime as an explicit fallback.
The real local OpenAI-compatible E2E proves upstream stream closure, no late
assistant rendering, and an unchanged cc-connect PID.
## 8. Sessions and history
Session inventory, ordinary user/assistant history, and deletion use
cc-connect's public Management/Bridge session endpoints. ClawX does not read or
mutate cc-connect session JSON files. User-assigned titles are ClawX UI metadata
stored atomically in `app/cc-connect-session-metadata.json`; deleting a public
session deletes its title in the same Host API operation. On first use, labels
from the old ClawX-owned `.clawx-supplemental-history.json` are imported without
copying its history payload.
The production Bridge adapter contains no parser for cc-connect session JSON or
Codex transcripts. It retains only messages observed on the current public
Bridge connection for immediate event delivery; durable list/delete and
authoritative ordinary messages always come from the provider's public
Management session client.
Pinned cc-connect can omit historical tool packets from public history for
Channel-originated sessions even though Codex recorded and executed those
tools. After public history has loaded, the provider may apply one degraded,
best-effort compatibility supplement that contributes only tool calls and
their results. It cannot create or replace user, assistant, system, attachment,
approval, real-time event, or usage records.
Candidate Codex JSONL files must match the owning Agent workspace. A stale or
exact `agent_session_id` does not bypass that check. Fallback discovery is
bounded to recent public user-turn text and the timestamp of that exact user
record, nearby transcript date directories, bounded path/file caches, and
truncated tool output. Missing, stale, cross-workspace, or ambiguous evidence
leaves public history unchanged. This exception does not satisfy replacement
readiness and must be removed when the pinned cc-connect runtime exposes
durable public Channel tool history.
ClawX owns logical session identity and display metadata; cc-connect owns
runtime sessions and message history. Public session responses carry the
logical/runtime binding, while `cc-connect-session-metadata.json` stores only
optional display labels and never copies runtime credentials or message
history.
cc-connect Session REST/Management APIs are the only production source for
list, create, ordinary message history, switch, and delete. The bounded
Channel tool supplement above is the only historical content exception. Rename
uses an official endpoint if the pinned binary exposes it; otherwise ClawX
stores only the display label in its logical index and does not rewrite
cc-connect private JSON. Hard delete is reported successful only after the
runtime API confirms deletion.
Runtime or provider switching preserves visible historical turns and detaches
the old backend binding. The first subsequent message creates a new runtime
session and includes a clearly identified continuation context once. OpenClaw
internal session ids are never passed to cc-connect.
Required cases include active, named, cross-Agent, Channel, Cron, restart,
rename, hard delete, and pagination. Session ids must not collide across
projects or provider accounts.
## 9. Token usage
Usage is a runtime contract, not a dashboard file scan.
```ts
interface RuntimeUsageRecord {
id: string
runtimeKind: RuntimeKind
logicalSessionId: string
runtimeSessionId: string
turnId: string
agentId: string
providerAccountId?: string
provider: string
model: string
timestamp: string
status: 'available' | 'missing' | 'error'
inputTokens: number
cachedInputTokens: number
outputTokens: number
reasoningTokens: number
totalTokens: number
costUsd?: number
}
```
Pinned cc-connect v1.4.1 does not currently expose per-turn token usage through
its documented Bridge or Management API, and an actual binary probe confirms
that enabling `reply_footer` does not add machine-readable usage to Bridge
replies. Therefore this acceptance row is **upstream-blocked**, not complete.
Production ClawX derives turn identity only from cc-connect public session
history. When that history has no usage payload, each assistant turn is
returned with `status: 'missing'` and zero counters so callers can distinguish
"the turn exists but usage is unavailable" from "there is no history". ClawX
does not fill those counters from private cc-connect stores or Codex JSONL.
Test code may use a managed transcript or provider response as an oracle, but
that evidence cannot close the exact-usage runtime-contract row.
`RuntimeProvider.listUsage` is the only Host API usage source. The OpenClaw
adapter owns its existing structured transcript scan; the cc-connect adapter
owns public Management session/history reads and emits one normalized record
per assistant turn. `usage-api` does not call `listSessions`/`loadHistory`
itself and does not know either runtime's storage layout. Runtime records carry
logical and runtime session ids, a stable turn identity, Agent/provider/model
attribution, status, counters, and optional cost/content compatibility fields.
The upstream audit was refreshed on 2026-07-26. npm still marks `1.4.1` as
`latest`; `1.5.0-beta.2` is the newest prerelease. The beta.2 release contains
only a Codex model-visibility fix on top of beta.1 and does not publish a usage
API. The stable and prerelease source trees parse Codex
`thread/tokenUsage/updated` into an internal `ContextUsageReporter`, but the
documented Management and Bridge session detail responses still expose only
message role/content/timestamp. When context display is enabled, the runtime
renders a lossy `[ctx: ~N%]` footer to the platform instead of a structured
per-turn payload. ClawX must not parse that display string or reach into
cc-connect's internal agent/session state. This is why upgrading to the beta or
enabling `reply_footer` does not close the contract.
Upstream PR [cc-connect#1428](https://github.com/chenhg5/cc-connect/pull/1428)
proposes an opt-in Bridge `usage` observer. It is useful directionally, but its
current head is conflicting and is not included in stable v1.4.1 or prerelease
v1.5.0-beta.2.
Its unversioned event contains `session_key`, `turn_id`, input/output/cache
counts and user metadata, but omits `project`, provider/model identity,
reasoning tokens, durable history semantics and replay after reconnect. Those
omissions prevent reliable multi-Agent attribution and historical dashboard
reconstruction, so ClawX must not implement production parity against that
unmerged schema. A future release may use the observer design provided the
published contract addresses these fields or exposes an equivalent durable
Management history field.
Completion requires a pinned cc-connect release to expose a versioned usage
event or history field containing project, session/turn, provider/model, and
token counts, plus documented reconnect/replay behavior or durable history.
ClawX must then map that public payload to `RuntimeUsageRecord`, add a real
API-key/OAuth oracle comparison, and remove the checked-in E2E `fixme`.
`cachedInputTokens` is a subset of input and `reasoningTokens` is a subset of
output. If total is absent, calculate `input + output`; never add cache again.
Cost is shown only when runtime/provider returns an explicit historical value.
Dashboard defaults to the active runtime and offers OpenClaw, cc-connect, and
combined filters.
The shared parser enforces this total rule for both adapters. Public payloads
may expose cache-read/cache-write and reasoning counters independently for
display, but inferred `totalTokens` remains `inputTokens + outputTokens` so
cache and reasoning subsets are never counted twice.
## 10. Channels and Feishu/Lark
Channel account metadata lives under `~/.clawx/app`; app secrets live in the
encrypted credential vault. Generated cc-connect TOML references environment
variables. Connect, disconnect, and delete mean config projection plus
Management API reload/status when the pinned binary lacks per-platform
lifecycle endpoints.
Feishu/Lark replacement evidence requires:
```text
tenant message -> cc-connect platform -> bound project/Agent/workspace
-> Codex -> cc-connect -> tenant reply
```
Both China Feishu and global Lark domain mappings are tested. Status is read
from project platform detail, not inferred from process state. Channel-created
sessions must appear in ClawX history and usage under the bound Agent.
Channel mutations require account-scoped authorization. Runtime hooks may be
used as an evidence collector, not as a second message processor.
Current live-credential evidence proves the Feishu platform reaches
`connected`/`running` through cc-connect, survives Host API disconnect/connect
reload, preserves both the ClawX desktop administrator and configured Channel
administrators, removes the account from managed config on delete, and cleans
up the runtime process. The same real test proves an existing OpenClaw channel
file is a read-only import source: non-secret account metadata is owned by the
canonical runtime config, the app secret is absent from that document and from
plaintext vault bytes, and neither import nor cc-connect-mode delete changes
the compatibility file. Sanitized machine evidence is written to
`artifacts/cc-connect/real-feishu-lifecycle.json`. A tenant-originated inbound
marker and its reply remain a separate manual gate; lifecycle success alone
does not claim message-delivery parity.
## 11. Cron
For the first replacement milestone, cc-connect native cron-expression jobs
are the only supported schedule kind. `at`, `every`, and manual run remain
explicitly unsupported unless the pinned stable binary exposes equivalent
native operations. ClawX must not maintain a second prompt scheduler.
GUI and Channel `/cron` operate the same cc-connect scheduler and store:
- Channel create/update/enable/disable/delete is visible in GUI.
- GUI mutations are visible through Channel `/cron`.
- Scheduled prompt execution returns to the configured Channel through
cc-connect.
- `admin_from` contains ClawX admins and explicit `cron-manager` role members;
other allow-listed users cannot mutate jobs.
- Jobs carry project, session key, workspace, schedule, enabled state, and
runtime ownership.
For prompt/exec jobs without external delivery, ClawX uses the managed local
LINE placeholder session key because cc-connect Cron resolves the first session
key segment as a configured platform. Agent/account/workspace ownership still
comes from the job's project. `clawx:<agent>:<session>` remains a Bridge session
key and must not be passed to the native scheduler. Announce jobs use the real
target platform and recipient key.
Capability metadata exposes `scheduleKinds: ['cron']`, Channel commands, and
the actual support state of manual execution. Unsupported operations are
non-mutating.
cc-connect manual execution is asynchronous: `POST /api/v1/cron/{id}/exec`
acknowledges that a run was triggered, but does not mean the run completed.
ClawX observes completion through the runtime-owned Cron list and maps the
official `last_run` and `last_error` fields to `CronJob.lastRun`; Go's zero
timestamp means the job has never run and is not exposed as a completed run.
Validation must wait for a successful `lastRun` before using public
session/history as delivery evidence.
The Cron UI keeps trigger acknowledgement non-blocking. After the immediate
list refresh, its store observes an unchanged run in the background with a
bounded exponential-backoff refresh until `lastRun` changes, the runtime
auto-removes the job, the user deletes it, the selected runtime changes, or the
job timeout elapses. A repeated trigger supersedes the prior observation. This
polling only observes the runtime-owned scheduler; it never executes the job in
ClawX.
Current real-runtime evidence covers both native scheduler paths with the
bundled cc-connect binary. An enabled exec job fired on an actual minute tick
and wrote its marker from the configured `work_dir`. A Codex OAuth prompt job
also fired on an actual minute tick, entered cc-connect through the managed
project, and exposed its prompt and assistant reply through the public
session-summary/history APIs. The evidence command is
`pnpm run verify:cc-connect:local-real:scheduled-cron`; it does not claim live
tenant-channel delivery, which remains a separate Feishu/Lark credential gate.
Both jobs preserve the cc-connect PID, remain visible through Host API and the
Cron page until cleanup, require delete success plus a second Host API list that
proves the job is absent, and write sanitized machine/visual evidence to
`artifacts/cc-connect/real-scheduled-{exec,prompt}-cron.{json,png}`. The prompt
artifact records only public session keys and success flags; it never records
OAuth material, Management tokens, or temporary absolute paths.
The bundled-runtime E2E also registers a simulated Feishu transport through the
public Bridge protocol and proves Channel `/cron add`, list, disable, enable,
and delete as the projected managed admin are reflected by Host API Cron
operations. A GUI-created announce
job targeting the same Feishu session is visible from Channel `/cron`, and the
runtime PID remains unchanged. Sanitized evidence is written to
`artifacts/cc-connect/real-channel-cron-bridge.json`. This verifies cc-connect
core/platform command routing and one shared native scheduler; it does not
replace live Feishu tenant inbound or scheduled-reply evidence.
The probe advertises Bridge `card` and `buttons` capabilities. Pinned
cc-connect v1.4.1 returns `/cron add` as a usable text acknowledgement and the
`/cron` list as a real card; the test invokes its disable, enable, and delete
callbacks through `card_action` and verifies each mutation through Host API.
Non-approval standalone-button and upstream-triggered delete-message evidence
remain separate from this card/action proof. Preview/update now have an
independent local-real proof: the bundled cc-connect v1.4.1 engine runs against
a deterministic Codex app-server protocol boundary, emits public
`preview_start`/`update_message`, and drives the GUI execution graph plus final
assistant reply. Sanitized evidence is written to
`artifacts/cc-connect/real-rich-progress-bridge.{json,png}`. This proves the
runtime/Bridge/UI integration without claiming a real OpenAI credential; real
OAuth remains a separate gate. Real media is covered independently: the bundled
`cc-connect send` CLI targets an active managed session and emits public Bridge
image/file/audio/video packets. The adapter copies decoded bytes under
`runtimes/cc-connect/media/outgoing/bridge`, session history merges these
runtime-owned attachments with Management API history, renderer final-event
deduplication uses each message id, and Chat keeps `gateway-media` cards visible
even when surrounding process narration is folded into the execution graph.
The real local OpenAI-compatible E2E verifies exact bytes, image preview, all
four GUI cards, and writes sanitized evidence to
`artifacts/cc-connect/real-cli-media-bridge.{json,png}`.
## 12. Health, Doctor, and logs
Runtime ready requires a live process, Management API, Bridge registration,
loaded projects, executable Agent binary, valid required workspace, and scoped
credential checks. A single expired Agent account degrades that Agent rather
than the whole runtime.
`checkHealth({ probe: true })` verifies the child is still alive, the Bridge
WebSocket is currently registered, and every projected project is readable
through Management API. Infrastructure probe failures return `ok: false` with
the failed component; account support/auth diagnostics stay project-scoped so
one invalid account does not mark unrelated Agents unhealthy.
Message preflight resolves the target Agent from the logical session key and
checks that project's provider profile. An invalid default account therefore
does not block an Agent with a valid explicit binding, and an invalid explicit
binding blocks only that Agent before any Bridge message is sent.
Agent create, rename, model/account binding, Channel binding, and delete
operations notify the active runtime. In cc-connect mode they rebuild or
restart cc-connect projects without invoking OpenClaw auth/model projection;
OpenClaw keeps its existing projection and reload behavior.
Skills are sourced from the shared ClawX/OpenClaw-compatible skill registry and
mirrored into every distinct Codex home used by current cc-connect projects.
Runtime start, skill enable/disable, and ClawHub install/uninstall all refresh
every project home, so account isolation does not split skill availability.
Startup order is data lock/version, managed config, skills, binary validation,
process, Management API, Bridge, projects, health, ready. Intentional stop
drains or cancels runs before terminating the process tree. Unexpected crashes
use bounded backoff and eventually enter error state.
Bridge registration is part of startup, not a background best effort. If the
process starts but Bridge registration fails, the provider closes registered
and in-flight WebSockets, terminates the managed process tree, reports `error`,
and leaves no child running. Stop/restart closes sockets that are still waiting
for `register_ack` and suppresses any reconnect scheduled by that close.
Main captures cc-connect stdout/stderr, redacts scoped provider/channel secrets
and common bearer/API-key forms before emission, keeps a bounded in-memory tail,
and writes mode-0600 `runtimes/cc-connect/logs/runtime.log` with size rotation.
Runtime diagnostics combine that stream, matching ClawX manager lines, and a
redacted managed config. Renderer never reads the process pipe or log path
directly.
cc-connect Doctor runs native `doctor user-isolation` against managed config
with an explicit managed `--out` path, then runs bundled `codex doctor --json`
inside the main project's managed `CODEX_HOME`. The adapter writes a
mode-0600 composite JSON audit under `runtimes/cc-connect/audits`; it never uses
the native default `~/.cc-connect/audits`. A Codex project without
`run_as_user` legitimately produces no native user-isolation file, which is
recorded as `auditGenerated: false` rather than treated as missing evidence.
The Codex Doctor subprocess is a provider-owned diagnostic exception only: it
accepts no prompt, creates no chat/session/tool run, and cannot replace or
bypass BridgePlatform delivery.
`doctor.fix` is unsupported in cc-connect mode and is hidden/disabled.
Runtime-neutral Settings strings must not report an OpenClaw Doctor result for
cc-connect.
cc-connect stdout, stderr, structured events, and doctor audits are captured
under `~/.clawx/logs/runtimes/cc-connect` with rotation and pre-write secret
redaction. Diagnostics use the active provider and must not include OpenClaw
gateway logs as cc-connect runtime logs.
## 13. Migration and rollback
Migration steps:
1. Create and lock `~/.clawx` layout.
2. Import ClawX application settings and provider accounts from legacy
Electron userData.
3. Register existing OpenClaw workspaces as external paths.
4. Encrypt provider secrets and create account-level OAuth homes.
5. Move ClawX-owned cc-connect data from legacy userData into the new runtime
directory.
6. Build logical session projection without modifying runtime stores.
7. Start the selected runtime only after migration commits.
Rollback means selecting OpenClaw, stopping cc-connect, and preserving its
managed data. Rollback never deletes credentials, sessions, workspace, or
cc-connect config. A migration failure restores the backup and leaves the prior
data version writable by the prior application.
## 14. Delivery phases and evidence gates
| Phase | Goal and implementation | Required verification | Impact |
| --- | --- | --- | --- |
| A. Contract and dependency | Pin/probe stable cc-connect; add runtime contracts and API client | Binary contract test, bundle manifest, type/unit tests | Shared types; no behavior switch |
| B. Data root and credentials | Add layout, fail-closed pre-write lock, migrations, encrypted vault, OAuth homes | vN to vN+1 and rollback packaged run; real two-Electron ownership/handover; secret scan | All persistent paths and runtime/scheduler startup |
| C. Workspace and skills | Registry, OpenClaw reuse, project `work_dir`, shared skill projection | Two-Agent isolation; real skill invocation; source-checkout negative test | Agent create/delete and files |
| D. Bridge chat/events | Official Bridge send, tools, approvals, cancellation, replay | Real API-key and OAuth tool-heavy chats; disconnect/replay; screenshots | Core communication path |
| E. Sessions and usage | Official APIs, logical binding, per-turn usage | Named/cross-Agent/Channel/restart/delete; token oracle comparison | Sidebar, history, Models |
| F. Channels and cron | Feishu/Lark full path; one native scheduler for GUI and Channel | Tenant inbound/reply; Channel/GUI Cron bidirectional CRUD and scheduled reply | Channel and Cron surfaces |
| G. Health and diagnostics | Scoped health, native doctor, real logs | Crash, port conflict, expired auth, doctor audit, log redaction | Settings and diagnostics |
| H. Packaging and release | Offline resources and platform smoke | Source bundle integrity; `afterPack` target verification; final macOS x64/arm64, Windows x64, Linux x64/arm64 resource checks; native Electron/Host API/runtime startup, Cron/Doctor, rollback, PID/port/process cleanup | Build/release only |
Every phase must produce code-level route evidence and actual runtime evidence
under `artifacts/cc-connect/<run-id>/`:
- `api/`: sanitized requests and responses.
- `logs/`: ClawX, cc-connect, Bridge, doctor, and scheduler excerpts.
- `screenshots/`: ClawX and Channel UI evidence.
- `fs/`: sanitized manifests, workspace trees, and migration checks.
- `report.json`: acceptance row, command, status, evidence paths, and gaps.
Mock-only evidence cannot close a real-runtime row. Opt-in credentials may stay
outside normal CI, but replacement readiness remains partial until the latest
report contains PASS evidence for real OAuth, external OpenAI API key, Feishu
inbound/reply, native Channel Cron, and packaged target platforms.
Deterministic Electron evidence covers same-account browser re-login projection
and protects Codex-refreshed managed auth from stale-vault rollback. A live
expired-token refresh failure followed by browser re-login still requires an
explicit real OAuth fixture and remains an external validation row.
## 15. Acceptance and explicit non-parity
cc-connect replacement is complete only when:
- No cc-connect Chat, session, tool, approval, cancellation, or usage path
launches or talks to Codex outside cc-connect.
- No shared cc-connect service writes OpenClaw config or cc-connect private
session files.
- GUI Chat, Feishu/Lark, and native Cron all execute through cc-connect and the
bound Agent/account/workspace.
- OpenAI OAuth and API-key modes pass real end-to-end tests with account
isolation.
- Session/history/title/delete and token usage match the shared runtime
contract across Agent and Channel cases.
- Skills are actually invoked, health/Doctor/logs are runtime-aware, and
packaged applications run offline.
- Required logs and screenshots exist and sensitive-data scans pass.
Accepted non-parity for the first milestone:
- cc-connect remains behind Developer Mode.
- cc-connect Doctor Fix does not replace OpenClaw Doctor Fix.
- Only native cron expressions are supported; `at` and `every` are not
emulated.
- Real credential and all-platform release checks remain opt-in until a
separate CI policy decision.
+12
View File
@@ -68,6 +68,10 @@ mac:
to: bin
- from: resources/cli/posix/
to: cli/
- from: build/cc-connect/darwin-${arch}/
to: cc-connect/
- from: build/codex/darwin-${arch}/
to: codex/
category: public.app-category.productivity
icon: resources/icons/icon.icns
target:
@@ -119,6 +123,10 @@ win:
to: bin
- from: resources/cli/win32/
to: cli/
- from: build/cc-connect/win32-${arch}/
to: cc-connect/
- from: build/codex/win32-${arch}/
to: codex/
icon: resources/icons/icon.ico
target:
- target: nsis
@@ -147,6 +155,10 @@ linux:
to: bin
- from: resources/cli/posix/
to: cli/
- from: build/cc-connect/linux-${arch}/
to: cc-connect/
- from: build/codex/linux-${arch}/
to: codex/
icon: resources/icons
target:
- target: AppImage
+14 -2
View File
@@ -1,4 +1,5 @@
import { createDiagnosticsApi } from '../../services/diagnostics-api';
import type { HostApiContribution, RuntimeHostAction } from '../../main/ipc/host-contract';
import type {
Extension,
ExtensionContext,
@@ -12,10 +13,21 @@ class DiagnosticsExtension implements HostApiProviderExtension {
// Diagnostics are exposed through host IPC contributions.
}
getHostApiContributions(ctx: ExtensionContext) {
getHostApiContributions(ctx: ExtensionContext): HostApiContribution[] {
const diagnostics = createDiagnosticsApi({
gatewayManager: ctx.gatewayManager,
runtimeManager: ctx.runtimeManager,
});
const actions: Record<string, RuntimeHostAction> = {
gatewaySnapshot: () => diagnostics.gatewaySnapshot(),
acpTrace: () => diagnostics.acpTrace(),
recordAcpTrace: (payload) => diagnostics.recordAcpTrace(
payload as Parameters<typeof diagnostics.recordAcpTrace>[0],
),
};
return [{
module: 'diagnostics',
actions: createDiagnosticsApi({ gatewayManager: ctx.gatewayManager }),
actions,
}];
}
}
+2
View File
@@ -1,5 +1,6 @@
import type { BrowserWindow } from 'electron';
import type { GatewayManager } from '../gateway/manager';
import type { RuntimeManager } from '../runtime/manager';
import type { HostApiContribution, HostApiContributionRegistrar } from '../main/ipc/host-contract';
import type {
MarketplaceSearchParams,
@@ -12,6 +13,7 @@ import type {
export interface ExtensionContext {
gatewayManager: GatewayManager;
runtimeManager: RuntimeManager;
getMainWindow: () => BrowserWindow | null;
hostApi: HostApiContributionRegistrar;
}
+10
View File
@@ -203,6 +203,16 @@ export function normalizeGatewayChatRuntimeEvent(payload: unknown): ChatRuntimeE
phase: readString(data.phase),
status: readString(data.status),
message: readString(data.message),
actions: Array.isArray(data.actions)
? data.actions.flatMap((action) => {
if (!action || typeof action !== 'object') return [];
const record = action as Record<string, unknown>;
const value = readString(record.action);
if (!value) return [];
const label = readString(record.label);
return [{ action: value, ...(label ? { label } : {}) }];
})
: undefined,
}
: null;
}
+95 -25
View File
@@ -33,8 +33,9 @@ import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
import { logger } from '../utils/logger';
import { prependPathEntry } from '../utils/env-path';
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe, buildCandidateSources } from '../utils/plugin-install';
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe, buildCandidateSources, repairTrustedOfficialPluginInstallRecords, removeTrustedOfficialPluginInstallRecord, syncTrustedOfficialPluginInstallRecord, resolvePluginNpmPackagePath } from '../utils/plugin-install';
import { CLAWX_OPENAI_IMAGE_PROVIDER_KEY } from '../utils/openclaw-image-relay-constants';
import { ensureOpenClaw2026_7_1UpgradeSnapshot } from '../utils/openclaw-upgrade-snapshot';
import { stripSystemdSupervisorEnv } from './config-sync-env';
import { cleanupAgentsSymlinkedSkills, cleanupStalePluginRuntimeDeps } from './skills-symlink-cleanup';
import {
@@ -81,15 +82,40 @@ const CHANNEL_PLUGIN_MAP: Record<string, { dirName: string; npmName: string }> =
};
/**
* OpenClaw 3.22+ ships Discord, Telegram, and other channels as built-in
* extensions. If a previous ClawX version copied one of these into
* ~/.openclaw/extensions/, the broken copy overrides the working built-in
* plugin and must be removed.
* OpenClaw ships some channel plugins as bundled extensions under
* dist/extensions/. If ClawX previously mirrored one of those ids into
* ~/.openclaw/extensions/, the stale copy overrides the bundled plugin.
* Only remove extension copies whose id is actually bundled in the
* currently resolved OpenClaw runtime (e.g. telegram in 2026.6.10).
*/
const BUILTIN_CHANNEL_EXTENSIONS = ['discord', 'telegram', 'qqbot'];
function listBundledOpenClawExtensionPluginIds(): string[] {
const extensionsDir = join(getOpenClawResolvedDir(), 'dist', 'extensions');
if (!existsSync(fsPath(extensionsDir))) {
return [];
}
const pluginIds: string[] = [];
for (const entry of readdirSync(fsPath(extensionsDir), { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const manifestPath = join(extensionsDir, entry.name, 'openclaw.plugin.json');
if (!existsSync(fsPath(manifestPath))) continue;
try {
const parsed = JSON.parse(readFileSync(fsPath(manifestPath), 'utf-8')) as { id?: unknown };
if (typeof parsed.id === 'string' && parsed.id.trim()) {
pluginIds.push(parsed.id.trim());
}
} catch {
// ignore malformed manifests
}
}
return pluginIds;
}
function cleanupStaleBuiltInExtensions(): void {
for (const ext of BUILTIN_CHANNEL_EXTENSIONS) {
for (const ext of listBundledOpenClawExtensionPluginIds()) {
const extDir = join(homedir(), '.openclaw', 'extensions', ext);
if (existsSync(fsPath(extDir))) {
logger.info(`[plugin] Removing stale built-in extension copy: ${ext}`);
@@ -169,6 +195,7 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
rmSync(fsPath(targetDir), { recursive: true, force: true });
cpSyncSafe(bundledDir, targetDir);
fixupPluginManifest(targetDir);
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
} catch (err) {
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin:`, err);
succeeded = false;
@@ -177,31 +204,35 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
// Same version already installed — still patch manifest ID in case it was
// never corrected (e.g. installed before MANIFEST_ID_FIXES included this plugin).
fixupPluginManifest(targetDir);
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
}
continue;
}
// Dev mode fallback: copy from node_modules/ with pnpm dep resolution
if (!app.isPackaged) {
const npmPkgPath = join(process.cwd(), 'node_modules', ...npmName.split('/'));
if (!existsSync(fsPath(join(npmPkgPath, 'openclaw.plugin.json')))) continue;
const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json'));
if (!sourceVersion) continue;
// Skip only if installed AND same version — but still patch manifest ID.
if (isInstalled && installedVersion && sourceVersion === installedVersion) {
fixupPluginManifest(targetDir);
continue;
}
const npmPkgPath = resolvePluginNpmPackagePath(npmName);
if (npmPkgPath && existsSync(fsPath(join(npmPkgPath, 'openclaw.plugin.json')))) {
const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json'));
if (!sourceVersion) continue;
// Skip only if installed AND same version — but still patch manifest ID.
if (isInstalled && installedVersion && sourceVersion === installedVersion) {
fixupPluginManifest(targetDir);
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
continue;
}
logger.info(`[plugin] ${isInstalled ? 'Auto-upgrading' : 'Installing'} ${channelType} plugin${isInstalled ? `: ${installedVersion}${sourceVersion}` : `: ${sourceVersion}`} (dev/node_modules)`);
logger.info(`[plugin] ${isInstalled ? 'Auto-upgrading' : 'Installing'} ${channelType} plugin${isInstalled ? `: ${installedVersion}${sourceVersion}` : `: ${sourceVersion}`} (dev/node_modules)`);
try {
mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true });
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
fixupPluginManifest(targetDir);
} catch (err) {
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin from node_modules:`, err);
succeeded = false;
try {
mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true });
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
fixupPluginManifest(targetDir);
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
} catch (err) {
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin from node_modules:`, err);
succeeded = false;
}
}
}
}
@@ -236,6 +267,18 @@ function cleanupUnconfiguredChannelPlugins(configuredChannels: string[]): boolea
return succeeded;
}
function cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels: string[]): void {
const configuredSet = new Set(configuredChannels);
for (const [channelType, { dirName }] of Object.entries(CHANNEL_PLUGIN_MAP)) {
if (configuredSet.has(channelType)) continue;
// Metadata can outlive the directory (for example after an interrupted
// 2026.6.10 → 2026.7.1 migration). OpenClaw validates tracked records even
// when the channel is no longer configured, so reconcile this on every
// launch rather than hiding it behind the directory-maintenance cache.
removeTrustedOfficialPluginInstallRecord(dirName);
}
}
function resolveImageGenerationPrimary(config: unknown): string | null {
if (!config || typeof config !== 'object') return null;
const agents = (config as { agents?: unknown }).agents;
@@ -493,6 +536,13 @@ export async function syncGatewayConfigBeforeLaunch(
},
));
maintenance['plugin-maintenance'] = result;
// Always refresh trusted install metadata through ClawX — this must not
// be skipped when plugin-maintenance is cache-hit, otherwise official
// external plugins like WhatsApp fail openKeyedStore at runtime.
measureSync(timingsMs, 'trustedPluginInstallSyncMs', () => {
cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels);
repairTrustedOfficialPluginInstallRecords();
});
} catch (err) {
logger.warn('Failed to auto-upgrade plugins:', err);
}
@@ -591,6 +641,19 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
throw new Error(`OpenClaw package not found at: ${openclawDir}`);
}
await measureAsync(timingsMs, 'upgradeSnapshotMs', async () => {
try {
const snapshot = await ensureOpenClaw2026_7_1UpgradeSnapshot();
if (snapshot.status === 'created') {
logger.info(`[upgrade] Created OpenClaw 2026.7.1 pre-migration snapshot (${snapshot.files.length} files): ${snapshot.snapshotDir}`);
}
} catch (error) {
// OpenClaw also maintains migration-specific backups. Keep startup
// available if the additional ClawX safety snapshot cannot be written.
logger.warn('[upgrade] Failed to create OpenClaw 2026.7.1 pre-migration snapshot:', error);
}
});
const appSettings = await measureAsync(timingsMs, 'settingsMs', getAllSettings);
const prelaunchSummary = await measureAsync(timingsMs, 'prelaunchSyncMs', async () => (
await syncGatewayConfigBeforeLaunch(appSettings, openclawDir)
@@ -636,8 +699,15 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
...proxyEnv,
OPENCLAW_GATEWAY_TOKEN: appSettings.gatewayToken,
OPENCLAW_SKIP_CHANNELS: skipChannels ? '1' : '',
CLAWDBOT_SKIP_CHANNELS: skipChannels ? '1' : '',
OPENCLAW_NO_RESPAWN: '1',
// Disable OpenClaw's interactive-shell env snapshot. When the Gateway runs
// as an Electron utilityProcess, `process.execPath` is the Electron binary,
// and OpenClaw captures the shell env by spawning `process.execPath -e
// <script>` inside a sanitized login shell that strips ELECTRON_RUN_AS_NODE.
// Electron then treats the script as an app path and pops up "Unable to find
// Electron app at <cwd>/const safe = new Set(...)". Turning the snapshot off
// avoids that broken spawn; exec tools fall back to the Gateway launch env.
OPENCLAW_EXEC_SHELL_SNAPSHOT: '0',
};
// Ensure extension-specific packages (e.g. grammy from the telegram
+98 -16
View File
@@ -14,13 +14,19 @@ import {
loadOrCreateDeviceIdentity,
type DeviceIdentity,
} from '../utils/device-identity';
import {
cancelLocalDeviceAutoApproval,
scheduleLocalDeviceAutoApproval,
} from '../utils/control-ui-device-pairing';
import {
DEFAULT_RECONNECT_CONFIG,
type ReconnectConfig,
type GatewayLifecycleState,
getReconnectScheduleDecision,
getReconnectSkipReason,
isOpenClawFatalConfigExitCode,
} from './process-policy';
import { removeOpenClaw2026_7_1UpgradeSnapshot } from '../utils/openclaw-upgrade-snapshot';
import {
clearPendingGatewayRequests,
rejectPendingGatewayRequest,
@@ -49,8 +55,19 @@ import {
loadGatewayReloadPolicy,
type GatewayReloadPolicy,
} from './reload-policy';
import { classifyGatewayStderrMessage, recordGatewayStartupStderrLine } from './startup-stderr';
import {
classifyGatewayStderrMessage,
GATEWAY_STARTUP_SLOW_STAGE_MS,
GATEWAY_STARTUP_SLOW_TOTAL_MS,
GatewayStartupTraceCollector,
recordGatewayStartupStderrLine,
} from './startup-stderr';
import { runGatewayStartupSequence } from './startup-orchestrator';
import {
hasFatalRuntimeFailureSignal,
hasInvalidConfigFailureSignal,
hasStartupMigrationLockSignal,
} from './startup-recovery';
import {
GatewayCapabilityMonitor,
type GatewayCapabilityName,
@@ -170,6 +187,7 @@ export class GatewayManager extends EventEmitter {
private startLock = false;
private lastSpawnSummary: string | null = null;
private recentStartupStderrLines: string[] = [];
private readonly startupTraceCollector = new GatewayStartupTraceCollector();
private pendingRequests: Map<string, PendingGatewayRequest> = new Map();
private deviceIdentity: DeviceIdentity | null = null;
private restartInFlight: Promise<void> | null = null;
@@ -179,6 +197,7 @@ export class GatewayManager extends EventEmitter {
private readonly restartGovernor = new GatewayRestartGovernor();
private reloadDebounceTimer: NodeJS.Timeout | null = null;
private initialReadyHeartbeatRecoveryTimer: NodeJS.Timeout | null = null;
private upgradeSnapshotCleanupAttempted = false;
private reloadPolicy: GatewayReloadPolicy = { ...DEFAULT_GATEWAY_RELOAD_POLICY };
private reloadPolicyLoadedAt = 0;
private reloadPolicyRefreshPromise: Promise<void> | null = null;
@@ -240,6 +259,7 @@ export class GatewayManager extends EventEmitter {
logger.info('Gateway subsystems ready (event received)');
this.setStatus({ gatewayReady: true });
}
void this.cleanupOpenClawUpgradeSnapshot();
});
this.on('gateway:health', (payload) => {
this.capabilityMonitor.recordOpenClawHealth(payload);
@@ -415,12 +435,23 @@ export class GatewayManager extends EventEmitter {
onConnectedToManagedGateway: () => {
this.startHealthCheck();
const tConnected = Date.now();
logger.info('[metric] gateway.startup', {
const spawnToReadyMs = tReady && tSpawned ? tReady - tSpawned : undefined;
const startupTrace = this.startupTraceCollector.getSummary();
const startupMetric = {
configSyncMs: tSpawned ? tSpawned - t0 : undefined,
spawnToReadyMs: tReady && tSpawned ? tReady - tSpawned : undefined,
spawnToReadyMs,
readyToConnectMs: tReady ? tConnected - tReady : undefined,
totalMs: tConnected - t0,
});
openclawTrace: startupTrace,
};
logger.info('[metric] gateway.startup', startupMetric);
if (spawnToReadyMs !== undefined && spawnToReadyMs >= GATEWAY_STARTUP_SLOW_TOTAL_MS) {
logger.warn('[gateway-startup] Slow managed Gateway startup detected', {
pid: this.status.pid,
spawnToReadyMs,
openclawTrace: startupTrace,
});
}
},
runDoctorRepair: async () => await runOpenClawDoctorRepair(),
onDoctorRepairSuccess: () => {
@@ -440,7 +471,17 @@ export class GatewayManager extends EventEmitter {
error
);
this.setStatus({ state: 'error', error: String(error) });
if (this.shouldReconnect) {
const fatalStartupFailure = isOpenClawFatalConfigExitCode(this.processExitCode)
|| hasFatalRuntimeFailureSignal(error, this.recentStartupStderrLines)
|| hasStartupMigrationLockSignal(error, this.recentStartupStderrLines)
|| hasInvalidConfigFailureSignal(error, this.recentStartupStderrLines);
if (fatalStartupFailure) {
// OpenClaw 2026.7.1 uses EX_CONFIG for fatal configuration failures.
// Runtime and SQLite compatibility failures are likewise not repaired
// by restarting the same binary, so leave recovery to a manual start.
this.shouldReconnect = false;
logger.error('Gateway startup failed fatally; automatic reconnect disabled');
} else if (this.shouldReconnect) {
logger.warn('Gateway start failed; scheduling auto-reconnect recovery');
this.scheduleReconnect();
}
@@ -468,6 +509,7 @@ export class GatewayManager extends EventEmitter {
*/
async stop(): Promise<void> {
logger.info('Gateway stop requested');
cancelLocalDeviceAutoApproval();
this.lifecycleController.bump('stop');
// Disable auto-reconnect
this.shouldReconnect = false;
@@ -1034,8 +1076,10 @@ export class GatewayManager extends EventEmitter {
await unloadLaunchctlGatewayService();
this.processExitCode = null;
// Per-process dedup map for stderr lines — resets on each new spawn.
// Per-process diagnostics reset on each new spawn so retries never mix
// timings or stderr deduplication state from different Gateway children.
const stderrDedup = new Map<string, number>();
this.startupTraceCollector.reset();
const { child, lastSpawnSummary } = await launchGatewayProcess({
port: this.status.port,
@@ -1045,6 +1089,7 @@ export class GatewayManager extends EventEmitter {
getShouldReconnect: () => this.shouldReconnect,
onStderrLine: (line) => {
recordGatewayStartupStderrLine(this.recentStartupStderrLines, line);
const traceStage = this.startupTraceCollector.record(line);
const classified = classifyGatewayStderrMessage(line);
if (classified.level === 'drop') return;
@@ -1059,10 +1104,24 @@ export class GatewayManager extends EventEmitter {
return;
}
if (traceStage) {
const message = `[gateway-startup] stage=${traceStage.name} durationMs=${traceStage.durationMs}`
+ (traceStage.totalMs === undefined ? '' : ` totalMs=${traceStage.totalMs}`);
if (traceStage.durationMs >= GATEWAY_STARTUP_SLOW_STAGE_MS) {
logger.warn(`${message} slow=true`);
} else {
logger.info(message);
}
return;
}
if (classified.level === 'debug') {
logger.debug(`[Gateway stderr] ${classified.normalized}`);
return;
}
if (classified.level === 'info') {
logger.info(`[Gateway stderr] ${classified.normalized}`);
return;
}
logger.warn(`[Gateway stderr] ${classified.normalized}`);
},
onSpawn: (pid) => {
@@ -1081,16 +1140,23 @@ export class GatewayManager extends EventEmitter {
this.setStatus({ state: 'stopped' });
}
// Always attempt reconnect from process exit. scheduleReconnect()
// internally checks shouldReconnect and reconnect-timer guards, so
// calling it unconditionally is safe — intentional stop() calls set
// shouldReconnect=false which makes scheduleReconnect() no-op.
//
// On Windows, the WS close handler intentionally skips reconnect
// (to avoid racing with this exit handler). However, WS close
// fires *before* process exit and sets state='stopped', which
// previously caused this handler to also skip reconnect — leaving
// the gateway permanently dead with no recovery path.
const orchestratedStartupFailure = isOpenClawFatalConfigExitCode(code)
|| hasFatalRuntimeFailureSignal(undefined, this.recentStartupStderrLines)
|| hasStartupMigrationLockSignal(undefined, this.recentStartupStderrLines)
|| hasInvalidConfigFailureSignal(undefined, this.recentStartupStderrLines);
if (orchestratedStartupFailure) {
// During startup the orchestrator may still perform its one bounded
// doctor repair. Do not race it with an independent reconnect timer.
// If orchestration cannot recover, start() disables reconnect in its
// catch path so migration/config failures cannot create an outer loop.
if (this.status.state !== 'starting') this.shouldReconnect = false;
logger.error(`Gateway process reported a non-retriable startup condition (code=${String(code)}); reconnect not scheduled`);
return;
}
// Always attempt reconnect from non-fatal process exits.
// scheduleReconnect() internally checks shouldReconnect and timer
// guards, so intentional stop() remains a no-op.
this.scheduleReconnect();
},
onError: () => {
@@ -1131,11 +1197,13 @@ export class GatewayManager extends EventEmitter {
});
this.startPing();
this.scheduleGatewayReadyFallback();
scheduleLocalDeviceAutoApproval(this);
},
onMessage: (message) => {
this.handleMessage(message);
},
onCloseAfterHandshake: (closeCode) => {
cancelLocalDeviceAutoApproval();
this.connectionMonitor.clear();
this.recordSocketClose(closeCode);
this.diagnostics.consecutiveHeartbeatMisses = 0;
@@ -1291,6 +1359,20 @@ export class GatewayManager extends EventEmitter {
this.initialReadyHeartbeatRecoveryTimer = null;
}
private async cleanupOpenClawUpgradeSnapshot(): Promise<void> {
if (this.upgradeSnapshotCleanupAttempted) return;
this.upgradeSnapshotCleanupAttempted = true;
try {
const result = await removeOpenClaw2026_7_1UpgradeSnapshot();
if (result.status === 'removed') {
logger.info(`[upgrade] Removed OpenClaw 2026.7.1 pre-migration snapshot: ${result.snapshotDir}`);
}
} catch (error) {
logger.warn('[upgrade] Failed to remove OpenClaw 2026.7.1 pre-migration snapshot:', error);
}
}
/**
* Schedule reconnection attempt with exponential backoff
*/
+17 -2
View File
@@ -80,6 +80,20 @@ const GATEWAY_FETCH_PRELOAD_SOURCE = `'use strict';
})();
`;
export function buildGatewayRuntimeEnv(
forkEnv: Record<string, string | undefined>,
): Record<string, string | undefined> {
return {
...forkEnv,
// ClawX does not expose LAN discovery, so keep Bonjour disabled even if
// the parent process inherited an explicit opt-in value.
OPENCLAW_DISABLE_BONJOUR: '1',
// OpenClaw's built-in trace contains stage names and timings only. Keep it
// enabled so packaged startup incidents are diagnosable from normal logs.
OPENCLAW_GATEWAY_STARTUP_TRACE: '1',
};
}
function ensureGatewayFetchPreload(): string {
const dest = path.join(app.getPath('userData'), 'gateway-fetch-preload.cjs');
try {
@@ -118,7 +132,7 @@ export async function launchGatewayProcess(options: {
);
const lastSpawnSummary = `mode=${mode}, entry="${entryScript}", args="${options.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}"`;
const runtimeEnv = { ...forkEnv };
const runtimeEnv = buildGatewayRuntimeEnv(forkEnv);
// Disable OpenClaw's mDNS/Bonjour gateway advertiser unconditionally.
//
@@ -136,7 +150,8 @@ export async function launchGatewayProcess(options: {
// `startGatewayBonjourAdvertiser()` (openclaw `src/infra/bonjour.ts`,
// `isDisabledByEnv()`). Set after the `forkEnv` spread so any
// pre-existing value inherited from the user shell cannot re-enable it.
runtimeEnv.OPENCLAW_DISABLE_BONJOUR = '1';
// buildGatewayRuntimeEnv() applies both this policy and startup tracing
// before any development-only environment augmentation below.
// Only apply the fetch/child_process preload in dev mode.
// In packaged builds Electron's UtilityProcess rejects NODE_OPTIONS
+7
View File
@@ -10,6 +10,13 @@ export const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
maxDelay: 30000,
};
/** sysexits(3) EX_CONFIG, used by OpenClaw 2026.7.1 for fatal config startup errors. */
export const OPENCLAW_EX_CONFIG_EXIT_CODE = 78;
export function isOpenClawFatalConfigExitCode(code: number | null | undefined): boolean {
return code === OPENCLAW_EX_CONFIG_EXIT_CODE;
}
export function nextLifecycleEpoch(currentEpoch: number): number {
return currentEpoch + 1;
}
+54 -6
View File
@@ -8,10 +8,25 @@
const INVALID_CONFIG_PATTERNS: RegExp[] = [
/\binvalid config\b/i,
/\bconfig invalid\b/i,
/\bfatal configuration error\b/i,
/\bunrecognized key\b/i,
/\bstartup migration(?:s)?\b.*\b(?:blocked|failed|did not complete cleanly)\b/i,
/\bmigration\b.*\bopenclaw doctor --fix\b/i,
/\brun:\s*openclaw doctor --fix\b/i,
];
const FATAL_RUNTIME_PATTERNS: RegExp[] = [
/\bNode(?:\.js)?\b.*\boutside the supported range\b/i,
/\buses SQLite\b.*\bnot WAL-reset-safe\b/i,
/\bSQLite\b.*\bWAL-reset-safe runtime required\b/i,
/\bInstall Node 24\.15\+.*\bNode 22\.22\.3\+\b/i,
];
const STARTUP_MIGRATION_LOCK_PATTERNS: RegExp[] = [
/\bstartup migrations? (?:is|are) already running\b/i,
/\bretry after the other gateway finishes\b/i,
];
const TRANSIENT_START_ERROR_PATTERNS: RegExp[] = [
/WebSocket closed before handshake/i,
/ECONNREFUSED/i,
@@ -61,6 +76,33 @@ export function hasInvalidConfigFailureSignal(
return isInvalidConfigSignal(errorText);
}
function startupFailureCandidates(startupError: unknown, startupStderrLines: string[]): string[] {
return [
...startupStderrLines,
startupError instanceof Error
? `${startupError.name}: ${startupError.message}`
: String(startupError ?? ''),
];
}
/** Returns true for OpenClaw runtime/SQLite failures that doctor cannot repair. */
export function hasFatalRuntimeFailureSignal(
startupError: unknown,
startupStderrLines: string[],
): boolean {
return startupFailureCandidates(startupError, startupStderrLines)
.some((text) => FATAL_RUNTIME_PATTERNS.some((pattern) => pattern.test(text)));
}
/** Returns true while another/stale OpenClaw startup migration lease is active. */
export function hasStartupMigrationLockSignal(
startupError: unknown,
startupStderrLines: string[],
): boolean {
return startupFailureCandidates(startupError, startupStderrLines)
.some((text) => STARTUP_MIGRATION_LOCK_PATTERNS.some((pattern) => pattern.test(text)));
}
/**
* Retry guard for one-time config repair during a single startup flow.
*/
@@ -136,12 +178,18 @@ export function getGatewayStartupRecoveryAction(options: {
attempt: number;
maxAttempts: number;
}): GatewayStartupRecoveryAction {
if (shouldAttemptConfigAutoRepair(
options.startupError,
options.startupStderrLines,
options.configRepairAttempted,
)) {
return 'repair';
if (
hasFatalRuntimeFailureSignal(options.startupError, options.startupStderrLines)
|| hasStartupMigrationLockSignal(options.startupError, options.startupStderrLines)
) {
return 'fail';
}
if (hasInvalidConfigFailureSignal(options.startupError, options.startupStderrLines)) {
// One doctor pass is the only automated repair. If the same migration or
// config failure remains afterward, stop instead of treating the generic
// process-exited error as transient.
return options.configRepairAttempted ? 'fail' : 'repair';
}
if (options.attempt < options.maxAttempts && isTransientGatewayStartError(options.startupError)) {
+107 -10
View File
@@ -1,40 +1,137 @@
export type GatewayStderrClassification = {
level: 'drop' | 'debug' | 'warn';
level: 'drop' | 'debug' | 'info' | 'warn';
normalized: string;
};
export type GatewayStartupTraceStage = {
name: string;
durationMs: number;
totalMs?: number;
};
export type GatewayStartupTraceSummary = {
stageCount: number;
lastStage?: string;
traceTotalMs?: number;
slowestStage?: string;
slowestStageMs?: number;
};
export const GATEWAY_STARTUP_SLOW_STAGE_MS = 10_000;
export const GATEWAY_STARTUP_SLOW_TOTAL_MS = 30_000;
const MAX_STDERR_LINES = 120;
const ANSI_ESCAPE_PATTERN = new RegExp(String.raw`\u001B\[[0-?]*[ -/]*[@-~]`, 'g');
const STARTUP_TRACE_PATTERN = /startup trace:\s+([^\s]+)\s+(\d+(?:\.\d+)?)ms(?:\s+total=(\d+(?:\.\d+)?)ms)?/i;
export function parseGatewayStartupTraceStage(message: string): GatewayStartupTraceStage | null {
const match = STARTUP_TRACE_PATTERN.exec(message.replace(ANSI_ESCAPE_PATTERN, ''));
if (!match) return null;
const durationMs = Number(match[2]);
const totalMs = match[3] === undefined ? undefined : Number(match[3]);
if (!Number.isFinite(durationMs) || (totalMs !== undefined && !Number.isFinite(totalMs))) {
return null;
}
return {
name: match[1]!,
durationMs,
...(totalMs === undefined ? {} : { totalMs }),
};
}
export class GatewayStartupTraceCollector {
private stageCount = 0;
private lastStage: GatewayStartupTraceStage | null = null;
private slowestStage: GatewayStartupTraceStage | null = null;
private maxTraceTotalMs: number | undefined;
reset(): void {
this.stageCount = 0;
this.lastStage = null;
this.slowestStage = null;
this.maxTraceTotalMs = undefined;
}
record(message: string): GatewayStartupTraceStage | null {
const stage = parseGatewayStartupTraceStage(message);
if (!stage) return null;
this.stageCount += 1;
this.lastStage = stage;
if (!this.slowestStage || stage.durationMs > this.slowestStage.durationMs) {
this.slowestStage = stage;
}
if (stage.totalMs !== undefined) {
this.maxTraceTotalMs = Math.max(this.maxTraceTotalMs ?? 0, stage.totalMs);
}
return stage;
}
getSummary(): GatewayStartupTraceSummary {
return {
stageCount: this.stageCount,
...(this.lastStage ? { lastStage: this.lastStage.name } : {}),
...(this.maxTraceTotalMs === undefined ? {} : { traceTotalMs: this.maxTraceTotalMs }),
...(this.slowestStage ? {
slowestStage: this.slowestStage.name,
slowestStageMs: this.slowestStage.durationMs,
} : {}),
};
}
}
export function classifyGatewayStderrMessage(message: string): GatewayStderrClassification {
const msg = message.trim();
if (!msg) {
return { level: 'drop', normalized: msg };
}
const plain = msg.replace(ANSI_ESCAPE_PATTERN, '');
// OpenClaw startup timing traces are expected diagnostics, not failures.
if (plain.includes('startup trace:')) {
return { level: 'info', normalized: msg };
}
// Known noisy lines that are not actionable for Gateway lifecycle debugging.
if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) {
if (plain.includes('openclaw-control-ui') && plain.includes('token_mismatch')) {
return { level: 'drop', normalized: msg };
}
if (msg.includes('closed before connect') && msg.includes('token mismatch')) {
if (plain.includes('closed before connect') && plain.includes('token mismatch')) {
return { level: 'drop', normalized: msg };
}
if (msg.includes('[ws] closed before connect') && msg.includes('code=1005')) {
if (plain.includes('[ws] closed before connect') && plain.includes('code=1005')) {
return { level: 'debug', normalized: msg };
}
if (msg.includes('security warning: dangerous config flags enabled')) {
if (
plain.includes('[ws] closed before connect')
&& plain.includes('code=1006')
&& plain.includes('phase=ws_upgrade_started')
&& plain.includes('ua=n/a')
) {
return { level: 'debug', normalized: msg };
}
if (plain.includes('security warning: dangerous config flags enabled')) {
return { level: 'debug', normalized: msg };
}
// Downgrade frequent non-fatal noise.
if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg };
if (msg.includes('DeprecationWarning')) return { level: 'debug', normalized: msg };
if (msg.includes('Debugger attached')) return { level: 'debug', normalized: msg };
if (plain.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg };
if (plain.includes('DeprecationWarning')) return { level: 'debug', normalized: msg };
if (plain.includes('Rename them by replacing the legacy prefix with OPENCLAW_')) {
return { level: 'debug', normalized: msg };
}
if (plain.includes('--trace-deprecation') && plain.includes('show where the warning was created')) {
return { level: 'debug', normalized: msg };
}
if (plain.includes('Debugger attached')) return { level: 'debug', normalized: msg };
// Gateway config warnings (e.g. stale plugin entries) are informational, not actionable.
if (msg.includes('Config warnings:')) return { level: 'debug', normalized: msg };
if (plain.includes('Config warnings:')) return { level: 'debug', normalized: msg };
// Electron restricts NODE_OPTIONS in packaged apps; this is expected and harmless.
if (msg.includes('node: --require is not allowed in NODE_OPTIONS')) {
if (plain.includes('node: --require is not allowed in NODE_OPTIONS')) {
return { level: 'debug', normalized: msg };
}
+128 -53
View File
@@ -2,9 +2,12 @@
* Electron Main Process Entry
* Manages window creation, system tray, and IPC handlers
*/
import { app, BrowserWindow, nativeImage, session, shell } from 'electron';
import { app, BrowserWindow, nativeImage, session, shell, type Session } from 'electron';
import { join } from 'path';
import { GatewayManager } from '../gateway/manager';
import { 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';
@@ -32,6 +35,8 @@ import { getMacTrafficLightPosition, syncMacTrafficLightPosition } from './traff
import { getSetting } from '../utils/store';
import { applyProxySettings } from './proxy';
import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup';
import { WebBrowserGuestRegistry, installWebBrowserGuestPolicy } from './web-browser-policy';
import { configureWebBrowserSession } from './web-browser-session';
import {
clearPendingSecondInstanceFocus,
consumeMainWindowReady,
@@ -51,19 +56,22 @@ import { deviceOAuthManager } from '../utils/device-oauth';
import { browserOAuthManager } from '../utils/browser-oauth';
import { whatsAppLoginManager } from '../utils/whatsapp-login';
import { syncAllProviderAuthToRuntime } from '../services/providers/provider-runtime-sync';
import { getClawXDataLayout, initializeClawXDataLayout } from '../utils/clawx-data-layout';
import { migrateLegacyProviderSecretsToVault } from '../services/secrets/secret-store';
import { migrateLegacyClawXData } from '../utils/clawx-data-migration';
const WINDOWS_APP_USER_MODEL_ID = 'app.clawx.desktop';
const isE2EMode = process.env.CLAWX_E2E === '1';
const requestedUserDataDir = process.env.CLAWX_USER_DATA_DIR?.trim();
const enforceWriterLockInE2E = process.env.CLAWX_E2E_ENFORCE_WRITER_LOCK === '1';
const requestedRemoteDebuggingPort = process.env.CLAWX_REMOTE_DEBUGGING_PORT?.trim();
const legacyElectronUserDataDir = app.getPath('userData');
const clawXDataLayout = getClawXDataLayout();
if (requestedRemoteDebuggingPort) {
app.commandLine.appendSwitch('remote-debugging-port', requestedRemoteDebuggingPort);
}
if (isE2EMode && requestedUserDataDir) {
app.setPath('userData', requestedUserDataDir);
}
app.setPath('userData', clawXDataLayout.electronUserDataDir);
// Disable GPU hardware acceleration globally for maximum stability across
// all GPU configurations (no GPU, integrated, discrete).
@@ -102,12 +110,17 @@ if (!gotElectronLock) {
}
let releaseProcessInstanceFileLock: () => void = () => {};
let gotFileLock = true;
if (gotElectronLock && !isE2EMode) {
if (gotElectronLock && (!isE2EMode || enforceWriterLockInE2E)) {
try {
const fileLock = acquireProcessInstanceFileLock({
userDataDir: app.getPath('userData'),
lockName: 'clawx',
force: true, // Electron lock already guarantees exclusivity; force-clean orphan/recycled-PID locks
userDataDir: clawXDataLayout.locksDir,
lockName: 'writer',
lockPath: clawXDataLayout.writerLockPath,
metadata: {
appVersion: app.getVersion(),
channel: process.env.CLAWX_RELEASE_CHANNEL?.trim() || (app.isPackaged ? 'stable' : 'dev'),
executable: process.execPath,
},
});
gotFileLock = fileLock.acquired;
releaseProcessInstanceFileLock = fileLock.release;
@@ -123,16 +136,32 @@ if (gotElectronLock && !isE2EMode) {
app.exit(0);
}
} catch (error) {
console.warn('[ClawX] Failed to acquire process instance file lock; continuing with Electron single-instance lock only', error);
gotFileLock = false;
console.error('[ClawX] Failed to acquire process instance file lock; refusing to start a shared-root writer', error);
app.exit(1);
}
}
const gotTheLock = gotElectronLock && gotFileLock;
if (gotTheLock) {
try {
// No shared-root state may be created or migrated until this process owns
// the cross-install writer lock.
initializeClawXDataLayout(clawXDataLayout);
} catch (error) {
releaseProcessInstanceFileLock();
throw error;
}
}
// Global references
let mainWindow: BrowserWindow | null = null;
let gatewayManager!: GatewayManager;
let runtimeManager!: RuntimeManager;
let clawHubService!: ClawHubService;
const hostApiRegistry = new HostApiRegistry();
const webBrowserGuestRegistry = new WebBrowserGuestRegistry();
let webBrowserSession!: Session;
const mainWindowFocusState = createMainWindowFocusState();
const quitLifecycleState = createQuitLifecycleState();
@@ -176,8 +205,6 @@ function createWindow(): BrowserWindow {
const isMac = process.platform === 'darwin';
const isWindows = process.platform === 'win32';
const useCustomTitleBar = isWindows;
const shouldSkipSetupForE2E = process.env.CLAWX_E2E_SKIP_SETUP === '1';
const win = new BrowserWindow({
width: 1280,
height: 800,
@@ -199,6 +226,11 @@ function createWindow(): BrowserWindow {
show: false,
});
installWebBrowserGuestPolicy(win.webContents, {
browserSession: webBrowserSession,
registry: webBrowserGuestRegistry,
});
registerZoomShortcuts(win);
// Handle external links — only allow safe protocols to prevent arbitrary
@@ -217,7 +249,12 @@ function createWindow(): BrowserWindow {
return { action: 'deny' };
});
// Load the app
return win;
}
function loadMainWindow(win: BrowserWindow): void {
const shouldSkipSetupForE2E = process.env.CLAWX_E2E_SKIP_SETUP === '1';
if (process.env.VITE_DEV_SERVER_URL) {
const rendererUrl = new URL(process.env.VITE_DEV_SERVER_URL);
if (shouldSkipSetupForE2E) {
@@ -234,8 +271,6 @@ function createWindow(): BrowserWindow {
: undefined,
});
}
return win;
}
function focusWindow(win: BrowserWindow): void {
@@ -310,6 +345,22 @@ async function initialize(): Promise<void> {
logger.debug(
`Runtime: platform=${process.platform}/${process.arch}, electron=${process.versions.electron}, node=${process.versions.node}, packaged=${app.isPackaged}, pid=${process.pid}, ppid=${process.ppid}`
);
const legacyMigration = await migrateLegacyClawXData({
legacyElectronUserDataDir,
layout: clawXDataLayout,
});
if (legacyMigration.copied.length > 0) {
logger.info(`Imported ${legacyMigration.copied.length} legacy ClawX data path(s) into ${clawXDataLayout.root}`);
}
const migratedSecretCount = await migrateLegacyProviderSecretsToVault();
if (migratedSecretCount > 0) {
logger.info(`Migrated ${migratedSecretCount} provider credential account(s) into the encrypted ClawX vault`);
}
webBrowserSession = configureWebBrowserSession({
registry: webBrowserGuestRegistry,
getMainWindow: () => mainWindow,
});
if (!isE2EMode) {
// Warm up network optimization (non-blocking)
@@ -331,11 +382,6 @@ async function initialize(): Promise<void> {
// Create the main window
const window = createMainWindow();
// Create system tray
if (!isE2EMode) {
createTray(window);
}
// Override security headers ONLY for the OpenClaw Gateway Control UI.
// The URL filter ensures this callback only fires for gateway requests,
// avoiding unnecessary overhead on every other HTTP response.
@@ -360,11 +406,28 @@ async function initialize(): Promise<void> {
);
// Register IPC handlers
registerIpcHandlers(gatewayManager, clawHubService, window, hostApiRegistry);
registerIpcHandlers(
gatewayManager,
runtimeManager,
clawHubService,
window,
hostApiRegistry,
webBrowserSession,
webBrowserGuestRegistry,
);
await runtimeManager.getActiveKind();
loadMainWindow(window);
// Create system tray
if (!isE2EMode) {
createTray(window);
}
// Initialize extension system
await extensionRegistry.initialize({
gatewayManager,
runtimeManager,
getMainWindow: () => mainWindow,
hostApi: {
register: (extensionId, contributions) => (
@@ -441,44 +504,44 @@ async function initialize(): Promise<void> {
// Bridge gateway and host-side events before any auto-start logic runs, so
// renderer subscribers observe the full startup lifecycle.
gatewayManager.on('status', (status: { state: string }) => {
runtimeManager.on('status', (status: { state: string; runtimeKind?: string }) => {
sendMainWindowEvent('gateway:status-changed', status);
if (status.state === 'running' && !isE2EMode) {
if (status.runtimeKind === 'openclaw' && status.state === 'running' && !isE2EMode) {
void ensureClawXContext().catch((error) => {
logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error);
});
}
});
gatewayManager.on('error', (error) => {
runtimeManager.on('error', (error) => {
sendMainWindowEvent('gateway:error', { message: error.message });
});
gatewayManager.on('notification', (notification) => {
runtimeManager.on('notification', (notification) => {
sendMainWindowEvent('gateway:notification', notification);
});
gatewayManager.on('gateway:health', (data) => {
runtimeManager.on('gateway:health', (data) => {
sendMainWindowEvent('gateway:health-changed', data);
});
gatewayManager.on('gateway:presence', (data) => {
runtimeManager.on('gateway:presence', (data) => {
sendMainWindowEvent('gateway:presence-changed', data);
});
gatewayManager.on('chat:message', (data) => {
runtimeManager.on('chat:message', (data) => {
sendMainWindowEvent('gateway:chat-message', data);
});
gatewayManager.on('chat:runtime-event', (data) => {
runtimeManager.on('chat:runtime-event', (data) => {
sendMainWindowEvent('chat:runtime-event', data);
});
gatewayManager.on('channel:status', (data) => {
runtimeManager.on('channel:status', (data) => {
sendMainWindowEvent('gateway:channel-status', data);
});
gatewayManager.on('exit', (code) => {
runtimeManager.on('exit', (code) => {
sendMainWindowEvent('gateway:exit', { code });
});
@@ -522,12 +585,14 @@ async function initialize(): Promise<void> {
const gatewayAutoStart = await getSetting('gatewayAutoStart');
if (!isE2EMode && gatewayAutoStart) {
try {
await syncAllProviderAuthToRuntime();
logger.debug('Auto-starting Gateway...');
await gatewayManager.start();
logger.info('Gateway auto-start succeeded');
if (await runtimeManager.getActiveKind() === 'openclaw') {
await syncAllProviderAuthToRuntime();
}
logger.debug(`Auto-starting ${await runtimeManager.getActiveKind()} runtime...`);
await runtimeManager.start();
logger.info('Runtime auto-start succeeded');
} catch (error) {
logger.error('Gateway auto-start failed:', error);
logger.error('Runtime auto-start failed:', error);
mainWindow?.webContents.send('gateway:error', String(error));
}
} else if (isE2EMode) {
@@ -580,6 +645,10 @@ if (gotTheLock) {
}
gatewayManager = new GatewayManager();
runtimeManager = new RuntimeManager({
openclaw: new OpenClawRuntimeProvider(gatewayManager),
ccConnect: new CcConnectRuntimeProvider(),
});
clawHubService = new ClawHubService();
// Register builtin extensions and load manifest
@@ -607,16 +676,19 @@ if (gotTheLock) {
});
// Application lifecycle
app.whenReady().then(() => {
void initialize().catch((error) => {
app.whenReady().then(async () => {
try {
await initialize();
} catch (error) {
logger.error('Application initialization failed:', error);
});
return;
}
// Register activate handler AFTER app is ready to prevent
// "Cannot create BrowserWindow before app is ready" on macOS.
// Register only after initialization so activation cannot race the initial
// window or claim the single browser guest before host handlers are ready.
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
loadMainWindow(createMainWindow());
} else {
focusMainWindow();
}
@@ -646,8 +718,8 @@ if (gotTheLock) {
void extensionRegistry.teardownAll();
const stopPromise = gatewayManager.stop().catch((err) => {
logger.warn('gatewayManager.stop() error during quit:', err);
const stopPromise = runtimeManager.stop().catch((err) => {
logger.warn('runtimeManager.stop() error during quit:', err);
});
const timeoutPromise = new Promise<'timeout'>((resolve) => {
setTimeout(() => resolve('timeout'), 5000);
@@ -655,14 +727,16 @@ if (gotTheLock) {
void Promise.race([stopPromise.then(() => 'stopped' as const), timeoutPromise]).then((result) => {
if (result === 'timeout') {
logger.warn('Gateway shutdown timed out during app quit; proceeding with forced quit');
void gatewayManager.forceTerminateOwnedProcessForQuit().then((terminated) => {
if (terminated) {
logger.warn('Forced gateway process termination completed after quit timeout');
}
}).catch((err) => {
logger.warn('Forced gateway termination failed after quit timeout:', err);
});
logger.warn('Runtime shutdown timed out during app quit; proceeding with forced quit');
if (runtimeManager.getActiveProvider().kind === 'openclaw') {
void gatewayManager.forceTerminateOwnedProcessForQuit().then((terminated) => {
if (terminated) {
logger.warn('Forced gateway process termination completed after quit timeout');
}
}).catch((err) => {
logger.warn('Forced gateway termination failed after quit timeout:', err);
});
}
}
markQuitCleanupCompleted(quitLifecycleState);
app.quit();
@@ -676,6 +750,7 @@ if (gotTheLock) {
logger.error(`${reason}:`, error);
try {
void gatewayManager?.stop().catch(() => { /* ignore */ });
void runtimeManager?.stop().catch(() => { /* ignore */ });
} catch {
// ignore — stop() may not be callable if state is corrupted
}
@@ -695,4 +770,4 @@ if (gotTheLock) {
}
// Export for testing
export { mainWindow, gatewayManager };
export { mainWindow, gatewayManager, runtimeManager };
+84 -45
View File
@@ -2,12 +2,13 @@
* IPC Handlers
* Registers all IPC handlers for main-renderer communication
*/
import { ipcMain, BrowserWindow, shell, dialog, app } from 'electron';
import { ipcMain, BrowserWindow, shell, dialog, app, type Session } from 'electron';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, extname, basename, resolve, sep, relative } from 'node:path';
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,
@@ -57,13 +58,18 @@ import { createSettingsApi } from '../services/settings-api';
import { createChannelsApi } from '../services/channels-api';
import { createAgentsApi } from '../services/agents-api';
import { createChatApi } from '../services/chat-api';
import { AcpSessionAccessRegistry } from '../services/acp-session-access-registry';
import { createAttachmentAccess, StagedAttachmentRegistry } from '../services/attachment-access';
import { createAttachmentOpenWithService } from '../services/attachment-open-with';
import { createCronApi } from '../services/cron-api';
import { createFilesApi } from '../services/files-api';
import { createMediaApi } from '../services/media-api';
import { createProvidersApi } from '../services/providers-api';
import { createSessionsApi } from '../services/sessions-api';
import { createSkillsApi } from '../services/skills-api';
import { createUsageApi } from '../services/usage-api';
import { createUsageApi, getRecentTokenHistoryForRuntime } from '../services/usage-api';
import { createWebBrowserApi } from '../services/web-browser-api';
import type { WebBrowserGuestRegistry } from './web-browser-policy';
import {
isLaunchAtStartupKey,
isProxyKey,
@@ -80,24 +86,35 @@ const gatewayRpcBackpressure = new GatewayRpcBackpressure();
*/
export function registerIpcHandlers(
gatewayManager: GatewayManager,
runtimeManager: RuntimeManager,
clawHubService: ClawHubService,
mainWindow: BrowserWindow,
hostApiRegistry: HostApiRegistry,
browserSession: Session,
registry: WebBrowserGuestRegistry,
): void {
// Unified request protocol (non-breaking: legacy channels remain available)
registerUnifiedRequestHandlers(gatewayManager);
registerUnifiedRequestHandlers(gatewayManager, runtimeManager);
// Typed host invoke handlers (new renderer facade; legacy channels remain available)
registerTypedHostHandlers(gatewayManager, clawHubService, mainWindow, hostApiRegistry);
registerTypedHostHandlers(
gatewayManager,
runtimeManager,
clawHubService,
mainWindow,
hostApiRegistry,
browserSession,
registry,
);
// Gateway handlers
registerGatewayHandlers(gatewayManager);
registerGatewayHandlers(runtimeManager);
// OpenClaw handlers
registerOpenClawHandlers();
// Provider handlers
registerProviderHandlers(gatewayManager);
registerProviderHandlers(gatewayManager, runtimeManager);
// Shell handlers
registerShellHandlers();
@@ -112,7 +129,7 @@ export function registerIpcHandlers(
registerSettingsHandlers(gatewayManager);
// Usage handlers
registerUsageHandlers();
registerUsageHandlers(runtimeManager);
// Cron task handlers (proxy to Gateway RPC)
registerCronHandlers(gatewayManager);
@@ -129,36 +146,58 @@ export function registerIpcHandlers(
function registerTypedHostHandlers(
gatewayManager: GatewayManager,
runtimeManager: RuntimeManager,
clawHubService: ClawHubService,
mainWindow: BrowserWindow,
hostApiRegistry: HostApiRegistry,
browserSession: Session,
registry: WebBrowserGuestRegistry,
): void {
const acpSessionAccessRegistry = new AcpSessionAccessRegistry();
const stagedAttachments = new StagedAttachmentRegistry();
const attachmentOpenWith = createAttachmentOpenWithService();
const attachmentAccess = createAttachmentAccess({
sessionAccessRegistry: acpSessionAccessRegistry,
stagedAttachments,
openWith: attachmentOpenWith,
});
hostApiRegistry.registerCoreServices({
app: createAppApi(),
app: createAppApi(runtimeManager),
openclaw: createOpenClawApi(),
shell: createShellApi(),
webBrowser: createWebBrowserApi({ browserSession, registry }),
dialog: createDialogApi(),
window: createWindowApi(mainWindow),
updates: createUpdatesApi(appUpdater),
uv: createUvApi(),
settings: createSettingsApi(gatewayManager),
gateway: createGatewayApi(gatewayManager, gatewayRpcBackpressure),
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,
attachmentAccess,
openWith: attachmentOpenWith,
stagedAttachments,
}),
media: createMediaApi({ runtimeManager, attachmentAccess }),
sessions: createSessionsApi(runtimeManager),
chat: createChatApi({
gatewayManager,
runtimeManager,
mainWindow,
acpSessionAccessRegistry,
}),
cron: createCronApi({ gatewayManager, runtimeManager }),
skills: createSkillsApi({ clawHubService, gatewayManager, runtimeManager }),
usage: createUsageApi(runtimeManager),
});
registerHostInvokeHandler(hostApiRegistry);
}
function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
function registerUnifiedRequestHandlers(gatewayManager: GatewayManager, runtimeManager: RuntimeManager): void {
const providerService = getProviderService();
const handleProxySettingsChange = async () => {
const settings = await getAllSettings();
@@ -508,12 +547,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
}
case 'usage': {
if (request.action === 'recentTokenHistory') {
const payload = request.payload as { limit?: number } | number | undefined;
const limit = typeof payload === 'number' ? payload : payload?.limit;
const safeLimit = typeof limit === 'number' && Number.isFinite(limit)
? Math.max(Math.floor(limit), 1)
: undefined;
data = await getRecentTokenUsageHistory(safeLimit);
data = await getRecentTokenHistoryForRuntime(request.payload, runtimeManager);
break;
}
return {
@@ -686,10 +720,10 @@ function registerCronHandlers(gatewayManager: GatewayManager): void {
/**
* Gateway-related IPC handlers
*/
function registerGatewayHandlers(gatewayManager: GatewayManager): void {
function registerGatewayHandlers(runtimeManager: RuntimeManager): void {
// Get Gateway status
ipcMain.handle('gateway:status', () => {
return gatewayManager.getStatus();
return runtimeManager.getStatus();
});
// Gateway RPC call
@@ -699,7 +733,7 @@ function registerGatewayHandlers(gatewayManager: GatewayManager): void {
method,
params,
timeoutMs,
(rpcMethod, rpcParams, rpcTimeoutMs) => gatewayManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
(rpcMethod, rpcParams, rpcTimeoutMs) => runtimeManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
);
return { success: true, result };
} catch (error) {
@@ -778,7 +812,10 @@ function registerWhatsAppHandlers(mainWindow: BrowserWindow): void {
/**
* Provider-related IPC handlers
*/
function registerProviderHandlers(gatewayManager: GatewayManager): void {
function registerProviderHandlers(
gatewayManager: GatewayManager,
runtimeManager: RuntimeManager,
): void {
const providerService = getProviderService();
const legacyProviderChannelsWarned = new Set<string>();
const logLegacyProviderChannel = (channel: string): void => {
@@ -796,9 +833,14 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`);
gatewayManager.debouncedRestart(8000);
});
browserOAuthManager.on('oauth:success', ({ provider, accountId }) => {
logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`);
gatewayManager.debouncedRestart(8000);
browserOAuthManager.on('oauth:success', async ({ provider, accountId }) => {
try {
if (await runtimeManager.getActiveKind() !== 'openclaw') return;
logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`);
gatewayManager.debouncedRestart(8000);
} catch (error) {
logger.warn('[IPC] Failed to resolve active runtime after browser OAuth success:', error);
}
});
// Get all providers with key info
@@ -1197,12 +1239,9 @@ function registerSettingsHandlers(gatewayManager: GatewayManager): void {
return { success: true, settings };
});
}
function registerUsageHandlers(): void {
ipcMain.handle('usage:recentTokenHistory', async (_, limit?: number) => {
const safeLimit = typeof limit === 'number' && Number.isFinite(limit)
? Math.max(Math.floor(limit), 1)
: undefined;
return await getRecentTokenUsageHistory(safeLimit);
function registerUsageHandlers(runtimeManager: RuntimeManager): void {
ipcMain.handle('usage:recentTokenHistory', async (_, payload?: number | { limit?: number; runtimeKind?: unknown }) => {
return await getRecentTokenHistoryForRuntime(payload, runtimeManager);
});
}
/**
@@ -1286,7 +1325,7 @@ function getMimeType(ext: string): string {
return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream';
}
const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound');
const OPENCLAW_OUTBOUND_DIR = join(getOpenClawMediaDir(), 'outbound');
// ── File preview (sandboxed) ──────────────────────────────────────────
//
@@ -1355,14 +1394,14 @@ function isPathInside(child: string, parent: string): boolean {
*/
function getFilePreviewWriteRoots(): string[] {
const roots: string[] = [];
const openclawDir = join(homedir(), '.openclaw');
roots.push(resolve(openclawDir));
roots.push(resolve(join(homedir(), '.openclaw')));
roots.push(resolve(getCcConnectMediaDir()));
try {
roots.push(resolve(app.getPath('userData')));
} catch {
// ignore — userData should always exist
}
roots.push(resolve(OUTBOUND_DIR));
roots.push(resolve(OPENCLAW_OUTBOUND_DIR));
return roots;
}
+113 -86
View File
@@ -1,30 +1,50 @@
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
const LOCK_SCHEMA = 'clawx-instance-lock';
const LOCK_VERSION = 1;
const LEGACY_LOCK_VERSION = 1;
const STRUCTURED_LOCK_VERSION = 2;
export interface StructuredLockContent {
schema: string;
version: number;
pid: number;
ownerToken?: string;
appVersion?: string;
channel?: string;
executable?: string;
startedAt?: string;
heartbeatAt?: string;
}
export interface ProcessInstanceFileLock {
acquired: boolean;
lockPath: string;
ownerPid?: number;
ownerFormat?: 'legacy' | 'structured' | 'unknown';
ownerDetails?: StructuredLockContent;
release: () => void;
}
export interface ProcessInstanceLockMetadata {
appVersion: string;
channel: string;
executable: string;
startedAt?: string;
}
export interface ProcessInstanceFileLockOptions {
userDataDir: string;
lockName: string;
pid?: number;
isPidAlive?: (pid: number) => boolean;
/**
* When true, unconditionally remove any existing lock file before attempting
* to acquire. Use this when an external mechanism (e.g. Electron's
* `requestSingleInstanceLock`) already guarantees that no other real instance
* is running, so a surviving lock file can only be stale (orphan child
* process, PID recycling on Windows, etc.).
*/
/** Legacy escape hatch. New shared-data-root callers must not use it. */
force?: boolean;
lockPath?: string;
metadata?: ProcessInstanceLockMetadata;
heartbeatIntervalMs?: number;
heartbeatExpiryMs?: number;
}
function defaultPidAlive(pid: number): boolean {
@@ -32,51 +52,35 @@ function defaultPidAlive(pid: number): boolean {
process.kill(pid, 0);
return true;
} catch (error) {
const errno = (error as NodeJS.ErrnoException).code;
return errno !== 'ESRCH';
return (error as NodeJS.ErrnoException).code !== 'ESRCH';
}
}
type ParsedLockOwner =
| { kind: 'legacy'; pid: number }
| { kind: 'structured'; pid: number }
| { kind: 'structured'; pid: number; details: StructuredLockContent }
| { kind: 'unknown' };
interface StructuredLockContent {
schema: string;
version: number;
pid: number;
}
function parsePositivePid(raw: string): number | undefined {
if (!/^\d+$/.test(raw)) {
return undefined;
}
if (!/^\d+$/.test(raw)) return undefined;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return undefined;
}
return parsed;
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
function parseStructuredLockContent(raw: string): StructuredLockContent | undefined {
try {
const parsed = JSON.parse(raw) as Partial<StructuredLockContent>;
if (
parsed?.schema === LOCK_SCHEMA
&& parsed?.version === LOCK_VERSION
&& typeof parsed?.pid === 'number'
parsed.schema === LOCK_SCHEMA
&& (parsed.version === LEGACY_LOCK_VERSION || parsed.version === STRUCTURED_LOCK_VERSION)
&& typeof parsed.pid === 'number'
&& Number.isFinite(parsed.pid)
&& parsed.pid > 0
) {
return {
schema: parsed.schema,
version: parsed.version,
pid: parsed.pid,
};
return parsed as StructuredLockContent;
}
} catch {
// ignore parse errors
// Unknown content is never removed automatically.
}
return undefined;
}
@@ -85,111 +89,135 @@ function readLockOwner(lockPath: string): ParsedLockOwner {
try {
const raw = readFileSync(lockPath, 'utf8').trim();
const legacyPid = parsePositivePid(raw);
if (legacyPid !== undefined) {
return { kind: 'legacy', pid: legacyPid };
}
if (legacyPid !== undefined) return { kind: 'legacy', pid: legacyPid };
const structured = parseStructuredLockContent(raw);
if (structured) {
return { kind: 'structured', pid: structured.pid };
}
if (structured) return { kind: 'structured', pid: structured.pid, details: structured };
} catch {
// ignore read errors
// Missing and unreadable lock files have unknown ownership.
}
return { kind: 'unknown' };
}
function writeLockAtomic(lockPath: string, content: string): void {
const temporaryPath = `${lockPath}.${process.pid}.${randomUUID()}.tmp`;
try {
writeFileSync(temporaryPath, content, { encoding: 'utf8', mode: 0o600 });
renameSync(temporaryPath, lockPath);
} catch (error) {
rmSync(temporaryPath, { force: true });
throw error;
}
}
function heartbeatExpired(owner: ParsedLockOwner, expiryMs: number): boolean {
if (owner.kind !== 'structured') return true;
if (!owner.details.heartbeatAt) return true;
const heartbeat = Date.parse(owner.details.heartbeatAt);
return !Number.isFinite(heartbeat) || Date.now() - heartbeat > expiryMs;
}
export function acquireProcessInstanceFileLock(
options: ProcessInstanceFileLockOptions,
): ProcessInstanceFileLock {
const pid = options.pid ?? process.pid;
const isPidAlive = options.isPidAlive ?? defaultPidAlive;
const lockPath = options.lockPath ?? join(options.userDataDir, `${options.lockName}.instance.lock`);
const heartbeatExpiryMs = options.heartbeatExpiryMs ?? 30_000;
mkdirSync(dirname(lockPath), { recursive: true });
mkdirSync(options.userDataDir, { recursive: true });
const lockPath = join(options.userDataDir, `${options.lockName}.instance.lock`);
// When force mode is enabled, unconditionally remove any existing lock file
// before attempting acquisition. This is safe because an external mechanism
// (Electron's requestSingleInstanceLock) already guarantees exclusivity.
if (options.force && existsSync(lockPath)) {
const staleOwner = readLockOwner(lockPath);
try {
rmSync(lockPath, { force: true });
} catch {
// best-effort; fall through to normal acquisition
}
if (staleOwner.kind !== 'unknown') {
console.info(
`[ClawX] Force-cleaned stale instance lock (pid=${staleOwner.pid}, format=${staleOwner.kind})`,
);
}
rmSync(lockPath, { force: true });
}
let ownerPid: number | undefined;
let ownerFormat: ProcessInstanceFileLock['ownerFormat'] = 'unknown';
let ownerDetails: StructuredLockContent | undefined;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const fd = openSync(lockPath, 'wx');
const ownerToken = randomUUID();
const startedAt = options.metadata?.startedAt ?? new Date().toISOString();
const structuredContent: StructuredLockContent | undefined = options.metadata
? {
schema: LOCK_SCHEMA,
version: STRUCTURED_LOCK_VERSION,
pid,
ownerToken,
appVersion: options.metadata.appVersion,
channel: options.metadata.channel,
executable: options.metadata.executable,
startedAt,
heartbeatAt: startedAt,
}
: undefined;
try {
// Keep writing legacy numeric format for broad backward compatibility.
// Parser accepts both legacy numeric and structured JSON formats.
writeFileSync(fd, String(pid), 'utf8');
writeFileSync(fd, structuredContent ? JSON.stringify(structuredContent) : String(pid), 'utf8');
} finally {
closeSync(fd);
}
let released = false;
const heartbeatTimer = structuredContent
? setInterval(() => {
const currentOwner = readLockOwner(lockPath);
if (currentOwner.kind !== 'structured' || currentOwner.details.ownerToken !== ownerToken) return;
structuredContent.heartbeatAt = new Date().toISOString();
try {
writeLockAtomic(lockPath, JSON.stringify(structuredContent));
} catch {
// A missed heartbeat never transfers ownership.
}
}, options.heartbeatIntervalMs ?? 5_000)
: undefined;
heartbeatTimer?.unref();
return {
acquired: true,
lockPath,
release: () => {
if (released) return;
released = true;
if (heartbeatTimer) clearInterval(heartbeatTimer);
try {
const currentOwner = readLockOwner(lockPath);
if (currentOwner.kind === 'unknown' || currentOwner.pid !== pid) return;
if (
(currentOwner.kind === 'legacy' || currentOwner.kind === 'structured')
&& currentOwner.pid !== pid
) {
return;
}
if (currentOwner.kind === 'unknown') {
return;
}
currentOwner.kind === 'structured'
&& currentOwner.details.ownerToken
&& currentOwner.details.ownerToken !== ownerToken
) return;
rmSync(lockPath, { force: true });
} catch {
// best-effort
// Best effort during shutdown.
}
},
};
} catch (error) {
const errno = (error as NodeJS.ErrnoException).code;
if (errno !== 'EEXIST') {
break;
}
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') break;
const owner = readLockOwner(lockPath);
if (owner.kind === 'legacy' || owner.kind === 'structured') {
ownerPid = owner.pid;
ownerFormat = owner.kind;
ownerDetails = owner.kind === 'structured' ? owner.details : undefined;
} else {
ownerPid = undefined;
ownerFormat = 'unknown';
ownerDetails = undefined;
}
const shouldTreatAsStale =
(owner.kind === 'legacy' || owner.kind === 'structured')
&& !isPidAlive(owner.pid);
if (shouldTreatAsStale && existsSync(lockPath)) {
const stale = (owner.kind === 'legacy' || owner.kind === 'structured')
&& !isPidAlive(owner.pid)
&& heartbeatExpired(owner, heartbeatExpiryMs);
if (stale && existsSync(lockPath)) {
try {
rmSync(lockPath, { force: true });
continue;
} catch {
// If deletion fails, treat as held lock.
// Treat an undeletable stale lock as held.
}
}
break;
}
}
@@ -199,8 +227,7 @@ export function acquireProcessInstanceFileLock(
lockPath,
ownerPid,
ownerFormat,
release: () => {
// no-op when lock wasn't acquired
},
ownerDetails,
release: () => {},
};
}
+214
View File
@@ -0,0 +1,214 @@
import type { Session, WebContents, WebPreferences } from 'electron';
import {
WEB_BROWSER_INITIAL_URL,
WEB_BROWSER_PARTITION,
WEB_BROWSER_USER_AGENT,
normalizeWebBrowserTopLevelUrl,
} from '../../shared/web-browser';
import { logger } from '../utils/logger';
const DENY_WINDOW_OPEN = { action: 'deny' } as const;
export class WebBrowserGuestRegistry {
private guest: WebContents | null = null;
private pendingAttachment = false;
beginAttachment(): boolean {
this.dropDestroyedGuest();
if (this.pendingAttachment || this.guest) {
return false;
}
this.pendingAttachment = true;
return true;
}
completeAttachment(guest: WebContents): void {
if (!this.pendingAttachment || this.guest) {
return;
}
this.pendingAttachment = false;
this.guest = guest;
guest.once('destroyed', () => {
if (this.guest === guest) {
this.guest = null;
}
});
}
cancelAttachment(): void {
this.pendingAttachment = false;
}
current(): WebContents | null {
this.dropDestroyedGuest();
return this.guest;
}
owns(contents: WebContents | null): boolean {
return contents !== null && this.current() === contents;
}
hasLiveGuest(): boolean {
return this.current() !== null;
}
private dropDestroyedGuest(): void {
if (this.guest?.isDestroyed()) {
this.guest = null;
}
}
}
export function isExpectedWebBrowserAttachment(
params: Record<string, unknown>,
): boolean {
return params.partition === WEB_BROWSER_PARTITION
&& params.src === WEB_BROWSER_INITIAL_URL
&& params.useragent === WEB_BROWSER_USER_AGENT
&& params.allowpopups === true
&& params.preload === '';
}
export function hardenWebBrowserPreferences(preferences: WebPreferences): void {
delete preferences.preload;
preferences.nodeIntegration = false;
preferences.nodeIntegrationInSubFrames = false;
preferences.nodeIntegrationInWorker = false;
preferences.plugins = false;
preferences.allowRunningInsecureContent = false;
preferences.contextIsolation = true;
preferences.sandbox = true;
preferences.webSecurity = true;
}
export function installWebBrowserGuestPolicy(
embedder: WebContents,
options: {
browserSession: Session;
registry: WebBrowserGuestRegistry;
},
): () => void {
const { browserSession, registry } = options;
let attachmentPending = false;
let cleanupGuestPolicy: (() => void) | null = null;
const handleWillAttach = (
event: Electron.Event,
preferences: WebPreferences,
params: Record<string, unknown>,
): void => {
if (!isExpectedWebBrowserAttachment(params)) {
logger.warn('[WebBrowser] Rejected webview attachment with unexpected identity');
event.preventDefault();
return;
}
if (!registry.beginAttachment()) {
logger.warn('[WebBrowser] Rejected additional webview attachment');
event.preventDefault();
return;
}
attachmentPending = true;
hardenWebBrowserPreferences(preferences);
};
const handleDidAttach = (_event: Electron.Event, guest: WebContents): void => {
if (!attachmentPending) {
logger.warn('[WebBrowser] Ignored attached guest without a reserved slot');
return;
}
attachmentPending = false;
if (guest.getType() !== 'webview' || guest.session !== browserSession) {
logger.warn('[WebBrowser] Rejected attached guest with unexpected type or session');
registry.cancelAttachment();
return;
}
registry.completeAttachment(guest);
if (!registry.owns(guest)) {
logger.warn('[WebBrowser] Failed to register reserved guest');
return;
}
guest.setUserAgent(WEB_BROWSER_USER_AGENT);
const rejectDisallowedNavigation = (
details: Electron.Event<Electron.WebContentsWillNavigateEventParams>,
): void => {
if (!details.isMainFrame || normalizeWebBrowserTopLevelUrl(details.url) !== null) {
return;
}
logger.warn(`[WebBrowser] Blocked top-level navigation to ${details.url}`);
details.preventDefault();
};
const rejectDisallowedRedirect = (
details: Electron.Event<Electron.WebContentsWillRedirectEventParams>,
): void => {
if (!details.isMainFrame || normalizeWebBrowserTopLevelUrl(details.url) !== null) {
return;
}
logger.warn(`[WebBrowser] Blocked top-level redirect to ${details.url}`);
details.preventDefault();
};
// Same-tab fallback cannot preserve window.opener, returned window handles, or full POST/referrer fidelity.
guest.setWindowOpenHandler(({ url }) => {
const target = normalizeWebBrowserTopLevelUrl(url);
if (!target || !registry.owns(guest)) {
logger.warn(`[WebBrowser] Blocked popup target ${url}`);
return DENY_WINDOW_OPEN;
}
try {
void guest.loadURL(target).catch((error) => {
logger.warn(`[WebBrowser] Failed to load popup target ${target}:`, error);
});
} catch (error) {
logger.warn(`[WebBrowser] Failed to load popup target ${target}:`, error);
}
return DENY_WINDOW_OPEN;
});
let cleaned = false;
const cleanup = (): void => {
if (cleaned) {
return;
}
cleaned = true;
guest.off('will-navigate', rejectDisallowedNavigation);
guest.off('will-redirect', rejectDisallowedRedirect);
guest.off('destroyed', cleanup);
if (!guest.isDestroyed()) {
guest.setWindowOpenHandler(() => DENY_WINDOW_OPEN);
}
if (cleanupGuestPolicy === cleanup) {
cleanupGuestPolicy = null;
}
};
guest.on('will-navigate', rejectDisallowedNavigation);
guest.on('will-redirect', rejectDisallowedRedirect);
guest.once('destroyed', cleanup);
cleanupGuestPolicy = cleanup;
};
embedder.on('will-attach-webview', handleWillAttach);
embedder.on('did-attach-webview', handleDidAttach);
return () => {
embedder.off('will-attach-webview', handleWillAttach);
embedder.off('did-attach-webview', handleDidAttach);
attachmentPending = false;
registry.cancelAttachment();
cleanupGuestPolicy?.();
};
}
+140
View File
@@ -0,0 +1,140 @@
import {
dialog,
session,
type BrowserWindow,
type MessageBoxOptions,
type MessageBoxReturnValue,
type Session,
} from 'electron';
import { WEB_BROWSER_PERMISSION_LABELS } from '@shared/i18n/resources';
import { resolveSupportedLanguage } from '@shared/language';
import {
WEB_BROWSER_PARTITION,
WEB_BROWSER_USER_AGENT,
} from '@shared/web-browser';
import { logger } from '../utils/logger';
import { getSetting } from '../utils/store';
import type { WebBrowserGuestRegistry } from './web-browser-policy';
const CLIPBOARD_PERMISSIONS = new Set([
'clipboard-read',
'clipboard-sanitized-write',
'deprecated-sync-clipboard-read',
]);
const DOWNLOAD_OBSERVED_SESSIONS = new WeakSet<Session>();
export interface ConfigureWebBrowserSessionOptions {
registry: WebBrowserGuestRegistry;
getMainWindow: () => BrowserWindow | null;
getLanguage?: () => Promise<string | undefined>;
showMessageBox?: (
window: BrowserWindow,
options: MessageBoxOptions,
) => Promise<MessageBoxReturnValue>;
}
export function configureWebBrowserSession(
options: ConfigureWebBrowserSessionOptions,
): Session {
const browserSession = session.fromPartition(WEB_BROWSER_PARTITION, { cache: true });
const getLanguage = options.getLanguage ?? (() => getSetting('language'));
// Resolve the method at request time so Electron E2E tests can replace the native dialog after startup.
const showMessageBox = options.showMessageBox
?? ((window, messageOptions) => dialog.showMessageBox(window, messageOptions));
// The macOS UA is fixed on every platform for stable website compatibility and deterministic requests.
browserSession.setUserAgent(WEB_BROWSER_USER_AGENT);
browserSession.setPermissionCheckHandler((_contents, permission) => (
CLIPBOARD_PERMISSIONS.has(permission)
));
browserSession.setPermissionRequestHandler((contents, permission, callback, details) => {
let callbackCalled = false;
const respond = (allowed: boolean): void => {
if (callbackCalled) return;
callbackCalled = true;
callback(allowed);
};
if (CLIPBOARD_PERMISSIONS.has(permission)) {
respond(true);
return;
}
if (permission === 'geolocation') {
// ClawX has no location service, so websites cannot receive a meaningful location.
respond(false);
return;
}
if (permission !== 'media' || !options.registry.owns(contents)) {
respond(false);
return;
}
const mediaDetails = details as Electron.MediaAccessPermissionRequest;
const mediaTypes = new Set(mediaDetails.mediaTypes ?? []);
const requestsCamera = mediaTypes.has('video');
const requestsMicrophone = mediaTypes.has('audio');
if (!requestsCamera && !requestsMicrophone) {
respond(false);
return;
}
const mainWindow = options.getMainWindow();
if (!mainWindow) {
respond(false);
return;
}
void (async () => {
try {
const language = resolveSupportedLanguage(await getLanguage());
const labels = WEB_BROWSER_PERMISSION_LABELS[language];
const capability = requestsCamera && requestsMicrophone
? labels.cameraAndMicrophone
: requestsCamera
? labels.camera
: labels.microphone;
const origin = mediaDetails.securityOrigin || mediaDetails.requestingUrl;
if (!options.registry.owns(contents)) {
respond(false);
return;
}
const result = await showMessageBox(mainWindow, {
type: 'question',
title: labels.title,
message: labels.message
.replace('{{origin}}', origin)
.replace('{{capability}}', capability),
buttons: [labels.allow, labels.deny],
defaultId: 0,
cancelId: 1,
noLink: true,
});
respond(result.response === 0 && options.registry.owns(contents));
} catch (error) {
logger.warn('[WebBrowser] Native media permission dialog failed:', error);
respond(false);
}
})();
});
if (!DOWNLOAD_OBSERVED_SESSIONS.has(browserSession)) {
DOWNLOAD_OBSERVED_SESSIONS.add(browserSession);
// Preserve Electron's default save location and UI by observing without cancelling or setting a path.
browserSession.on('will-download', (_event, item) => {
item.once('done', (_doneEvent, state) => {
if (state === 'interrupted') {
logger.warn('[WebBrowser] Download interrupted');
}
});
});
}
// This isolated browser Session intentionally does not mirror client proxy settings or recycle connections.
return browserSession;
}
@@ -0,0 +1,115 @@
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { app } from 'electron';
import { getClawXDataLayout, resolveClawXDataRoot } from '../utils/clawx-data-layout';
export type CcConnectPermissionMode = 'suggest' | 'full-auto';
type AgentBinding = {
providerAccountId?: string;
permissionMode?: CcConnectPermissionMode;
updatedAt: string;
};
type AgentBindingDocument = {
schema: 'clawx-agent-bindings';
version: 1;
agents: Record<string, AgentBinding>;
};
function bindingsPath(): string {
const layout = getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData')));
return join(layout.appDir, 'agent-bindings.json');
}
async function readDocument(): Promise<AgentBindingDocument> {
try {
const parsed = JSON.parse(await readFile(bindingsPath(), 'utf8')) as Partial<AgentBindingDocument>;
if (parsed.schema === 'clawx-agent-bindings' && parsed.version === 1 && parsed.agents) {
return parsed as AgentBindingDocument;
}
} catch {
// Missing or malformed bindings start empty and are replaced atomically on write.
}
return { schema: 'clawx-agent-bindings', version: 1, agents: {} };
}
async function writeDocument(document: AgentBindingDocument): Promise<void> {
const path = bindingsPath();
await mkdir(dirname(path), { recursive: true });
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
await writeFile(temporaryPath, `${JSON.stringify(document, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
await rename(temporaryPath, path);
}
export async function listCcConnectAgentProviderBindings(): Promise<Record<string, string>> {
const document = await readDocument();
return Object.fromEntries(Object.entries(document.agents).flatMap(([agentId, binding]) => (
binding.providerAccountId ? [[agentId, binding.providerAccountId]] : []
)));
}
export async function listCcConnectAgentPermissionModes(): Promise<Record<string, CcConnectPermissionMode>> {
const document = await readDocument();
return Object.fromEntries(Object.entries(document.agents).flatMap(([agentId, binding]) => (
binding.permissionMode === 'suggest' || binding.permissionMode === 'full-auto'
? [[agentId, binding.permissionMode]]
: []
)));
}
export async function setCcConnectAgentProviderBinding(
agentId: string,
providerAccountId: string | null,
): Promise<void> {
const normalizedAgentId = agentId.trim();
if (!normalizedAgentId) throw new Error('agentId is required');
const document = await readDocument();
const normalizedAccountId = providerAccountId?.trim();
if (normalizedAccountId) {
document.agents[normalizedAgentId] = {
...document.agents[normalizedAgentId],
providerAccountId: normalizedAccountId,
updatedAt: new Date().toISOString(),
};
} else {
const existing = document.agents[normalizedAgentId];
if (existing?.permissionMode) {
document.agents[normalizedAgentId] = {
permissionMode: existing.permissionMode,
updatedAt: new Date().toISOString(),
};
} else {
delete document.agents[normalizedAgentId];
}
}
await writeDocument(document);
}
export async function setCcConnectAgentPermissionMode(
agentId: string,
permissionMode: CcConnectPermissionMode,
): Promise<void> {
const normalizedAgentId = agentId.trim();
if (!normalizedAgentId) throw new Error('agentId is required');
if (permissionMode !== 'suggest' && permissionMode !== 'full-auto') {
throw new Error('permissionMode must be suggest or full-auto');
}
const document = await readDocument();
document.agents[normalizedAgentId] = {
...document.agents[normalizedAgentId],
permissionMode,
updatedAt: new Date().toISOString(),
};
await writeDocument(document);
}
export async function deleteCcConnectAgentBinding(agentId: string): Promise<void> {
const normalizedAgentId = agentId.trim();
if (!normalizedAgentId) return;
const document = await readDocument();
if (!(normalizedAgentId in document.agents)) return;
delete document.agents[normalizedAgentId];
await writeDocument(document);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
import { chmod, mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { getCcConnectManagedDir } from './cc-connect-paths';
function safeName(value: string): string {
return encodeURIComponent(value.trim() || 'default').replace(/%/g, '_');
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
export async function ensureCcConnectCodexLauncher(options: {
accountId: string;
codexHomeDir: string;
codexPath: string;
envAliases?: Record<string, string>;
}): Promise<string> {
const launchersDir = join(getCcConnectManagedDir(), 'config', 'launchers');
await mkdir(launchersDir, { recursive: true });
const baseName = `codex-${safeName(options.accountId)}`;
if (process.platform === 'win32') {
const path = join(launchersDir, `${baseName}.cmd`);
const content = [
'@echo off',
`set "CODEX_HOME=${options.codexHomeDir}"`,
...Object.entries(options.envAliases ?? {}).map(([target, source]) => `set "${target}=%${source}%"`),
`"${options.codexPath.replace(/"/g, '""')}" %*`,
'',
].join('\r\n');
await writeFile(path, content, { encoding: 'utf8', mode: 0o700 });
return path;
}
const path = join(launchersDir, baseName);
const content = [
'#!/bin/sh',
`export CODEX_HOME=${shellQuote(options.codexHomeDir)}`,
...Object.entries(options.envAliases ?? {}).map(([target, source]) => `export ${target}="\${${source}}"`),
`exec ${shellQuote(options.codexPath)} "$@"`,
'',
].join('\n');
await writeFile(path, content, { encoding: 'utf8', mode: 0o700 });
await chmod(path, 0o700);
return path;
}
@@ -0,0 +1,452 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import type { RawMessage } from '@shared/chat/types';
const MAX_TRANSCRIPT_SEARCH_DEPTH = 6;
const MAX_TOOL_OUTPUT_CHARS = 16_000;
const TRANSCRIPT_TURN_MATCH_WINDOW_MS = 2 * 60_000;
const MAX_TRANSCRIPT_FILE_CACHE_ENTRIES = 512;
const MAX_TRANSCRIPT_PATH_CACHE_ENTRIES = 2_048;
const MAX_FALLBACK_TURN_HINTS = 20;
const MAX_FALLBACK_DIRECTORIES = 12;
const MAX_FALLBACK_CANDIDATE_FILES = 64;
const MAX_FALLBACK_FILE_BYTES = 8 * 1024 * 1024;
const MAX_FALLBACK_TOTAL_BYTES = 32 * 1024 * 1024;
type CachedTranscriptFile = {
mtimeMs: number;
size: number;
jsonl: string;
turnMetadata?: {
sessionTimestamp?: number;
sessionWorkDir?: string;
userTurns: Array<{
content: string;
timestamp?: number;
}>;
};
toolMessages?: RawMessage[];
};
const transcriptFileCache = new Map<string, CachedTranscriptFile>();
const transcriptPathBySessionId = new Map<string, string>();
function setBoundedCache<K, V>(cache: Map<K, V>, key: K, value: V, maxEntries: number): void {
cache.delete(key);
cache.set(key, value);
while (cache.size > maxEntries) {
const oldestKey = cache.keys().next().value;
if (oldestKey === undefined) break;
cache.delete(oldestKey);
}
}
export type CcConnectTranscriptTurnHint = {
content: string;
timestamp: number;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function parseTimestamp(value: unknown): number | undefined {
if (typeof value !== 'string' || !value.trim()) return undefined;
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function parseToolArguments(value: unknown): unknown {
if (typeof value !== 'string') return value ?? {};
const trimmed = value.trim();
if (!trimmed) return {};
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
function displayToolName(name: string): string {
switch (name) {
case 'exec_command':
return 'Bash';
case 'apply_patch':
return 'Patch';
case 'web_search':
case 'web_search_call':
return 'Web Search';
default:
return name || 'tool';
}
}
function toolOutputIsError(output: string): boolean {
const exitCode = output.match(/\bProcess exited with code (\d+)\b/i)?.[1];
return exitCode !== undefined && Number(exitCode) !== 0;
}
function toolOutputText(value: unknown): string {
if (typeof value === 'string') return value;
return JSON.stringify(value ?? '');
}
function truncateToolOutput(output: string): string {
return output.length > MAX_TOOL_OUTPUT_CHARS
? `${output.slice(0, MAX_TOOL_OUTPUT_CHARS)}\n… output truncated by ClawX`
: output;
}
async function readTranscriptFile(path: string): Promise<CachedTranscriptFile | null> {
const metadata = await stat(path).catch(() => null);
if (!metadata) return null;
const cached = transcriptFileCache.get(path);
if (cached && cached.mtimeMs === metadata.mtimeMs && cached.size === metadata.size) {
setBoundedCache(transcriptFileCache, path, cached, MAX_TRANSCRIPT_FILE_CACHE_ENTRIES);
return cached;
}
const jsonl = await readFile(path, 'utf8').catch(() => '');
const entry = {
mtimeMs: metadata.mtimeMs,
size: metadata.size,
jsonl,
};
setBoundedCache(transcriptFileCache, path, entry, MAX_TRANSCRIPT_FILE_CACHE_ENTRIES);
return entry;
}
async function findTranscriptFile(
directory: string,
agentSessionId: string,
depth = 0,
): Promise<string | undefined> {
if (depth > MAX_TRANSCRIPT_SEARCH_DEPTH) return undefined;
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (!entry.isFile()) continue;
if (entry.name.endsWith('.jsonl') && entry.name.includes(agentSessionId)) {
return join(directory, entry.name);
}
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const match = await findTranscriptFile(join(directory, entry.name), agentSessionId, depth + 1);
if (match) return match;
}
return undefined;
}
function transcriptDateParts(timestamp: number, utc: boolean): [string, string, string] {
const date = new Date(timestamp);
const year = utc ? date.getUTCFullYear() : date.getFullYear();
const month = (utc ? date.getUTCMonth() : date.getMonth()) + 1;
const day = utc ? date.getUTCDate() : date.getDate();
return [String(year), String(month).padStart(2, '0'), String(day).padStart(2, '0')];
}
function transcriptCandidateDateParts(timestamp: number): Array<[string, string, string]> {
const candidates = [
transcriptDateParts(timestamp - 24 * 60 * 60_000, false),
transcriptDateParts(timestamp, false),
transcriptDateParts(timestamp + 24 * 60 * 60_000, false),
transcriptDateParts(timestamp, true),
];
return Array.from(new Map(candidates.map((parts) => [parts.join('/'), parts])).values());
}
function transcriptTurnMetadata(file: CachedTranscriptFile): NonNullable<CachedTranscriptFile['turnMetadata']> {
if (file.turnMetadata) return file.turnMetadata;
let sessionTimestamp: number | undefined;
let sessionWorkDir: string | undefined;
const userTurns: NonNullable<CachedTranscriptFile['turnMetadata']>['userTurns'] = [];
for (const line of file.jsonl.split(/\r?\n/)) {
if (!line.trim()) continue;
let record: Record<string, unknown>;
try {
const parsed = JSON.parse(line);
if (!isRecord(parsed)) continue;
record = parsed;
} catch {
continue;
}
if (record.type === 'session_meta' && isRecord(record.payload)) {
sessionTimestamp = parseTimestamp(record.payload.timestamp) ?? parseTimestamp(record.timestamp);
sessionWorkDir = typeof record.payload.cwd === 'string' ? record.payload.cwd : undefined;
continue;
}
if (record.type !== 'response_item' || !isRecord(record.payload)) continue;
const payload = record.payload;
if (payload.type !== 'message' || payload.role !== 'user' || !Array.isArray(payload.content)) continue;
const timestamp = parseTimestamp(record.timestamp) ?? sessionTimestamp;
for (const item of payload.content) {
if (!isRecord(item) || item.type !== 'input_text' || typeof item.text !== 'string') continue;
userTurns.push({
content: item.text.trim(),
...(timestamp !== undefined ? { timestamp } : {}),
});
}
}
file.turnMetadata = { sessionTimestamp, sessionWorkDir, userTurns };
return file.turnMetadata;
}
function transcriptMatchesWorkDir(file: CachedTranscriptFile, expectedWorkDir?: string): boolean {
if (!expectedWorkDir) return true;
const { sessionWorkDir } = transcriptTurnMetadata(file);
return sessionWorkDir !== undefined && resolve(sessionWorkDir) === resolve(expectedWorkDir);
}
function transcriptMatchesTurn(
file: CachedTranscriptFile,
hints: CcConnectTranscriptTurnHint[],
expectedWorkDir?: string,
): boolean {
const { userTurns } = transcriptTurnMetadata(file);
if (userTurns.length === 0 || !transcriptMatchesWorkDir(file, expectedWorkDir)) return false;
return hints.some((hint) => userTurns.some((turn) => (
turn.timestamp !== undefined
&& Math.abs(turn.timestamp - hint.timestamp) <= TRANSCRIPT_TURN_MATCH_WINDOW_MS
&& turn.content === hint.content.trim()
)));
}
async function findTurnTranscriptFiles(
codexHomeDir: string,
hints: CcConnectTranscriptTurnHint[],
expectedWorkDir?: string,
): Promise<string[]> {
const sessionRoot = join(codexHomeDir, 'sessions');
const directories = new Map<string, string>();
const recentHints = [...hints]
.sort((left, right) => right.timestamp - left.timestamp)
.slice(0, MAX_FALLBACK_TURN_HINTS);
for (const hint of recentHints) {
for (const parts of transcriptCandidateDateParts(hint.timestamp)) {
const directory = join(sessionRoot, ...parts);
directories.set(directory, directory);
if (directories.size >= MAX_FALLBACK_DIRECTORIES) break;
}
if (directories.size >= MAX_FALLBACK_DIRECTORIES) break;
}
const matches: string[] = [];
let candidateFiles = 0;
let candidateBytes = 0;
for (const directory of directories.values()) {
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
const transcriptEntries = entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'))
.sort((left, right) => right.name.localeCompare(left.name));
for (const entry of transcriptEntries) {
if (candidateFiles >= MAX_FALLBACK_CANDIDATE_FILES) return matches;
candidateFiles += 1;
const path = join(directory, entry.name);
const metadata = await stat(path).catch(() => null);
if (!metadata || metadata.size > MAX_FALLBACK_FILE_BYTES) continue;
if (candidateBytes + metadata.size > MAX_FALLBACK_TOTAL_BYTES) return matches;
candidateBytes += metadata.size;
const file = await readTranscriptFile(path);
if (file?.jsonl && transcriptMatchesTurn(file, recentHints, expectedWorkDir)) matches.push(path);
}
}
return matches;
}
export function parseCcConnectCodexTranscriptTools(jsonl: string): RawMessage[] {
const messages: RawMessage[] = [];
const toolNamesByCallId = new Map<string, string>();
for (const line of jsonl.split(/\r?\n/)) {
if (!line.trim()) continue;
let record: Record<string, unknown>;
try {
const parsed = JSON.parse(line);
if (!isRecord(parsed)) continue;
record = parsed;
} catch {
continue;
}
if (record.type !== 'response_item' || !isRecord(record.payload)) continue;
const payload = record.payload;
const payloadType = typeof payload.type === 'string' ? payload.type : '';
const callId = typeof payload.call_id === 'string'
? payload.call_id.trim()
: typeof payload.id === 'string'
? payload.id.trim()
: '';
if (!callId) continue;
const timestamp = parseTimestamp(record.timestamp);
if (payloadType === 'function_call' || payloadType === 'custom_tool_call') {
const rawName = typeof payload.name === 'string' ? payload.name.trim() : '';
const name = displayToolName(rawName);
toolNamesByCallId.set(callId, name);
messages.push({
id: `cc-connect-codex-tool-${callId}`,
role: 'assistant',
content: [{
type: 'toolCall',
id: callId,
name,
arguments: parseToolArguments(payload.arguments ?? payload.input),
}],
...(timestamp !== undefined ? { timestamp } : {}),
stopReason: 'tool_use',
});
continue;
}
if (payloadType === 'function_call_output' || payloadType === 'custom_tool_call_output') {
const rawOutput = toolOutputText(payload.output ?? payload.content);
const output = truncateToolOutput(rawOutput);
const name = toolNamesByCallId.get(callId) || 'tool';
const isError = toolOutputIsError(rawOutput);
messages.push({
id: `cc-connect-codex-tool-result-${callId}`,
role: 'toolresult',
toolCallId: callId,
toolName: name,
content: output,
details: {
status: isError ? 'error' : 'completed',
aggregated: output,
},
...(isError ? { isError: true } : {}),
...(timestamp !== undefined ? { timestamp } : {}),
});
continue;
}
if (payloadType === 'web_search_call') {
const name = 'Web Search';
messages.push({
id: `cc-connect-codex-tool-${callId}`,
role: 'assistant',
content: [{
type: 'toolCall',
id: callId,
name,
arguments: payload.action ?? {},
}],
...(timestamp !== undefined ? { timestamp } : {}),
stopReason: 'tool_use',
});
const status = typeof payload.status === 'string' ? payload.status.toLowerCase() : '';
const isError = ['cancelled', 'error', 'failed'].includes(status);
if (status === 'completed' || isError) {
const output = isError ? `Web search ${status}` : 'Web search completed';
messages.push({
id: `cc-connect-codex-tool-result-${callId}`,
role: 'toolresult',
toolCallId: callId,
toolName: name,
content: output,
details: {
status: isError ? 'error' : 'completed',
aggregated: output,
},
...(isError ? { isError: true } : {}),
...(timestamp !== undefined ? { timestamp } : {}),
});
}
continue;
}
if (payloadType === 'mcp_tool_call') {
const server = typeof payload.server === 'string' ? payload.server : '';
const tool = typeof payload.tool === 'string'
? payload.tool
: typeof payload.name === 'string'
? payload.name
: 'tool';
const name = server ? `${server}: ${tool}` : tool;
messages.push({
id: `cc-connect-codex-tool-${callId}`,
role: 'assistant',
content: [{
type: 'toolCall',
id: callId,
name,
arguments: parseToolArguments(payload.arguments ?? payload.input),
}],
...(timestamp !== undefined ? { timestamp } : {}),
stopReason: 'tool_use',
});
if (payload.result !== undefined || payload.error !== undefined) {
const isError = payload.error !== undefined;
const output = truncateToolOutput(toolOutputText(payload.error ?? payload.result));
messages.push({
id: `cc-connect-codex-tool-result-${callId}`,
role: 'toolresult',
toolCallId: callId,
toolName: name,
content: output,
details: {
status: isError ? 'error' : 'completed',
aggregated: output,
},
...(isError ? { isError: true } : {}),
...(timestamp !== undefined ? { timestamp } : {}),
});
}
}
}
return messages;
}
export async function loadCcConnectCodexTranscriptTools(
codexHomeDirs: string | Iterable<string>,
agentSessionId: string,
turnHints: CcConnectTranscriptTurnHint[] = [],
expectedWorkDir?: string,
): Promise<RawMessage[]> {
const hasValidAgentSessionId = /^[A-Za-z0-9_-]+$/.test(agentSessionId);
if (!hasValidAgentSessionId && turnHints.length === 0) return [];
const homes = typeof codexHomeDirs === 'string'
? [codexHomeDirs]
: Array.from(codexHomeDirs);
const uniqueHomes = Array.from(new Set(homes.filter(Boolean)));
const idMatchedPaths = new Set<string>();
for (const codexHomeDir of uniqueHomes) {
if (hasValidAgentSessionId) {
const sessionPathCacheKey = `${resolve(codexHomeDir)}\0${agentSessionId}`;
let transcriptPath = transcriptPathBySessionId.get(sessionPathCacheKey);
if (!transcriptPath) {
transcriptPath = await findTranscriptFile(join(codexHomeDir, 'sessions'), agentSessionId);
}
if (transcriptPath) {
setBoundedCache(
transcriptPathBySessionId,
sessionPathCacheKey,
transcriptPath,
MAX_TRANSCRIPT_PATH_CACHE_ENTRIES,
);
const file = await readTranscriptFile(transcriptPath);
const matchesPublicTurn = turnHints.length === 0
|| (file !== null && transcriptMatchesTurn(file, turnHints, expectedWorkDir));
if (file?.jsonl && transcriptMatchesWorkDir(file, expectedWorkDir) && matchesPublicTurn) {
idMatchedPaths.add(transcriptPath);
}
}
}
}
let transcriptPaths = new Set<string>();
if (idMatchedPaths.size === 1) {
transcriptPaths = idMatchedPaths;
} else if (idMatchedPaths.size === 0) {
const fallbackPaths = new Set<string>();
for (const codexHomeDir of uniqueHomes) {
for (const path of await findTurnTranscriptFiles(codexHomeDir, turnHints, expectedWorkDir)) {
fallbackPaths.add(path);
}
}
if (fallbackPaths.size === 1) transcriptPaths = fallbackPaths;
}
const messages: RawMessage[] = [];
for (const transcriptPath of transcriptPaths) {
const file = await readTranscriptFile(transcriptPath);
if (!file?.jsonl) continue;
file.toolMessages ??= parseCcConnectCodexTranscriptTools(file.jsonl);
messages.push(...file.toolMessages);
}
return messages.sort((left, right) => (left.timestamp ?? 0) - (right.timestamp ?? 0));
}
@@ -0,0 +1,8 @@
export const CC_CONNECT_MANAGEMENT_PORT = 9820;
export function buildCcConnectWebAdminUrl(port = CC_CONNECT_MANAGEMENT_PORT): string {
const normalizedPort = Number.isFinite(port) && port > 0
? Math.trunc(port)
: CC_CONNECT_MANAGEMENT_PORT;
return `http://127.0.0.1:${normalizedPort}/`;
}
+63
View File
@@ -0,0 +1,63 @@
import { app } from 'electron';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { getClawXDataLayout, resolveClawXDataRoot } from '../utils/clawx-data-layout';
function binaryName(): string {
return process.platform === 'win32' ? 'cc-connect.exe' : 'cc-connect';
}
export function getCcConnectManagedDir(): string {
return getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData'))).ccConnectRuntimeDir;
}
export function getCcConnectConfigPath(): string {
return join(getCcConnectManagedDir(), 'config.toml');
}
export function getCcConnectCodexHomeDir(): string {
return join(getCcConnectManagedDir(), 'codex-home');
}
export function getCcConnectAccountCodexHomeDir(accountId: string): string {
const normalized = accountId.trim() || 'default';
const safeAccountId = encodeURIComponent(normalized).replace(/%/g, '_');
const layout = getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData')));
return join(layout.credentialsDir, 'oauth', safeAccountId, 'codex-home');
}
export function getCcConnectWorkspacesDir(): string {
return getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData'))).agentWorkspacesDir;
}
export function getCcConnectAgentWorkspaceDir(agentId = 'main'): string {
const safeAgentId = agentId.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-') || 'main';
return join(getCcConnectWorkspacesDir(), safeAgentId);
}
export function getCcConnectProviderProfilePath(): string {
return join(getCcConnectManagedDir(), 'provider-profile.json');
}
export function getCcConnectBinaryPath(): string {
if (!app.isPackaged && process.env.CLAWX_CC_CONNECT_PATH) {
return process.env.CLAWX_CC_CONNECT_PATH;
}
if (app.isPackaged) {
return join(process.resourcesPath, 'cc-connect', binaryName());
}
const bundledDevBinary = join(process.cwd(), 'build', 'cc-connect', `${process.platform}-${process.arch}`, binaryName());
if (existsSync(bundledDevBinary)) {
return bundledDevBinary;
}
return bundledDevBinary;
}
export function assertCcConnectBinaryPath(candidate = getCcConnectBinaryPath()): string {
if (!existsSync(candidate)) {
throw new Error(
`cc-connect binary not found at ${candidate}. Run pnpm run bundle:cc-connect:current before selecting cc-connect runtime.`,
);
}
return candidate;
}
@@ -0,0 +1,786 @@
import { access, chmod, cp, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { app } from 'electron';
import { getProviderAccount, getDefaultProviderAccountId } from '@electron/services/providers/provider-store';
import { getProviderSecret, getSecretStore } from '@electron/services/secrets/secret-store';
import { getProviderDefaultModel } from '@electron/utils/provider-registry';
import type { ProviderAccount, ProviderSecret } from '@electron/shared/providers/types';
import {
getCcConnectAccountCodexHomeDir,
getCcConnectCodexHomeDir,
getCcConnectProviderProfilePath,
} from './cc-connect-paths';
export type CodexProviderProfile = {
providerId: string | null;
vendorId: string | null;
label?: string;
authMode?: string;
model?: string;
modelRef?: string;
supported: boolean;
unsupportedReason?: string;
codexArgs: string[];
env?: Record<string, string>;
envKeys?: string[];
launcherEnv?: Record<string, string>;
ccConnectProvider?: {
name: string;
apiKeyEnvKey?: string;
baseUrl?: string;
model?: string;
wireApi?: 'responses';
};
secretAvailable: boolean;
codexHomeDir?: string;
updatedAt: string;
};
type OpenAIOAuthTokenSet = {
idToken: string;
accessToken: string;
refreshToken: string;
accountId: string;
};
type OpenAIOAuthTokenResolution = {
tokens: OpenAIOAuthTokenSet;
source: 'managed' | 'secret';
};
type OpenAIOAuthTokenResolutionOptions = {
preferSecret?: boolean;
};
export type CodexOAuthAuthFileSummary = {
path: string;
exists: boolean;
complete: boolean;
accountId?: string;
authMode?: string;
lastRefresh?: string;
updatedAt?: string;
error?: string;
};
export type CodexOAuthProviderSummary = {
accountId: string;
vendorId: string;
authMode?: string;
hasOAuthSecret: boolean;
subject?: string;
email?: string;
managedMatchesAccount?: boolean;
userMatchesAccount?: boolean;
};
export type CodexOAuthStatus = {
success: true;
managedCodexHome: string;
authPath: string;
managed: CodexOAuthAuthFileSummary;
user: CodexOAuthAuthFileSummary;
provider?: CodexOAuthProviderSummary;
};
function resolveModel(account: ProviderAccount): string | undefined {
const model = account.model?.trim();
if (model) return model;
return getProviderDefaultModel(account.vendorId)?.trim() || undefined;
}
function publicProfile(profile: CodexProviderProfile): CodexProviderProfile {
const { env, ...rest } = profile;
return {
...rest,
envKeys: Object.keys(env ?? {}),
};
}
function tomlString(value: string): string {
return JSON.stringify(value);
}
function tomlInlineStringMap(values: Record<string, string>): string {
return `{ ${Object.entries(values).map(([key, value]) => `${tomlString(key)} = ${tomlString(value)}`).join(', ')} }`;
}
function normalizeOpenAIResponsesBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, '').replace(/\/responses$/i, '');
}
function normalizeModelHubCodexResponsesBaseUrl(baseUrl: string): string | null {
const trimmed = baseUrl.trim();
if (!trimmed) return null;
try {
const url = new URL(trimmed);
if (url.hostname !== 'aidp.bytedance.net') return null;
if (!url.pathname.startsWith('/api/modelhub/online')) return null;
url.pathname = '/api/modelhub/online';
url.search = '';
url.hash = '';
return url.toString().replace(/\/$/, '');
} catch {
return null;
}
}
async function writeManagedCodexResponsesConfig(options: {
accountId: string;
providerKey: string;
providerName: string;
baseUrl: string;
envKey: string;
model?: string;
envHttpHeaders?: Record<string, string>;
modelReasoningEffort?: string;
}): Promise<string> {
const codexHomeDir = getCcConnectAccountCodexHomeDir(options.accountId);
await mkdir(codexHomeDir, { recursive: true });
const configPath = join(codexHomeDir, 'config.toml');
const tableKey = /^[A-Za-z_][A-Za-z0-9_-]*$/.test(options.providerKey)
? options.providerKey
: tomlString(options.providerKey);
const envHeaderEntries = Object.entries(options.envHttpHeaders ?? {});
const lines = [
...(options.model ? [`model = ${tomlString(options.model)}`] : []),
`model_provider = ${tomlString(options.providerKey)}`,
...(options.modelReasoningEffort ? [`model_reasoning_effort = ${tomlString(options.modelReasoningEffort)}`] : []),
'',
`[model_providers.${tableKey}]`,
`name = ${tomlString(options.providerName)}`,
`base_url = ${tomlString(options.baseUrl)}`,
`env_key = ${tomlString(options.envKey)}`,
'wire_api = "responses"',
...(envHeaderEntries.length > 0
? [`env_http_headers = ${tomlInlineStringMap(options.envHttpHeaders ?? {})}`]
: []),
'',
];
await writeFile(configPath, lines.join('\n'), { encoding: 'utf8', mode: 0o600 });
await chmod(configPath, 0o600).catch(() => {});
return codexHomeDir;
}
function stableModelHubSessionId(account: ProviderAccount): string {
return `clawx-cc-connect-${account.id}`;
}
function sanitizedEnvKeyPart(value: string): string {
const sanitized = value
.trim()
.replace(/[^A-Za-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.toUpperCase();
return sanitized || 'HEADER';
}
function accountScopedEnvKey(accountId: string, purpose: string): string {
return `CLAWX_CODEX_${sanitizedEnvKeyPart(accountId)}_${sanitizedEnvKeyPart(purpose)}`;
}
function buildCustomHeaderEnv(account: ProviderAccount, options?: { exclude?: Set<string> }): {
env: Record<string, string>;
envHttpHeaders: Record<string, string>;
} {
const entries = Object.entries(account.headers ?? {})
.map(([name, value]) => [name.trim(), String(value ?? '').trim()] as const)
.filter(([name, value]) => name && value)
.filter(([name]) => !options?.exclude?.has(name.toLowerCase()));
const env: Record<string, string> = {};
const envHttpHeaders: Record<string, string> = {};
const used = new Set<string>();
for (const [name, value] of entries) {
const baseKey = accountScopedEnvKey(account.id, `HEADER_${name}`);
let envKey = baseKey;
let index = 2;
while (used.has(envKey)) {
envKey = `${baseKey}_${index}`;
index += 1;
}
used.add(envKey);
env[envKey] = value;
envHttpHeaders[name] = envKey;
}
return { env, envHttpHeaders };
}
function extractSessionIdFromExtraHeader(value: string): string | undefined {
try {
const parsed = JSON.parse(value) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
const sessionId = (parsed as Record<string, unknown>).session_id;
return typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : undefined;
} catch {
return undefined;
}
}
function buildModelHubEnv(account: ProviderAccount, apiKey: string): {
env: Record<string, string>;
envHttpHeaders: Record<string, string>;
} {
const apiKeyEnvKey = accountScopedEnvKey(account.id, 'API_KEY');
const extraHeaderEnvKey = accountScopedEnvKey(account.id, 'EXTRA_HEADER');
const stickySessionEnvKey = accountScopedEnvKey(account.id, 'STICKY_SESSION_ID');
const customHeaders = buildCustomHeaderEnv(account, { exclude: new Set(['api-key', 'extra']) });
const existingExtraHeader = account.headers?.extra?.trim();
const sessionId = existingExtraHeader
? extractSessionIdFromExtraHeader(existingExtraHeader) ?? stableModelHubSessionId(account)
: stableModelHubSessionId(account);
const extraHeader = existingExtraHeader || JSON.stringify({ session_id: sessionId });
return {
env: {
[apiKeyEnvKey]: apiKey,
...customHeaders.env,
[extraHeaderEnvKey]: extraHeader,
[stickySessionEnvKey]: sessionId,
},
envHttpHeaders: {
...customHeaders.envHttpHeaders,
'Api-Key': apiKeyEnvKey,
extra: extraHeaderEnvKey,
},
};
}
function getUserCodexAuthPath(): string {
const e2eOverride = process.env.CLAWX_E2E_USER_CODEX_AUTH_JSON?.trim();
if (process.env.CLAWX_E2E === '1' && e2eOverride) {
return e2eOverride;
}
return join(app.getPath('home'), '.codex', 'auth.json');
}
async function ensureAccountCodexHome(accountId: string): Promise<string> {
const accountHome = getCcConnectAccountCodexHomeDir(accountId);
await mkdir(accountHome, { recursive: true });
return accountHome;
}
async function migrateLegacyCodexHomeToAccount(accountId: string): Promise<void> {
const accountHome = getCcConnectAccountCodexHomeDir(accountId);
const legacyHome = getCcConnectCodexHomeDir();
const accountExists = await access(accountHome).then(() => true).catch(() => false);
if (accountExists) return;
const legacyExists = await access(legacyHome).then(() => true).catch(() => false);
if (!legacyExists) return;
await mkdir(dirname(accountHome), { recursive: true });
try {
await rename(legacyHome, accountHome);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EXDEV') {
await cp(legacyHome, accountHome, { recursive: true, force: false, errorOnExist: false });
await rm(legacyHome, { recursive: true, force: true });
return;
}
throw error;
}
}
async function writeManagedOpenAIOAuthAuthFile(
tokens: OpenAIOAuthTokenSet,
accountId: string,
): Promise<string> {
const codexHomeDir = getCcConnectAccountCodexHomeDir(accountId);
await mkdir(codexHomeDir, { recursive: true });
const authPath = join(codexHomeDir, 'auth.json');
await writeFile(authPath, JSON.stringify({
auth_mode: 'chatgpt',
OPENAI_API_KEY: null,
tokens: {
id_token: tokens.idToken,
access_token: tokens.accessToken,
refresh_token: tokens.refreshToken,
account_id: tokens.accountId,
},
last_refresh: new Date().toISOString(),
}, null, 2), { encoding: 'utf8', mode: 0o600 });
await chmod(authPath, 0o600).catch(() => {});
return codexHomeDir;
}
async function readCompleteCodexAuthTokens(authPath: string): Promise<OpenAIOAuthTokenSet | undefined> {
try {
const auth = JSON.parse(await readFile(authPath, 'utf8')) as {
tokens?: {
id_token?: unknown;
access_token?: unknown;
refresh_token?: unknown;
account_id?: unknown;
};
};
const tokens = auth.tokens;
if (
!tokens ||
typeof tokens.id_token !== 'string' ||
typeof tokens.access_token !== 'string' ||
typeof tokens.refresh_token !== 'string' ||
typeof tokens.account_id !== 'string' ||
!tokens.id_token.trim() ||
!tokens.access_token.trim() ||
!tokens.refresh_token.trim() ||
!tokens.account_id.trim()
) {
return undefined;
}
return {
idToken: tokens.id_token.trim(),
accessToken: tokens.access_token.trim(),
refreshToken: tokens.refresh_token.trim(),
accountId: tokens.account_id.trim(),
};
} catch {
return undefined;
}
}
async function readCodexAuthSummary(authPath: string): Promise<CodexOAuthAuthFileSummary> {
let raw: string;
try {
raw = await readFile(authPath, 'utf8');
} catch {
return { path: authPath, exists: false, complete: false };
}
const updatedAt = await stat(authPath)
.then((fileStat) => fileStat.mtime.toISOString())
.catch(() => undefined);
try {
const parsed = JSON.parse(raw) as {
auth_mode?: unknown;
tokens?: { account_id?: unknown };
last_refresh?: unknown;
};
const tokens = await readCompleteCodexAuthTokens(authPath);
return {
path: authPath,
exists: true,
complete: Boolean(tokens),
accountId: tokens?.accountId ?? (
typeof parsed.tokens?.account_id === 'string' && parsed.tokens.account_id.trim()
? parsed.tokens.account_id.trim()
: undefined
),
authMode: typeof parsed.auth_mode === 'string' ? parsed.auth_mode : undefined,
lastRefresh: typeof parsed.last_refresh === 'string' ? parsed.last_refresh : undefined,
updatedAt,
};
} catch {
return {
path: authPath,
exists: true,
complete: false,
updatedAt,
error: 'Invalid Codex auth.json',
};
}
}
function codexTokensMatchAccount(
tokens: OpenAIOAuthTokenSet,
account: ProviderAccount,
secret?: Extract<ProviderSecret, { type: 'oauth' }>,
): boolean {
if (!secret) return true;
const expectedAccountId = secret.subject?.trim();
const userAccountId = tokens.accountId.trim();
const accessMatches = tokens.accessToken === secret.accessToken;
const refreshMatches = tokens.refreshToken === secret.refreshToken;
const accountMatches = Boolean(expectedAccountId && userAccountId && expectedAccountId === userAccountId);
const providerIdMatches = Boolean(userAccountId && account.id === userAccountId);
return accessMatches || refreshMatches || accountMatches || providerIdMatches;
}
async function resolveProviderAccount(accountId?: string): Promise<{
account: ProviderAccount | null;
secret?: Extract<ProviderSecret, { type: 'oauth' }>;
}> {
const resolvedAccountId = accountId?.trim() || await getDefaultProviderAccountId();
const account = resolvedAccountId ? await getProviderAccount(resolvedAccountId) : null;
const secret = account ? await getProviderSecret(account.id) : null;
return {
account,
secret: secret?.type === 'oauth' && secret.accessToken && secret.refreshToken ? secret : undefined,
};
}
async function resolveOpenAIOAuthTokens(
account: ProviderAccount,
secret?: Extract<ProviderSecret, { type: 'oauth' }>,
options?: OpenAIOAuthTokenResolutionOptions,
): Promise<OpenAIOAuthTokenResolution | undefined> {
const secretIdToken = secret?.idToken?.trim();
const secretResolution: OpenAIOAuthTokenResolution | undefined = secret && secretIdToken
? {
tokens: {
idToken: secretIdToken,
accessToken: secret.accessToken,
refreshToken: secret.refreshToken,
accountId: secret.subject?.trim() || account.id,
},
source: 'secret',
}
: undefined;
// Browser re-login is authoritative once; normal starts keep Codex-rotated managed tokens.
if (options?.preferSecret && secretResolution) {
return secretResolution;
}
const managedAuthPath = join(await ensureAccountCodexHome(account.id), 'auth.json');
const managedTokens = await readCompleteCodexAuthTokens(managedAuthPath);
if (managedTokens && codexTokensMatchAccount(managedTokens, account, secret)) {
return { tokens: managedTokens, source: 'managed' };
}
return secretResolution;
}
export async function getCcConnectCodexOAuthStatus(payload?: {
accountId?: string;
}): Promise<CodexOAuthStatus> {
const { account, secret } = await resolveProviderAccount(payload?.accountId);
const resolvedAccountId = account?.id ?? payload?.accountId?.trim() ?? 'default';
const managedCodexHome = await ensureAccountCodexHome(resolvedAccountId);
const authPath = join(managedCodexHome, 'auth.json');
const userAuthPath = getUserCodexAuthPath();
const [managed, user] = await Promise.all([
readCodexAuthSummary(authPath),
readCodexAuthSummary(userAuthPath),
]);
const managedTokens = account ? await readCompleteCodexAuthTokens(authPath) : undefined;
const userTokens = account ? await readCompleteCodexAuthTokens(userAuthPath) : undefined;
return {
success: true,
managedCodexHome,
authPath,
managed,
user,
...(account ? {
provider: {
accountId: account.id,
vendorId: account.vendorId,
authMode: account.authMode,
hasOAuthSecret: Boolean(secret),
subject: secret?.subject,
email: secret?.email,
managedMatchesAccount: managedTokens ? codexTokensMatchAccount(managedTokens, account, secret) : undefined,
userMatchesAccount: userTokens ? codexTokensMatchAccount(userTokens, account, secret) : undefined,
},
} : {}),
};
}
export async function importUserCodexOAuthToManagedHome(payload?: {
accountId?: string;
}): Promise<CodexOAuthStatus> {
const { account, secret } = await resolveProviderAccount(payload?.accountId);
const userAuthPath = getUserCodexAuthPath();
const tokens = await readCompleteCodexAuthTokens(userAuthPath);
if (!tokens) {
throw new Error(`No complete Codex OAuth auth.json found at ${userAuthPath}`);
}
if (account && !codexTokensMatchAccount(tokens, account, secret)) {
throw new Error('Local Codex OAuth credentials do not match the selected provider account');
}
await writeManagedOpenAIOAuthAuthFile(tokens, account?.id ?? payload?.accountId?.trim() ?? 'default');
return getCcConnectCodexOAuthStatus({ accountId: account?.id ?? payload?.accountId });
}
export async function logoutCcConnectCodexOAuth(payload?: {
accountId?: string;
managedOnly?: boolean;
}): Promise<CodexOAuthStatus> {
const { account } = await resolveProviderAccount(payload?.accountId);
const accountId = account?.id ?? payload?.accountId?.trim() ?? 'default';
const managedHome = await ensureAccountCodexHome(accountId);
await rm(join(managedHome, 'auth.json'), { force: true });
if (!payload?.managedOnly && account?.authMode === 'oauth_browser') {
await getSecretStore().delete(account.id);
}
return getCcConnectCodexOAuthStatus({ accountId });
}
async function buildProfileForAccount(
account: ProviderAccount,
options?: OpenAIOAuthTokenResolutionOptions,
): Promise<CodexProviderProfile> {
const secret = await getProviderSecret(account.id);
const model = resolveModel(account);
const base = {
providerId: account.id,
vendorId: account.vendorId,
label: account.label,
authMode: account.authMode,
model,
modelRef: model ? `${account.vendorId}/${model}` : undefined,
secretAvailable: Boolean(secret),
updatedAt: new Date().toISOString(),
};
if (account.vendorId === 'openai') {
if (account.authMode === 'oauth_browser') {
const oauthSecret = secret?.type === 'oauth' && secret.accessToken && secret.refreshToken
? secret
: undefined;
const tokenResolution = await resolveOpenAIOAuthTokens(account, oauthSecret, options);
if (!tokenResolution) {
return {
...base,
supported: false,
unsupportedReason: 'Codex OAuth credentials are missing. Sign in to Codex using the ClawX-managed CODEX_HOME or sign in to OpenAI again before using cc-connect Codex runtime.',
codexArgs: [],
};
}
const codexHomeDir = tokenResolution.source === 'managed'
? await ensureAccountCodexHome(account.id)
: await writeManagedOpenAIOAuthAuthFile(tokenResolution.tokens, account.id);
return {
...base,
supported: true,
codexArgs: model ? ['--model', model] : [],
env: { CODEX_HOME: codexHomeDir },
codexHomeDir,
secretAvailable: true,
};
}
const env: Record<string, string> = {};
const apiKeyEnvKey = accountScopedEnvKey(account.id, 'API_KEY');
if ((secret?.type === 'api_key' || secret?.type === 'local') && secret.apiKey) {
env[apiKeyEnvKey] = secret.apiKey;
}
if (!env[apiKeyEnvKey]) {
return {
...base,
supported: false,
unsupportedReason: 'OpenAI API key credentials are missing. Add an OpenAI API key before using the cc-connect Codex runtime with this provider.',
codexArgs: [],
};
}
const baseUrl = account.baseUrl?.trim();
if (baseUrl) {
const providerKey = 'clawx-openai';
const normalizedBaseUrl = normalizeOpenAIResponsesBaseUrl(baseUrl);
const codexHomeDir = await writeManagedCodexResponsesConfig({
accountId: account.id,
providerKey,
providerName: 'OpenAI',
baseUrl: normalizedBaseUrl,
envKey: apiKeyEnvKey,
model,
});
return {
...base,
supported: true,
codexArgs: [
'-c',
`model_provider=${tomlString(providerKey)}`,
'-c',
`model_providers.${providerKey}.name="OpenAI"`,
'-c',
`model_providers.${providerKey}.base_url=${tomlString(normalizedBaseUrl)}`,
'-c',
`model_providers.${providerKey}.env_key=${tomlString(apiKeyEnvKey)}`,
'-c',
`model_providers.${providerKey}.wire_api="responses"`,
...(model ? ['--model', model] : []),
],
env: {
...env,
CODEX_HOME: codexHomeDir,
},
codexHomeDir,
launcherEnv: { OPENAI_API_KEY: apiKeyEnvKey },
ccConnectProvider: {
name: providerKey,
apiKeyEnvKey,
baseUrl: normalizedBaseUrl,
wireApi: 'responses',
...(model ? { model } : {}),
},
};
}
const codexHomeDir = await ensureAccountCodexHome(account.id);
return {
...base,
supported: true,
codexArgs: model ? ['--model', model] : [],
env: { ...env, CODEX_HOME: codexHomeDir },
codexHomeDir,
launcherEnv: { OPENAI_API_KEY: apiKeyEnvKey },
ccConnectProvider: {
name: 'openai',
apiKeyEnvKey,
...(model ? { model } : {}),
},
};
}
if (account.vendorId === 'custom') {
const protocol = account.apiProtocol || 'openai-completions';
if (protocol !== 'openai-responses') {
return {
...base,
supported: false,
unsupportedReason: `cc-connect Codex runtime cannot use custom provider "${account.label}" because Codex 0.137 only supports the Responses wire API. This provider is configured for Chat Completions.`,
codexArgs: [],
};
}
const baseUrl = account.baseUrl?.trim();
if (!baseUrl) {
return {
...base,
supported: false,
unsupportedReason: `cc-connect Codex runtime cannot use custom provider "${account.label}" because a Responses-compatible base URL is required.`,
codexArgs: [],
};
}
if ((secret?.type !== 'api_key' && secret?.type !== 'local') || !secret.apiKey) {
return {
...base,
supported: false,
unsupportedReason: `cc-connect Codex runtime cannot use custom provider "${account.label}" because its API key is missing.`,
codexArgs: [],
};
}
const modelHubBaseUrl = normalizeModelHubCodexResponsesBaseUrl(baseUrl);
const providerKey = modelHubBaseUrl ? 'modelhub_openapi' : 'clawx-custom';
const envKey = accountScopedEnvKey(account.id, 'API_KEY');
const normalizedBaseUrl = modelHubBaseUrl ?? normalizeOpenAIResponsesBaseUrl(baseUrl);
const customHeaders = modelHubBaseUrl
? buildModelHubEnv(account, secret.apiKey)
: buildCustomHeaderEnv(account);
const env: Record<string, string> = modelHubBaseUrl
? customHeaders.env
: { [envKey]: secret.apiKey, ...customHeaders.env };
const envHttpHeaders = Object.keys(customHeaders.envHttpHeaders).length > 0
? customHeaders.envHttpHeaders
: undefined;
const codexHomeDir = await writeManagedCodexResponsesConfig({
accountId: account.id,
providerKey,
providerName: modelHubBaseUrl ? 'ByteDance ModelHub OpenAPI' : (account.label || 'Custom'),
baseUrl: normalizedBaseUrl,
envKey,
model,
envHttpHeaders,
...(modelHubBaseUrl ? { modelReasoningEffort: 'none' } : {}),
});
return {
...base,
supported: true,
codexArgs: [
'-c',
`model_provider=${tomlString(providerKey)}`,
'-c',
`model_providers.${providerKey}.name=${tomlString(modelHubBaseUrl ? 'ByteDance ModelHub OpenAPI' : (account.label || 'Custom'))}`,
'-c',
`model_providers.${providerKey}.base_url=${tomlString(normalizedBaseUrl)}`,
'-c',
`model_providers.${providerKey}.env_key=${tomlString(envKey)}`,
'-c',
`model_providers.${providerKey}.wire_api="responses"`,
...(modelHubBaseUrl ? [
'-c',
'model_reasoning_effort="none"',
] : []),
...(envHttpHeaders ? [
'-c',
`model_providers.${providerKey}.env_http_headers=${tomlInlineStringMap(envHttpHeaders)}`,
] : []),
...(model ? ['--model', model] : []),
],
env: {
...env,
CODEX_HOME: codexHomeDir,
},
codexHomeDir,
ccConnectProvider: {
name: providerKey,
apiKeyEnvKey: envKey,
baseUrl: normalizedBaseUrl,
wireApi: 'responses',
...(model ? { model } : {}),
},
};
}
if (account.vendorId === 'ollama') {
return {
...base,
supported: true,
codexArgs: [
'--oss',
'--local-provider',
'ollama',
...(model ? ['--model', model] : []),
],
};
}
return {
...base,
supported: false,
unsupportedReason: `cc-connect Codex runtime currently supports OpenAI/Codex and Ollama provider accounts; "${account.vendorId}" is not supported yet.`,
codexArgs: [],
};
}
export async function buildCcConnectProviderProfileForAccount(
accountId: string,
): Promise<CodexProviderProfile> {
const account = await getProviderAccount(accountId);
if (account) return buildProfileForAccount(account);
return {
providerId: accountId,
vendorId: null,
supported: false,
unsupportedReason: `Provider account "${accountId}" was not found`,
codexArgs: [],
secretAvailable: false,
updatedAt: new Date().toISOString(),
};
}
export async function syncCcConnectProviderProfile(
payload?: { providerId?: string; reason?: string },
): Promise<CodexProviderProfile> {
const providerId = payload?.providerId?.trim() || await getDefaultProviderAccountId();
const account = providerId ? await getProviderAccount(providerId) : null;
if (account?.vendorId === 'openai' && account.authMode === 'oauth_browser') {
await migrateLegacyCodexHomeToAccount(account.id);
}
const profile: CodexProviderProfile = account
? await buildProfileForAccount(account, { preferSecret: payload?.reason === 'oauth' })
: {
providerId: null,
vendorId: null,
supported: true,
codexArgs: [],
secretAvailable: false,
updatedAt: new Date().toISOString(),
};
const profilePath = getCcConnectProviderProfilePath();
await mkdir(dirname(profilePath), { recursive: true });
await writeFile(profilePath, JSON.stringify({
...publicProfile(profile),
reason: payload?.reason ?? 'sync',
}, null, 2), 'utf8');
return profile;
}
export function toPublicCodexProviderProfile(profile: CodexProviderProfile): CodexProviderProfile {
return publicProfile(profile);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,125 @@
import { randomUUID } from 'node:crypto';
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { app } from 'electron';
import { getClawXDataLayout, resolveClawXDataRoot } from '../utils/clawx-data-layout';
import { getCcConnectManagedDir } from './cc-connect-paths';
type SessionMetadataDocument = {
schema: 'clawx-cc-connect-session-metadata';
version: 1;
labels: Record<string, string>;
updatedAt: string;
migratedFromLegacyAt?: string;
};
export interface CcConnectSessionMetadataStore {
getLabel(sessionKey: string): Promise<string | undefined>;
setLabel(sessionKey: string, label: string): Promise<void>;
deleteLabel(sessionKey: string): Promise<void>;
}
function defaultMetadataPath(): string {
const layout = getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData')));
return join(layout.appDir, 'cc-connect-session-metadata.json');
}
function defaultLegacyPath(): string {
return join(getCcConnectManagedDir(), 'data', 'sessions', '.clawx-supplemental-history.json');
}
async function writeAtomic(path: string, document: SessionMetadataDocument): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
await writeFile(temporaryPath, `${JSON.stringify(document, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
await chmod(temporaryPath, 0o600).catch(() => {});
await rename(temporaryPath, path);
await chmod(path, 0o600).catch(() => {});
}
function emptyDocument(): SessionMetadataDocument {
return {
schema: 'clawx-cc-connect-session-metadata',
version: 1,
labels: {},
updatedAt: new Date(0).toISOString(),
};
}
function normalizedLabels(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return Object.fromEntries(Object.entries(value).flatMap(([key, label]) => (
typeof label === 'string' && label.trim() ? [[key, label.trim().slice(0, 80)]] : []
)));
}
export class FileCcConnectSessionMetadataStore implements CcConnectSessionMetadataStore {
private queue = Promise.resolve();
constructor(
private readonly metadataPath = defaultMetadataPath(),
private readonly legacyPath = defaultLegacyPath(),
) {}
async getLabel(sessionKey: string): Promise<string | undefined> {
const document = await this.readDocument();
return document.labels[sessionKey];
}
async setLabel(sessionKey: string, label: string): Promise<void> {
const normalized = label.trim().slice(0, 80);
if (!normalized) throw new Error('Label cannot be empty');
await this.exclusive(async () => {
const document = await this.readDocument();
document.labels[sessionKey] = normalized;
document.updatedAt = new Date().toISOString();
await writeAtomic(this.metadataPath, document);
});
}
async deleteLabel(sessionKey: string): Promise<void> {
await this.exclusive(async () => {
const document = await this.readDocument();
if (!(sessionKey in document.labels)) return;
delete document.labels[sessionKey];
document.updatedAt = new Date().toISOString();
await writeAtomic(this.metadataPath, document);
});
}
private async readDocument(): Promise<SessionMetadataDocument> {
try {
const parsed = JSON.parse(await readFile(this.metadataPath, 'utf8')) as Partial<SessionMetadataDocument>;
if (parsed.schema !== 'clawx-cc-connect-session-metadata' || parsed.version !== 1) {
throw new Error(`Unsupported cc-connect session metadata: ${this.metadataPath}`);
}
return { ...parsed, labels: normalizedLabels(parsed.labels) } as SessionMetadataDocument;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
const document = emptyDocument();
try {
const legacy = JSON.parse(await readFile(this.legacyPath, 'utf8')) as { labels?: unknown };
document.labels = normalizedLabels(legacy.labels);
document.migratedFromLegacyAt = new Date().toISOString();
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
document.updatedAt = new Date().toISOString();
await writeAtomic(this.metadataPath, document);
return document;
}
private async exclusive<T>(operation: () => Promise<T>): Promise<T> {
const previous = this.queue;
let release!: () => void;
this.queue = new Promise<void>((resolve) => { release = resolve; });
await previous;
try {
return await operation();
} finally {
release();
}
}
}
+73
View File
@@ -0,0 +1,73 @@
import { cp, mkdir, rm, writeFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import type { SkillsStatusResult } from '@shared/host-api/contract';
import { getCcConnectCodexHomeDir } from './cc-connect-paths';
import { listLocalSkills, type LocalSkillRecord } from '../services/skills/local-skill-service';
function safeSkillDirName(skill: Pick<LocalSkillRecord, 'id' | 'slug' | 'baseDir'>): string {
const candidate = skill.slug || skill.id || (skill.baseDir ? basename(skill.baseDir) : 'skill');
return candidate.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'skill';
}
function isCodexNativeSkill(skill: LocalSkillRecord): boolean {
return skill.source === 'agents-skills-personal' || skill.source === 'agents-skills-project';
}
export async function syncCcConnectSkillRecords(
records: LocalSkillRecord[],
codexHomeDir = getCcConnectCodexHomeDir(),
): Promise<SkillsStatusResult> {
const skillsRoot = join(codexHomeDir, 'skills');
await mkdir(skillsRoot, { recursive: true });
const enabled = records.filter((skill) => skill.enabled !== false && skill.baseDir);
const manifest: Array<Record<string, unknown>> = [];
for (const skill of enabled) {
const targetDirName = safeSkillDirName(skill);
const targetDir = join(skillsRoot, targetDirName);
await rm(targetDir, { recursive: true, force: true });
const native = isCodexNativeSkill(skill);
if (!native) {
await cp(skill.baseDir!, targetDir, { recursive: true, force: true });
}
const runtimeDir = native ? skill.baseDir! : targetDir;
manifest.push({
skillKey: skill.id,
slug: skill.slug,
name: skill.name,
description: skill.description,
source: skill.source,
baseDir: runtimeDir,
filePath: join(runtimeDir, 'SKILL.md'),
projection: native ? 'codex-native' : 'mirrored',
version: skill.version,
bundled: skill.isBundled,
always: skill.isCore,
});
}
await writeFile(join(skillsRoot, 'manifest.json'), JSON.stringify({
updatedAt: new Date().toISOString(),
skills: manifest,
}, null, 2), 'utf8');
return {
skills: manifest.map((skill) => ({
skillKey: String(skill.skillKey || ''),
slug: typeof skill.slug === 'string' ? skill.slug : undefined,
name: typeof skill.name === 'string' ? skill.name : undefined,
description: typeof skill.description === 'string' ? skill.description : undefined,
disabled: false,
version: typeof skill.version === 'string' ? skill.version : undefined,
bundled: skill.bundled === true,
always: skill.always === true,
source: typeof skill.source === 'string' ? skill.source : undefined,
baseDir: typeof skill.baseDir === 'string' ? skill.baseDir : undefined,
filePath: typeof skill.filePath === 'string' ? skill.filePath : undefined,
})),
};
}
export async function syncCcConnectSkills(codexHomeDir?: string): Promise<SkillsStatusResult> {
return syncCcConnectSkillRecords(await listLocalSkills(), codexHomeDir);
}
+62
View File
@@ -0,0 +1,62 @@
import { app } from 'electron';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
export type CodexBundle = {
baseDir: string;
binaryPath: string;
pathDir: string;
targetTriple: string;
};
function codexBinaryName(): string {
return process.platform === 'win32' ? 'codex.exe' : 'codex';
}
function codexTargetTriple(platform = process.platform, arch = process.arch): string {
if (platform === 'darwin' && arch === 'x64') return 'x86_64-apple-darwin';
if (platform === 'darwin' && arch === 'arm64') return 'aarch64-apple-darwin';
if (platform === 'linux' && arch === 'x64') return 'x86_64-unknown-linux-musl';
if (platform === 'linux' && arch === 'arm64') return 'aarch64-unknown-linux-musl';
if (platform === 'win32' && arch === 'x64') return 'x86_64-pc-windows-msvc';
if (platform === 'win32' && arch === 'arm64') return 'aarch64-pc-windows-msvc';
throw new Error(`Unsupported Codex target: ${platform}-${arch}`);
}
function baseDir(): string {
if (app.isPackaged) {
return join(process.resourcesPath, 'codex');
}
if (process.env.CLAWX_CODEX_PATH) {
return dirname(dirname(process.env.CLAWX_CODEX_PATH));
}
return join(process.cwd(), 'build', 'codex', `${process.platform}-${process.arch}`);
}
export function getCodexBundle(): CodexBundle {
const base = baseDir();
return {
baseDir: base,
binaryPath: join(base, 'bin', codexBinaryName()),
pathDir: join(base, 'codex-path'),
targetTriple: codexTargetTriple(),
};
}
export function assertCodexBundle(candidate = getCodexBundle()): CodexBundle {
if (!existsSync(candidate.binaryPath)) {
throw new Error(
`Codex binary not found at ${candidate.binaryPath}. Run pnpm run bundle:codex:current before selecting cc-connect runtime.`,
);
}
return candidate;
}
export function prependCodexPathDir(env: NodeJS.ProcessEnv, bundle = getCodexBundle()): NodeJS.ProcessEnv {
if (!existsSync(bundle.pathDir)) return env;
const delimiter = process.platform === 'win32' ? ';' : ':';
return {
...env,
PATH: [bundle.pathDir, env.PATH || ''].filter(Boolean).join(delimiter),
};
}
+142
View File
@@ -0,0 +1,142 @@
import { EventEmitter } from 'node:events';
import { getSetting, setSetting } from '@electron/utils/store';
import type {
RuntimeCapabilities,
RuntimeEventName,
RuntimeKind,
RuntimeOperationCapabilities,
RuntimeProvider,
RuntimeStatus,
} from './types';
export type RuntimeManagerOptions = {
openclaw: RuntimeProvider;
ccConnect: RuntimeProvider;
};
function normalizeRuntimeKind(value: unknown): RuntimeKind {
return value === 'cc-connect' ? 'cc-connect' : 'openclaw';
}
export class RuntimeManager extends EventEmitter {
private activeKind: RuntimeKind | null = null;
private readonly providers: Record<RuntimeKind, RuntimeProvider>;
private selectionQueue: Promise<void> = Promise.resolve();
constructor(options: RuntimeManagerOptions) {
super();
this.providers = {
openclaw: options.openclaw,
'cc-connect': options.ccConnect,
};
this.forwardProviderEvents(options.openclaw);
this.forwardProviderEvents(options.ccConnect);
}
async getActiveKind(): Promise<RuntimeKind> {
await this.selectionQueue;
return await this.ensureActiveKind();
}
private async ensureActiveKind(): Promise<RuntimeKind> {
if (!this.activeKind) {
const persistedKind = normalizeRuntimeKind(await getSetting('runtimeKind'));
const devModeUnlocked = await getSetting('devModeUnlocked');
this.activeKind = devModeUnlocked === true ? persistedKind : 'openclaw';
if (persistedKind !== this.activeKind) {
await setSetting('runtimeKind', this.activeKind);
}
}
return this.activeKind;
}
getActiveProvider(): RuntimeProvider {
return this.providers[this.activeKind ?? 'openclaw'];
}
getProvider(kind: RuntimeKind): RuntimeProvider {
return this.providers[kind];
}
async setActiveKind(kind: RuntimeKind): Promise<void> {
const change = async () => {
await this.ensureActiveKind();
const requestedKind = normalizeRuntimeKind(kind);
const devModeUnlocked = await getSetting('devModeUnlocked');
const nextKind = requestedKind === 'cc-connect' && devModeUnlocked !== true
? 'openclaw'
: requestedKind;
const previous = this.getActiveProvider();
if (this.activeKind !== nextKind) {
await previous.stop();
}
this.activeKind = nextKind;
await setSetting('runtimeKind', nextKind);
this.emit('status', this.getStatus());
};
const result = this.selectionQueue.then(change, change);
this.selectionQueue = result.then(() => undefined, () => undefined);
await result;
}
listCapabilities(): RuntimeCapabilities {
return this.getActiveProvider().listCapabilities();
}
listOperationCapabilities(): RuntimeOperationCapabilities {
return this.getActiveProvider().listOperationCapabilities();
}
getStatus(): RuntimeStatus {
return this.getActiveProvider().getStatus();
}
start(): Promise<void> {
return this.getActiveProvider().start();
}
stop(): Promise<void> {
return this.getActiveProvider().stop();
}
restart(): Promise<void> {
return this.getActiveProvider().restart();
}
checkHealth(options?: { probe?: boolean }) {
return this.getActiveProvider().checkHealth(options);
}
rpc<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T> {
return this.getActiveProvider().rpc(method, params, timeoutMs);
}
private forwardProviderEvents(provider: RuntimeProvider): void {
const events: RuntimeEventName[] = [
'status',
'error',
'notification',
'gateway:health',
'gateway:presence',
'chat:message',
'chat:runtime-event',
'channel:status',
'exit',
];
for (const eventName of events) {
provider.on(eventName, (payload: unknown) => {
if (provider !== this.getActiveProvider()) return;
if (eventName === 'status' && payload && typeof payload === 'object') {
this.emit(eventName, {
...(payload as Record<string, unknown>),
runtimeKind: provider.kind,
capabilities: provider.listCapabilities(),
operationCapabilities: provider.listOperationCapabilities(),
});
return;
}
this.emit(eventName, payload);
});
}
}
}
+186
View File
@@ -0,0 +1,186 @@
import { EventEmitter } from 'node:events';
import type { GatewayManager } from '../gateway/manager';
import type {
RuntimeControlUiPayload,
RuntimeProvider,
RuntimeConfigRefreshPayload,
RuntimeSendWithMediaPayload,
} from './types';
import {
OPENCLAW_RUNTIME_CAPABILITIES,
withRuntimeStatus,
} from './types';
import { getRuntimeOperationCapabilities } from './rpc-contract';
import { createChatSendWithMediaHandler } from '../services/chat-api';
import {
createOpenClawCronJob,
deleteOpenClawCronJob,
listCronJobs,
toggleOpenClawCronJob,
triggerOpenClawCronJob,
updateOpenClawCronJob,
} from '../services/cron-api';
import { createSessionsApi } from '../services/sessions-api';
import { logger } from '../utils/logger';
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../utils/openclaw-doctor';
import { PORTS } from '../utils/config';
import { scheduleControlUiDeviceAutoApproval } from '../utils/control-ui-device-pairing';
import { buildOpenClawControlUiUrl } from '../utils/openclaw-control-ui';
import { getSetting } from '../utils/store';
import { getRecentTokenUsageHistory } from '../utils/token-usage';
import { writeOpenClawCompatibilityProjection } from '../utils/channel-config';
import type { OpenClawDoctorMode } from '@shared/host-api/contract';
import { runtimeUsageLimit, toRuntimeUsageRecords } from './usage';
export class OpenClawRuntimeProvider extends EventEmitter implements RuntimeProvider {
readonly kind = 'openclaw' as const;
private readonly sessionsApi = createSessionsApi();
constructor(private readonly gatewayManager: GatewayManager) {
super();
const forward = (eventName: string) => (payload: unknown) => {
this.emit(eventName, payload);
};
for (const eventName of [
'status',
'error',
'notification',
'gateway:health',
'gateway:presence',
'chat:message',
'chat:runtime-event',
'channel:status',
'exit',
]) {
this.gatewayManager.on(eventName, forward(eventName));
}
}
listCapabilities() {
return OPENCLAW_RUNTIME_CAPABILITIES;
}
listOperationCapabilities() {
return getRuntimeOperationCapabilities(this.kind);
}
getStatus() {
return withRuntimeStatus(
this.gatewayManager.getStatus(),
this.kind,
this.listCapabilities(),
undefined,
this.listOperationCapabilities(),
);
}
async start() {
await writeOpenClawCompatibilityProjection();
return await this.gatewayManager.start();
}
stop() {
return this.gatewayManager.stop();
}
async restart() {
await writeOpenClawCompatibilityProjection();
return await this.gatewayManager.restart();
}
checkHealth(options?: { probe?: boolean }) {
return this.gatewayManager.checkHealth(options);
}
rpc<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T> {
switch (method) {
case 'cron.list':
return listCronJobs(this.gatewayManager) as Promise<T>;
case 'cron.create':
case 'cron.add':
return createOpenClawCronJob(this.gatewayManager, params as never) as Promise<T>;
case 'cron.update': {
const body = params && typeof params === 'object' ? params as Record<string, unknown> : {};
if ('input' in body) {
return updateOpenClawCronJob(this.gatewayManager, body as never) as Promise<T>;
}
return this.gatewayManager.rpc(method, params, timeoutMs);
}
case 'cron.delete':
case 'cron.remove':
return deleteOpenClawCronJob(this.gatewayManager, params) as Promise<T>;
case 'cron.toggle':
return toggleOpenClawCronJob(this.gatewayManager, params as never) as Promise<T>;
case 'cron.run':
return triggerOpenClawCronJob(this.gatewayManager, params) as Promise<T>;
case 'runtime.controlUi':
return this.getControlUi(params as never) as Promise<T>;
case 'sessions.rename':
case 'session.rename':
return this.sessionsApi.rename(params as never) as Promise<T>;
default:
return this.gatewayManager.rpc(method, params, timeoutMs);
}
}
async sendMessageWithMedia(payload: RuntimeSendWithMediaPayload) {
const handler = createChatSendWithMediaHandler(this.gatewayManager, logger);
const response = await handler(payload);
if (!response.success) {
throw new Error(response.error || 'OpenClaw chat send failed');
}
return response.result ?? {};
}
async listSessions(payload?: unknown) {
return await this.sessionsApi.summaries(payload as never);
}
async loadHistory(payload?: unknown) {
return await this.sessionsApi.history(payload as never);
}
async deleteSession(payload?: unknown) {
return await this.sessionsApi.delete(payload as never);
}
async listUsage(payload?: unknown) {
const limit = runtimeUsageLimit(payload);
const entries = await getRecentTokenUsageHistory({
...(limit !== undefined ? { limit } : {}),
runtimeKind: 'openclaw',
});
return {
success: true,
records: toRuntimeUsageRecords(entries, { runtimeKind: this.kind }),
};
}
async listLogs() {
return { content: logger.getRecentLogs().join('\n') };
}
runDoctor(mode: OpenClawDoctorMode) {
return mode === 'fix' ? runOpenClawDoctorFix() : runOpenClawDoctor();
}
async refreshConfig(payload: RuntimeConfigRefreshPayload): Promise<void> {
if (this.gatewayManager.getStatus().state === 'stopped') return;
if (payload.forceRestart) {
this.gatewayManager.debouncedRestart(150);
return;
}
this.gatewayManager.debouncedReload(150);
}
async getControlUi(payload?: RuntimeControlUiPayload) {
if (!this.listCapabilities().controlUi) {
return { success: false, error: 'openclaw runtime does not support Control UI' };
}
const token = await getSetting('gatewayToken');
const port = this.getStatus().port || PORTS.OPENCLAW_GATEWAY;
const url = buildOpenClawControlUiUrl(port, token, { view: payload?.view });
scheduleControlUiDeviceAutoApproval(this.gatewayManager);
return { success: true, url, token, port };
}
}
+128
View File
@@ -0,0 +1,128 @@
import type {
RuntimeCapabilities,
RuntimeKind,
RuntimeOperationCapabilities,
RuntimeOperationSupport,
} from './types';
export type RuntimeRpcContractEntry = {
runtime: RuntimeKind;
method: string;
capability: keyof RuntimeCapabilities;
support: RuntimeOperationSupport;
notes: string;
};
const OPENCLAW_PROXY_METHODS: Array<[string, keyof RuntimeCapabilities, string]> = [
['chat.send', 'chat', 'Sent through OpenClaw Gateway chat.send.'],
['chat.abort', 'chat', 'Forwarded to OpenClaw Gateway.'],
['chat.approval.respond', 'chat', 'Forwarded to OpenClaw Gateway.'],
['sessions.list', 'sessions', 'Served by the OpenClaw session API facade.'],
['chat.history', 'history', 'Served by the OpenClaw session API facade.'],
['sessions.delete', 'sessions', 'Served by the OpenClaw session API facade.'],
['session.delete', 'sessions', 'Compatibility alias for sessions.delete.'],
['chat.session.delete', 'sessions', 'Compatibility alias for sessions.delete.'],
['sessions.rename', 'sessions', 'Served by the OpenClaw session API facade.'],
['session.rename', 'sessions', 'Compatibility alias for sessions.rename.'],
['providers.sync', 'providers', 'Forwarded to OpenClaw Gateway/provider services.'],
['providers.profile', 'providers', 'Forwarded to OpenClaw Gateway/provider services.'],
['models.sync', 'models', 'Forwarded to OpenClaw Gateway/model services.'],
['models.profile', 'models', 'Forwarded to OpenClaw Gateway/model services.'],
['skills.status', 'skills', 'Forwarded to OpenClaw skills service.'],
['skills.update', 'skills', 'Forwarded to OpenClaw skills service.'],
['channels.status', 'channels', 'Forwarded to OpenClaw Gateway.'],
['channels.add', 'channels', 'Forwarded to OpenClaw Gateway.'],
['channels.requestQr', 'channels', 'Forwarded to OpenClaw Gateway.'],
['channels.connect', 'channels', 'Forwarded to OpenClaw Gateway.'],
['channels.disconnect', 'channels', 'Forwarded to OpenClaw Gateway.'],
['channels.delete', 'channels', 'Forwarded to OpenClaw Gateway.'],
['runtime.controlUi', 'controlUi', 'Opens the OpenClaw Control UI.'],
['cron.list', 'cron', 'Adapted by the OpenClaw runtime provider.'],
['cron.create', 'cron', 'Adapted by the OpenClaw runtime provider.'],
['cron.add', 'cron', 'Compatibility alias for cron.create.'],
['cron.update', 'cron', 'Adapted by the OpenClaw runtime provider.'],
['cron.delete', 'cron', 'Adapted by the OpenClaw runtime provider.'],
['cron.remove', 'cron', 'Compatibility alias for cron.delete.'],
['cron.toggle', 'cron', 'Adapted by the OpenClaw runtime provider.'],
['cron.run', 'cron', 'Adapted by the OpenClaw runtime provider.'],
['logs.list', 'logs', 'Served from the OpenClaw log buffer.'],
['doctor.run', 'doctor', 'Runs openclaw doctor.'],
['doctor.fix', 'doctor', 'Runs openclaw doctor --fix.'],
['doctor.memory.status', 'doctor', 'Forwarded to OpenClaw memory doctor RPCs.'],
];
const CC_CONNECT_NATIVE_METHODS: Array<[string, keyof RuntimeCapabilities, string]> = [
['chat.send', 'chat', 'Delivered through cc-connect BridgePlatform into Codex.'],
['chat.abort', 'chat', 'Sends cc-connect /stop to the active Bridge session; runtime restart is only a disconnected-Bridge fallback.'],
['chat.approval.respond', 'chat', 'Returns a validated card_action through cc-connect BridgePlatform for a pending approval, question, or runtime choice.'],
['sessions.list', 'sessions', 'Loaded from the cc-connect public Management session API.'],
['chat.history', 'history', 'Loaded from the cc-connect public Management session history API.'],
['sessions.delete', 'sessions', 'Deletes the runtime session through the cc-connect public Management API.'],
['session.delete', 'sessions', 'Compatibility alias for sessions.delete.'],
['chat.session.delete', 'sessions', 'Compatibility alias for sessions.delete.'],
['sessions.rename', 'sessions', 'Stores a ClawX display label without mutating cc-connect private session files.'],
['session.rename', 'sessions', 'Compatibility alias for sessions.rename.'],
['providers.sync', 'providers', 'Writes the managed Codex provider profile and restarts when needed.'],
['providers.profile', 'providers', 'Returns the managed Codex profile plus public cc-connect project provider/model state without restart.'],
['models.sync', 'models', 'Aliases provider sync for the active Codex model profile.'],
['models.profile', 'models', 'Returns the managed Codex model plus public cc-connect project provider/model state without restart.'],
['skills.status', 'skills', 'Synchronizes skills into the managed cc-connect Codex home.'],
['skills.update', 'skills', 'Synchronizes skills into the managed cc-connect Codex home.'],
['channels.status', 'channels', 'Reads configured channel accounts plus live cc-connect project platform status.'],
['channels.connect', 'channels', 'Reloads cc-connect channel platform config through the Management API.'],
['channels.disconnect', 'channels', 'Reloads cc-connect channel platform config through the Management API.'],
['channels.delete', 'channels', 'Reloads cc-connect channel platform config after channel config deletion.'],
['runtime.controlUi', 'controlUi', 'Opens the cc-connect Web Admin.'],
['cron.list', 'cron', 'Uses cc-connect management API.'],
['cron.create', 'cron', 'Uses cc-connect management API.'],
['cron.add', 'cron', 'Compatibility alias for cron.create.'],
['cron.update', 'cron', 'Uses cc-connect management API.'],
['cron.delete', 'cron', 'Uses cc-connect management API.'],
['cron.remove', 'cron', 'Compatibility alias for cron.delete.'],
['cron.toggle', 'cron', 'Uses cc-connect management API update with enabled=true/false.'],
['cron.run', 'cron', 'Uses cc-connect management API.'],
['logs.list', 'logs', 'Served from managed cc-connect config and runtime paths.'],
['doctor.run', 'doctor', 'Runs cc-connect doctor user-isolation.'],
];
const CC_CONNECT_UNSUPPORTED_METHODS: Array<[string, keyof RuntimeCapabilities, string]> = [
['channels.add', 'channels', 'Channel accounts are configured through the ClawX Host API before cc-connect reload.'],
['channels.requestQr', 'channels', 'cc-connect does not expose the OpenClaw QR pairing RPC.'],
['doctor.fix', 'doctor', 'cc-connect Doctor does not support fix mode.'],
['doctor.memory.status', 'doctor', 'OpenClaw Dreams memory doctor RPCs do not have a cc-connect equivalent.'],
];
function entries(
runtime: RuntimeKind,
support: RuntimeOperationSupport,
items: Array<[string, keyof RuntimeCapabilities, string]>,
): RuntimeRpcContractEntry[] {
return items.map(([method, capability, notes]) => ({
runtime,
method,
capability,
support,
notes,
}));
}
export const RUNTIME_RPC_CONTRACT: RuntimeRpcContractEntry[] = [
...entries('openclaw', 'proxy', OPENCLAW_PROXY_METHODS),
...entries('cc-connect', 'native', CC_CONNECT_NATIVE_METHODS),
...entries('cc-connect', 'unsupported', CC_CONNECT_UNSUPPORTED_METHODS),
];
export function getRuntimeRpcCoverage(runtime: RuntimeKind): RuntimeRpcContractEntry[] {
return RUNTIME_RPC_CONTRACT.filter((entry) => entry.runtime === runtime);
}
export function getRuntimeOperationCapabilities(runtime: RuntimeKind): RuntimeOperationCapabilities {
return Object.fromEntries(getRuntimeRpcCoverage(runtime).map((entry) => [
entry.method,
{
capability: entry.capability,
support: entry.support,
notes: entry.notes,
},
]));
}
+190
View File
@@ -0,0 +1,190 @@
import type { EventEmitter } from 'node:events';
import type { RawMessage } from '@shared/chat/types';
import type { OpenClawDoctorMode, OpenClawDoctorResult } from '@shared/host-api/contract';
import type {
GatewayHealth,
GatewayStatus,
RuntimeCapabilities,
RuntimeKind,
RuntimeOperationCapabilities,
} from '@shared/types/gateway';
export type {
RuntimeCapabilities,
RuntimeKind,
RuntimeOperationCapabilities,
RuntimeOperationSupport,
} from '@shared/types/gateway';
export type RuntimeStatus = GatewayStatus & {
runtimeKind: RuntimeKind;
capabilities: RuntimeCapabilities;
configDir?: string;
};
export type RuntimeHealth = GatewayHealth;
export type RuntimeSessionListResult = {
success?: boolean;
sessions?: Array<{ key: string; displayName?: string; agentId?: string }>;
summaries?: Array<{ sessionKey: string; firstUserText: string | null; lastTimestamp: number | null }>;
error?: string;
};
export type RuntimeHistoryResult = {
success?: boolean;
messages?: RawMessage[];
error?: string;
};
export type RuntimeDeleteSessionResult = {
success: boolean;
error?: string;
};
export type RuntimeLogResult = {
content: string;
};
export type RuntimeUsageRecord = {
id: string;
runtimeKind: RuntimeKind;
logicalSessionId: string;
runtimeSessionId: string;
turnId: string;
agentId: string;
providerAccountId?: string;
provider: string;
model: string;
timestamp: string;
status: 'available' | 'missing' | 'error';
inputTokens: number;
cachedInputTokens: number;
cacheWriteTokens: number;
outputTokens: number;
reasoningTokens: number;
totalTokens: number;
costUsd?: number;
content?: string;
};
export type RuntimeUsageListResult = {
success: boolean;
records: RuntimeUsageRecord[];
error?: string;
};
export type RuntimeSendWithMediaPayload = {
sessionKey: string;
message: string;
deliver?: boolean;
idempotencyKey: string;
media?: Array<{ filePath: string; mimeType: string; fileName: string }>;
};
export type RuntimeSendWithMediaResult = {
runId?: string;
};
export type RuntimeConfigRefreshPayload = {
scope: 'channels' | 'providers' | 'skills' | 'runtime';
reason: string;
channelType?: string;
forceRestart?: boolean;
};
export type RuntimeProviderSyncPayload = {
providerId?: string;
reason: string;
};
export type RuntimeControlUiPayload = {
view?: 'dreams';
};
export type RuntimeControlUiResult = {
success: boolean;
url?: string;
token?: string;
port?: number;
error?: string;
};
export type RuntimeEventName =
| 'status'
| 'error'
| 'notification'
| 'gateway:health'
| 'gateway:presence'
| 'chat:message'
| 'chat:runtime-event'
| 'channel:status'
| 'exit';
export type RuntimeProvider = {
kind: RuntimeKind;
on: EventEmitter['on'];
off: EventEmitter['off'];
start: () => Promise<void>;
stop: () => Promise<void>;
restart: () => Promise<void>;
getStatus: () => RuntimeStatus;
checkHealth: (options?: { probe?: boolean }) => Promise<RuntimeHealth>;
rpc: <T = unknown>(method: string, params?: unknown, timeoutMs?: number) => Promise<T>;
sendMessageWithMedia: (payload: RuntimeSendWithMediaPayload) => Promise<RuntimeSendWithMediaResult>;
listSessions: (payload?: unknown) => Promise<RuntimeSessionListResult>;
loadHistory: (payload?: unknown) => Promise<RuntimeHistoryResult>;
deleteSession: (payload?: unknown) => Promise<RuntimeDeleteSessionResult>;
listUsage: (payload?: unknown) => Promise<RuntimeUsageListResult>;
listLogs: (payload?: { tailLines?: number }) => Promise<RuntimeLogResult>;
runDoctor: (mode: OpenClawDoctorMode) => Promise<OpenClawDoctorResult>;
listCapabilities: () => RuntimeCapabilities;
listOperationCapabilities: () => RuntimeOperationCapabilities;
refreshConfig?: (payload: RuntimeConfigRefreshPayload) => Promise<void>;
syncProviderProfile?: (payload: RuntimeProviderSyncPayload) => Promise<unknown>;
getControlUi?: (payload?: RuntimeControlUiPayload) => Promise<RuntimeControlUiResult>;
};
export const OPENCLAW_RUNTIME_CAPABILITIES: RuntimeCapabilities = {
chat: true,
sessions: true,
history: true,
providers: true,
models: true,
channels: true,
cron: true,
logs: true,
skills: true,
doctor: true,
controlUi: true,
};
export const CC_CONNECT_RUNTIME_CAPABILITIES: RuntimeCapabilities = {
chat: true,
sessions: true,
history: true,
providers: true,
models: true,
channels: true,
cron: true,
logs: true,
skills: true,
doctor: true,
controlUi: true,
};
export function withRuntimeStatus(
status: GatewayStatus,
runtimeKind: RuntimeKind,
capabilities: RuntimeCapabilities,
configDir?: string,
operationCapabilities?: RuntimeOperationCapabilities,
): RuntimeStatus {
return {
...status,
runtimeKind,
capabilities,
...(operationCapabilities ? { operationCapabilities } : {}),
...(configDir ? { configDir } : {}),
};
}
+94
View File
@@ -0,0 +1,94 @@
import { createHash } from 'node:crypto';
import type { TokenUsageHistoryEntry } from '../utils/token-usage-core';
import type { RuntimeKind, RuntimeUsageRecord } from './types';
type RuntimeUsageIdentity = {
runtimeKind: RuntimeKind;
logicalSessionId?: string;
runtimeSessionId?: string;
providerAccountId?: string;
provider?: string;
model?: string;
};
export function runtimeUsageLimit(payload: unknown): number | undefined {
const value = payload && typeof payload === 'object' && !Array.isArray(payload)
? (payload as { limit?: unknown }).limit
: payload;
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.max(Math.floor(value), 1);
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return Math.max(Math.floor(parsed), 1);
}
return undefined;
}
export function toRuntimeUsageRecords(
entries: TokenUsageHistoryEntry[],
identity: RuntimeUsageIdentity,
): RuntimeUsageRecord[] {
return entries.map((entry) => {
const logicalSessionId = identity.logicalSessionId ?? entry.sessionId;
const runtimeSessionId = identity.runtimeSessionId ?? entry.sessionId;
const fallbackTurnId = createHash('sha256')
.update(JSON.stringify([
runtimeSessionId,
entry.timestamp,
entry.provider,
entry.model,
entry.content,
entry.inputTokens,
entry.outputTokens,
entry.totalTokens,
]))
.digest('hex')
.slice(0, 20);
const turnId = entry.turnId ?? `${runtimeSessionId}:${fallbackTurnId}`;
return {
id: `${identity.runtimeKind}:${runtimeSessionId}:${turnId}`,
runtimeKind: identity.runtimeKind,
logicalSessionId,
runtimeSessionId,
turnId,
agentId: entry.agentId,
...(identity.providerAccountId ? { providerAccountId: identity.providerAccountId } : {}),
provider: entry.provider ?? identity.provider ?? 'unknown',
model: entry.model ?? identity.model ?? 'unknown',
timestamp: entry.timestamp,
status: entry.usageStatus,
inputTokens: entry.inputTokens,
cachedInputTokens: entry.cacheReadTokens,
cacheWriteTokens: entry.cacheWriteTokens,
outputTokens: entry.outputTokens,
reasoningTokens: entry.reasoningTokens ?? 0,
totalTokens: entry.totalTokens,
...(entry.costUsd !== undefined ? { costUsd: entry.costUsd } : {}),
...(entry.content ? { content: entry.content } : {}),
};
});
}
export function toTokenUsageHistoryEntry(record: RuntimeUsageRecord): TokenUsageHistoryEntry {
return {
runtimeKind: record.runtimeKind,
timestamp: record.timestamp,
sessionId: record.logicalSessionId,
runtimeSessionId: record.runtimeSessionId,
turnId: record.turnId,
agentId: record.agentId,
...(record.providerAccountId ? { providerAccountId: record.providerAccountId } : {}),
model: record.model,
provider: record.provider,
...(record.content ? { content: record.content } : {}),
usageStatus: record.status,
inputTokens: record.inputTokens,
outputTokens: record.outputTokens,
cacheReadTokens: record.cachedInputTokens,
cacheWriteTokens: record.cacheWriteTokens,
...(record.reasoningTokens > 0 ? { reasoningTokens: record.reasoningTokens } : {}),
totalTokens: record.totalTokens,
...(record.costUsd !== undefined ? { costUsd: record.costUsd } : {}),
};
}
+768
View File
@@ -0,0 +1,768 @@
import type { BrowserWindow } from 'electron';
import { fork, type ChildProcess } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { Readable, Writable } from 'node:stream';
import {
ClientSideConnection,
ndJsonStream,
PROTOCOL_VERSION,
type Client,
type ContentBlock,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
} from '@agentclientprotocol/sdk';
import { HOST_EVENT_CHANNELS } from '@shared/host-events/contract';
import type {
AcpChatCancelPayload,
AcpChatLoadPayload,
AcpChatOperationResult,
AcpChatPromptPayload,
AcpChatRespondPermissionPayload,
AcpPermissionRequestEnvelope,
AcpSessionUpdateEnvelope,
} from '@shared/acp-chat/types';
import { getOpenClawEmbeddedForkSpec } from '../utils/openclaw-cli';
import {
approvePendingLocalDeviceRequests,
type GatewayPairingRpcClient,
} from '../utils/control-ui-device-pairing';
import { logger } from '../utils/logger';
import { recordAcpTrace } from './acp-trace';
import { AcpSessionAccessRegistry, type AcpSessionAccessContext } from './acp-session-access-registry';
import { expandPath } from '../utils/paths';
type AcpConnection = Pick<ClientSideConnection, 'initialize' | 'newSession' | 'loadSession' | 'prompt' | 'cancel'>;
type MainWindowLike = {
webContents: Pick<BrowserWindow['webContents'], 'send'>;
};
type PermissionWaiter = {
sessionKey: string;
generation: number;
resolve: (response: RequestPermissionResponse) => void;
};
type AcpSessionLoadBatch = {
sessionKey: string;
generation: number;
sessionUpdates: Array<{
acpSessionId: string;
envelope: AcpSessionUpdateEnvelope;
}>;
};
type AcpLivePromptContext = {
sessionKey: string;
acpSessionId: string;
generation: number;
accessGrant: AcpSessionAccessContext;
};
type AcpChildProcess = ChildProcess & {
stdin: NonNullable<ChildProcess['stdin']>;
stdout: NonNullable<ChildProcess['stdout']>;
stderr: NonNullable<ChildProcess['stderr']>;
};
function ok(generation?: number, sessionUpdates?: AcpSessionUpdateEnvelope[]): AcpChatOperationResult {
return {
success: true,
...(generation != null ? { generation } : {}),
...(sessionUpdates?.length ? { sessionUpdates } : {}),
};
}
function fail(error: unknown): AcpChatOperationResult {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
function cancelledPermissionResponse(): RequestPermissionResponse {
return { outcome: { outcome: 'cancelled' } };
}
function isValidSessionKey(value: unknown): value is string {
return typeof value === 'string' && value.startsWith('agent:') && value.length > 'agent:'.length;
}
function sessionUpdateType(notification: SessionNotification): string | undefined {
const update = (notification as { update?: { sessionUpdate?: unknown } }).update;
return typeof update?.sessionUpdate === 'string' ? update.sessionUpdate : undefined;
}
// OpenClaw can emit clack/doctor diagnostics to stdout during ACP startup.
// Keep those lines away from the SDK's strict NDJSON parser.
// Upstream fixed this in https://github.com/openclaw/openclaw/pull/89997 .
function filterAcpStdoutDiagnostics(output: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
return new ReadableStream<Uint8Array>({
async start(controller) {
const reader = output.getReader();
let buffered = '';
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (!value) continue;
buffered += decoder.decode(value, { stream: true });
const lines = buffered.split('\n');
buffered = lines.pop() ?? '';
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
if (trimmedLine.startsWith('{')) {
controller.enqueue(encoder.encode(`${line}\n`));
} else {
logger.info(`[acp-chat] [stdout] ${line}`);
}
}
}
} finally {
reader.releaseLock();
controller.close();
}
},
});
}
export class AcpChatService {
private child: AcpChildProcess | null = null;
private connection: AcpConnection | null;
private initializing: Promise<AcpConnection> | null = null;
private initialized = false;
private generation = 0;
private generationSeq = 0;
private activeSessionKey: string | null = null;
private activeAcpSessionId: string | null = null;
private loadedSessionKey: string | null = null;
private loadedAcpSessionId: string | null = null;
private historicalSessionKey: string | null = null;
private historicalGeneration: number | null = null;
private permissionsEnabled = false;
private loadQueue: Promise<void> | null = null;
private activeLoadBatch: AcpSessionLoadBatch | null = null;
private readonly livePrompts = new Map<string, AcpLivePromptContext>();
private permissionSeq = 0;
private readonly permissionWaiters = new Map<string, PermissionWaiter>();
readonly client: Client;
constructor(
private readonly mainWindow: MainWindowLike,
private readonly accessRegistry: AcpSessionAccessRegistry,
injectedConnection?: AcpConnection,
private readonly gateway?: GatewayPairingRpcClient,
) {
this.connection = injectedConnection ?? null;
this.client = {
sessionUpdate: async (notification) => this.emitSessionUpdate(notification),
requestPermission: async (request) => this.requestPermission(request),
};
}
private trace(
event: string,
input: { direction?: string; sessionKey?: string | null; generation?: number; details?: unknown } = {},
): void {
try {
const sessionKey = input.sessionKey === null
? undefined
: input.sessionKey ?? this.activeSessionKey ?? undefined;
const generation = input.generation ?? (this.generation > 0 ? this.generation : undefined);
recordAcpTrace({
source: 'main',
event,
...(input.direction ? { direction: input.direction } : {}),
...(sessionKey ? { sessionKey } : {}),
...(generation != null ? { generation } : {}),
...(input.details !== undefined ? { details: input.details } : {}),
});
} catch (error) {
logger.warn(`[acp-chat] trace failed: ${String(error)}`);
}
}
loadSession(payload: AcpChatLoadPayload): Promise<AcpChatOperationResult> {
const previousLoad = this.loadQueue;
let releaseLoad!: () => void;
const currentLoad = new Promise<void>((resolve) => {
releaseLoad = resolve;
});
this.loadQueue = currentLoad;
const run = async () => {
if (previousLoad) await previousLoad;
try {
return await this.performLoadSession(payload);
} finally {
releaseLoad();
if (this.loadQueue === currentLoad) this.loadQueue = null;
}
};
return run();
}
private async performLoadSession(payload: AcpChatLoadPayload): Promise<AcpChatOperationResult> {
if (!isValidSessionKey(payload.sessionKey) || !payload.workspaceRoot || !payload.cwd) {
return fail('Invalid ACP session load payload');
}
const previousPermissionsEnabled = this.permissionsEnabled;
this.permissionsEnabled = false;
this.trace('session/load:start', {
sessionKey: payload.sessionKey,
details: { createIfMissing: !!payload.createIfMissing, cwdPresent: Boolean(payload.cwd) },
});
let previousSessionKey = this.activeSessionKey;
let previousAcpSessionId = this.activeAcpSessionId;
let previousLoadedSessionKey = this.loadedSessionKey;
let previousLoadedAcpSessionId = this.loadedAcpSessionId;
let previousHistoricalSessionKey = this.historicalSessionKey;
let previousHistoricalGeneration = this.historicalGeneration;
let previousGeneration = this.generation;
let nextGeneration = this.generationSeq + 1;
let stateAdvanced = false;
let loadBatch: AcpSessionLoadBatch | null = null;
let previousAccessGrant: AcpSessionAccessContext | null = null;
try {
const connection = await this.ensureConnection();
const livePrompt = this.livePrompts.get(payload.sessionKey);
if (livePrompt) {
const preparedAccessGrant = await this.accessRegistry.prepareGrant({
sessionKey: payload.sessionKey,
generation: livePrompt.generation,
workspaceRoot: payload.workspaceRoot,
executionCwd: payload.cwd,
});
if (
preparedAccessGrant.workspaceRoot !== livePrompt.accessGrant.workspaceRoot
|| preparedAccessGrant.executionCwd !== livePrompt.accessGrant.executionCwd
) {
throw new Error('Cannot change workspace while an ACP prompt is active');
}
this.generation = livePrompt.generation;
this.activeSessionKey = livePrompt.sessionKey;
this.activeAcpSessionId = livePrompt.acpSessionId;
this.loadedSessionKey = livePrompt.sessionKey;
this.loadedAcpSessionId = livePrompt.acpSessionId;
this.historicalSessionKey = null;
this.historicalGeneration = null;
this.permissionsEnabled = true;
this.accessRegistry.commitGrant(livePrompt.accessGrant);
this.trace('session/load:resumed-active-prompt', {
sessionKey: livePrompt.sessionKey,
generation: livePrompt.generation,
details: { acpSessionId: livePrompt.acpSessionId },
});
return {
success: true,
generation: livePrompt.generation,
resumedActivePrompt: true,
};
}
previousSessionKey = this.activeSessionKey;
previousAcpSessionId = this.activeAcpSessionId;
previousLoadedSessionKey = this.loadedSessionKey;
previousLoadedAcpSessionId = this.loadedAcpSessionId;
previousHistoricalSessionKey = this.historicalSessionKey;
previousHistoricalGeneration = this.historicalGeneration;
previousGeneration = this.generation;
nextGeneration = this.generationSeq + 1;
previousAccessGrant = this.accessRegistry.snapshot();
const preparedAccessGrant = await this.accessRegistry.prepareGrant({
sessionKey: payload.sessionKey,
generation: nextGeneration,
workspaceRoot: payload.workspaceRoot,
executionCwd: payload.cwd,
});
this.generation = nextGeneration;
this.activeSessionKey = payload.sessionKey;
this.activeAcpSessionId = payload.createIfMissing ? null : payload.sessionKey;
this.loadedSessionKey = null;
this.loadedAcpSessionId = null;
this.historicalSessionKey = payload.createIfMissing ? null : payload.sessionKey;
this.historicalGeneration = payload.createIfMissing ? null : nextGeneration;
loadBatch = {
sessionKey: payload.sessionKey,
generation: nextGeneration,
sessionUpdates: [],
};
this.activeLoadBatch = loadBatch;
stateAdvanced = true;
if (previousSessionKey && !this.livePrompts.has(previousSessionKey)) {
this.resolvePermissionWaitersForSession(previousSessionKey, cancelledPermissionResponse());
}
let acpSessionId = payload.sessionKey;
if (payload.createIfMissing) {
const created = await connection.newSession({
cwd: preparedAccessGrant.executionCwd,
mcpServers: [],
_meta: { sessionKey: payload.sessionKey, prefixCwd: true },
});
acpSessionId = created.sessionId;
} else {
await connection.loadSession({
sessionId: payload.sessionKey,
cwd: preparedAccessGrant.executionCwd,
mcpServers: [],
});
}
this.activeAcpSessionId = acpSessionId;
this.loadedSessionKey = payload.sessionKey;
this.loadedAcpSessionId = acpSessionId;
this.generationSeq = nextGeneration;
this.accessRegistry.commitGrant(preparedAccessGrant);
this.trace('session/load:success', {
sessionKey: payload.sessionKey,
generation: nextGeneration,
details: { createIfMissing: !!payload.createIfMissing, acpSessionId },
});
if (this.activeLoadBatch === loadBatch) this.activeLoadBatch = null;
return ok(
nextGeneration,
loadBatch.sessionUpdates
.filter((entry) => entry.acpSessionId === acpSessionId)
.map((entry) => entry.envelope),
);
} catch (error) {
if (this.activeLoadBatch === loadBatch) this.activeLoadBatch = null;
this.resolvePermissionWaitersForSession(payload.sessionKey, cancelledPermissionResponse());
if (
stateAdvanced
&& this.activeSessionKey === payload.sessionKey
&& this.generation === nextGeneration
) {
this.generation = previousGeneration;
this.activeSessionKey = previousSessionKey;
this.activeAcpSessionId = previousAcpSessionId;
this.loadedSessionKey = previousLoadedSessionKey;
this.loadedAcpSessionId = previousLoadedAcpSessionId;
this.historicalSessionKey = previousHistoricalSessionKey;
this.historicalGeneration = previousHistoricalGeneration;
this.permissionsEnabled = previousPermissionsEnabled;
this.accessRegistry.restore(previousAccessGrant);
}
logger.error(`[acp-chat] loadSession failed: ${String(error)}`);
this.trace('session/load:failed', {
sessionKey: payload.sessionKey,
generation: previousGeneration,
details: { error: error instanceof Error ? error.message : String(error) },
});
return fail(error);
}
}
async sendPrompt(payload: AcpChatPromptPayload): Promise<AcpChatOperationResult> {
if (!isValidSessionKey(payload.sessionKey) || !payload.cwd) return fail('Invalid ACP prompt payload');
if (!this.activeSessionKey) return fail('No active ACP session');
if (payload.sessionKey !== this.activeSessionKey) return fail('ACP prompt session is not active');
if (this.loadedSessionKey !== payload.sessionKey || !this.loadedAcpSessionId) return fail('ACP session is not loaded');
if (this.livePrompts.has(payload.sessionKey)) return fail('ACP prompt is already active');
const generation = this.generation;
const acpSessionId = this.loadedAcpSessionId;
const accessGrant = this.accessRegistry.get(payload.sessionKey, generation);
if (!accessGrant) return fail('ACP session access grant is not active');
const promptContext: AcpLivePromptContext = {
sessionKey: payload.sessionKey,
acpSessionId,
generation,
accessGrant,
};
this.livePrompts.set(payload.sessionKey, promptContext);
try {
const promptCwd = payload.cwd === accessGrant.executionCwd
? payload.cwd
: await import('node:fs/promises')
.then((fsP) => fsP.realpath(expandPath(payload.cwd)))
.catch(() => null);
if (promptCwd !== accessGrant.executionCwd) {
return fail('ACP prompt cwd does not match the registered execution cwd');
}
this.trace('session/prompt:start', {
sessionKey: payload.sessionKey,
generation,
details: { messageLength: payload.message?.length ?? 0, mediaCount: payload.media?.length ?? 0 },
});
const connection = await this.ensureConnection();
const prompt = await this.buildPromptBlocks(payload);
if (this.historicalSessionKey === payload.sessionKey) {
this.historicalSessionKey = null;
this.historicalGeneration = null;
}
this.permissionsEnabled = true;
const messageId = payload.messageId ?? randomUUID();
await connection.prompt({
sessionId: acpSessionId,
prompt,
// ACP 1.1 removed messageId from the PromptRequest wire shape. Keep
// ClawX correlation metadata in the protocol extension envelope.
_meta: { sessionKey: payload.sessionKey, prefixCwd: true, messageId },
});
this.trace('session/prompt:success', {
sessionKey: payload.sessionKey,
generation,
details: { blockCount: prompt.length, acpSessionId },
});
return ok(generation);
} catch (error) {
logger.error(`[acp-chat] prompt failed: ${String(error)}`);
this.trace('session/prompt:failed', {
sessionKey: payload.sessionKey,
details: { error: error instanceof Error ? error.message : String(error) },
});
return fail(error);
} finally {
if (this.livePrompts.get(payload.sessionKey) === promptContext) {
this.livePrompts.delete(payload.sessionKey);
this.resolvePermissionWaitersForSession(payload.sessionKey, cancelledPermissionResponse());
}
this.permissionsEnabled = this.activeSessionKey != null && this.livePrompts.has(this.activeSessionKey);
}
}
async cancelSession(payload: AcpChatCancelPayload): Promise<AcpChatOperationResult> {
if (!isValidSessionKey(payload.sessionKey)) return fail('Invalid ACP cancel payload');
if (payload.sessionKey !== this.activeSessionKey || !this.loadedAcpSessionId) return fail('ACP session is not loaded');
try {
this.trace('session/cancel:start', { sessionKey: payload.sessionKey });
const connection = await this.ensureConnection();
await connection.cancel({ sessionId: this.loadedAcpSessionId });
this.permissionsEnabled = false;
this.resolvePermissionWaitersForSession(payload.sessionKey, cancelledPermissionResponse());
this.trace('session/cancel:success', { sessionKey: payload.sessionKey });
return ok(this.generation);
} catch (error) {
logger.error(`[acp-chat] cancel failed: ${String(error)}`);
this.trace('session/cancel:failed', {
sessionKey: payload.sessionKey,
details: { error: error instanceof Error ? error.message : String(error) },
});
return fail(error);
}
}
async respondPermission(payload: AcpChatRespondPermissionPayload): Promise<AcpChatOperationResult> {
const waiter = this.permissionWaiters.get(payload.requestId);
if (!waiter || waiter.sessionKey !== payload.sessionKey) return fail('Unknown ACP permission request');
waiter.resolve({ outcome: payload.outcome });
this.permissionWaiters.delete(payload.requestId);
this.trace('permission/responded', {
sessionKey: payload.sessionKey,
details: { requestId: payload.requestId, outcome: payload.outcome.outcome },
});
return ok(waiter.generation);
}
private async ensureConnection(): Promise<AcpConnection> {
if (this.connection && this.initialized) return this.connection;
if (this.initializing) return this.initializing;
this.initializing = this.initializeConnection();
try {
return await this.initializing;
} finally {
this.initializing = null;
}
}
private async initializeConnection(): Promise<AcpConnection> {
await this.approveLocalDeviceRequests();
for (let attempt = 1; attempt <= 2; attempt++) {
try {
return await this.initializeConnectionOnce(attempt);
} catch (error) {
if (attempt >= 2) throw error;
logger.info(
`[acp-chat] ACP connect failed on attempt ${attempt}; auto-approving local device requests and retrying: ${
error instanceof Error ? error.message : String(error)
}`,
);
await this.approveLocalDeviceRequests();
}
}
throw new Error('ACP connection failed');
}
private async initializeConnectionOnce(attempt: number): Promise<AcpConnection> {
if (!this.connection) this.connection = this.spawnConnection();
const connection = this.connection;
const child = this.child;
this.trace('connection/initialize:start', { details: { attempt } });
const initOutcome = await Promise.race([
connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {},
}).then((result) => ({ kind: 'initialized' as const, result })),
this.waitForChildExit(child).then((exitCode) => ({ kind: 'exited' as const, exitCode })),
]);
if (initOutcome.kind === 'exited') {
if (child) this.dropConnectionForChild(child);
throw new Error(`ACP process exited with code ${String(initOutcome.exitCode)}`);
}
const result = initOutcome.result;
if (this.connection !== connection) {
throw new Error('ACP connection closed during initialization');
}
if (!result.agentCapabilities?.loadSession) {
this.trace('connection/initialize:failed', { details: { reason: 'missing-loadSession-capability' } });
throw new Error('ACP agent does not support session/load');
}
this.initialized = true;
this.trace('connection/initialize:success', { details: { protocolVersion: PROTOCOL_VERSION, attempt } });
return connection;
}
private async approveLocalDeviceRequests(): Promise<void> {
if (!this.gateway) return;
try {
await approvePendingLocalDeviceRequests(this.gateway);
} catch (error) {
logger.debug(`[acp-chat] Local device auto-approve skipped: ${String(error)}`);
}
}
private waitForChildExit(child: AcpChildProcess | null): Promise<number | null> {
if (!child) return Promise.resolve(null);
if (child.exitCode !== null) return Promise.resolve(child.exitCode);
if (child.signalCode) return Promise.resolve(child.exitCode);
return new Promise((resolve) => {
const onExit = (code: number | null) => {
child.off('exit', onExit);
resolve(code);
};
child.on('exit', onExit);
});
}
private spawnConnection(): ClientSideConnection {
const spec = getOpenClawEmbeddedForkSpec(['acp']);
const forked = fork(spec.modulePath, spec.args, spec.options);
if (!forked.stdin || !forked.stdout || !forked.stderr) {
forked.kill();
throw new Error('ACP process did not expose stdio pipes');
}
this.child = forked as AcpChildProcess;
const child = this.child;
child.stderr.on('data', (chunk) => {
const message = String(chunk).trimEnd();
if (message) logger.info(`[acp-chat] ${message}`);
});
child.on('error', (error) => {
logger.error(`[acp-chat] ACP process error: ${String(error)}`);
this.dropConnectionForChild(child);
});
child.on('exit', (code) => {
logger.info(`[acp-chat] ACP process exited with code ${String(code)}`);
this.dropConnectionForChild(child);
});
const input = Writable.toWeb(child.stdin) as WritableStream<Uint8Array>;
const output = filterAcpStdoutDiagnostics(Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>);
const stream = ndJsonStream(input, output);
return new ClientSideConnection(() => this.client, stream);
}
private dropConnectionForChild(child: AcpChildProcess): void {
if (this.child !== child) return;
this.trace('connection/dropped', { details: { pendingPermissionCount: this.permissionWaiters.size } });
this.resolveAllPermissionWaiters(cancelledPermissionResponse());
this.initialized = false;
this.initializing = null;
this.connection = null;
this.child = null;
this.loadedSessionKey = null;
this.loadedAcpSessionId = null;
this.historicalSessionKey = null;
this.historicalGeneration = null;
this.permissionsEnabled = false;
this.livePrompts.clear();
}
private emitSessionUpdate(notification: SessionNotification): void {
const acpSessionId = notification.sessionId;
const livePrompt = [...this.livePrompts.values()].find((context) => context.acpSessionId === acpSessionId);
const sessionKey = livePrompt?.sessionKey ?? this.activeSessionKey;
const generation = livePrompt?.generation ?? this.generation;
const updateType = sessionUpdateType(notification);
this.trace('session-update:received', {
direction: 'upstream',
sessionKey: sessionKey ?? null,
details: { acpSessionId, updateType },
});
if (!sessionKey) {
this.trace('session-update:ignored', {
direction: 'upstream',
sessionKey: null,
details: { reason: 'no-active-session', acpSessionId, updateType },
});
return;
}
if (!livePrompt && this.activeAcpSessionId && acpSessionId !== this.activeAcpSessionId) {
this.trace('session-update:ignored', {
direction: 'upstream',
sessionKey,
details: { reason: 'session-mismatch', acpSessionId, activeAcpSessionId: this.activeAcpSessionId, updateType },
});
return;
}
const envelope: AcpSessionUpdateEnvelope = {
sessionKey,
generation,
...(!livePrompt && this.historicalSessionKey === sessionKey && this.historicalGeneration === generation
? { historical: true }
: {}),
notification: { ...notification, sessionId: sessionKey },
};
const loadBatch = this.activeLoadBatch;
if (loadBatch?.sessionKey === sessionKey && loadBatch.generation === generation) {
loadBatch.sessionUpdates.push({ acpSessionId, envelope });
this.trace('session-update:buffered', {
direction: 'downstream',
sessionKey,
details: { acpSessionId, updateType, historical: !!envelope.historical },
});
return;
}
this.mainWindow.webContents.send(HOST_EVENT_CHANNELS.chat.acpSessionUpdate, envelope);
this.trace('session-update:forwarded', {
direction: 'downstream',
sessionKey,
details: { acpSessionId, updateType, historical: !!envelope.historical },
});
}
private requestPermission(request: RequestPermissionRequest): Promise<RequestPermissionResponse> {
const acpSessionId = request.sessionId;
const livePrompt = [...this.livePrompts.values()].find((context) => context.acpSessionId === acpSessionId);
const sessionKey = livePrompt?.sessionKey ?? this.activeSessionKey;
const generation = livePrompt?.generation ?? this.generation;
if (!livePrompt && !this.permissionsEnabled) {
this.trace('permission:ignored', {
direction: 'upstream',
sessionKey: sessionKey ?? null,
details: { reason: 'no-active-prompt', acpSessionId },
});
return Promise.resolve(cancelledPermissionResponse());
}
if (this.activeLoadBatch && !livePrompt) {
this.trace('permission:ignored', {
direction: 'upstream',
sessionKey: sessionKey ?? null,
details: { reason: 'session-loading', acpSessionId },
});
return Promise.resolve(cancelledPermissionResponse());
}
if (!sessionKey || (!livePrompt && this.activeAcpSessionId && acpSessionId !== this.activeAcpSessionId)) {
this.trace('permission:ignored', {
direction: 'upstream',
sessionKey: sessionKey ?? null,
details: {
reason: !sessionKey ? 'no-active-session' : 'session-mismatch',
acpSessionId,
activeAcpSessionId: this.activeAcpSessionId,
},
});
return Promise.resolve(cancelledPermissionResponse());
}
const requestId = `acp-permission-${Date.now()}-${this.permissionSeq += 1}`;
const envelope: AcpPermissionRequestEnvelope = {
sessionKey,
generation,
requestId,
request: { ...request, sessionId: sessionKey },
};
this.mainWindow.webContents.send(HOST_EVENT_CHANNELS.chat.acpPermissionRequest, envelope);
this.trace('permission:forwarded', {
direction: 'downstream',
sessionKey,
details: { requestId, acpSessionId, optionCount: request.options.length },
});
return new Promise((resolve) => {
this.permissionWaiters.set(requestId, { sessionKey, generation, resolve });
});
}
private resolvePermissionWaitersForSession(sessionKey: string, response: RequestPermissionResponse): void {
for (const [requestId, waiter] of this.permissionWaiters) {
if (waiter.sessionKey !== sessionKey) continue;
waiter.resolve(response);
this.permissionWaiters.delete(requestId);
}
}
private resolveAllPermissionWaiters(response: RequestPermissionResponse): void {
for (const [requestId, waiter] of this.permissionWaiters) {
waiter.resolve(response);
this.permissionWaiters.delete(requestId);
}
}
private async buildPromptBlocks(payload: AcpChatPromptPayload): Promise<ContentBlock[]> {
const blocks: ContentBlock[] = [];
const text = payload.message?.trim();
if (text) blocks.push({ type: 'text', text });
const media = payload.media ?? [];
if (media.length > 0) {
const fsP = await import('node:fs/promises');
for (const item of media) {
const mimeType = item.mimeType || 'application/octet-stream';
if (mimeType.startsWith('image/')) {
const data = await fsP.readFile(item.filePath, 'base64');
blocks.push({
type: 'image',
data,
mimeType,
uri: item.filePath,
_meta: {
clawx: {
stagingId: item.stagingId,
...(item.fileName ? { fileName: item.fileName } : {}),
},
},
});
} else {
blocks.push({
type: 'resource_link',
uri: item.filePath,
name: item.fileName ?? item.filePath,
mimeType: item.mimeType,
_meta: {
clawx: {
stagingId: item.stagingId,
},
},
});
}
}
}
if (blocks.length === 0) blocks.push({ type: 'text', text: '' });
return blocks;
}
}
export function createAcpChatService(
mainWindow: MainWindowLike,
accessRegistry: AcpSessionAccessRegistry,
gateway?: GatewayPairingRpcClient,
): AcpChatService {
return new AcpChatService(mainWindow, accessRegistry, undefined, gateway);
}
@@ -0,0 +1,53 @@
import { realpath, stat } from 'node:fs/promises';
import { isAbsolute, relative, sep } from 'node:path';
import { expandPath } from '../utils/paths';
export type AcpSessionAccessContext = {
sessionKey: string;
generation: number;
workspaceRoot: string;
executionCwd: string;
};
async function canonicalDirectory(input: string, label: string): Promise<string> {
const canonicalPath = await realpath(expandPath(input));
const directoryStat = await stat(canonicalPath);
if (!directoryStat.isDirectory()) throw new Error(`${label} must be a directory`);
return canonicalPath;
}
function isInside(child: string, parent: string): boolean {
const relativePath = relative(parent, child);
return relativePath === ''
|| (!isAbsolute(relativePath) && relativePath !== '..' && !relativePath.startsWith(`..${sep}`));
}
export class AcpSessionAccessRegistry {
private activeGrant: AcpSessionAccessContext | null = null;
async prepareGrant(input: AcpSessionAccessContext): Promise<AcpSessionAccessContext> {
const workspaceRoot = await canonicalDirectory(input.workspaceRoot, 'ACP workspace root');
const executionCwd = await canonicalDirectory(input.executionCwd, 'ACP execution cwd');
if (!isInside(executionCwd, workspaceRoot)) {
throw new Error('ACP execution cwd must be inside the workspace root');
}
return { ...input, workspaceRoot, executionCwd };
}
snapshot(): AcpSessionAccessContext | null {
return this.activeGrant ? { ...this.activeGrant } : null;
}
commitGrant(context: AcpSessionAccessContext): void {
this.activeGrant = { ...context };
}
restore(snapshot: AcpSessionAccessContext | null): void {
this.activeGrant = snapshot ? { ...snapshot } : null;
}
get(sessionKey: string, generation: number): AcpSessionAccessContext | null {
if (this.activeGrant?.sessionKey !== sessionKey || this.activeGrant.generation !== generation) return null;
return { ...this.activeGrant };
}
}
+137
View File
@@ -0,0 +1,137 @@
import type {
AcpTraceEntry,
AcpTraceRecordPayload,
AcpTraceSnapshot,
AttachmentAccessError,
} from '@shared/host-api/contract';
import { isRecord } from './payload-utils';
type AcpTraceRecordInput = Omit<AcpTraceEntry, 'seq' | 'timestamp'>;
const MAX_ACP_TRACE_ENTRIES = 500;
const MAX_STRING_LENGTH = 300;
const SENSITIVE_KEY_RE = /(authorization|api[_-]?key|token|secret|password|bearer)/i;
const OPENCLAW_MEDIA_DETAIL_KEYS = new Set([
'source',
'reason',
'candidateCount',
'matchedCount',
'rejectedCount',
'attachmentCount',
'imageCount',
'missingCount',
'previewCount',
'evidenceHash',
'identityHash',
'operationId',
'latestGeneration',
'error',
]);
let sequence = 0;
let entries: AcpTraceEntry[] = [];
function sanitize(value: unknown, depth = 0): unknown {
if (value == null || typeof value === 'boolean' || typeof value === 'number') return value;
if (typeof value === 'string') {
if (/bearer\s+\S+/i.test(value) || /^sk-[A-Za-z0-9_-]{8,}/.test(value)) return '[redacted]';
if (value.length <= MAX_STRING_LENGTH) return value;
return `${value.slice(0, MAX_STRING_LENGTH)}...[truncated ${value.length - MAX_STRING_LENGTH} chars]`;
}
if (depth >= 4) return '[max-depth]';
if (Array.isArray(value)) {
const items = value.slice(0, 20).map((item) => sanitize(item, depth + 1));
return value.length > 20 ? { type: 'array', length: value.length, items } : items;
}
if (!isRecord(value)) return String(value);
const output: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(value)) {
output[key] = SENSITIVE_KEY_RE.test(key) ? '[redacted]' : sanitize(nested, depth + 1);
}
return output;
}
function optionalString(value: unknown, maxLength = 120): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim().slice(0, maxLength) : undefined;
}
export function recordAcpTrace(input: AcpTraceRecordInput): AcpTraceEntry {
const entry: AcpTraceEntry = {
seq: sequence += 1,
timestamp: new Date().toISOString(),
source: input.source,
event: input.event.slice(0, 120),
...(input.direction ? { direction: input.direction } : {}),
...(input.sessionKey ? { sessionKey: input.sessionKey } : {}),
...(typeof input.generation === 'number' ? { generation: input.generation } : {}),
...(input.details !== undefined ? { details: sanitize(input.details) } : {}),
};
entries.push(entry);
if (entries.length > MAX_ACP_TRACE_ENTRIES) entries = entries.slice(-MAX_ACP_TRACE_ENTRIES);
return entry;
}
export function getAcpTraceSnapshot(): AcpTraceSnapshot {
return {
capturedAt: Date.now(),
maxSize: MAX_ACP_TRACE_ENTRIES,
size: entries.length,
entries: entries.map((entry) => ({ ...entry })),
};
}
export function normalizeRendererAcpTracePayload(payload: unknown): AcpTraceRecordInput | null {
if (!isRecord(payload)) return null;
const event = optionalString(payload.event);
if (!event) return null;
const sessionKey = optionalString(payload.sessionKey, 200);
const direction = optionalString(payload.direction, 80) ?? 'projection';
const generation = typeof payload.generation === 'number' && Number.isFinite(payload.generation)
? payload.generation
: undefined;
const details = event.startsWith('openclaw-media:') && isRecord(payload.details)
? Object.fromEntries(Object.entries(payload.details).filter(([key]) => OPENCLAW_MEDIA_DETAIL_KEYS.has(key)))
: payload.details;
return {
source: 'renderer',
event,
direction,
...(sessionKey ? { sessionKey } : {}),
...(generation != null ? { generation } : {}),
...(details !== undefined ? { details } : {}),
};
}
export function recordRendererAcpTrace(payload: AcpTraceRecordPayload): { success: boolean; error?: string } {
const normalized = normalizeRendererAcpTracePayload(payload);
if (!normalized) return { success: false, error: 'Invalid ACP trace payload' };
recordAcpTrace(normalized);
return { success: true };
}
export function recordAttachmentOpenTrace(input: {
ok: boolean;
reason: AttachmentAccessError | 'success';
sourceKind: 'local' | 'remote' | 'invalid';
sessionKey: string;
generation: number;
identity: string;
}): AcpTraceEntry {
return recordAcpTrace({
source: 'main',
event: `attachment/open:${input.ok ? 'success' : 'failure'}`,
direction: 'open',
sessionKey: input.sessionKey,
generation: input.generation,
details: {
reason: input.reason,
sourceKind: input.sourceKind,
identity: input.identity.slice(0, 64),
},
});
}
export function clearAcpTraceForTests(): void {
sequence = 0;
entries = [];
}
+68 -21
View File
@@ -1,4 +1,5 @@
import type { GatewayManager } from '../gateway/manager';
import type { RuntimeManager } from '../runtime/manager';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import {
assignChannelToAgent,
@@ -11,6 +12,11 @@ import {
updateAgentModel,
updateAgentName,
} from '../utils/agent-config';
import {
deleteCcConnectAgentBinding,
setCcConnectAgentPermissionMode,
setCcConnectAgentProviderBinding,
} from '../runtime/cc-connect-agent-bindings';
import { deleteChannelAccountConfig } from '../utils/channel-config';
import { ensureClawXContext } from '../utils/openclaw-workspace';
import { isRecord } from './payload-utils';
@@ -18,6 +24,7 @@ import { syncAgentModelOverrideToRuntime, syncAllProviderAuthToRuntime } from '.
type AgentsApiContext = {
gatewayManager: GatewayManager;
runtimeManager?: RuntimeManager;
};
function requireString(payload: unknown, key: string): string {
@@ -27,23 +34,34 @@ function requireString(payload: unknown, key: string): string {
return payload[key].trim();
}
function scheduleGatewayReload(ctx: AgentsApiContext, reason: string): void {
if (ctx.gatewayManager.getStatus().state !== 'stopped') {
ctx.gatewayManager.debouncedReload();
async function refreshActiveRuntime(ctx: AgentsApiContext, reason: string): Promise<void> {
const provider = ctx.runtimeManager?.getActiveProvider();
if (provider?.refreshConfig) {
await provider.refreshConfig({ scope: 'runtime', reason });
return;
}
void reason;
if (ctx.gatewayManager.getStatus().state !== 'stopped') {
ctx.gatewayManager.debouncedReload();
}
}
async function restartGatewayForAgentDeletion(ctx: AgentsApiContext): Promise<void> {
async function restartRuntimeForAgentDeletion(ctx: AgentsApiContext): Promise<void> {
try {
await ctx.gatewayManager.restart();
console.log('[agents] Gateway restart completed after agent deletion');
if (ctx.runtimeManager) {
await ctx.runtimeManager.restart();
} else {
await ctx.gatewayManager.restart();
}
console.log('[agents] Runtime restart completed after agent deletion');
} catch (err) {
console.warn('[agents] Gateway restart after agent deletion failed:', err);
console.warn('[agents] Runtime restart after agent deletion failed:', err);
}
}
function usesCcConnect(ctx: AgentsApiContext): boolean {
return ctx.runtimeManager?.getActiveProvider().kind === 'cc-connect';
}
export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegistry['agents'] {
return {
list: async () => ({ success: true, ...(await listAgentsSnapshot()) }),
@@ -51,10 +69,12 @@ export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegis
const name = requireString(payload, 'name');
const inheritWorkspace = isRecord(payload) ? payload.inheritWorkspace === true : undefined;
const snapshot = await createAgent(name, { inheritWorkspace });
syncAllProviderAuthToRuntime().catch((err) => {
console.warn('[agents] Failed to sync provider auth after agent creation:', err);
});
scheduleGatewayReload(ctx, 'create-agent');
if (!usesCcConnect(ctx)) {
syncAllProviderAuthToRuntime().catch((err) => {
console.warn('[agents] Failed to sync provider auth after agent creation:', err);
});
}
await refreshActiveRuntime(ctx, 'create-agent');
void ensureClawXContext({ waitForAllConfiguredWorkspaces: true }).catch((err) => {
console.warn('[agents] Failed to ensure ClawX context after agent creation:', err);
});
@@ -64,25 +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.
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);
});
@@ -92,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) => {
@@ -118,7 +165,7 @@ export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegis
await clearChannelBinding(channelType, accountId);
}
const snapshot = await listAgentsSnapshot();
scheduleGatewayReload(ctx, 'remove-agent-channel');
await refreshActiveRuntime(ctx, 'remove-agent-channel');
return { success: true, ...snapshot };
},
};
+7 -2
View File
@@ -1,4 +1,5 @@
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { RuntimeManager } from '../runtime/manager';
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../utils/openclaw-doctor';
import { isRecord } from './payload-utils';
@@ -6,11 +7,15 @@ type OpenClawDoctorPayload = {
mode?: unknown;
};
export function createAppApi(): CompleteHostServiceRegistry['app'] {
export function createAppApi(runtimeManager?: RuntimeManager): CompleteHostServiceRegistry['app'] {
return {
openClawDoctor: async (payload) => {
const body = isRecord(payload) ? payload as OpenClawDoctorPayload : {};
return body.mode === 'fix' ? runOpenClawDoctorFix() : runOpenClawDoctor();
const mode = body.mode === 'fix' ? 'fix' : 'diagnose';
if (runtimeManager) {
return runtimeManager.getActiveProvider().runDoctor(mode);
}
return mode === 'fix' ? runOpenClawDoctorFix() : runOpenClawDoctor();
},
};
}
+881
View File
@@ -0,0 +1,881 @@
import { shell as electronShell } from 'electron';
import { createHash } from 'node:crypto';
import { constants } from 'node:fs';
import type { Stats } from 'node:fs';
import type { FileHandle } from 'node:fs/promises';
import {
basename,
extname,
isAbsolute,
join,
posix,
relative,
resolve,
sep,
win32,
} from 'node:path';
import { fileURLToPath } from 'node:url';
import type {
AttachmentAccessError,
AttachmentFileRef,
AttachmentOpenHandlersResult,
AttachmentSourceRef,
OpenAttachmentResult,
OpenAttachmentWithPayload,
ReadAttachmentBinaryPayload,
ReadAttachmentBinaryResult,
ReadAttachmentTextResult,
ResolveAttachmentPayload,
ResolveAttachmentResult,
} from '@shared/host-api/contract';
import {
FILE_PREVIEW_MAX_BINARY_BYTES,
FILE_PREVIEW_MAX_TEXT_BYTES,
} from '@shared/file-preview/limits';
import type { AcpSessionAccessRegistry } from './acp-session-access-registry';
import { recordAttachmentOpenTrace } from './acp-trace';
import {
HANDLER_ID_MAX_LENGTH,
type AttachmentOpenWithService,
} from './attachment-open-with';
import {
expandPath,
resolveOpenClawConfigDir,
resolveOpenClawStateDir,
} from '../utils/paths';
const MAX_REFERENCE_LENGTH = 4096;
const MAX_DISPLAY_NAME_LENGTH = 160;
const MAX_OUTGOING_RECORD_BYTES = 64 * 1024;
const SAFE_ATTACHMENT_ID = /^[A-Za-z0-9._-]+$/;
const EXT_MIME_MAP: Record<string, string> = {
'.bmp': 'image/bmp',
'.csv': 'text/csv',
'.gif': 'image/gif',
'.htm': 'text/html',
'.html': 'text/html',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.json': 'application/json',
'.md': 'text/markdown',
'.pdf': 'application/pdf',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.txt': 'text/plain',
'.webp': 'image/webp',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.zip': 'application/zip',
};
type AttachmentFs = {
lstat: (path: string) => Promise<Stats>;
open: (path: string, flags: number) => Promise<FileHandle>;
realpath: (path: string) => Promise<string>;
stat: (path: string) => Promise<Stats>;
};
type AttachmentShell = {
openPath: (path: string) => Promise<string>;
openExternal: (url: string) => Promise<void>;
showItemInFolder: (path: string) => void;
};
export type AttachmentAccess = {
resolveAttachment: (payload: ResolveAttachmentPayload) => Promise<ResolveAttachmentResult>;
readAttachmentText: (ref: AttachmentFileRef) => Promise<ReadAttachmentTextResult>;
readAttachmentBinary: (payload: ReadAttachmentBinaryPayload) => Promise<ReadAttachmentBinaryResult>;
openAttachment: (ref: AttachmentSourceRef) => Promise<OpenAttachmentResult>;
listAttachmentOpenHandlers: (ref: AttachmentFileRef) => Promise<AttachmentOpenHandlersResult>;
openAttachmentWith: (payload: OpenAttachmentWithPayload) => Promise<OpenAttachmentResult>;
revealAttachment: (ref: AttachmentFileRef) => Promise<OpenAttachmentResult>;
};
type AttachmentAccessDependencies = {
sessionAccessRegistry: AcpSessionAccessRegistry;
stagedAttachments: StagedAttachmentRegistry;
stateDir?: string;
configDir?: string;
fs?: AttachmentFs;
shell?: AttachmentShell;
openWith: AttachmentOpenWithService;
};
type LocalScope = 'workspace' | 'openclaw-media' | 'staging';
type ResolvedLocal = {
kind: 'local';
canonicalPath: string;
scope: LocalScope;
mimeType: string;
size: number;
authorizationRoot?: string;
};
type ResolvedRemote = {
kind: 'remote';
normalizedUrl: string;
mimeType: string;
size: number;
};
type ResolvedTarget = ResolvedLocal | ResolvedRemote;
type PinnedDirectory = {
canonicalPath: string;
dev: number;
ino: number;
};
type ManagedAuthoritySlot = {
lexicalParent: string;
parent?: PinnedDirectory;
media?: PinnedDirectory;
pinning?: Promise<void>;
mediaPinning?: Promise<void>;
};
class AttachmentFailure extends Error {
constructor(readonly code: AttachmentAccessError) {
super(code);
}
}
export class StagedAttachmentRegistry {
private readonly files = new Map<string, { canonicalPath: string; displayPath?: string }>();
register(id: string, canonicalPath: string, displayPath?: string): void {
if (id && canonicalPath) this.files.set(id, {
canonicalPath,
...(displayPath ? { displayPath } : {}),
});
}
get(id: string): string | null {
return this.files.get(id)?.canonicalPath ?? null;
}
getDisplayPath(id: string): string | null {
return this.files.get(id)?.displayPath ?? null;
}
hasPath(canonicalPath: string): boolean {
return Array.from(this.files.values()).some((file) => isSamePath(file.canonicalPath, canonicalPath));
}
}
export function resolveClawXStagingDir(stateDir = resolveOpenClawStateDir()): string {
return join(resolve(stateDir), 'media', 'outbound', 'clawx-staging');
}
function attachmentFailure(error: unknown): AttachmentAccessError {
if (error instanceof AttachmentFailure) return error.code;
const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
if (code === 'ENOENT') return 'unavailable';
return 'operationFailed';
}
function opaqueIdentity(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
function safeDisplayName(value: unknown, fallback: string): string {
const raw = typeof value === 'string' && value.trim() ? value : fallback;
const filename = posix.basename(raw.replace(/\\/gu, '/'));
const withoutControls = Array.from(filename.slice(0, MAX_DISPLAY_NAME_LENGTH * 4), (character) => {
const codePoint = character.codePointAt(0) ?? 0;
const isControl = codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
const isBidiFormatting = codePoint === 0x061c
|| codePoint === 0x200e
|| codePoint === 0x200f
|| (codePoint >= 0x202a && codePoint <= 0x202e)
|| (codePoint >= 0x2066 && codePoint <= 0x2069);
return isControl || isBidiFormatting ? ' ' : character;
}).join('');
const cleaned = withoutControls
.replace(/\s+/gu, ' ')
.trim()
.slice(0, MAX_DISPLAY_NAME_LENGTH);
return cleaned || 'attachment';
}
function decodedBasename(uri: string): string {
try {
if (/^https?:/i.test(uri)) {
const url = new URL(uri);
return decodeURIComponent(posix.basename(url.pathname)) || url.hostname;
}
if (/^file:/i.test(uri)) return basename(fileURLToPath(uri));
} catch {
// A safe generic label is returned below for malformed input.
}
return basename(uri.replace(/[?#].*$/u, '')) || 'attachment';
}
function mimeTypeForPath(path: string): string {
return EXT_MIME_MAP[extname(path).toLowerCase()] ?? 'application/octet-stream';
}
function hasTraversal(value: string): boolean {
return value.split(/[\\/]+/u).includes('..');
}
function validateReferenceSyntax(uri: unknown): asserts uri is string {
if (typeof uri !== 'string' || !uri.trim() || uri.length > MAX_REFERENCE_LENGTH || uri.includes('\0')) {
throw new AttachmentFailure('invalidReference');
}
if (uri.startsWith('\\\\') || uri.startsWith('//')) throw new AttachmentFailure('invalidReference');
if (hasTraversal(uri)) throw new AttachmentFailure('invalidReference');
if (uri.includes('%')) {
let decoded: string;
try {
decoded = decodeURIComponent(uri);
} catch {
throw new AttachmentFailure('invalidReference');
}
if (decoded.includes('\0') || hasTraversal(decoded)) throw new AttachmentFailure('invalidReference');
}
}
function isInside(child: string, parent: string): boolean {
const relativePath = relative(parent, child);
return relativePath === ''
|| (!isAbsolute(relativePath) && relativePath !== '..' && !relativePath.startsWith(`..${sep}`));
}
function isSamePath(left: string, right: string): boolean {
const resolvedLeft = resolve(left);
const resolvedRight = resolve(right);
return process.platform === 'win32'
? resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
: resolvedLeft === resolvedRight;
}
function localPathFromUri(uri: string, executionCwd: string): string {
if (/^file:/i.test(uri)) {
let url: URL;
try {
url = new URL(uri);
} catch {
throw new AttachmentFailure('invalidReference');
}
if (url.username || url.password || (url.hostname && url.hostname.toLowerCase() !== 'localhost')) {
throw new AttachmentFailure('invalidReference');
}
try {
return fileURLToPath(url);
} catch {
throw new AttachmentFailure('invalidReference');
}
}
if (/^[A-Za-z][A-Za-z0-9+.-]*:/u.test(uri) && !win32.isAbsolute(uri)) {
throw new AttachmentFailure('invalidReference');
}
if (uri.startsWith('~')) return resolve(expandPath(uri));
if (isAbsolute(uri) || win32.isAbsolute(uri)) return resolve(uri);
return resolve(executionCwd, uri);
}
function parseOutgoingUrl(uri: string): { attachmentId: string; sessionKey: string } | null {
let url: URL;
try {
url = uri.startsWith('/') ? new URL(uri, 'http://clawx.local') : new URL(uri);
} catch {
return null;
}
const isRelativeGatewayUrl = uri.startsWith('/');
const isLocalGatewayUrl = (url.protocol === 'http:' || url.protocol === 'https:')
&& (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]');
if (!isRelativeGatewayUrl && !isLocalGatewayUrl) return null;
if (url.username || url.password) throw new AttachmentFailure('unsafeUrl');
const segments = url.pathname.split('/');
if (segments.length !== 8
|| segments[1] !== 'api'
|| segments[2] !== 'chat'
|| segments[3] !== 'media'
|| segments[4] !== 'outgoing'
|| segments[7] !== 'full') {
return null;
}
let sessionKey: string;
let attachmentId: string;
try {
sessionKey = decodeURIComponent(segments[5]);
attachmentId = decodeURIComponent(segments[6]);
} catch {
throw new AttachmentFailure('invalidReference');
}
if (!sessionKey || !SAFE_ATTACHMENT_ID.test(attachmentId)) {
throw new AttachmentFailure('invalidReference');
}
return { attachmentId, sessionKey };
}
async function canonicalManagedMediaRoots(
stateDir: string,
configDir: string,
fs: AttachmentFs,
): Promise<string[]> {
// OpenClaw 2026.6.10 exposes only resolveStateDir()/media and resolveConfigDir()/media.
// Keep this list exact until the distributed runtime adds a real media-root setting.
const managedRoots = await Promise.all([stateDir, configDir].map(async (parentPath) => {
try {
const parent = await fs.realpath(parentPath);
if (!(await fs.stat(parent)).isDirectory()) return null;
const mediaPath = join(parentPath, 'media');
if ((await fs.lstat(mediaPath)).isSymbolicLink()) return null;
const media = await fs.realpath(mediaPath);
return (await fs.stat(media)).isDirectory() && isInside(media, parent) ? media : null;
} catch {
return null;
}
}));
return Array.from(new Set([
...managedRoots.filter((root): root is string => root !== null),
]));
}
function pinnedDirectory(path: string, stat: Stats): PinnedDirectory {
return { canonicalPath: path, dev: stat.dev, ino: stat.ino };
}
async function ensureManagedAuthority(
slot: ManagedAuthoritySlot,
fs: AttachmentFs,
): Promise<{ parent: PinnedDirectory; media: PinnedDirectory | null } | null> {
if (!slot.parent) {
if (!slot.pinning) {
slot.pinning = (async () => {
try {
const parentPath = await fs.realpath(slot.lexicalParent);
const parentStat = await fs.stat(parentPath);
if (!parentStat.isDirectory()) return;
slot.parent = pinnedDirectory(parentPath, parentStat);
} catch {
// A configured parent that does not exist yet is retried on a later operation.
}
})().finally(() => {
slot.pinning = undefined;
});
}
await slot.pinning;
}
if (!slot.parent) return null;
const mediaPath = join(slot.lexicalParent, 'media');
if (!slot.media) {
if (!slot.mediaPinning) {
slot.mediaPinning = (async () => {
try {
if ((await fs.lstat(mediaPath)).isSymbolicLink()) return;
const canonicalMedia = await fs.realpath(mediaPath);
const mediaStat = await fs.stat(canonicalMedia);
if (!mediaStat.isDirectory() || !isInside(canonicalMedia, slot.parent!.canonicalPath)) return;
slot.media = pinnedDirectory(canonicalMedia, mediaStat);
} catch {
// Media dir not available yet.
}
})().finally(() => {
slot.mediaPinning = undefined;
});
}
try {
await slot.mediaPinning;
} catch {
return { parent: slot.parent, media: null };
}
}
return { parent: slot.parent, media: slot.media ?? null };
}
async function frozenCanonicalDirectory(path: string, fs: AttachmentFs): Promise<string> {
try {
return await fs.realpath(path);
} catch {
return path;
}
}
async function readOpenedFile(handle: FileHandle, maxBytes: number): Promise<Buffer | null> {
const chunks: Buffer[] = [];
let total = 0;
while (total <= maxBytes) {
const length = Math.min(64 * 1024, maxBytes + 1 - total);
const chunk = Buffer.allocUnsafe(length);
const { bytesRead } = await handle.read(chunk, 0, length, total);
if (bytesRead === 0) break;
chunks.push(chunk.subarray(0, bytesRead));
total += bytesRead;
}
return total > maxBytes ? null : Buffer.concat(chunks, total);
}
function looksLikeBinary(buffer: Buffer): boolean {
const length = Math.min(buffer.length, 8192);
for (let index = 0; index < length; index += 1) {
if (buffer[index] === 0) return true;
}
return false;
}
async function openRevalidatedLocal(local: ResolvedLocal, fs: AttachmentFs): Promise<{
handle: FileHandle;
stat: Stats;
}> {
let handle: FileHandle | undefined;
try {
const noFollow = process.platform === 'win32' ? 0 : constants.O_NOFOLLOW;
handle = await fs.open(local.canonicalPath, constants.O_RDONLY | noFollow);
const handleStat = await handle.stat();
if (!handleStat.isFile()) {
throw new AttachmentFailure('notFile');
}
return { handle, stat: handleStat };
} catch (error) {
await handle?.close().catch(() => undefined);
throw error;
}
}
function normalizeRemote(uri: string): string {
let url: URL;
try {
url = new URL(uri);
} catch {
throw new AttachmentFailure('unsafeUrl');
}
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname || url.username || url.password) {
throw new AttachmentFailure('unsafeUrl');
}
return url.href;
}
function boundedBinaryCap(value: unknown): number {
const requested = typeof value === 'number' && Number.isFinite(value) ? value : FILE_PREVIEW_MAX_BINARY_BYTES;
return Math.max(1, Math.min(requested, FILE_PREVIEW_MAX_BINARY_BYTES));
}
export async function resolveOutgoingMediaAttachment(input: {
uri: string;
expectedSessionKey?: string;
transcriptMessageId?: string;
stateDir?: string;
configDir?: string;
managedMediaRoots?: string[];
fs?: AttachmentFs;
}): Promise<{ path: string; mimeType: string; size: number; authorizationRoot: string } | null> {
try {
validateReferenceSyntax(input.uri);
const outgoing = parseOutgoingUrl(input.uri);
if (!outgoing || (input.expectedSessionKey && outgoing.sessionKey !== input.expectedSessionKey)) return null;
const fs = input.fs ?? await import('node:fs/promises');
const stateDir = resolve(input.stateDir ?? resolveOpenClawStateDir());
const configDir = resolve(input.configDir ?? resolveOpenClawConfigDir());
const recordPath = join(stateDir, 'media', 'outgoing', 'records', `${outgoing.attachmentId}.json`);
let handle: FileHandle | undefined;
let raw: Buffer | null;
try {
const noFollow = process.platform === 'win32' ? 0 : constants.O_NOFOLLOW;
handle = await fs.open(recordPath, constants.O_RDONLY | noFollow);
if (!(await handle.stat()).isFile()) return null;
raw = await readOpenedFile(handle, MAX_OUTGOING_RECORD_BYTES);
} finally {
await handle?.close().catch(() => undefined);
}
if (!raw) return null;
const record = JSON.parse(raw.toString('utf8')) as Record<string, unknown>;
const original = record.original && typeof record.original === 'object'
? record.original as Record<string, unknown>
: null;
const recordId = typeof record.attachmentId === 'string' ? record.attachmentId : undefined;
if (recordId !== outgoing.attachmentId
|| record.sessionKey !== outgoing.sessionKey
|| (input.transcriptMessageId && typeof record.messageId === 'string'
&& record.messageId !== input.transcriptMessageId)
|| !original
|| typeof original.path !== 'string') {
return null;
}
validateReferenceSyntax(original.path);
const originalPath = localPathFromUri(original.path, stateDir);
const roots = input.managedMediaRoots ?? await canonicalManagedMediaRoots(stateDir, configDir, fs);
const canonicalPath = await fs.realpath(originalPath);
const authorizationRoot = roots.find((root) => isInside(canonicalPath, root));
if (!authorizationRoot) return null;
const fileStat = await fs.stat(canonicalPath);
if (!fileStat.isFile()) return null;
return {
path: canonicalPath,
mimeType: typeof original.contentType === 'string' && original.contentType
? original.contentType
: mimeTypeForPath(canonicalPath),
size: fileStat.size,
authorizationRoot,
};
} catch {
return null;
}
}
export function createAttachmentAccess(dependencies: AttachmentAccessDependencies): AttachmentAccess {
const stateDir = resolve(dependencies.stateDir ?? resolveOpenClawStateDir());
const configDir = resolve(dependencies.configDir ?? resolveOpenClawConfigDir());
const shell = dependencies.shell ?? electronShell;
const getFs = async (): Promise<AttachmentFs> => dependencies.fs ?? await import('node:fs/promises');
const stateAuthority: ManagedAuthoritySlot = { lexicalParent: stateDir };
const configAuthority: ManagedAuthoritySlot = { lexicalParent: configDir };
const initializeAuthorities = async () => {
const fs = await getFs();
await Promise.all([
ensureManagedAuthority(stateAuthority, fs),
ensureManagedAuthority(configAuthority, fs),
]);
};
void initializeAuthorities().catch(() => undefined);
const verifyManagedAuthorities = async (fs: AttachmentFs) => {
const [state, config] = await Promise.all([
ensureManagedAuthority(stateAuthority, fs),
ensureManagedAuthority(configAuthority, fs),
]);
return {
stateParent: state?.parent ?? null,
mediaRoots: Array.from(new Set([
...(state?.media ? [state.media.canonicalPath] : []),
...(config?.media ? [config.media.canonicalPath] : []),
])),
};
};
const resolveLocalCandidate = async (
ref: AttachmentSourceRef,
candidateInput: string,
mimeTypeHint?: string,
mediaOnly = false,
): Promise<ResolvedLocal> => {
const context = dependencies.sessionAccessRegistry.get(ref.sessionKey, ref.generation);
if (!context) throw new AttachmentFailure('staleSession');
const fs = await getFs();
const candidate = resolve(candidateInput);
if (ref.stagingId) {
const stagedPath = dependencies.stagedAttachments.get(ref.stagingId);
if (stagedPath) {
let canonicalCandidate: string;
try {
canonicalCandidate = await fs.realpath(candidate);
} catch (error) {
throw new AttachmentFailure(attachmentFailure(error));
}
if (!isSamePath(canonicalCandidate, stagedPath)) throw new AttachmentFailure('invalidReference');
const stagedStat = await fs.stat(canonicalCandidate);
if (!stagedStat.isFile()) throw new AttachmentFailure('notFile');
return {
kind: 'local',
canonicalPath: canonicalCandidate,
scope: 'staging',
mimeType: mimeTypeHint || mimeTypeForPath(canonicalCandidate),
size: stagedStat.size,
};
}
}
let canonicalCandidate: string;
try {
canonicalCandidate = await fs.realpath(candidate);
} catch (error) {
throw new AttachmentFailure(attachmentFailure(error));
}
const targetStat = await fs.stat(canonicalCandidate);
if (!targetStat.isFile()) throw new AttachmentFailure('notFile');
const workspaceRoot = mediaOnly ? null : await frozenCanonicalDirectory(context.workspaceRoot, fs);
const scope: LocalScope = workspaceRoot && isInside(canonicalCandidate, workspaceRoot)
? 'workspace'
: 'openclaw-media';
return {
kind: 'local',
canonicalPath: canonicalCandidate,
scope,
mimeType: mimeTypeHint || mimeTypeForPath(canonicalCandidate),
size: targetStat.size,
};
};
const resolveOutgoing = async (
ref: AttachmentSourceRef,
outgoing: { attachmentId: string; sessionKey: string },
): Promise<ResolvedLocal> => {
if (outgoing.sessionKey !== ref.sessionKey) throw new AttachmentFailure('invalidReference');
const fs = await getFs();
const { mediaRoots } = await verifyManagedAuthorities(fs);
const resolved = await resolveOutgoingMediaAttachment({
uri: ref.uri,
expectedSessionKey: ref.sessionKey,
transcriptMessageId: ref.transcriptMessageId,
stateDir,
configDir,
managedMediaRoots: mediaRoots,
fs,
});
if (!resolved) throw new AttachmentFailure('invalidReference');
return {
kind: 'local',
canonicalPath: resolved.path,
scope: 'openclaw-media',
mimeType: resolved.mimeType,
size: resolved.size,
authorizationRoot: resolved.authorizationRoot,
};
};
const resolveTarget = async (
ref: AttachmentSourceRef,
metadata: Pick<ResolveAttachmentPayload, 'mimeType' | 'size'> = {},
): Promise<ResolvedTarget> => {
if (!ref || typeof ref.sessionKey !== 'string' || !Number.isFinite(ref.generation)) {
throw new AttachmentFailure('invalidReference');
}
validateReferenceSyntax(ref.uri);
const context = dependencies.sessionAccessRegistry.get(ref.sessionKey, ref.generation);
if (!context) throw new AttachmentFailure('staleSession');
const outgoing = parseOutgoingUrl(ref.uri);
if (outgoing) return resolveOutgoing(ref, outgoing);
if (/^https?:/i.test(ref.uri)) {
return {
kind: 'remote',
normalizedUrl: normalizeRemote(ref.uri),
mimeType: metadata.mimeType || mimeTypeForPath(new URL(ref.uri).pathname),
size: typeof metadata.size === 'number' && Number.isFinite(metadata.size) && metadata.size >= 0
? metadata.size
: 0,
};
}
const localPath = localPathFromUri(ref.uri, context.executionCwd);
return resolveLocalCandidate(ref, localPath, metadata.mimeType);
};
const resolveAttachment = async (payload: ResolveAttachmentPayload): Promise<ResolveAttachmentResult> => {
const ref = payload?.ref;
const fallbackName = decodedBasename(typeof ref?.uri === 'string' ? ref.uri : 'attachment');
const displayName = safeDisplayName(payload?.name, fallbackName);
try {
const target = await resolveTarget(ref, payload);
const localName = target.kind === 'local' ? basename(target.canonicalPath) : fallbackName;
const finalDisplayName = safeDisplayName(payload?.name, localName);
if (target.kind === 'remote') {
return {
ok: true,
identity: opaqueIdentity(target.normalizedUrl),
displayName: finalDisplayName,
mimeType: target.mimeType,
size: target.size,
target: { kind: 'remote', ref, url: target.normalizedUrl },
};
}
const displayPath = ref.stagingId
? dependencies.stagedAttachments.getDisplayPath(ref.stagingId)
: null;
return {
ok: true,
identity: opaqueIdentity(target.canonicalPath),
displayName: finalDisplayName,
...(displayPath ? { displayPath } : {}),
mimeType: target.mimeType,
size: target.size,
target: { kind: 'local', scope: target.scope, ref },
};
} catch (error) {
return { ok: false, displayName, error: attachmentFailure(error) };
}
};
const readAttachmentText = async (ref: AttachmentFileRef): Promise<ReadAttachmentTextResult> => {
let opened: { handle: FileHandle; stat: Stats } | undefined;
try {
const target = await resolveTarget(ref);
if (target.kind !== 'local') throw new AttachmentFailure('invalidReference');
opened = await openRevalidatedLocal(target, await getFs());
if (!dependencies.sessionAccessRegistry.get(ref.sessionKey, ref.generation)) {
throw new AttachmentFailure('staleSession');
}
if (opened.stat.size > FILE_PREVIEW_MAX_TEXT_BYTES) {
return { ok: false, error: 'tooLarge', size: opened.stat.size };
}
const buffer = await readOpenedFile(opened.handle, FILE_PREVIEW_MAX_TEXT_BYTES);
if (!buffer) return { ok: false, error: 'tooLarge', size: FILE_PREVIEW_MAX_TEXT_BYTES + 1 };
if (looksLikeBinary(buffer)) return { ok: false, error: 'binary', size: buffer.length };
return {
ok: true,
content: buffer.toString('utf8'),
mimeType: target.mimeType,
size: buffer.length,
readOnly: true,
};
} catch (error) {
return { ok: false, error: attachmentFailure(error) };
} finally {
await opened?.handle.close().catch(() => undefined);
}
};
const readAttachmentBinary = async (
payload: ReadAttachmentBinaryPayload,
): Promise<ReadAttachmentBinaryResult> => {
let opened: { handle: FileHandle; stat: Stats } | undefined;
try {
const target = await resolveTarget(payload?.ref);
if (target.kind !== 'local') throw new AttachmentFailure('invalidReference');
opened = await openRevalidatedLocal(target, await getFs());
if (!dependencies.sessionAccessRegistry.get(payload.ref.sessionKey, payload.ref.generation)) {
throw new AttachmentFailure('staleSession');
}
const cap = boundedBinaryCap(payload.maxBytes);
if (opened.stat.size > cap) return { ok: false, error: 'tooLarge', size: opened.stat.size };
const buffer = await readOpenedFile(opened.handle, cap);
if (!buffer) return { ok: false, error: 'tooLarge', size: cap + 1 };
return {
ok: true,
data: new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength),
mimeType: target.mimeType,
size: buffer.length,
readOnly: true,
};
} catch (error) {
return { ok: false, error: attachmentFailure(error) };
} finally {
await opened?.handle.close().catch(() => undefined);
}
};
const openAttachment = async (ref: AttachmentSourceRef): Promise<OpenAttachmentResult> => {
let identity = opaqueIdentity(typeof ref?.uri === 'string' ? ref.uri : 'invalid');
let sourceKind: 'local' | 'remote' | 'invalid' = typeof ref?.uri === 'string'
? (/^https?:/i.test(ref.uri) ? 'remote' : 'local')
: 'invalid';
try {
const target = await resolveTarget(ref);
if (target.kind === 'remote') {
sourceKind = 'remote';
identity = opaqueIdentity(target.normalizedUrl);
if (!dependencies.sessionAccessRegistry.get(ref.sessionKey, ref.generation)) {
throw new AttachmentFailure('staleSession');
}
await shell.openExternal(target.normalizedUrl);
} else {
sourceKind = 'local';
identity = opaqueIdentity(target.canonicalPath);
const revalidated = await resolveTarget(ref);
if (revalidated.kind !== 'local') throw new AttachmentFailure('invalidReference');
identity = opaqueIdentity(revalidated.canonicalPath);
if (!dependencies.sessionAccessRegistry.get(ref.sessionKey, ref.generation)) {
throw new AttachmentFailure('staleSession');
}
const error = await shell.openPath(revalidated.canonicalPath);
if (error) throw new AttachmentFailure('operationFailed');
}
recordAttachmentOpenTrace({
ok: true,
reason: 'success',
sourceKind,
sessionKey: ref.sessionKey,
generation: ref.generation,
identity,
});
return { ok: true };
} catch (error) {
const reason = attachmentFailure(error);
recordAttachmentOpenTrace({
ok: false,
reason,
sourceKind,
sessionKey: typeof ref?.sessionKey === 'string' ? ref.sessionKey : '',
generation: typeof ref?.generation === 'number' ? ref.generation : -1,
identity,
});
return { ok: false, error: reason };
}
};
const requireCurrentLocalTarget = async (ref: AttachmentFileRef): Promise<ResolvedLocal> => {
const target = await resolveTarget(ref);
if (target.kind !== 'local') throw new AttachmentFailure('invalidReference');
if (!dependencies.sessionAccessRegistry.get(ref.sessionKey, ref.generation)) {
throw new AttachmentFailure('staleSession');
}
return target;
};
const listAttachmentOpenHandlers = async (
ref: AttachmentFileRef,
): Promise<AttachmentOpenHandlersResult> => {
try {
const target = await requireCurrentLocalTarget(ref);
if (dependencies.openWith.platform === 'linux') {
return { ok: true, platform: 'linux', handlers: [] };
}
const handlers = await dependencies.openWith.list(target.canonicalPath);
if (!dependencies.sessionAccessRegistry.get(ref.sessionKey, ref.generation)) {
throw new AttachmentFailure('staleSession');
}
return {
ok: true,
platform: dependencies.openWith.platform,
handlers: handlers.map(({ id, name, iconDataUrl, isDefault }) => ({
handlerId: id,
name,
...(iconDataUrl ? { iconDataUrl } : {}),
isDefault,
})),
};
} catch (error) {
return { ok: false, error: attachmentFailure(error) };
}
};
const openAttachmentWith = async (
payload: OpenAttachmentWithPayload,
): Promise<OpenAttachmentResult> => {
try {
const target = await requireCurrentLocalTarget(payload?.ref);
if (typeof payload?.handlerId !== 'string'
|| !payload.handlerId.trim()
|| payload.handlerId.length > HANDLER_ID_MAX_LENGTH) {
throw new AttachmentFailure('invalidReference');
}
await dependencies.openWith.open(
target.canonicalPath,
payload.handlerId,
async () => (await requireCurrentLocalTarget(payload.ref)).canonicalPath,
);
return { ok: true };
} catch (error) {
return { ok: false, error: attachmentFailure(error) };
}
};
const revealAttachment = async (ref: AttachmentFileRef): Promise<OpenAttachmentResult> => {
try {
await requireCurrentLocalTarget(ref);
const revalidated = await requireCurrentLocalTarget(ref);
shell.showItemInFolder(revalidated.canonicalPath);
return { ok: true };
} catch (error) {
return { ok: false, error: attachmentFailure(error) };
}
};
return {
resolveAttachment,
readAttachmentText,
readAttachmentBinary,
openAttachment,
listAttachmentOpenHandlers,
openAttachmentWith,
revealAttachment,
};
}
+566
View File
@@ -0,0 +1,566 @@
import { app, type NativeImage } from 'electron';
import {
execFile as nodeExecFile,
spawn as nodeSpawn,
type ChildProcess,
type ChildProcessWithoutNullStreams,
type ExecFileException,
type ExecFileOptionsWithStringEncoding,
type SpawnOptionsWithoutStdio,
} from 'node:child_process';
import { createHash } from 'node:crypto';
import path from 'node:path';
export const PROCESS_TIMEOUT_MS = 5_000;
export const PROCESS_MAX_BUFFER_BYTES = 1_048_576;
export const HANDLER_NAME_MAX_LENGTH = 256;
export const HANDLER_ID_MAX_LENGTH = 512;
export const NATIVE_PATH_MAX_LENGTH = 4_096;
export const ICON_DATA_URL_MAX_BYTES = 65_536;
export const CACHE_TTL_MS = 300_000;
export const CACHE_MAX_ENTRIES = 128;
export type OpenWithPlatform = 'darwin' | 'win32' | 'linux';
export type SystemOpenHandler = {
id: string;
name: string;
iconDataUrl?: string;
isDefault: boolean;
};
export type AttachmentOpenWithService = {
platform: OpenWithPlatform;
list(filePath: string): Promise<SystemOpenHandler[]>;
open(
filePath: string,
handlerId: string,
revalidateFile: () => Promise<string>,
): Promise<void>;
};
type ExecFileDependency = (
file: string,
args: string[],
options: ExecFileOptionsWithStringEncoding,
callback: (error: ExecFileException | null, stdout: string, stderr: string) => void,
) => ChildProcess;
type SpawnDependency = (
command: string,
args: string[],
options: SpawnOptionsWithoutStdio,
) => ChildProcessWithoutNullStreams;
type IconImage = Pick<NativeImage, 'isEmpty' | 'toPNG'>;
export type AttachmentOpenWithDependencies = {
platform?: OpenWithPlatform;
clock?: () => number;
execFile?: ExecFileDependency;
spawn?: SpawnDependency;
loadIcon?: (filePath: string) => Promise<IconImage>;
resolveHelperPath?: () => string;
};
type NativeOpenHandler = SystemOpenHandler & {
nativeId: string;
applicationPath?: string;
iconSourcePath?: string;
};
type CacheEntry = {
createdAt: number;
handlers: NativeOpenHandler[];
};
const cacheSizeReaders = new WeakMap<AttachmentOpenWithService, () => number>();
/** @internal Test-only retention check; never exposes cache keys or values. */
export function getAttachmentOpenWithCacheSizeForTest(service: AttachmentOpenWithService): number {
return cacheSizeReaders.get(service)?.() ?? 0;
}
const WINDOWS_PUBLIC_ID = /^[a-f0-9]{64}$/;
const POWERSHELL_PREFIX_ARGS = [
'-NoLogo',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-File',
];
const MACOS_JXA_PROGRAM = String.raw`
ObjC.import('Foundation');
ObjC.import('AppKit');
function stringValue(value) {
if (!value) return '';
try { return ObjC.unwrap(value); } catch (_) { return String(value); }
}
function iconPngBase64(workspace, bundlePath) {
try {
var image = workspace.iconForFile(bundlePath);
var targetSize = $.NSMakeSize(32, 32);
var resized = $.NSImage.alloc.initWithSize(targetSize);
resized.lockFocus;
image.drawInRectFromRectOperationFraction(
$.NSMakeRect(0, 0, 32, 32),
$.NSZeroRect,
$.NSCompositingOperationCopy,
1.0
);
resized.unlockFocus;
var bitmap = $.NSBitmapImageRep.imageRepWithData(resized.TIFFRepresentation);
if (!bitmap) return '';
var png = bitmap.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $({}));
if (!png || Number(png.length) === 0) return '';
return stringValue(png.base64EncodedStringWithOptions(0));
} catch (_) {
return '';
}
}
function run(argv) {
if (!argv || (argv.length !== 1 && argv.length !== 2)) return JSON.stringify([]);
var includeIcons = argv.length === 2 && argv[1] === 'icons';
if (argv.length === 2 && !includeIcons) return JSON.stringify([]);
var fileURL = $.NSURL.fileURLWithPath(argv[0]);
var workspace = $.NSWorkspace.sharedWorkspace;
var applicationURLs = workspace.URLsForApplicationsToOpenURL(fileURL);
var defaultURL = workspace.URLForApplicationToOpenURL(fileURL);
var records = [];
for (var index = 0; index < Number(applicationURLs.count); index += 1) {
var applicationURL = applicationURLs.objectAtIndex(index);
var bundle = $.NSBundle.bundleWithURL(applicationURL);
var bundleId = stringValue(bundle.bundleIdentifier);
var bundlePath = stringValue(applicationURL.path);
var name = stringValue($.NSFileManager.defaultManager.displayNameAtPath(bundlePath));
if (!bundleId || !name || !bundlePath) continue;
var record = {
nativeId: bundleId,
name: name,
applicationPath: bundlePath,
isDefault: Boolean(defaultURL && applicationURL.isEqual(defaultURL))
};
if (includeIcons) {
var icon = iconPngBase64(workspace, bundlePath);
if (icon) record.iconPngBase64 = icon;
}
records.push(record);
}
return JSON.stringify(records);
}
`;
function hasControlCharacters(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const codeUnit = value.charCodeAt(index);
if (codeUnit <= 0x1f || (codeUnit >= 0x7f && codeUnit <= 0x9f)) return true;
}
return false;
}
function isBoundedString(value: unknown, maxLength: number): value is string {
return typeof value === 'string'
&& value.length > 0
&& value.length <= maxLength
&& !hasControlCharacters(value);
}
function isOptionalBoundedPath(value: unknown): value is string | undefined {
return value === undefined || isBoundedString(value, NATIVE_PATH_MAX_LENGTH);
}
function iconDataUrlFromBase64(value: unknown): string | undefined {
if (typeof value !== 'string' || value.length === 0) return undefined;
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
return undefined;
}
const iconDataUrl = `data:image/png;base64,${value}`;
if (Buffer.byteLength(iconDataUrl, 'utf8') > ICON_DATA_URL_MAX_BYTES) return undefined;
const png = Buffer.from(value, 'base64');
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
if (png.length < pngSignature.length || !png.subarray(0, pngSignature.length).equals(pngSignature)) {
return undefined;
}
return iconDataUrl;
}
function associationKey(platform: OpenWithPlatform, filePath: string): string {
const pathApi = platform === 'win32' ? path.win32 : path.posix;
const basename = pathApi.basename(filePath).toLocaleLowerCase('en-US');
const extension = pathApi.extname(basename);
return extension || basename;
}
function processEnvironment(platform: OpenWithPlatform): NodeJS.ProcessEnv {
if (platform === 'win32') {
const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows';
const env: NodeJS.ProcessEnv = {
SystemRoot: systemRoot,
WINDIR: systemRoot,
PATH: [
path.win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0'),
path.win32.join(systemRoot, 'System32'),
systemRoot,
].join(';'),
PATHEXT: '.COM;.EXE;.BAT;.CMD',
};
for (const key of ['TEMP', 'TMP', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA'] as const) {
if (process.env[key]) env[key] = process.env[key];
}
return env;
}
const env: NodeJS.ProcessEnv = { PATH: '/usr/bin:/bin:/usr/sbin:/sbin' };
for (const key of ['HOME', 'TMPDIR', 'USER', 'LOGNAME', 'LANG', 'LC_ALL'] as const) {
if (process.env[key]) env[key] = process.env[key];
}
return env;
}
function publicHandlers(handlers: NativeOpenHandler[]): SystemOpenHandler[] {
return handlers.map(({ id, name, iconDataUrl, isDefault }) => ({
id,
name,
...(iconDataUrl ? { iconDataUrl } : {}),
isDefault,
}));
}
function windowsPublicId(nativeId: string): string {
return createHash('sha256').update(`win32\0${nativeId}`, 'utf8').digest('hex');
}
function normalizeRecords(platform: OpenWithPlatform, output: string): NativeOpenHandler[] {
let parsed: unknown;
try {
parsed = JSON.parse(output.replace(/^\uFEFF/, '')) as unknown;
} catch {
return [];
}
if (!Array.isArray(parsed)) return [];
const deduplicated = new Map<string, NativeOpenHandler>();
for (const value of parsed) {
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
const record = value as Record<string, unknown>;
if (!isBoundedString(record.nativeId, HANDLER_ID_MAX_LENGTH)) continue;
if (!isBoundedString(record.name, HANDLER_NAME_MAX_LENGTH)) continue;
if (typeof record.isDefault !== 'boolean') continue;
if (!isOptionalBoundedPath(record.applicationPath)) continue;
if (!isOptionalBoundedPath(record.iconSourcePath)) continue;
if (platform === 'darwin' && !isBoundedString(record.applicationPath, NATIVE_PATH_MAX_LENGTH)) {
continue;
}
const id = platform === 'win32' ? windowsPublicId(record.nativeId) : record.nativeId;
const iconDataUrl = platform === 'darwin'
? iconDataUrlFromBase64(record.iconPngBase64)
: undefined;
const existing = deduplicated.get(id);
if (existing) {
if (record.isDefault) existing.isDefault = true;
if (!existing.iconDataUrl && iconDataUrl) existing.iconDataUrl = iconDataUrl;
continue;
}
deduplicated.set(id, {
id,
nativeId: record.nativeId,
name: record.name,
...(record.applicationPath ? { applicationPath: record.applicationPath } : {}),
...(record.iconSourcePath ? { iconSourcePath: record.iconSourcePath } : {}),
...(iconDataUrl ? { iconDataUrl } : {}),
isDefault: record.isDefault,
});
}
const handlers = [...deduplicated.values()];
return [
...handlers.filter((handler) => handler.isDefault),
...handlers.filter((handler) => !handler.isDefault),
];
}
function defaultHelperPath(): string {
const root = app.isPackaged ? process.resourcesPath : app.getAppPath();
return path.join(root, 'resources', 'scripts', 'attachment-open-with.ps1');
}
function processError(reason: string): Error {
return new Error(`attachment-open-with:${reason}`);
}
export function createAttachmentOpenWithService(
dependencies: AttachmentOpenWithDependencies = {},
): AttachmentOpenWithService {
const platform = dependencies.platform ?? (
process.platform === 'darwin' || process.platform === 'win32' ? process.platform : 'linux'
);
const clock = dependencies.clock ?? Date.now;
const execFile = dependencies.execFile ?? (nodeExecFile as ExecFileDependency);
const spawn = dependencies.spawn ?? (nodeSpawn as SpawnDependency);
const loadIcon = dependencies.loadIcon ?? ((filePath: string) => app.getFileIcon(filePath, { size: 'normal' }));
const resolveHelperPath = dependencies.resolveHelperPath ?? defaultHelperPath;
const cache = new Map<string, CacheEntry>();
function runExec(command: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile(command, args, {
timeout: PROCESS_TIMEOUT_MS,
maxBuffer: PROCESS_MAX_BUFFER_BYTES,
encoding: 'utf8',
windowsHide: true,
shell: false,
env: processEnvironment(platform),
}, (error, stdout) => {
if (error) {
reject(processError('process-failed'));
return;
}
resolve(stdout);
});
});
}
async function discover(filePath: string, includeIcons = false): Promise<NativeOpenHandler[]> {
try {
if (platform === 'darwin') {
const output = await runExec('/usr/bin/osascript', [
'-l',
'JavaScript',
'-e',
MACOS_JXA_PROGRAM,
'--',
filePath,
...(includeIcons ? ['icons'] : []),
]);
return normalizeRecords(platform, output);
}
if (platform === 'win32') {
const output = await runExec('powershell.exe', [
...POWERSHELL_PREFIX_ARGS,
resolveHelperPath(),
'list',
filePath,
]);
return normalizeRecords(platform, output);
}
return [];
} catch {
return [];
}
}
async function enrichIcons(handlers: NativeOpenHandler[]): Promise<NativeOpenHandler[]> {
if (platform === 'darwin') return handlers;
return await Promise.all(handlers.map(async (handler) => {
const iconPath = handler.iconSourcePath ?? handler.applicationPath;
if (!iconPath) return handler;
try {
const image = await loadIcon(iconPath);
if (image.isEmpty()) return handler;
const png = image.toPNG();
if (png.length === 0) return handler;
const iconDataUrl = `data:image/png;base64,${png.toString('base64')}`;
if (Buffer.byteLength(iconDataUrl, 'utf8') > ICON_DATA_URL_MAX_BYTES) return handler;
return { ...handler, iconDataUrl };
} catch {
return handler;
}
}));
}
async function list(filePath: string): Promise<SystemOpenHandler[]> {
const now = clock();
for (const [key, entry] of cache) {
if (now - entry.createdAt >= CACHE_TTL_MS) cache.delete(key);
}
if (platform === 'linux') return [];
if (!isBoundedString(filePath, NATIVE_PATH_MAX_LENGTH)) return [];
const cacheKey = `${platform}:${associationKey(platform, filePath)}`;
const cached = cache.get(cacheKey);
if (cached && now - cached.createdAt < CACHE_TTL_MS) {
return publicHandlers(cached.handlers);
}
const handlers = await enrichIcons(await discover(filePath, true));
cache.set(cacheKey, { createdAt: now, handlers });
while (cache.size > CACHE_MAX_ENTRIES) {
const oldestKey = cache.keys().next().value;
if (oldestKey === undefined) break;
cache.delete(oldestKey);
}
return publicHandlers(handlers);
}
async function openMac(
filePath: string,
handlerId: string,
revalidateFile: () => Promise<string>,
): Promise<void> {
const handlers = await discover(filePath);
const selected = handlers.find((handler) => handler.id === handlerId && handler.applicationPath);
if (!selected?.applicationPath) throw processError('unknown-handler');
const revalidatedPath = await revalidateFile();
if (!isBoundedString(revalidatedPath, NATIVE_PATH_MAX_LENGTH)) throw processError('invalid-path');
if (associationKey(platform, revalidatedPath) !== associationKey(platform, filePath)) {
throw processError('association-changed');
}
try {
await runExec('/usr/bin/open', ['-a', selected.applicationPath, revalidatedPath]);
} catch {
throw processError('invoke-failed');
}
}
function openWindows(
filePath: string,
handlerId: string,
revalidateFile: () => Promise<string>,
): Promise<void> {
if (!WINDOWS_PUBLIC_ID.test(handlerId)) return Promise.reject(processError('unknown-handler'));
return new Promise((resolve, reject) => {
let child: ChildProcessWithoutNullStreams;
try {
child = spawn('powershell.exe', [
...POWERSHELL_PREFIX_ARGS,
resolveHelperPath(),
'prepare-open',
filePath,
handlerId,
], {
windowsHide: true,
shell: false,
stdio: 'pipe',
env: processEnvironment(platform),
});
} catch {
reject(processError('helper-start-failed'));
return;
}
let settled = false;
let ready = false;
let invokeSent = false;
let outputBytes = 0;
let stdoutBuffer = '';
const finish = (error?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (error) reject(error);
else resolve();
};
const abort = (error: Error) => {
if (settled) return;
try {
child.kill();
} catch {
// The bounded rejection is sufficient if the process already exited.
}
finish(error);
};
const handleReady = async () => {
try {
const revalidatedPath = await revalidateFile();
if (settled) return;
if (!isBoundedString(revalidatedPath, NATIVE_PATH_MAX_LENGTH)) {
abort(processError('invalid-path'));
return;
}
if (associationKey(platform, revalidatedPath) !== associationKey(platform, filePath)) {
abort(processError('association-changed'));
return;
}
invokeSent = true;
child.stdin.end(`${JSON.stringify({ command: 'invoke', path: revalidatedPath })}\n`, 'utf8');
} catch (error) {
abort(error instanceof Error ? error : processError('revalidation-failed'));
}
};
const timeout = setTimeout(() => abort(processError('helper-timeout')), PROCESS_TIMEOUT_MS);
const accountOutput = (chunk: Buffer | string): boolean => {
outputBytes += Buffer.byteLength(chunk);
if (outputBytes > PROCESS_MAX_BUFFER_BYTES) {
abort(processError('helper-output-limit'));
return false;
}
return true;
};
child.stdout.on('data', (chunk: Buffer | string) => {
if (settled || !accountOutput(chunk)) return;
if (ready) {
abort(processError('helper-protocol'));
return;
}
stdoutBuffer += chunk.toString();
const newline = stdoutBuffer.indexOf('\n');
if (newline < 0) return;
const line = stdoutBuffer.slice(0, newline).replace(/\r$/, '');
const remainder = stdoutBuffer.slice(newline + 1);
let message: unknown;
try {
message = JSON.parse(line) as unknown;
} catch {
abort(processError('helper-protocol'));
return;
}
if (!message || typeof message !== 'object' || Array.isArray(message)) {
abort(processError('helper-protocol'));
return;
}
const record = message as Record<string, unknown>;
if (record.ready !== true || Object.keys(record).length !== 1 || remainder.trim()) {
abort(processError('helper-protocol'));
return;
}
ready = true;
stdoutBuffer = '';
void handleReady();
});
child.stderr.on('data', (chunk: Buffer | string) => {
if (!settled) accountOutput(chunk);
});
child.stdin.on('error', () => abort(processError('helper-stdin')));
child.on('error', () => abort(processError('helper-start-failed')));
child.on('close', (code) => {
if (settled) return;
if (code === 0 && invokeSent) {
finish();
return;
}
finish(processError(ready ? 'helper-failed' : 'unknown-handler'));
});
});
}
async function open(
filePath: string,
handlerId: string,
revalidateFile: () => Promise<string>,
): Promise<void> {
if (!isBoundedString(filePath, NATIVE_PATH_MAX_LENGTH)) throw processError('invalid-path');
if (!isBoundedString(handlerId, HANDLER_ID_MAX_LENGTH)) throw processError('unknown-handler');
if (platform === 'linux') throw processError('unsupported-platform');
if (platform === 'darwin') return await openMac(filePath, handlerId, revalidateFile);
return await openWindows(filePath, handlerId, revalidateFile);
}
const service = { platform, list, open };
cacheSizeReaders.set(service, () => cache.size);
return service;
}
+38 -16
View File
@@ -73,6 +73,7 @@ import {
import { buildGatewayHealthSummary } from '../utils/gateway-health';
import { logger } from '../utils/logger';
import type { GatewayManager, GatewayHealthSummary } from '../gateway/manager';
import type { RuntimeManager } from '../runtime/manager';
import { isRecord } from './payload-utils';
const WECHAT_QR_TIMEOUT_MS = 8 * 60 * 1000;
@@ -83,6 +84,7 @@ async function listWhatsAppDirectoryPeersFromConfig(_params: unknown): Promise<u
type ChannelsApiContext = {
gatewayManager: GatewayManager;
runtimeManager?: RuntimeManager;
mainWindow?: BrowserWindow;
};
@@ -265,11 +267,12 @@ export async function buildChannelAccountsView(
]);
let gatewayStatus: GatewayChannelStatusPayload | null = null;
const runtimeStatusSource = ctx.runtimeManager ?? ctx.gatewayManager;
if (!skipRuntime) {
try {
const probe = options?.probe === true;
const rpcStartedAt = Date.now();
gatewayStatus = await ctx.gatewayManager.rpc<GatewayChannelStatusPayload>(
gatewayStatus = await runtimeStatusSource.rpc<GatewayChannelStatusPayload>(
'channels.status',
{ probe },
probe ? 5000 : 8000,
@@ -292,8 +295,9 @@ export async function buildChannelAccountsView(
consecutiveHeartbeatMisses: 0,
consecutiveRpcFailures: 0,
};
const status = ctx.runtimeManager?.getStatus() ?? ctx.gatewayManager.getStatus();
const gatewayHealth = buildGatewayHealthSummary({
status: ctx.gatewayManager.getStatus(),
status,
diagnostics: gatewayDiagnostics,
lastChannelsStatusOkAt,
lastChannelsStatusFailureAt,
@@ -379,7 +383,7 @@ export async function buildChannelAccountsView(
const baseGroupStatus = pickChannelRuntimeStatus(visibleAccountSnapshots, channelSummary, {
gatewayHealthState: effectiveGatewayHealthState,
});
const groupStatus = !gatewayStatus && !skipRuntime && ctx.gatewayManager.getStatus().state === 'running'
const groupStatus = !gatewayStatus && !skipRuntime && status.state === 'running'
? 'degraded'
: effectiveGatewayHealthState && !hasRuntimeError && baseGroupStatus === 'connected'
? 'degraded'
@@ -391,7 +395,7 @@ export async function buildChannelAccountsView(
channelType: uiChannelType,
defaultAccountId,
status: groupStatus,
statusReason: !gatewayStatus && !skipRuntime && ctx.gatewayManager.getStatus().state === 'running'
statusReason: !gatewayStatus && !skipRuntime && status.state === 'running'
? 'channels_status_timeout'
: groupStatus === 'degraded' && effectiveGatewayHealthState
? overlayStatusReason(gatewayHealth, 'gateway_degraded')
@@ -985,13 +989,29 @@ async function ensureScopedChannelBinding(channelType: string, accountId?: strin
await migrateLegacyChannelWideBinding(storedChannelType);
}
function scheduleGatewayChannelRestart(ctx: ChannelsApiContext, reason: string): void {
async function scheduleGatewayChannelRestart(ctx: ChannelsApiContext, reason: string): Promise<void> {
const provider = ctx.runtimeManager?.getActiveProvider();
if (provider?.refreshConfig) {
await provider.refreshConfig({ scope: 'channels', reason, forceRestart: true });
return;
}
if (ctx.gatewayManager.getStatus().state === 'stopped') return;
ctx.gatewayManager.debouncedRestart();
void reason;
}
function scheduleGatewayChannelSaveRefresh(ctx: ChannelsApiContext, channelType: string, reason: string): void {
async function scheduleGatewayChannelSaveRefresh(ctx: ChannelsApiContext, channelType: string, reason: string): Promise<void> {
const provider = ctx.runtimeManager?.getActiveProvider();
if (provider?.refreshConfig) {
const storedChannelType = resolveStoredChannelType(channelType);
await provider.refreshConfig({
scope: 'channels',
reason,
channelType: storedChannelType,
forceRestart: FORCE_RESTART_CHANNELS.has(storedChannelType),
});
return;
}
const storedChannelType = resolveStoredChannelType(channelType);
if (ctx.gatewayManager.getStatus().state === 'stopped') return;
if (FORCE_RESTART_CHANNELS.has(storedChannelType)) {
@@ -1072,7 +1092,7 @@ async function awaitWeChatQrLogin(
});
await saveChannelConfig(UI_WECHAT_CHANNEL_TYPE, { enabled: true }, normalizedAccountId);
await ensureScopedChannelBinding(UI_WECHAT_CHANNEL_TYPE, normalizedAccountId);
scheduleGatewayChannelSaveRefresh(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE, `wechat:loginSuccess:${normalizedAccountId}`);
await scheduleGatewayChannelSaveRefresh(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE, `wechat:loginSuccess:${normalizedAccountId}`);
if (activeQrLogins.get(loginKey) !== sessionKey) return;
emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'success', {
@@ -1135,7 +1155,7 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
const accountId = requireString(payload, 'accountId');
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
await setChannelDefaultAccount(channelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setDefaultAccount:${channelType}`);
await scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setDefaultAccount:${channelType}`);
return { success: true };
},
bindingSave: async (payload) => {
@@ -1152,7 +1172,7 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
await migrateLegacyChannelWideBinding(storedChannelType);
}
await assignChannelAccountToAgent(agentId, storedChannelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setBinding:${channelType}`);
await scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setBinding:${channelType}`);
return { success: true };
},
bindingDelete: async (payload) => {
@@ -1160,7 +1180,7 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
const accountId = optionalString(payload, 'accountId');
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
await clearChannelBinding(resolveStoredChannelType(channelType), accountId);
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:clearBinding:${channelType}`);
await scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:clearBinding:${channelType}`);
return { success: true };
},
validateConfig: async (payload) => {
@@ -1178,23 +1198,25 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
const accountId = optionalString(payload, 'accountId');
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
const storedChannelType = resolveStoredChannelType(channelType);
await ensureChannelPluginInstalled(storedChannelType);
if (ctx.runtimeManager?.getActiveProvider().kind !== 'cc-connect') {
await ensureChannelPluginInstalled(storedChannelType);
}
const existingValues = await getChannelFormValues(channelType, accountId);
if (isSameConfigValues(existingValues, config)) {
await ensureScopedChannelBinding(channelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfigNoChange:${storedChannelType}`);
await scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfigNoChange:${storedChannelType}`);
return { success: true, noChange: true };
}
await saveChannelConfig(channelType, config, accountId);
await ensureScopedChannelBinding(channelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfig:${storedChannelType}`);
await scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfig:${storedChannelType}`);
return { success: true };
},
setEnabled: async (payload) => {
const channelType = requireString(payload, 'channelType');
const enabled = isRecord(payload) && payload.enabled === true;
await setChannelEnabled(channelType, enabled);
scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${resolveStoredChannelType(channelType)}`);
await scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${resolveStoredChannelType(channelType)}`);
return { success: true };
},
formValues: async (payload) => {
@@ -1209,11 +1231,11 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
if (accountId) {
await deleteChannelAccountConfig(channelType, accountId);
await clearChannelBinding(storedChannelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:deleteAccount:${storedChannelType}`);
await scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:deleteAccount:${storedChannelType}`);
} else {
await deleteChannelConfig(channelType);
await clearAllBindingsForChannel(storedChannelType);
scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${storedChannelType}`);
await scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${storedChannelType}`);
}
return { success: true };
},
+68 -19
View File
@@ -1,6 +1,11 @@
import type { BrowserWindow } from 'electron';
import type { GatewayManager } from '../gateway/manager';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { RuntimeManager } from '../runtime/manager';
import type { RuntimeSendWithMediaPayload } from '../runtime/types';
import { logger } from '../utils/logger';
import { createAcpChatService } from './acp-chat-service';
import type { AcpSessionAccessRegistry } from './acp-session-access-registry';
import { isRecord } from './payload-utils';
const VISION_MIME_TYPES = new Set([
@@ -38,17 +43,19 @@ function normalizeMedia(media: unknown): Array<{ filePath: string; mimeType: str
});
}
export function createChatApi({ gatewayManager }: { gatewayManager: GatewayManager }): CompleteHostServiceRegistry['chat'] {
return {
sendWithMedia: async (payload) => {
const body = isRecord(payload) ? payload as ChatSendWithMediaPayload : {};
const sessionKey = typeof body.sessionKey === 'string' ? body.sessionKey : '';
const idempotencyKey = typeof body.idempotencyKey === 'string' ? body.idempotencyKey : '';
if (!sessionKey || !idempotencyKey) {
return { success: false, error: 'Invalid chat send payload' };
}
export function createChatSendWithMediaHandler(
gatewayManager: GatewayManager,
log = logger,
): (payload?: unknown) => ReturnType<CompleteHostServiceRegistry['chat']['sendWithMedia']> {
return async (payload) => {
const body = isRecord(payload) ? payload as ChatSendWithMediaPayload : {};
const sessionKey = typeof body.sessionKey === 'string' ? body.sessionKey : '';
const idempotencyKey = typeof body.idempotencyKey === 'string' ? body.idempotencyKey : '';
if (!sessionKey || !idempotencyKey) {
return { success: false, error: 'Invalid chat send payload' };
}
try {
try {
let message = typeof body.message === 'string' ? body.message : '';
const imageAttachments: Array<Record<string, unknown>> = [];
const fileReferences: string[] = [];
@@ -58,8 +65,8 @@ 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(
`[chat:sendWithMedia] Processing file: ${item.fileName} (${item.mimeType}), path: ${item.filePath}, exists: ${exists}, isVision: ${VISION_MIME_TYPES.has(item.mimeType)}`,
log.info(
`[chat:sendWithMedia] Processing media: name=${item.fileName}, mimeType=${item.mimeType}, exists=${exists}, isVision=${VISION_MIME_TYPES.has(item.mimeType)}`,
);
fileReferences.push(
@@ -69,7 +76,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,19 +101,61 @@ export function createChatApi({ gatewayManager }: { gatewayManager: GatewayManag
rpcParams.attachments = imageAttachments;
}
logger.info(
`[chat:sendWithMedia] Sending: message="${message.substring(0, 100)}", attachments=${imageAttachments.length}, fileRefs=${fileReferences.length}`,
log.info(
`[chat:sendWithMedia] Sending: messageLength=${message.length}, attachments=${imageAttachments.length}, fileRefs=${fileReferences.length}`,
);
const result = await gatewayManager.rpc('chat.send', rpcParams, 120000);
logger.info(`[chat:sendWithMedia] RPC result: ${JSON.stringify(result)}`);
const response = isRecord(result) && typeof result.runId === 'string'
? { runId: result.runId }
const hasRunId = isRecord(result) && typeof result.runId === 'string';
log.info(`[chat:sendWithMedia] RPC result: runId=${hasRunId ? 'present' : 'absent'}`);
const response = hasRunId
? { runId: result.runId as string }
: undefined;
return { success: true, ...(response ? { result: response } : {}) };
} catch (error) {
log.error(`[chat:sendWithMedia] Error: ${String(error)}`);
return { success: false, error: String(error) };
}
};
}
export function createChatApi({
gatewayManager,
runtimeManager,
mainWindow,
acpSessionAccessRegistry,
}: {
gatewayManager: GatewayManager;
runtimeManager?: RuntimeManager;
mainWindow: BrowserWindow;
acpSessionAccessRegistry: AcpSessionAccessRegistry;
}): CompleteHostServiceRegistry['chat'] {
const acpChat = createAcpChatService(mainWindow, acpSessionAccessRegistry, gatewayManager);
const openClawHandler = createChatSendWithMediaHandler(gatewayManager, logger);
const withOpenClawAcp = async <T>(operation: () => Promise<T>) => {
if (runtimeManager && await runtimeManager.getActiveKind() !== 'openclaw') {
return {
success: false,
error: 'ACP chat is only available for the OpenClaw runtime',
} as T;
}
return operation();
};
return {
sendWithMedia: async (payload) => {
if (!runtimeManager) return openClawHandler(payload);
try {
const result = await runtimeManager.getActiveProvider().sendMessageWithMedia(
(isRecord(payload) ? payload : {}) as RuntimeSendWithMediaPayload,
);
return { success: true, result };
} catch (error) {
logger.error(`[chat:sendWithMedia] Error: ${String(error)}`);
return { success: false, error: String(error) };
}
},
loadAcpSession: (payload) => withOpenClawAcp(() => acpChat.loadSession(payload)),
sendAcpPrompt: (payload) => withOpenClawAcp(() => acpChat.sendPrompt(payload)),
cancelAcpSession: (payload) => withOpenClawAcp(() => acpChat.cancelSession(payload)),
respondAcpPermission: (payload) => withOpenClawAcp(() => acpChat.respondPermission(payload)),
};
}
+178 -70
View File
@@ -1,8 +1,10 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { CronJob, CronJobDelivery, CronSchedule } from '@shared/types/cron';
import type { HostSuccess } from '@shared/host-api/contract';
import type { CronJob, CronJobCreateInput, CronJobDelivery, CronJobUpdateInput, CronSchedule } from '@shared/types/cron';
import type { GatewayManager } from '../gateway/manager';
import type { RuntimeManager } from '../runtime/manager';
import { getOpenClawConfigDir } from '../utils/paths';
import { resolveAgentIdFromChannel } from '../utils/agent-config';
import { toOpenClawChannelType, toUiChannelType } from '../utils/channel-alias';
@@ -53,7 +55,7 @@ interface CronSessionKeyParts {
interface CronSessionFallbackMessage {
id: string;
role: 'assistant' | 'system';
role: 'user' | 'assistant';
content: string;
timestamp: number;
isError?: boolean;
@@ -117,14 +119,14 @@ function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSession
return {
id: `cron-run-${entry.sessionId ?? entry.ts ?? index}`,
role: status === 'error' ? 'system' : 'assistant',
role: 'assistant',
content,
timestamp,
...(status === 'error' ? { isError: true } : {}),
};
}
async function readCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
async function readLegacyCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
const logPath = join(getOpenClawConfigDir(), 'cron', 'runs', `${jobId}.jsonl`);
const raw = await readFile(logPath, 'utf8').catch(() => '');
if (!raw.trim()) return [];
@@ -145,6 +147,24 @@ async function readCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
return entries;
}
async function readCronRunHistory(
gatewayManager: GatewayManager,
jobId: string,
limit: number,
): Promise<CronRunLogEntry[]> {
try {
const result = await gatewayManager.rpc<{ entries?: CronRunLogEntry[] }>('cron.runs', {
id: jobId,
limit,
sortDir: 'asc',
}, 8000);
if (Array.isArray(result?.entries)) return result.entries;
} catch {
// OpenClaw versions before SQLite cron history may not expose cron.runs.
}
return readLegacyCronRunLog(jobId);
}
async function readSessionStoreEntry(
agentId: string,
sessionKey: string,
@@ -204,12 +224,10 @@ function buildCronSessionFallbackMessages(params: {
: (normalizeTimestampMs(params.job?.state?.runningAtMs) ?? params.sessionEntry?.updatedAt);
if (taskName || prompt) {
const lines = [taskName ? `Scheduled task: ${taskName}` : 'Scheduled task'];
if (prompt) lines.push(`Prompt: ${prompt}`);
messages.push({
id: `cron-meta-${parsed.jobId}`,
role: 'system',
content: lines.join('\n'),
role: 'user',
content: prompt || taskName,
timestamp: Math.max(0, (firstRelevantTimestamp ?? Date.now()) - 1),
});
}
@@ -224,17 +242,10 @@ function buildCronSessionFallbackMessages(params: {
if (runningAt) {
messages.push({
id: `cron-running-${parsed.jobId}`,
role: 'system',
role: 'assistant',
content: 'This scheduled task is still running in OpenClaw, but no chat transcript is available yet.',
timestamp: runningAt,
});
} else if (messages.length === 0) {
messages.push({
id: `cron-empty-${parsed.jobId}`,
role: 'system',
content: 'No chat transcript is available for this scheduled task yet.',
timestamp: params.sessionEntry?.updatedAt ?? Date.now(),
});
}
}
@@ -292,6 +303,35 @@ function normalizeCronSchedule(schedule: GatewayCronJob['schedule']): CronJob['s
return typeof schedule.expr === 'string' ? schedule.expr : '';
}
/**
* Normalize a UI-supplied schedule (plain cron string or structured CronSchedule)
* into the structured form the Gateway expects. Plain strings become a cron
* schedule; structured `at` / `every` / `cron` objects pass through after a
* minimal shape check.
*/
function normalizeScheduleInput(schedule: unknown): CronSchedule {
if (typeof schedule === 'string') {
return { kind: 'cron', expr: schedule };
}
if (schedule && typeof schedule === 'object') {
const record = schedule as Record<string, unknown>;
if (record.kind === 'at' && typeof record.at === 'string' && record.at.trim()) {
return { kind: 'at', at: record.at };
}
if (record.kind === 'every' && typeof record.everyMs === 'number' && Number.isFinite(record.everyMs)) {
return {
kind: 'every',
everyMs: record.everyMs,
...(typeof record.anchorMs === 'number' ? { anchorMs: record.anchorMs } : {}),
};
}
if (record.kind === 'cron' && typeof record.expr === 'string') {
return { kind: 'cron', expr: record.expr, ...(typeof record.tz === 'string' && record.tz ? { tz: record.tz } : {}) };
}
}
throw new Error('Invalid schedule: expected a cron expression string or a CronSchedule object');
}
function normalizeCronDeliveryPatch(rawDelivery: unknown): Record<string, unknown> {
if (!rawDelivery || typeof rawDelivery !== 'object') return {};
@@ -312,7 +352,7 @@ function normalizeCronDeliveryPatch(rawDelivery: unknown): Record<string, unknow
function buildCronUpdatePatch(input: Record<string, unknown>): Record<string, unknown> {
const patch = { ...input };
if (typeof patch.schedule === 'string') patch.schedule = { kind: 'cron', expr: patch.schedule };
if ('schedule' in patch && patch.schedule !== undefined) patch.schedule = normalizeScheduleInput(patch.schedule);
if (typeof patch.message === 'string') {
patch.payload = { kind: 'agentTurn', message: patch.message };
delete patch.message;
@@ -364,7 +404,7 @@ function transformCronJob(job: GatewayCronJob): CronJob {
};
}
async function listCronJobs(gatewayManager: GatewayManager): Promise<CronJob[]> {
export async function listCronJobs(gatewayManager: GatewayManager): Promise<CronJob[]> {
let jobs: GatewayCronJob[] = [];
let usedFallback = false;
@@ -468,67 +508,128 @@ function getId(payload: unknown): string {
return id.trim();
}
export function createCronApi({ gatewayManager }: { gatewayManager: GatewayManager }): CompleteHostServiceRegistry['cron'] {
export async function createOpenClawCronJob(
gatewayManager: GatewayManager,
input: CronJobCreateInput,
): Promise<CronJob> {
const agentId = typeof input.agentId === 'string' && input.agentId.trim() ? input.agentId.trim() : 'main';
const delivery = normalizeCronDelivery(input.delivery);
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(delivery.channel);
if (delivery.mode === 'announce' && unsupportedDeliveryError) {
throw new Error(unsupportedDeliveryError);
}
const result = await gatewayManager.rpc('cron.add', {
name: input.name,
schedule: normalizeScheduleInput(input.schedule),
payload: { kind: 'agentTurn', message: input.message },
enabled: typeof input.enabled === 'boolean' ? input.enabled : true,
wakeMode: 'next-heartbeat',
sessionTarget: 'isolated',
agentId,
delivery,
});
if (!result || typeof result !== 'object') {
throw new Error('Cron create returned an invalid job');
}
return transformCronJob(result as GatewayCronJob);
}
export async function updateOpenClawCronJob(
gatewayManager: GatewayManager,
payload: { id: string; input: CronJobUpdateInput },
): Promise<CronJob> {
const id = getId(payload);
const input = isRecord(payload.input) ? payload.input : {};
const patch = buildCronUpdatePatch(input);
delete patch.id;
delete patch.input;
const deliveryPatch = patch.delivery && typeof patch.delivery === 'object'
? patch.delivery as Record<string, unknown>
: undefined;
const deliveryChannel = typeof deliveryPatch?.channel === 'string' && deliveryPatch.channel.trim()
? deliveryPatch.channel.trim()
: undefined;
const deliveryMode = typeof deliveryPatch?.mode === 'string' && deliveryPatch.mode.trim()
? deliveryPatch.mode.trim()
: undefined;
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(deliveryChannel);
if (unsupportedDeliveryError && deliveryMode !== 'none') {
throw new Error(unsupportedDeliveryError);
}
const result = await gatewayManager.rpc('cron.update', { id, patch });
if (!result || typeof result !== 'object') {
throw new Error('Cron update returned an invalid job');
}
return transformCronJob(result as GatewayCronJob);
}
function normalizeHostSuccess(result: unknown): HostSuccess {
if (isRecord(result) && typeof result.success === 'boolean') {
return { success: result.success, ...(typeof result.error === 'string' ? { error: result.error } : {}) };
}
return { success: true };
}
export async function deleteOpenClawCronJob(gatewayManager: GatewayManager, payload: unknown): Promise<HostSuccess> {
return normalizeHostSuccess(await gatewayManager.rpc('cron.remove', { id: getId(payload) }));
}
export async function toggleOpenClawCronJob(gatewayManager: GatewayManager, payload: { id: string; enabled: boolean }): Promise<HostSuccess> {
return normalizeHostSuccess(await gatewayManager.rpc('cron.update', {
id: getId(payload),
patch: { enabled: payload.enabled === true },
}));
}
export async function triggerOpenClawCronJob(gatewayManager: GatewayManager, payload: unknown): Promise<HostSuccess> {
return normalizeHostSuccess(await gatewayManager.rpc('cron.run', { id: getId(payload), mode: 'force' }));
}
export function createCronApi({
gatewayManager,
runtimeManager,
}: {
gatewayManager: GatewayManager;
runtimeManager?: RuntimeManager;
}): CompleteHostServiceRegistry['cron'] {
const runtimeSupportsCron = () => runtimeManager?.listCapabilities().cron === true;
return {
list: async () => listCronJobs(gatewayManager),
list: async () => {
if (runtimeSupportsCron()) {
return await runtimeManager!.rpc<CronJob[]>('cron.list');
}
return listCronJobs(gatewayManager);
},
create: async (payload) => {
const input = payload;
const agentId = typeof input.agentId === 'string' && input.agentId.trim() ? input.agentId.trim() : 'main';
const delivery = normalizeCronDelivery(input.delivery);
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(delivery.channel);
if (delivery.mode === 'announce' && unsupportedDeliveryError) {
throw new Error(unsupportedDeliveryError);
if (runtimeSupportsCron()) {
return await runtimeManager!.rpc<CronJob>('cron.create', payload);
}
const result = await gatewayManager.rpc('cron.add', {
name: input.name,
schedule: { kind: 'cron', expr: input.schedule },
payload: { kind: 'agentTurn', message: input.message },
enabled: typeof input.enabled === 'boolean' ? input.enabled : true,
wakeMode: 'next-heartbeat',
sessionTarget: 'isolated',
agentId,
delivery,
});
if (!result || typeof result !== 'object') {
throw new Error('Cron create returned an invalid job');
}
return transformCronJob(result as GatewayCronJob);
return createOpenClawCronJob(gatewayManager, payload);
},
update: async (payload) => {
const body = payload;
const id = getId(body);
const input = isRecord(body.input) ? body.input : {};
const patch = buildCronUpdatePatch(input);
delete patch.id;
delete patch.input;
const deliveryPatch = patch.delivery && typeof patch.delivery === 'object'
? patch.delivery as Record<string, unknown>
: undefined;
const deliveryChannel = typeof deliveryPatch?.channel === 'string' && deliveryPatch.channel.trim()
? deliveryPatch.channel.trim()
: undefined;
const deliveryMode = typeof deliveryPatch?.mode === 'string' && deliveryPatch.mode.trim()
? deliveryPatch.mode.trim()
: undefined;
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(deliveryChannel);
if (unsupportedDeliveryError && deliveryMode !== 'none') {
throw new Error(unsupportedDeliveryError);
if (runtimeSupportsCron()) {
return await runtimeManager!.rpc<CronJob>('cron.update', payload);
}
const result = await gatewayManager.rpc('cron.update', { id, patch });
if (!result || typeof result !== 'object') {
throw new Error('Cron update returned an invalid job');
return updateOpenClawCronJob(gatewayManager, payload);
},
delete: async (payload) => {
if (runtimeSupportsCron()) {
return await runtimeManager!.rpc<HostSuccess>('cron.delete', { id: getId(payload) });
}
return transformCronJob(result as GatewayCronJob);
return deleteOpenClawCronJob(gatewayManager, payload);
},
delete: async (payload) => gatewayManager.rpc('cron.remove', { id: getId(payload) }),
toggle: async (payload) => {
const body = payload;
return gatewayManager.rpc('cron.update', {
id: getId(body),
patch: { enabled: body.enabled === true },
});
if (runtimeSupportsCron()) {
return await runtimeManager!.rpc<HostSuccess>('cron.toggle', { id: getId(payload), enabled: payload.enabled === true });
}
return toggleOpenClawCronJob(gatewayManager, payload);
},
trigger: async (payload) => {
if (runtimeSupportsCron()) {
return await runtimeManager!.rpc<HostSuccess>('cron.run', { id: getId(payload), mode: 'force' });
}
return triggerOpenClawCronJob(gatewayManager, payload);
},
trigger: async (payload) => gatewayManager.rpc('cron.run', { id: getId(payload), mode: 'force' }),
sessionHistory: async (payload) => {
const body = payload;
const sessionKey = typeof body.sessionKey === 'string' ? body.sessionKey.trim() : '';
@@ -537,10 +638,17 @@ export function createCronApi({ gatewayManager }: { gatewayManager: GatewayManag
const rawLimit = typeof body.limit === 'number' ? body.limit : Number(body.limit || 200);
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 200) : 200;
const activeProvider = runtimeManager?.getActiveProvider();
if (activeProvider?.listCapabilities().history) {
const history = await activeProvider.loadHistory({ sessionKey, limit });
if (history.messages && history.messages.length > 0) {
return history;
}
}
const [jobsResult, runs, sessionEntry] = await Promise.all([
gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000)
.catch(() => ({ jobs: [] as GatewayCronJob[] })),
readCronRunLog(parsedSession.jobId),
readCronRunHistory(gatewayManager, parsedSession.jobId, limit),
readSessionStoreEntry(parsedSession.agentId, sessionKey),
]);
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
+217 -1
View File
@@ -1,16 +1,31 @@
import { execFile } from 'node:child_process';
import { open } from 'node:fs/promises';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { GatewayManager } from '../gateway/manager';
import type { RuntimeManager } from '../runtime/manager';
import type { RuntimeProvider } from '../runtime/types';
import type { CronJob } from '@shared/types/cron';
import { logger } from '../utils/logger';
import { getOpenClawConfigDir } from '../utils/paths';
import { buildGatewayHealthSummary } from '../utils/gateway-health';
import { buildChannelAccountsView, getChannelStatusDiagnostics } from './channels-api';
import { getAcpTraceSnapshot, recordRendererAcpTrace } from './acp-trace';
import {
getCcConnectBinaryPath,
getCcConnectCodexHomeDir,
getCcConnectConfigPath,
getCcConnectManagedDir,
getCcConnectProviderProfilePath,
} from '../runtime/cc-connect-paths';
import { getCodexBundle } from '../runtime/codex-paths';
import { getCcConnectCodexOAuthStatus } from '../runtime/cc-connect-provider-profile';
const DEFAULT_TAIL_LINES = 200;
type DiagnosticsApiContext = {
gatewayManager: GatewayManager;
runtimeManager?: RuntimeManager;
};
async function readTail(filePath: string, tailLines = DEFAULT_TAIL_LINES): Promise<string> {
@@ -45,6 +60,204 @@ async function readTail(filePath: string, tailLines = DEFAULT_TAIL_LINES): Promi
}
}
async function readJsonFile(filePath: string): Promise<Record<string, unknown> | null> {
try {
const file = await open(filePath, 'r');
try {
const stat = await file.stat();
if (stat.size <= 0 || stat.size > 1024 * 1024) return null;
const buffer = Buffer.allocUnsafe(stat.size);
const { bytesRead } = await file.read(buffer, 0, stat.size, 0);
const parsed = JSON.parse(buffer.subarray(0, bytesRead).toString('utf8')) as unknown;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: null;
} finally {
await file.close();
}
} catch {
return null;
}
}
async function runVersionCommand(binaryPath: string): Promise<Record<string, unknown>> {
return await new Promise((resolve) => {
execFile(binaryPath, ['--version'], { timeout: 5_000 }, (error, stdout, stderr) => {
const output = `${stdout || ''}${stderr ? `\n${stderr}` : ''}`.trim();
if (error) {
resolve({
success: false,
command: `${binaryPath} --version`,
error: error.message,
output,
});
return;
}
resolve({
success: true,
command: `${binaryPath} --version`,
output,
version: output.split('\n')[0]?.trim() || undefined,
});
});
});
}
async function buildBinaryDiagnostics(binaryPath: string, manifestPath: string): Promise<Record<string, unknown>> {
const [manifest, versionCommand] = await Promise.all([
readJsonFile(manifestPath),
runVersionCommand(binaryPath),
]);
return {
binaryPath,
manifestPath,
manifest,
versionCommand,
};
}
async function probeCcConnectManagement(activeProvider: ReturnType<RuntimeManager['getActiveProvider']> | undefined) {
if (!activeProvider?.getControlUi) {
return { success: false, error: 'cc-connect control UI route is unavailable' };
}
try {
const control = await activeProvider.getControlUi();
if (!control.success || !control.url) {
return {
success: false,
port: control.port,
error: control.error || 'cc-connect control UI route is unavailable',
};
}
const url = new URL('/api/v1/status', control.url);
const response = await fetch(url, {
headers: control.token ? { Authorization: `Bearer ${control.token}` } : undefined,
});
const text = await response.text();
return {
success: response.ok,
port: control.port,
status: response.status,
body: text.trim().slice(0, 2_000),
...(response.ok ? {} : { error: text.trim() || `HTTP ${response.status}` }),
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
async function buildCcConnectCronDiagnostics(activeProvider: RuntimeProvider | undefined): Promise<Record<string, unknown>> {
const knownGaps = [
'scheduled-prompt-delivery-unproven',
'heartbeat-unproven',
'external-channel-delivery-targets-unproven',
'muted-scheduled-delivery-behavior-unproven',
];
if (!activeProvider?.rpc) {
return {
success: false,
knownGaps,
error: 'active runtime provider RPC is unavailable',
};
}
try {
const jobs = await activeProvider.rpc<CronJob[]>('cron.list');
const list = Array.isArray(jobs) ? jobs : [];
return {
success: true,
jobCount: list.length,
jobs: list.slice(0, 50).map((job) => ({
id: job.id,
name: job.name,
agentId: job.agentId,
enabled: job.enabled,
deliveryMode: job.delivery?.mode,
hasPrompt: Boolean(job.message && !job.exec),
hasExec: Boolean(job.exec),
sessionMode: job.sessionMode,
timeoutMins: job.timeoutMins,
mute: job.mute,
nextRun: job.nextRun,
lastRun: job.lastRun ? {
time: job.lastRun.time,
success: job.lastRun.success,
hasError: Boolean(job.lastRun.error),
duration: job.lastRun.duration,
} : undefined,
})),
truncated: list.length > 50,
knownGaps,
};
} catch (error) {
return {
success: false,
knownGaps,
error: error instanceof Error ? error.message : String(error),
};
}
}
async function buildRuntimeDiagnostics(ctx: DiagnosticsApiContext) {
const runtimeStatus = ctx.runtimeManager?.getStatus();
const activeProvider = ctx.runtimeManager?.getActiveProvider();
const base = {
activeKind: activeProvider?.kind ?? runtimeStatus?.runtimeKind ?? 'openclaw',
status: runtimeStatus,
operationCapabilities: activeProvider?.listOperationCapabilities?.(),
};
if ((activeProvider?.kind ?? runtimeStatus?.runtimeKind) !== 'cc-connect') {
return base;
}
const managedDir = getCcConnectManagedDir();
const configPath = getCcConnectConfigPath();
const providerProfilePath = getCcConnectProviderProfilePath();
const ccConnectBinaryPath = getCcConnectBinaryPath();
const codexBundle = getCodexBundle();
const [oauth, providerProfile, runtimeLogs, ccConnectBinary, codexBinary, managementApi, cron] = await Promise.all([
getCcConnectCodexOAuthStatus().catch((error) => ({
success: false,
error: error instanceof Error ? error.message : String(error),
})),
readJsonFile(providerProfilePath),
activeProvider?.listLogs?.().catch((error) => ({
content: `Failed to read cc-connect logs: ${String(error)}`,
})),
buildBinaryDiagnostics(ccConnectBinaryPath, join(dirname(ccConnectBinaryPath), 'manifest.json')),
buildBinaryDiagnostics(codexBundle.binaryPath, join(codexBundle.baseDir, 'manifest.json')),
probeCcConnectManagement(activeProvider),
buildCcConnectCronDiagnostics(activeProvider),
]);
const codexHomeDir = providerProfile
&& typeof providerProfile === 'object'
&& typeof (providerProfile as Record<string, unknown>).codexHomeDir === 'string'
? (providerProfile as Record<string, string>).codexHomeDir
: getCcConnectCodexHomeDir();
return {
...base,
ccConnect: {
managedDir,
configPath,
codexHomeDir,
providerProfilePath,
oauth,
providerProfile,
binaries: {
ccConnect: ccConnectBinary,
codex: codexBinary,
},
managementApi,
cron,
logTail: runtimeLogs?.content ?? '',
},
};
}
export function createDiagnosticsApi(ctx: DiagnosticsApiContext): CompleteHostServiceRegistry['diagnostics'] {
return {
gatewaySnapshot: async () => {
@@ -73,11 +286,14 @@ 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')),
gatewayErrLogTail: await readTail(join(openClawDir, 'logs', 'gateway.err.log')),
};
},
acpTrace: async () => getAcpTraceSnapshot(),
recordAcpTrace: async (payload) => recordRendererAcpTrace(payload),
};
}
+645 -38
View File
@@ -1,14 +1,46 @@
import { app, nativeImage } from 'electron';
import { app, nativeImage, shell } from 'electron';
import crypto from 'node:crypto';
import { constants } from 'node:fs';
import type { Stats } from 'node:fs';
import type { FileHandle } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, extname, join, relative, resolve, sep } from 'node:path';
import {
basename,
dirname,
extname,
isAbsolute,
join,
posix,
relative,
resolve,
sep,
win32,
} from 'node:path';
import type {
FilePreviewError,
FilePreviewTreeNode,
FilePreviewTreeOptions,
FileReadBinaryOptions,
WorkspaceNativeFileError,
WorkspaceFileRef,
} from '@shared/host-api/contract';
import {
FILE_PREVIEW_MAX_BINARY_BYTES,
FILE_PREVIEW_MAX_TEXT_BYTES,
} from '@shared/file-preview/limits';
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 {
resolveClawXStagingDir,
type AttachmentAccess,
type StagedAttachmentRegistry,
} from './attachment-access';
import {
HANDLER_ID_MAX_LENGTH,
type AttachmentOpenWithService,
} from './attachment-open-with';
import { isRecord } from './payload-utils';
const EXT_MIME_MAP: Record<string, string> = {
@@ -54,10 +86,7 @@ const EXT_MIME_MAP: Record<string, string> = {
'.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;
const FILE_PREVIEW_TREE_MAX_DEPTH = 6;
const FILE_PREVIEW_TREE_MAX_NODES = 5000;
const FILE_PREVIEW_DIR_BLACKLIST = new Set([
@@ -93,6 +122,45 @@ type ResolvedSandboxedPath = {
readOnly: boolean;
};
type ResolvedWorkspaceTarget = {
root: string;
target: string;
};
type OpenWorkspaceTarget = ResolvedWorkspaceTarget & {
handle: FileHandle;
stat: Stats;
};
type WorkspaceFs = {
open: (path: string, flags: number) => Promise<FileHandle>;
realpath: (path: string) => Promise<string>;
stat: (path: string) => Promise<Stats>;
};
type FilesApiDependencies = {
workspaceFs?: WorkspaceFs;
runtimeManager?: Pick<RuntimeManager, 'getStatus'>;
attachmentAccess?: AttachmentAccess;
openWith?: AttachmentOpenWithService;
stagedAttachments?: StagedAttachmentRegistry;
stagingHooks?: {
beforeDestinationOpen?: (input: { stagingDir: string; destinationPath: string }) => Promise<void>;
};
};
type PinnedStagingDirectory = {
lexicalPath: string;
canonicalPath: string;
dev: number;
ino: number;
};
type PinnedStagingArea = {
stagingDir: string;
directories: PinnedStagingDirectory[];
};
function getMimeType(ext: string): string {
return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream';
}
@@ -124,6 +192,24 @@ async function generateImagePreview(filePath: string, mimeType: string): Promise
}
}
function generateImageBufferPreview(buffer: Buffer, mimeType: string): string | null {
try {
const img = nativeImage.createFromBuffer(buffer);
if (img.isEmpty()) return null;
const size = img.getSize();
const maxDim = 512;
if (size.width > maxDim || size.height > maxDim) {
const resized = size.width >= size.height
? img.resize({ width: maxDim })
: img.resize({ height: maxDim });
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
}
return `data:${mimeType};base64,${buffer.toString('base64')}`;
} catch {
return null;
}
}
function requirePath(payload: unknown): string {
const path = isRecord(payload) ? payload.path : payload;
if (typeof path !== 'string' || !path.trim()) {
@@ -132,18 +218,168 @@ function requirePath(payload: unknown): string {
return path;
}
function isPathInside(child: string, parent: string): boolean {
const c = resolve(child);
const p = resolve(parent);
if (process.platform === 'win32') {
const cl = c.toLowerCase();
const pl = p.toLowerCase();
return cl === pl || cl.startsWith(pl + sep);
}
return c === p || c.startsWith(p + sep);
export function isPathInside(
child: string,
parent: string,
platform: NodeJS.Platform = process.platform,
): boolean {
const pathApi = platform === 'win32' ? win32 : posix;
const c = pathApi.resolve(child);
const p = pathApi.resolve(parent);
const childFromParent = pathApi.relative(p, c);
return childFromParent === ''
|| (!childFromParent.startsWith(`..${pathApi.sep}`)
&& childFromParent !== '..'
&& !pathApi.isAbsolute(childFromParent));
}
function getFilePreviewWriteRoots(): string[] {
function workspaceError(error: unknown): FilePreviewError {
const message = error instanceof Error ? error.message : String(error);
if (message === 'outsideSandbox' || message === 'notFound' || message === 'notDirectory') {
return message;
}
const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
if (code === 'ENOENT') return 'notFound';
if (code === 'ENOTDIR') return 'notDirectory';
if (code === 'ELOOP') return 'outsideSandbox';
return 'operationFailed';
}
function workspaceNativeError(error: unknown): WorkspaceNativeFileError {
const message = error instanceof Error ? error.message : String(error);
if (message === 'outsideSandbox' || message === 'notFound' || message === 'notFile') {
return message;
}
const mapped = workspaceError(error);
if (mapped === 'outsideSandbox') return 'outsideSandbox';
if (mapped === 'notFound') return 'notFound';
return 'operationFailed';
}
function isSamePath(left: string, right: string): boolean {
const normalizedLeft = resolve(left);
const normalizedRight = resolve(right);
return process.platform === 'win32'
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
: normalizedLeft === normalizedRight;
}
async function resolveWorkspaceTarget(
ref: WorkspaceFileRef,
fsP: WorkspaceFs,
): Promise<ResolvedWorkspaceTarget> {
if (!ref || typeof ref.workspaceRoot !== 'string' || !ref.workspaceRoot.trim()
|| typeof ref.relativePath !== 'string' || !ref.relativePath.trim()) {
throw new Error('outsideSandbox');
}
const relativePath = ref.relativePath;
if (isAbsolute(relativePath) || posix.isAbsolute(relativePath) || win32.isAbsolute(relativePath)
|| relativePath.split(/[\\/]+/).includes('..')) {
throw new Error('outsideSandbox');
}
let root: string;
try {
root = await fsP.realpath(expandPath(ref.workspaceRoot));
if (!(await fsP.stat(root)).isDirectory()) throw new Error('outsideSandbox');
} catch (error) {
if (error instanceof Error && error.message === 'outsideSandbox') throw error;
throw new Error('outsideSandbox', { cause: error });
}
const candidate = resolve(root, relativePath);
if (!isPathInside(candidate, root)) throw new Error('outsideSandbox');
try {
const target = await fsP.realpath(candidate);
if (!isPathInside(target, root)) throw new Error('outsideSandbox');
return { root, target };
} catch (error) {
if (error instanceof Error && error.message === 'outsideSandbox') throw error;
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') throw error;
}
let parent = dirname(candidate);
while (true) {
try {
const existingParent = await fsP.realpath(parent);
if (!isPathInside(existingParent, root)) throw new Error('outsideSandbox');
throw new Error('notFound');
} catch (error) {
if (error instanceof Error && (error.message === 'outsideSandbox' || error.message === 'notFound')) {
throw error;
}
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') throw error;
const nextParent = dirname(parent);
if (nextParent === parent) throw new Error('outsideSandbox', { cause: error });
parent = nextParent;
}
}
}
async function revalidateWorkspaceTarget(
resolvedTarget: ResolvedWorkspaceTarget,
fsP: WorkspaceFs,
): Promise<string> {
const root = await fsP.realpath(resolvedTarget.root);
if (!isSamePath(root, resolvedTarget.root)) throw new Error('outsideSandbox');
if (!(await fsP.stat(root)).isDirectory()) throw new Error('outsideSandbox');
const target = await fsP.realpath(resolvedTarget.target);
if (!isSamePath(target, resolvedTarget.target) || !isPathInside(target, root)) {
throw new Error('outsideSandbox');
}
return target;
}
async function resolveWorkspaceRegularFile(
ref: WorkspaceFileRef,
fsP: WorkspaceFs,
): Promise<string> {
const resolvedTarget = await resolveWorkspaceTarget(ref, fsP);
if (!(await fsP.stat(resolvedTarget.target)).isFile()) throw new Error('notFile');
return resolvedTarget.target;
}
async function openWorkspaceTarget(ref: WorkspaceFileRef, fsP: WorkspaceFs): Promise<OpenWorkspaceTarget> {
const resolvedTarget = await resolveWorkspaceTarget(ref, fsP);
let handle: FileHandle | undefined;
try {
const noFollow = process.platform === 'win32' ? 0 : constants.O_NOFOLLOW;
handle = await fsP.open(resolvedTarget.target, constants.O_RDONLY | noFollow);
const stat = await handle.stat();
const target = await revalidateWorkspaceTarget(resolvedTarget, fsP);
const pathStat = await fsP.stat(target);
if (stat.dev !== pathStat.dev || stat.ino !== pathStat.ino) {
throw new Error('outsideSandbox');
}
return { ...resolvedTarget, handle, stat };
} catch (error) {
await handle?.close().catch(() => undefined);
throw error;
}
}
async function readOpenedFile(handle: FileHandle, maxBytes: number): Promise<Buffer | null> {
const chunks: Buffer[] = [];
let total = 0;
while (total <= maxBytes) {
const length = Math.min(64 * 1024, maxBytes + 1 - total);
const chunk = Buffer.allocUnsafe(length);
const { bytesRead } = await handle.read(chunk, 0, length, total);
if (bytesRead === 0) break;
chunks.push(chunk.subarray(0, bytesRead));
total += bytesRead;
}
return total > maxBytes ? null : Buffer.concat(chunks, total);
}
function getWorkspaceBinaryCap(value: unknown): number {
const maxBytes = typeof value === 'number' && Number.isFinite(value) ? value : undefined;
return Math.max(1, Math.min(maxBytes ?? FILE_PREVIEW_MAX_BINARY_BYTES, FILE_PREVIEW_MAX_BINARY_BYTES));
}
function getFilePreviewWriteRoots(runtimeManager?: Pick<RuntimeManager, 'getStatus'>): string[] {
const roots: string[] = [];
roots.push(resolve(join(homedir(), '.openclaw')));
try {
@@ -151,13 +387,15 @@ function getFilePreviewWriteRoots(): string[] {
} catch {
// ignore
}
roots.push(resolve(OUTBOUND_DIR));
roots.push(resolve(resolveClawXStagingDir()));
roots.push(resolve(getRuntimeOutboundMediaDir(runtimeManager)));
return roots;
}
async function resolveSandboxedPath(
input: string,
mode: 'read' | 'write' = 'read',
runtimeManager?: Pick<RuntimeManager, 'getStatus'>,
): Promise<ResolvedSandboxedPath> {
if (!input.trim()) {
throw new Error('outsideSandbox');
@@ -170,7 +408,7 @@ async function resolveSandboxedPath(
} catch {
real = resolve(expanded);
}
const writeRoots = getFilePreviewWriteRoots();
const writeRoots = getFilePreviewWriteRoots(runtimeManager);
if (writeRoots.some((root) => isPathInside(real, root))) {
return { realPath: real, readOnly: false };
}
@@ -207,7 +445,175 @@ function getBinaryOptions(opts: unknown): FileReadBinaryOptions {
return isRecord(opts) ? opts as FileReadBinaryOptions : {};
}
export function createFilesApi(): CompleteHostServiceRegistry['files'] {
export function createFilesApi(dependencies: FilesApiDependencies = {}): CompleteHostServiceRegistry['files'] {
const getWorkspaceFs = async (): Promise<WorkspaceFs> => dependencies.workspaceFs
?? await import('node:fs/promises');
const stagingAreaName = `clawx-${process.pid}-${crypto.randomUUID()}`;
let stagingAreaPromise: Promise<PinnedStagingArea> | null = null;
const initializeStagingArea = async (): Promise<PinnedStagingArea> => {
const fsP = await import('node:fs/promises');
const directories: PinnedStagingDirectory[] = [];
const ensureDirectory = async (lexicalPath: string, parent?: PinnedStagingDirectory) => {
let entryStat: Stats;
try {
entryStat = await fsP.lstat(lexicalPath);
} catch (error) {
const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
if (code !== 'ENOENT') throw error;
await fsP.mkdir(lexicalPath, { mode: 0o700 });
entryStat = await fsP.lstat(lexicalPath);
}
if (entryStat.isSymbolicLink() || !entryStat.isDirectory()) {
throw new Error('Invalid ClawX staging directory');
}
const canonicalPath = await fsP.realpath(lexicalPath);
const canonicalStat = await fsP.stat(canonicalPath);
if (!canonicalStat.isDirectory()
|| (parent && !isPathInside(canonicalPath, parent.canonicalPath))) {
throw new Error('Invalid ClawX staging directory');
}
const pinned = {
lexicalPath,
canonicalPath,
dev: canonicalStat.dev,
ino: canonicalStat.ino,
};
directories.push(pinned);
return pinned;
};
const runtimeOutboundDir = resolve(getRuntimeOutboundMediaDir(dependencies.runtimeManager));
const runtimeStateDir = dirname(dirname(runtimeOutboundDir));
const isCcConnect = dependencies.runtimeManager?.getStatus().runtimeKind === 'cc-connect';
const stateDir = isCcConnect
? await (async () => {
const dataRootPath = dirname(dirname(runtimeStateDir));
const dataRoot = await ensureDirectory(dataRootPath);
const runtimesDir = await ensureDirectory(join(dataRoot.canonicalPath, 'runtimes'), dataRoot);
return ensureDirectory(runtimeStateDir, runtimesDir);
})()
: await ensureDirectory(runtimeStateDir);
const mediaDir = await ensureDirectory(join(stateDir.canonicalPath, 'media'), stateDir);
const outboundDir = await ensureDirectory(join(mediaDir.canonicalPath, 'outbound'), mediaDir);
const stagingRoot = await ensureDirectory(join(outboundDir.canonicalPath, 'clawx-staging'), outboundDir);
const stagingArea = await ensureDirectory(join(stagingRoot.canonicalPath, stagingAreaName), stagingRoot);
return { stagingDir: stagingArea.canonicalPath, directories };
};
const getStagingArea = () => {
stagingAreaPromise ??= initializeStagingArea();
return stagingAreaPromise;
};
const verifyStagingArea = async (area: PinnedStagingArea) => {
const fsP = await import('node:fs/promises');
for (const directory of area.directories) {
const entryStat = await fsP.lstat(directory.lexicalPath);
if (entryStat.isSymbolicLink()) throw new Error('Invalid ClawX staging directory');
const currentPath = await fsP.realpath(directory.lexicalPath);
const currentStat = await fsP.stat(currentPath);
if (!currentStat.isDirectory()
|| !isSamePath(currentPath, directory.canonicalPath)
|| currentStat.dev !== directory.dev
|| currentStat.ino !== directory.ino) {
throw new Error('Invalid ClawX staging directory');
}
}
};
const cleanupOwnedDestination = async (destinationPath: string, identity?: { dev: number; ino: number }) => {
if (!identity) return;
const fsP = await import('node:fs/promises');
try {
const current = await fsP.stat(destinationPath);
if (current.dev === identity.dev && current.ino === identity.ino) {
await fsP.unlink(destinationPath);
}
} catch {
// The destination was already removed or redirected again.
}
};
const createStagedFile = async (
fileName: string,
write: (handle: FileHandle) => Promise<void>,
): Promise<{ path: string; stat: Stats }> => {
const fsP = await import('node:fs/promises');
const area = await getStagingArea();
await verifyStagingArea(area);
const destinationPath = join(area.stagingDir, fileName);
await dependencies.stagingHooks?.beforeDestinationOpen?.({
stagingDir: area.stagingDir,
destinationPath,
});
let handle: FileHandle | undefined;
let identity: { dev: number; ino: number } | undefined;
try {
const noFollow = process.platform === 'win32' ? 0 : constants.O_NOFOLLOW;
handle = await fsP.open(
destinationPath,
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow,
0o600,
);
const openedStat = await handle.stat();
identity = { dev: openedStat.dev, ino: openedStat.ino };
// Node has no openat. Validate the unpredictable empty destination before writing bytes.
await verifyStagingArea(area);
const canonicalDestination = await fsP.realpath(destinationPath);
const pathStat = await fsP.stat(canonicalDestination);
if (!isPathInside(canonicalDestination, area.stagingDir)
|| pathStat.dev !== openedStat.dev
|| pathStat.ino !== openedStat.ino) {
throw new Error('Invalid ClawX staging destination');
}
await write(handle);
const finalStat = await handle.stat();
await verifyStagingArea(area);
const finalPath = await fsP.realpath(destinationPath);
const finalPathStat = await fsP.stat(finalPath);
if (!isSamePath(finalPath, canonicalDestination)
|| finalPathStat.dev !== finalStat.dev
|| finalPathStat.ino !== finalStat.ino) {
throw new Error('Invalid ClawX staging destination');
}
await handle.close();
handle = undefined;
await verifyStagingArea(area);
const registrationPath = await fsP.realpath(destinationPath);
const registrationStat = await fsP.stat(registrationPath);
if (!isSamePath(registrationPath, finalPath)
|| registrationStat.dev !== finalStat.dev
|| registrationStat.ino !== finalStat.ino) {
throw new Error('Invalid ClawX staging destination');
}
return { path: registrationPath, stat: finalStat };
} catch (error) {
await handle?.close().catch(() => undefined);
await cleanupOwnedDestination(destinationPath, identity);
throw error;
}
};
const copyIntoHandle = async (sourcePath: string, destination: FileHandle) => {
const fsP = await import('node:fs/promises');
const source = await fsP.open(sourcePath, constants.O_RDONLY);
try {
const buffer = Buffer.allocUnsafe(64 * 1024);
let position = 0;
while (true) {
const { bytesRead } = await source.read(buffer, 0, buffer.length, position);
if (bytesRead === 0) break;
await destination.write(buffer, 0, bytesRead, position);
position += bytesRead;
}
} finally {
await source.close();
}
};
return {
stagePaths: async (payload) => {
const body = isRecord(payload) ? payload as StagePathsPayload : {};
@@ -215,8 +621,6 @@ 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 results = [];
for (const filePath of filePaths) {
const id = crypto.randomUUID();
@@ -235,14 +639,13 @@ export function createFilesApi(): CompleteHostServiceRegistry['files'] {
}
const ext = extname(filePath);
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
await fsP.copyFile(filePath, stagedPath);
const s = await fsP.stat(stagedPath);
const mimeType = getMimeType(ext);
const preview = mimeType.startsWith('image/')
? await generateImagePreview(stagedPath, mimeType)
? await generateImagePreview(filePath, mimeType)
: null;
results.push({ id, fileName, mimeType, fileSize: s.size, stagedPath, preview });
const staged = await createStagedFile(`${id}${ext}`, (handle) => copyIntoHandle(filePath, handle));
dependencies.stagedAttachments?.register(id, staged.path, filePath);
results.push({ id, fileName, mimeType, fileSize: staged.stat.size, stagedPath: staged.path, preview });
}
return results;
},
@@ -251,32 +654,216 @@ export function createFilesApi(): CompleteHostServiceRegistry['files'] {
if (typeof body.base64 !== 'string' || typeof body.fileName !== 'string') {
throw new Error('Invalid staged buffer payload');
}
const fsP = await import('node:fs/promises');
await fsP.mkdir(OUTBOUND_DIR, { 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 buffer = Buffer.from(body.base64, 'base64');
await fsP.writeFile(stagedPath, buffer);
const mimeType = payloadMimeType || getMimeType(ext);
const preview = mimeType.startsWith('image/')
? await generateImagePreview(stagedPath, mimeType)
? generateImageBufferPreview(buffer, mimeType)
: null;
const staged = await createStagedFile(`${id}${ext}`, async (handle) => {
await handle.writeFile(buffer);
});
dependencies.stagedAttachments?.register(id, staged.path);
return {
id,
fileName: body.fileName,
mimeType,
fileSize: buffer.length,
stagedPath,
stagedPath: staged.path,
preview,
};
},
resolveWorkspaceContext: async (input) => {
if (!input || typeof input.workspaceRoot !== 'string' || !input.workspaceRoot.trim()
|| typeof input.executionCwd !== 'string' || !input.executionCwd.trim()) {
return { ok: false, error: 'outsideSandbox' };
}
const fsP = await getWorkspaceFs();
try {
const [workspaceRoot, executionCwd] = await Promise.all([
fsP.realpath(expandPath(input.workspaceRoot)),
fsP.realpath(expandPath(input.executionCwd)),
]);
const [rootStat, cwdStat] = await Promise.all([
fsP.stat(workspaceRoot),
fsP.stat(executionCwd),
]);
if (!rootStat.isDirectory() || !cwdStat.isDirectory()) {
return { ok: false, error: 'notDirectory' };
}
if (!isPathInside(executionCwd, workspaceRoot)) {
return { ok: false, error: 'outsideSandbox' };
}
return { ok: true, workspaceRoot, executionCwd };
} catch (error) {
return { ok: false, error: workspaceError(error) };
}
},
readWorkspaceText: async (ref) => {
let opened: OpenWorkspaceTarget | undefined;
try {
opened = await openWorkspaceTarget(ref, await getWorkspaceFs());
const { stat, target } = opened;
if (!stat.isFile()) return { ok: false, error: 'notFound' };
if (stat.size > FILE_PREVIEW_MAX_TEXT_BYTES) {
return { ok: false, error: 'tooLarge', size: stat.size };
}
const buf = await readOpenedFile(opened.handle, FILE_PREVIEW_MAX_TEXT_BYTES);
if (!buf) return { ok: false, error: 'tooLarge', size: FILE_PREVIEW_MAX_TEXT_BYTES + 1 };
if (looksLikeBinary(buf)) return { ok: false, error: 'binary', size: buf.length };
return {
ok: true,
content: buf.toString('utf8'),
mimeType: getMimeType(extname(target)),
size: buf.length,
readOnly: true,
};
} catch (error) {
return { ok: false, error: workspaceError(error) };
} finally {
await opened?.handle.close().catch(() => undefined);
}
},
readWorkspaceBinary: async (input) => {
let opened: OpenWorkspaceTarget | undefined;
try {
opened = await openWorkspaceTarget(input, await getWorkspaceFs());
const { stat, target } = opened;
if (!stat.isFile()) return { ok: false, error: 'notFound' };
const cap = getWorkspaceBinaryCap(input.maxBytes);
if (stat.size > cap) return { ok: false, error: 'tooLarge', size: stat.size };
const buf = await readOpenedFile(opened.handle, cap);
if (!buf) return { ok: false, error: 'tooLarge', size: cap + 1 };
return {
ok: true,
data: new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength),
mimeType: getMimeType(extname(target)),
size: buf.length,
readOnly: true,
};
} catch (error) {
return { ok: false, error: workspaceError(error) };
} finally {
await opened?.handle.close().catch(() => undefined);
}
},
statWorkspaceFile: async (ref) => {
let opened: OpenWorkspaceTarget | undefined;
try {
opened = await openWorkspaceTarget(ref, await getWorkspaceFs());
const { stat } = opened;
return {
ok: true,
size: stat.size,
mtime: stat.mtimeMs,
isFile: stat.isFile(),
isDir: stat.isDirectory(),
readOnly: true,
};
} catch (error) {
return { ok: false, error: workspaceError(error) };
} finally {
await opened?.handle.close().catch(() => undefined);
}
},
listWorkspaceOpenHandlers: async (ref) => {
try {
const openWith = dependencies.openWith;
if (!openWith) throw new Error('operationFailed');
const target = await resolveWorkspaceRegularFile(ref, await getWorkspaceFs());
if (openWith.platform === 'linux') {
return { ok: true, platform: 'linux', handlers: [] };
}
const handlers = await openWith.list(target);
return {
ok: true,
platform: openWith.platform,
handlers: handlers.map(({ id, name, iconDataUrl, isDefault }) => ({
handlerId: id,
name,
...(iconDataUrl ? { iconDataUrl } : {}),
isDefault,
})),
};
} catch (error) {
return { ok: false, error: workspaceNativeError(error) };
}
},
openWorkspaceWith: async (payload) => {
try {
const openWith = dependencies.openWith;
if (!openWith) throw new Error('operationFailed');
const fsP = await getWorkspaceFs();
const target = await resolveWorkspaceRegularFile(payload?.ref, fsP);
if (typeof payload?.handlerId !== 'string'
|| !payload.handlerId.trim()
|| payload.handlerId.length > HANDLER_ID_MAX_LENGTH) {
throw new Error('operationFailed');
}
if (openWith.platform === 'linux') {
return { ok: false, error: 'unsupportedPlatform' };
}
await openWith.open(
target,
payload.handlerId,
() => resolveWorkspaceRegularFile(payload.ref, fsP),
);
return { ok: true };
} catch (error) {
return { ok: false, error: workspaceNativeError(error) };
}
},
revealWorkspaceFile: async (ref) => {
try {
const fsP = await getWorkspaceFs();
await resolveWorkspaceRegularFile(ref, fsP);
const target = await resolveWorkspaceRegularFile(ref, fsP);
shell.showItemInFolder(target);
return { ok: true };
} catch (error) {
return { ok: false, error: workspaceNativeError(error) };
}
},
resolveAttachment: async (payload) => dependencies.attachmentAccess?.resolveAttachment(payload) ?? {
ok: false,
displayName: 'attachment',
error: 'operationFailed',
},
readAttachmentText: async (ref) => dependencies.attachmentAccess?.readAttachmentText(ref) ?? {
ok: false,
error: 'operationFailed',
},
readAttachmentBinary: async (payload) => dependencies.attachmentAccess?.readAttachmentBinary(payload) ?? {
ok: false,
error: 'operationFailed',
},
openAttachment: async (ref) => dependencies.attachmentAccess?.openAttachment(ref) ?? {
ok: false,
error: 'operationFailed',
},
listAttachmentOpenHandlers: async (ref) => dependencies.attachmentAccess
?.listAttachmentOpenHandlers(ref) ?? {
ok: false,
error: 'operationFailed',
},
openAttachmentWith: async (payload) => dependencies.attachmentAccess?.openAttachmentWith(payload) ?? {
ok: false,
error: 'operationFailed',
},
revealAttachment: async (ref) => dependencies.attachmentAccess?.revealAttachment(ref) ?? {
ok: false,
error: 'operationFailed',
},
readText: async (payload) => {
try {
const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read');
const { realPath: real, readOnly } = await resolveSandboxedPath(
requirePath(payload),
'read',
dependencies.runtimeManager,
);
const fsP = await import('node:fs/promises');
const stat = await fsP.stat(real);
if (!stat.isFile()) return { ok: false, error: 'notFound' };
@@ -301,7 +888,11 @@ 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',
dependencies.runtimeManager,
);
const fsP = await import('node:fs/promises');
const stat = await fsP.stat(real);
if (!stat.isFile()) return { ok: false, error: 'notFound' };
@@ -331,7 +922,11 @@ 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',
dependencies.runtimeManager,
);
const fsP = await import('node:fs/promises');
let stat;
try {
@@ -351,7 +946,11 @@ 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',
dependencies.runtimeManager,
);
const fsP = await import('node:fs/promises');
const stat = await fsP.stat(real);
return {
@@ -371,7 +970,11 @@ 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',
dependencies.runtimeManager,
);
const fsP = await import('node:fs/promises');
const dirents = await fsP.readdir(real, { withFileTypes: true });
const entries = await Promise.all(dirents.map(async (entry) => {
@@ -401,7 +1004,11 @@ 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',
dependencies.runtimeManager,
);
const fsP = await import('node:fs/promises');
const stat = await fsP.stat(real);
if (!stat.isDirectory()) return { ok: false, error: 'notDirectory' };
+19 -17
View File
@@ -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),
);
},
};
+98 -23
View File
@@ -2,6 +2,11 @@ import { dialog, nativeImage } from 'electron';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { RuntimeManager } from '../runtime/manager';
import type { AttachmentFileRef } from '@shared/host-api/contract';
import { resolveOutgoingMediaAttachment, type AttachmentAccess } from './attachment-access';
import { resolveOpenClawStateDir } from '../utils/paths';
import { getRuntimeOutgoingMediaRecordDirs } from '../utils/runtime-media-paths';
import {
CLAWX_OPENAI_IMAGE_DEFAULT_MODEL,
CLAWX_OPENAI_IMAGE_PROVIDER_KEY,
@@ -19,9 +24,18 @@ import { isRecord } from './payload-utils';
type ThumbnailEntry = {
filePath?: unknown;
gatewayUrl?: unknown;
attachmentFileRef?: unknown;
key?: unknown;
mimeType?: unknown;
};
type MediaApiDependencies = {
runtimeManager?: Pick<RuntimeManager, 'getStatus'>;
attachmentAccess?: Pick<AttachmentAccess, 'resolveAttachment' | 'readAttachmentBinary'>;
};
const OPAQUE_ATTACHMENT_KEY = /^[a-f0-9]{64}$/;
type SaveImagePayload = {
base64?: unknown;
mimeType?: unknown;
@@ -62,28 +76,22 @@ async function generateImagePreview(filePath: string, mimeType: string): Promise
}
}
async function resolveOutgoingMediaUrl(
gatewayUrl: string,
): Promise<{ path: string; mimeType: string } | null> {
function generateImagePreviewFromBuffer(buffer: Buffer, mimeType: string): 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');
const record = JSON.parse(raw) as {
original?: { path?: string; contentType?: string };
};
const original = record?.original;
if (!original?.path) return null;
return {
path: original.path,
mimeType: typeof original.contentType === 'string' && original.contentType
? original.contentType
: 'application/octet-stream',
};
if (mimeType === 'image/svg+xml') {
return `data:${mimeType};base64,${buffer.toString('base64')}`;
}
const img = nativeImage.createFromBuffer(buffer);
if (img.isEmpty()) return null;
const size = img.getSize();
const maxDim = 512;
if (size.width > maxDim || size.height > maxDim) {
const resized = size.width >= size.height
? img.resize({ width: maxDim })
: img.resize({ height: maxDim });
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
}
return `data:${mimeType};base64,${buffer.toString('base64')}`;
} catch {
return null;
}
@@ -94,7 +102,38 @@ function normalizeThumbnailEntries(payload: unknown): ThumbnailEntry[] {
return Array.isArray(value) ? value as ThumbnailEntry[] : [];
}
export function createMediaApi(): CompleteHostServiceRegistry['media'] {
async function resolveRuntimeOutgoingMediaUrl(
gatewayUrl: string,
runtimeManager?: Pick<RuntimeManager, 'getStatus'>,
): Promise<{ path: string; mimeType: string } | null> {
try {
const match = gatewayUrl.match(/\/api\/chat\/media\/outgoing\/[^/]+\/([^/]+)\//);
if (!match) return null;
const attachmentId = decodeURIComponent(match[1]);
if (!/^[A-Za-z0-9._-]+$/.test(attachmentId)) return null;
const fsP = await import('node:fs/promises');
for (const recordDir of getRuntimeOutgoingMediaRecordDirs(runtimeManager)) {
try {
const raw = await fsP.readFile(join(recordDir, `${attachmentId}.json`), 'utf8');
const record = JSON.parse(raw) as {
original?: { path?: string; contentType?: string };
};
if (!record.original?.path) continue;
return {
path: record.original.path,
mimeType: record.original.contentType || 'application/octet-stream',
};
} catch {
// Continue across current and historical runtime media roots.
}
}
} catch {
// Treat malformed or unavailable runtime media records as missing.
}
return null;
}
export function createMediaApi(dependencies: MediaApiDependencies = {}): CompleteHostServiceRegistry['media'] {
return {
thumbnails: async (payload) => {
const entries = normalizeThumbnailEntries(payload);
@@ -102,6 +141,39 @@ export function createMediaApi(): CompleteHostServiceRegistry['media'] {
const results: Record<string, { preview: string | null; fileSize: number }> = {};
for (const entry of entries) {
const mimeType = typeof entry.mimeType === 'string' ? entry.mimeType : 'application/octet-stream';
if (entry.attachmentFileRef && typeof entry.attachmentFileRef === 'object') {
const key = typeof entry.key === 'string' && entry.key ? entry.key : null;
if (!key || !OPAQUE_ATTACHMENT_KEY.test(key) || !dependencies.attachmentAccess) continue;
const ref = entry.attachmentFileRef as AttachmentFileRef;
const resolution = await dependencies.attachmentAccess.resolveAttachment({ ref });
if (!resolution.ok
|| resolution.identity !== key
|| resolution.target.kind !== 'local') {
continue;
}
const readResult = await dependencies.attachmentAccess.readAttachmentBinary({
ref,
});
if (!readResult.ok) {
results[key] = { preview: null, fileSize: 0 };
continue;
}
const effectiveMimeType = mimeType === 'application/octet-stream'
? readResult.mimeType
: mimeType;
const buffer = Buffer.from(
readResult.data.buffer,
readResult.data.byteOffset,
readResult.data.byteLength,
);
results[key] = {
preview: effectiveMimeType.startsWith('image/')
? generateImagePreviewFromBuffer(buffer, effectiveMimeType)
: null,
fileSize: readResult.size,
};
continue;
}
if (typeof entry.filePath === 'string' && entry.filePath) {
try {
const stat = await fsP.stat(entry.filePath);
@@ -116,7 +188,10 @@ export function createMediaApi(): CompleteHostServiceRegistry['media'] {
}
if (typeof entry.gatewayUrl === 'string' && entry.gatewayUrl) {
const resolved = await resolveOutgoingMediaUrl(entry.gatewayUrl);
const resolved = await resolveOutgoingMediaAttachment({
uri: entry.gatewayUrl,
stateDir: resolveOpenClawStateDir(),
}) ?? await resolveRuntimeOutgoingMediaUrl(entry.gatewayUrl, dependencies.runtimeManager);
if (!resolved) {
results[entry.gatewayUrl] = { preview: null, fileSize: 0 };
continue;
+225 -39
View File
@@ -2,6 +2,7 @@ import type { BrowserWindow } from 'electron';
import type { HostApiContract } from '@shared/host-api/contract';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { GatewayManager } from '../gateway/manager';
import type { RuntimeManager } from '../runtime/manager';
import type { ProviderConfig } from '../utils/secure-storage';
import { browserOAuthManager, type BrowserOAuthProviderType } from '../utils/browser-oauth';
import { deviceOAuthManager, type OAuthProviderType } from '../utils/device-oauth';
@@ -22,9 +23,15 @@ import {
import { validateApiKeyWithProvider } from './providers/provider-validation';
import type { ProviderAccount } from '../shared/providers/types';
import { isRecord } from './payload-utils';
import {
getCcConnectCodexOAuthStatus,
importUserCodexOAuthToManagedHome,
logoutCcConnectCodexOAuth,
} from '../runtime/cc-connect-provider-profile';
type ProvidersApiContext = {
gatewayManager: GatewayManager;
runtimeManager?: RuntimeManager;
mainWindow: BrowserWindow;
};
@@ -46,6 +53,21 @@ function hasObjectChanges<T extends Record<string, unknown>>(
return keys.some((key) => JSON.stringify(existing[key]) !== JSON.stringify(patch[key]));
}
function selectReplacementDefaultAccount(
accounts: ProviderAccount[],
deletedAccountId: string,
): ProviderAccount | undefined {
return accounts
.filter((account) => account.id !== deletedAccountId)
.sort((left, right) => {
if (left.enabled !== right.enabled) {
return left.enabled ? -1 : 1;
}
const updatedAtOrder = right.updatedAt.localeCompare(left.updatedAt);
return updatedAtOrder !== 0 ? updatedAtOrder : left.id.localeCompare(right.id);
})[0];
}
function payloadString(payload: unknown, key: string): string | undefined {
if (typeof payload === 'string') return payload;
if (!isRecord(payload)) return undefined;
@@ -135,6 +157,96 @@ function getSavePayload(payload: unknown): { config: ProviderConfig; apiKey?: st
};
}
async function syncActiveRuntimeProviderProfile(
ctx: Pick<ProvidersApiContext, 'runtimeManager'>,
payload: { providerId?: string; reason: string },
): Promise<boolean> {
const provider = ctx.runtimeManager?.getActiveProvider();
if (!provider?.syncProviderProfile) return false;
await provider.syncProviderProfile(payload);
return true;
}
async function syncProviderApiKeyToActiveRuntime(
providerType: string,
providerId: string,
apiKey: string,
ctx: Pick<ProvidersApiContext, 'runtimeManager'>,
): Promise<void> {
if (await syncActiveRuntimeProviderProfile(ctx, { providerId, reason: 'api-key' })) {
return;
}
await syncProviderApiKeyToRuntime(providerType, providerId, apiKey);
}
async function syncSavedProviderToActiveRuntime(
config: ProviderConfig,
apiKey: string | undefined,
ctx: Pick<ProvidersApiContext, 'gatewayManager' | 'runtimeManager'>,
): Promise<void> {
if (await syncActiveRuntimeProviderProfile(ctx, { providerId: config.id, reason: 'save' })) {
return;
}
await syncSavedProviderToRuntime(config, apiKey, ctx.gatewayManager);
}
async function syncUpdatedProviderToActiveRuntime(
config: ProviderConfig,
apiKey: string | undefined,
ctx: Pick<ProvidersApiContext, 'gatewayManager' | 'runtimeManager'>,
reason = 'update',
): Promise<void> {
if (await syncActiveRuntimeProviderProfile(ctx, { providerId: config.id, reason })) {
return;
}
await syncUpdatedProviderToRuntime(config, apiKey, ctx.gatewayManager);
}
async function syncDeletedProviderToActiveRuntime(
provider: ProviderConfig | null,
providerId: string,
ctx: Pick<ProvidersApiContext, 'gatewayManager' | 'runtimeManager'>,
runtimeProviderKey?: string,
): Promise<void> {
if (await syncActiveRuntimeProviderProfile(ctx, { providerId, reason: 'delete' })) {
return;
}
await syncDeletedProviderToRuntime(provider, providerId, ctx.gatewayManager, runtimeProviderKey);
}
async function syncDeletedProviderApiKeyToActiveRuntime(
provider: ProviderConfig | null,
providerId: string,
ctx: Pick<ProvidersApiContext, 'runtimeManager'>,
runtimeProviderKey?: string,
): Promise<void> {
if (await syncActiveRuntimeProviderProfile(ctx, { providerId, reason: 'delete-api-key' })) {
return;
}
await syncDeletedProviderApiKeyToRuntime(provider, providerId, runtimeProviderKey);
}
async function syncDefaultProviderToActiveRuntime(
providerId: string,
ctx: Pick<ProvidersApiContext, 'gatewayManager' | 'runtimeManager'>,
): Promise<void> {
if (await syncActiveRuntimeProviderProfile(ctx, { providerId, reason: 'set-default' })) {
return;
}
await syncDefaultProviderToRuntime(providerId, ctx.gatewayManager);
}
async function removeProviderFromActiveRuntime(
providerKey: string,
ctx: Pick<ProvidersApiContext, 'runtimeManager'>,
providerId: string,
): Promise<void> {
if (await syncActiveRuntimeProviderProfile(ctx, { providerId, reason: 'remove-provider' })) {
return;
}
await removeProviderFromOpenClaw(providerKey);
}
async function validateKey(payload: ProviderPayload<'validateKey'>): Promise<{ valid: boolean; error?: string }> {
try {
const body = getPayloadRecord(payload, 'validateKey');
@@ -174,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 {
@@ -183,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);
@@ -244,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);
@@ -270,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)) {
@@ -304,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() : '';
@@ -330,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) };
@@ -339,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');
@@ -351,22 +465,32 @@ async function deleteAccount(
try {
const existing = await providerService.getAccount(accountId);
const runtimeProviderKey = existing?.authMode === 'oauth_browser' && existing.vendorId === 'openai'
? 'openai-codex'
? 'openai'
: undefined;
if (apiKeyOnly) {
await providerService._deleteProviderApiKeyInternal(accountId);
await syncDeletedProviderApiKeyToRuntime(
await syncDeletedProviderApiKeyToActiveRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
ctx,
runtimeProviderKey,
);
return { success: true };
}
const currentDefaultAccountId = await providerService.getDefaultAccountId();
const replacementDefault = currentDefaultAccountId === accountId
? selectReplacementDefaultAccount(await providerService.listAccounts(), accountId)
: undefined;
await providerService.deleteAccount(accountId);
await syncDeletedProviderToRuntime(
if (replacementDefault) {
await providerService.setDefaultAccount(replacementDefault.id);
await syncDefaultProviderToActiveRuntime(replacementDefault.id, ctx);
}
await syncDeletedProviderToActiveRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
gatewayManager,
ctx,
runtimeProviderKey,
);
return { success: true };
@@ -375,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 {
@@ -384,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) };
@@ -440,10 +564,69 @@ async function submitOAuth(payload: ProviderPayload<'submitOAuth'>) {
}
}
async function codexOAuthStatus(payload?: ProviderPayload<'codexOAuthStatus'>) {
try {
const accountId = payloadString(payload, 'accountId');
return await getCcConnectCodexOAuthStatus({ accountId });
} catch (error) {
logger.error('providers.codexOAuthStatus failed', error);
return { success: false, error: String(error) };
}
}
async function importCodexOAuth(
payload: ProviderPayload<'importCodexOAuth'> | undefined,
ctx: Pick<ProvidersApiContext, 'runtimeManager'>,
) {
try {
const accountId = payloadString(payload, 'accountId');
const result = await importUserCodexOAuthToManagedHome({ accountId });
await syncActiveRuntimeProviderProfile(ctx, {
providerId: result.provider?.accountId ?? accountId,
reason: 'codex-oauth-import',
});
return result;
} catch (error) {
logger.error('providers.importCodexOAuth failed', error);
return { success: false, error: String(error) };
}
}
async function logoutCodexOAuth(
payload: ProviderPayload<'logoutCodexOAuth'> | undefined,
ctx: Pick<ProvidersApiContext, 'runtimeManager'>,
) {
try {
const accountId = payloadString(payload, 'accountId');
const managedOnly = isRecord(payload) && payload.managedOnly === true;
const result = await logoutCcConnectCodexOAuth({ accountId, managedOnly });
await syncActiveRuntimeProviderProfile(ctx, {
providerId: result.provider?.accountId ?? accountId,
reason: 'codex-oauth-logout',
});
return result;
} catch (error) {
logger.error('providers.logoutCodexOAuth failed', error);
return { success: false, error: String(error) };
}
}
export function createProvidersApi(ctx: ProvidersApiContext): CompleteHostServiceRegistry['providers'] {
const providerService = getProviderService();
deviceOAuthManager.setWindow(ctx.mainWindow);
browserOAuthManager.setWindow(ctx.mainWindow);
browserOAuthManager.setSuccessHandler(async ({ accountId }) => {
const account = await providerService.getAccount(accountId);
if (!account) {
throw new Error(`Provider account not found after OAuth success: ${accountId}`);
}
await syncUpdatedProviderToActiveRuntime(
providerAccountToConfig(account),
undefined,
ctx,
'oauth',
);
});
return {
list: async () => providerService._listProvidersWithKeyInfoInternal(),
@@ -452,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(),
@@ -465,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),
};
}
@@ -7,16 +7,19 @@ import { getProviderConfig, getProviderDefaultModel } from '../../utils/provider
import {
ensureAnthropicMessagesModelMaxTokens,
ensureOpenClawProviderAgentRuntimePins,
migrateAllAgentAuthProfilesToSqlite,
pruneInvalidApiProviderEntries,
removeProviderFromOpenClaw,
removeProviderKeyFromOpenClaw,
saveOAuthTokenToOpenClaw,
saveProviderKeyToOpenClaw,
OPENAI_CODEX_OAUTH_PROVIDER_CONFIG,
setOpenClawDefaultModel,
setOpenClawDefaultModelWithOverride,
syncProviderConfigToOpenClaw,
updateAgentModelProvider,
updateSingleAgentModelProvider,
getProviderApiKeyFromOpenClaw,
} from '../../utils/openclaw-auth';
import {
piAiModelsJsonModelEntry,
@@ -25,7 +28,8 @@ import {
import { logger } from '../../utils/logger';
import { listAgentsSnapshot } from '../../utils/agent-config';
const OPENAI_OAUTH_RUNTIME_PROVIDER = 'openai-codex';
/** OpenClaw Codex OAuth hooks only apply to the canonical `openai` provider id. */
const OPENAI_OAUTH_RUNTIME_PROVIDER = 'openai';
const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.5`;
/**
@@ -94,6 +98,10 @@ export function getOpenClawProviderKey(type: string, providerId: string): string
if (type === 'minimax-portal-cn') {
return 'minimax-portal';
}
// OpenClaw Z.AI provider key is always `zai` (Global UI vendor aliases here).
if (type === 'zai-global') {
return 'zai';
}
return type;
}
@@ -210,6 +218,7 @@ export async function syncProviderApiKeyToRuntime(
}
export async function syncAllProviderAuthToRuntime(): Promise<void> {
await migrateAllAgentAuthProfilesToSqlite();
const accounts = await listProviderAccounts();
for (const account of accounts) {
const runtimeProviderKey = await resolveRuntimeProviderKey({
@@ -312,7 +321,8 @@ async function syncRuntimeProviderConfig(
config: ProviderConfig,
context: RuntimeProviderSyncContext,
): Promise<void> {
await syncProviderConfigToOpenClaw(context.runtimeProviderKey, config.model, {
const modelId = normalizeRuntimeModelId(context.runtimeProviderKey, config.model);
await syncProviderConfigToOpenClaw(context.runtimeProviderKey, modelId, {
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl, context.api),
api: context.api,
apiKeyEnv: context.meta?.apiKeyEnv,
@@ -334,7 +344,7 @@ async function syncCustomProviderAgentModel(
return;
}
const modelId = config.model;
const modelId = normalizeRuntimeModelId(runtimeProviderKey, config.model);
await updateAgentModelProvider(runtimeProviderKey, {
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl, config.apiProtocol || 'openai-completions'),
api: config.apiProtocol || 'openai-completions',
@@ -374,6 +384,26 @@ async function removeDeletedProviderFromOpenClaw(
for (const key of keys) {
await removeProviderFromOpenClaw(key);
}
// Legacy Codex OAuth used runtime key openai-codex; cleanup may leave a bare
// models.providers.openai entry behind. Drop that slot when no API key credentials remain.
if (runtimeProviderKey === OPENAI_OAUTH_RUNTIME_PROVIDER || runtimeProviderKey === 'openai-codex') {
const openClawKey = await getProviderApiKeyFromOpenClaw('openai');
if (openClawKey) {
return;
}
const storeAccounts = await listProviderAccounts();
for (const account of storeAccounts) {
if (account.vendorId !== 'openai' || account.authMode === 'oauth_browser') {
continue;
}
const apiKey = await getApiKey(account.id);
if (apiKey) {
return;
}
}
await removeProviderFromOpenClaw('openai');
}
}
function parseModelRef(modelRef: string): { providerKey: string; modelId: string } | null {
@@ -389,6 +419,16 @@ function parseModelRef(modelRef: string): { providerKey: string; modelId: string
};
}
function normalizeRuntimeModelId(
runtimeProviderKey: string,
modelId: string | undefined,
): string | undefined {
const value = modelId?.trim();
if (!value) return undefined;
const prefix = `${runtimeProviderKey}/`;
return value.startsWith(prefix) ? value.slice(prefix.length) : value;
}
async function buildRuntimeProviderConfigMap(): Promise<Map<string, ProviderConfig>> {
const configs = await getAllProviders();
const runtimeMap = new Map<string, ProviderConfig>();
@@ -517,7 +557,8 @@ export async function syncUpdatedProviderToRuntime(
const defaultProviderId = await getDefaultProvider();
const isDefaultProvider = defaultProviderId === config.id;
if (isDefaultProvider) {
const modelOverride = config.model ? `${ock}/${config.model}` : undefined;
const selectedModelId = normalizeRuntimeModelId(ock, config.model);
const modelOverride = selectedModelId ? `${ock}/${selectedModelId}` : undefined;
if (!isUnregisteredProviderType(config.type)) {
if (shouldUseExplicitDefaultOverride(config, ock)) {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
@@ -680,17 +721,26 @@ export async function syncDefaultProviderToRuntime(
expires: secret.expiresAt,
email: secret.email,
projectId: secret.subject,
accountId: secret.subject,
});
}
const defaultModelRef = OPENAI_OAUTH_DEFAULT_MODEL_REF;
const modelOverride = provider.model
? (provider.model.startsWith(`${browserOAuthRuntimeProvider}/`)
? provider.model
? provider.model.replace(/^openai-codex\//, `${browserOAuthRuntimeProvider}/`)
: `${browserOAuthRuntimeProvider}/${provider.model}`)
: defaultModelRef;
await setOpenClawDefaultModel(browserOAuthRuntimeProvider, modelOverride, fallbackModels);
await setOpenClawDefaultModelWithOverride(
browserOAuthRuntimeProvider,
modelOverride,
{
baseUrl: OPENAI_CODEX_OAUTH_PROVIDER_CONFIG.baseUrl,
api: OPENAI_CODEX_OAUTH_PROVIDER_CONFIG.api,
},
fallbackModels.map((fallback) => fallback.replace(/^openai-codex\//, `${browserOAuthRuntimeProvider}/`)),
);
logger.info(`Configured openclaw.json for browser OAuth provider "${provider.id}"`);
try {
await syncAgentModelsToRuntime();
+92 -13
View File
@@ -73,9 +73,38 @@ function inferProviderVendorIdFromOpenClawEntry(
}
}
// OpenClaw stores a single `zai` key; pick CN vs Global UI vendor from baseUrl.
if (key === 'zai') {
const baseUrl = typeof entry.baseUrl === 'string' ? entry.baseUrl.toLowerCase() : '';
if (baseUrl.includes('api.z.ai')) {
return 'zai-global';
}
return 'zai';
}
return ((BUILTIN_PROVIDER_TYPES as readonly string[]).includes(key) ? key : 'custom') as ProviderType | 'custom';
}
function providerMetadataEquals(
left: ProviderAccount['metadata'] | undefined,
right: ProviderAccount['metadata'] | undefined,
): boolean {
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
}
function mergeSyncedProviderMetadata(
existing: ProviderAccount['metadata'] | undefined,
synced: ProviderAccount['metadata'] | undefined,
): ProviderAccount['metadata'] | undefined {
const next = { ...(existing ?? {}) };
if (synced?.customModels && synced.customModels.length > 0) {
next.customModels = synced.customModels;
} else {
delete next.customModels;
}
return Object.keys(next).length > 0 ? next : undefined;
}
export class ProviderService {
async listVendors(): Promise<ProviderDefinition[]> {
return PROVIDER_DEFINITIONS;
@@ -112,15 +141,19 @@ export class ProviderService {
let hasConfiguredOpenAiApiKey = false;
if (activeProviders.has('openai')) {
for (const account of storeByKey.get('openai') ?? []) {
if (account.authMode === 'oauth_browser') {
continue;
}
const apiKey = await getApiKey(account.id);
const openClawKey = await getProviderApiKeyFromOpenClaw('openai');
if (apiKey || openClawKey) {
hasConfiguredOpenAiApiKey = true;
break;
const openClawKey = await getProviderApiKeyFromOpenClaw('openai');
if (openClawKey) {
hasConfiguredOpenAiApiKey = true;
} else {
for (const account of storeByKey.get('openai') ?? []) {
if (account.authMode === 'oauth_browser') {
continue;
}
const apiKey = await getApiKey(account.id);
if (apiKey) {
hasConfiguredOpenAiApiKey = true;
break;
}
}
}
}
@@ -143,10 +176,9 @@ export class ProviderService {
const aliasAccounts = storeGroup.filter((a) => a.vendorId !== key);
const candidates = aliasAccounts.length > 0 ? aliasAccounts : storeGroup;
candidates.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
result.push(candidates[0]);
// Clean up orphaned duplicates from the store.
const kept = candidates[0];
let kept = candidates[0];
for (const account of storeGroup) {
if (account.id !== kept.id) {
logger.info(
@@ -155,6 +187,34 @@ export class ProviderService {
await deleteProviderAccount(account.id);
}
}
const entry = openClawProviders[key];
if (entry) {
const [syncedAccount] = ProviderService.buildAccountsFromOpenClawEntries(
{ [key]: entry },
new Set(),
new Set(),
defaultModel,
);
if (syncedAccount) {
const nextMetadata = mergeSyncedProviderMetadata(kept.metadata, syncedAccount.metadata);
const shouldSyncSelectedModel = defaultModel?.startsWith(`${key}/`) ?? false;
const nextModel = shouldSyncSelectedModel ? syncedAccount.model : kept.model;
const shouldSyncModelState = kept.model !== nextModel
|| !providerMetadataEquals(kept.metadata, nextMetadata);
if (shouldSyncModelState) {
kept = {
...kept,
model: nextModel,
metadata: nextMetadata,
updatedAt: new Date().toISOString(),
};
await saveProviderAccount(kept);
}
}
}
result.push(kept);
} else {
// No store account for this key — create a seed from openclaw.json.
const entry = openClawProviders[key];
@@ -174,7 +234,7 @@ export class ProviderService {
}
}
if (activeProviders.has(OPENAI_CODEX_RUNTIME_PROVIDER_KEY)) {
if (activeProviders.has(OPENAI_CODEX_RUNTIME_PROVIDER_KEY) || !hasConfiguredOpenAiApiKey) {
const openaiStoreAccounts = storeByKey.get('openai') ?? [];
for (const account of openaiStoreAccounts) {
if (account.authMode !== 'api_key' && account.authMode !== undefined) {
@@ -184,9 +244,16 @@ export class ProviderService {
const openClawKey = await getProviderApiKeyFromOpenClaw('openai');
if (!apiKey && !openClawKey) {
logger.info(
`[provider-sync] Removing unconfigured OpenAI API key account "${account.id}" (OAuth uses ${OPENAI_CODEX_RUNTIME_PROVIDER_KEY})`,
`[provider-sync] Removing unconfigured OpenAI API key account "${account.id}"`
+ (activeProviders.has(OPENAI_CODEX_RUNTIME_PROVIDER_KEY)
? ` (OAuth uses ${OPENAI_CODEX_RUNTIME_PROVIDER_KEY})`
: ' (Codex OAuth removed)'),
);
await deleteProviderAccount(account.id);
const resultIndex = result.findIndex((entry) => entry.id === account.id);
if (resultIndex >= 0) {
result.splice(resultIndex, 1);
}
}
}
}
@@ -231,6 +298,15 @@ export class ProviderService {
}
const baseUrl = typeof entry.baseUrl === 'string' ? entry.baseUrl : definition?.providerConfig?.baseUrl;
const customModels = Array.isArray(entry.models)
? Array.from(new Set(entry.models
.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return '';
const raw = (item as Record<string, unknown>).id;
return typeof raw === 'string' ? raw.trim() : '';
})
.filter(Boolean)))
: undefined;
// Infer model from the default model if it belongs to this provider
let model: string | undefined;
@@ -251,6 +327,9 @@ export class ProviderService {
? (entry.headers as Record<string, string>)
: undefined),
model,
metadata: customModels && customModels.length > 0
? { customModels }
: undefined,
enabled: true,
isDefault: false,
createdAt: now,
@@ -1,3 +1,6 @@
import { app } from 'electron';
import { getClawXDataLayout, resolveClawXDataRoot } from '../../utils/clawx-data-layout';
// Lazy-load electron-store (ESM module) from the main process only.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let providerStore: any = null;
@@ -7,6 +10,7 @@ export async function getClawXProviderStore() {
const Store = (await import('electron-store')).default;
providerStore = new Store({
name: 'clawx-providers',
cwd: getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData'))).appDir,
defaults: {
schemaVersion: 0,
providers: {} as Record<string, unknown>,
@@ -0,0 +1,177 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from 'node:crypto';
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { app, safeStorage } from 'electron';
import type { ProviderSecret } from '../../shared/providers/types';
import { getClawXDataLayout, resolveClawXDataRoot } from '../../utils/clawx-data-layout';
const VAULT_SCHEMA = 'clawx-credential-vault';
const VAULT_VERSION = 1;
let credentialMutationQueue: Promise<void> = Promise.resolve();
type CredentialVaultDocument = {
schema: typeof VAULT_SCHEMA;
version: typeof VAULT_VERSION;
secrets: Record<string, ProviderSecret>;
channelSecrets: Record<string, Record<string, string>>;
};
export interface CredentialCipher {
isEncryptionAvailable(): boolean;
encryptString(value: string): Buffer;
decryptString(value: Buffer): string;
}
function credentialPaths() {
const layout = getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData')));
return {
vaultPath: join(layout.credentialsDir, 'secrets.enc'),
indexPath: join(layout.credentialsDir, 'index.json'),
};
}
function e2eCredentialCipher(secret: string): CredentialCipher {
const key = createHash('sha256').update(secret).digest();
return {
isEncryptionAvailable: () => true,
encryptString: (value) => {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
return Buffer.concat([iv, cipher.getAuthTag(), encrypted]);
},
decryptString: (value) => {
const iv = value.subarray(0, 12);
const authTag = value.subarray(12, 28);
const encrypted = value.subarray(28);
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
},
};
}
function defaultCredentialCipher(): CredentialCipher {
const e2eKey = process.env.CLAWX_E2E_CREDENTIAL_KEY?.trim();
if (process.env.CLAWX_E2E === '1' && e2eKey) return e2eCredentialCipher(e2eKey);
return safeStorage;
}
function emptyVault(): CredentialVaultDocument {
return { schema: VAULT_SCHEMA, version: VAULT_VERSION, secrets: {}, channelSecrets: {} };
}
async function writeAtomic(path: string, content: string | Buffer, mode = 0o600): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
await writeFile(temporaryPath, content, { mode });
await chmod(temporaryPath, mode).catch(() => {});
await rename(temporaryPath, path);
await chmod(path, mode).catch(() => {});
}
function serializeCredentialMutation<T>(mutation: () => Promise<T>): Promise<T> {
const result = credentialMutationQueue.then(mutation, mutation);
credentialMutationQueue = result.then(() => undefined, () => undefined);
return result;
}
export async function readCredentialVault(
cipher: CredentialCipher = defaultCredentialCipher(),
): Promise<CredentialVaultDocument> {
const { vaultPath } = credentialPaths();
let encrypted: Buffer;
try {
encrypted = await readFile(vaultPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return emptyVault();
throw error;
}
if (!cipher.isEncryptionAvailable()) {
throw new Error('OS credential encryption is unavailable; refusing to read ClawX provider secrets');
}
const parsed = JSON.parse(cipher.decryptString(encrypted)) as Partial<CredentialVaultDocument>;
if (parsed.schema !== VAULT_SCHEMA || parsed.version !== VAULT_VERSION || !parsed.secrets) {
throw new Error('Unsupported or invalid ClawX credential vault');
}
return {
...(parsed as CredentialVaultDocument),
channelSecrets: parsed.channelSecrets ?? {},
};
}
export async function writeCredentialVault(
document: CredentialVaultDocument,
cipher: CredentialCipher = defaultCredentialCipher(),
): Promise<void> {
if (!cipher.isEncryptionAvailable()) {
throw new Error('OS credential encryption is unavailable; refusing to persist provider secrets');
}
const { vaultPath, indexPath } = credentialPaths();
const encrypted = cipher.encryptString(JSON.stringify(document));
await writeAtomic(vaultPath, encrypted);
await writeAtomic(indexPath, `${JSON.stringify({
schema: 'clawx-credential-index',
version: 1,
accountIds: Object.keys(document.secrets).sort(),
channelCredentialIds: Object.keys(document.channelSecrets).sort(),
updatedAt: new Date().toISOString(),
}, null, 2)}\n`);
}
export async function getVaultSecret(
accountId: string,
cipher: CredentialCipher = defaultCredentialCipher(),
): Promise<ProviderSecret | null> {
return (await readCredentialVault(cipher)).secrets[accountId] ?? null;
}
export async function setVaultSecret(
secret: ProviderSecret,
cipher: CredentialCipher = defaultCredentialCipher(),
): Promise<void> {
await serializeCredentialMutation(async () => {
const document = await readCredentialVault(cipher);
document.secrets[secret.accountId] = secret;
await writeCredentialVault(document, cipher);
});
}
export async function deleteVaultSecret(
accountId: string,
cipher: CredentialCipher = defaultCredentialCipher(),
): Promise<void> {
await serializeCredentialMutation(async () => {
const document = await readCredentialVault(cipher);
if (!(accountId in document.secrets)) return;
delete document.secrets[accountId];
if (Object.keys(document.secrets).length === 0 && Object.keys(document.channelSecrets).length === 0) {
const { vaultPath, indexPath } = credentialPaths();
await Promise.all([rm(vaultPath, { force: true }), rm(indexPath, { force: true })]);
return;
}
await writeCredentialVault(document, cipher);
});
}
export async function getChannelVaultSecrets(
cipher: CredentialCipher = defaultCredentialCipher(),
): Promise<Record<string, Record<string, string>>> {
return (await readCredentialVault(cipher)).channelSecrets;
}
export async function replaceChannelVaultSecrets(
channelSecrets: Record<string, Record<string, string>>,
cipher: CredentialCipher = defaultCredentialCipher(),
): Promise<void> {
await serializeCredentialMutation(async () => {
const document = await readCredentialVault(cipher);
document.channelSecrets = channelSecrets;
if (Object.keys(document.secrets).length === 0 && Object.keys(channelSecrets).length === 0) {
const { vaultPath, indexPath } = credentialPaths();
await Promise.all([rm(vaultPath, { force: true }), rm(indexPath, { force: true })]);
return;
}
await writeCredentialVault(document, cipher);
});
}
+45 -19
View File
@@ -1,5 +1,6 @@
import type { ProviderSecret } from '../../shared/providers/types';
import { getClawXProviderStore } from '../providers/store-instance';
import { deleteVaultSecret, getVaultSecret, setVaultSecret } from './credential-vault';
export interface SecretStore {
get(accountId: string): Promise<ProviderSecret | null>;
@@ -9,10 +10,19 @@ export interface SecretStore {
export class ElectronStoreSecretStore implements SecretStore {
async get(accountId: string): Promise<ProviderSecret | null> {
const encrypted = await getVaultSecret(accountId);
if (encrypted) {
const store = await getClawXProviderStore();
await this.clearLegacySecret(store, accountId);
return encrypted;
}
const store = await getClawXProviderStore();
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
const secret = secrets[accountId];
if (secret) {
await setVaultSecret(secret);
await this.clearLegacySecret(store, accountId);
return secret;
}
@@ -22,37 +32,32 @@ export class ElectronStoreSecretStore implements SecretStore {
return null;
}
return {
const migrated: ProviderSecret = {
type: 'api_key',
accountId,
apiKey,
};
await setVaultSecret(migrated);
await this.clearLegacySecret(store, accountId);
return migrated;
}
async set(secret: ProviderSecret): Promise<void> {
await setVaultSecret(secret);
const store = await getClawXProviderStore();
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
secrets[secret.accountId] = secret;
store.set('providerSecrets', secrets);
// Keep legacy apiKeys in sync until the rest of the app moves to account-based secrets.
const apiKeys = (store.get('apiKeys') ?? {}) as Record<string, string>;
if (secret.type === 'api_key') {
apiKeys[secret.accountId] = secret.apiKey;
} else if (secret.type === 'local') {
if (secret.apiKey) {
apiKeys[secret.accountId] = secret.apiKey;
} else {
delete apiKeys[secret.accountId];
}
} else {
delete apiKeys[secret.accountId];
}
store.set('apiKeys', apiKeys);
await this.clearLegacySecret(store, secret.accountId);
}
async delete(accountId: string): Promise<void> {
await deleteVaultSecret(accountId);
const store = await getClawXProviderStore();
await this.clearLegacySecret(store, accountId);
}
private async clearLegacySecret(store: {
get(key: string): unknown;
set(key: string, value: unknown): void;
}, accountId: string): Promise<void> {
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
delete secrets[accountId];
store.set('providerSecrets', secrets);
@@ -63,6 +68,27 @@ export class ElectronStoreSecretStore implements SecretStore {
}
}
export async function migrateLegacyProviderSecretsToVault(): Promise<number> {
const store = await getClawXProviderStore();
const legacySecrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
const legacyApiKeys = (store.get('apiKeys') ?? {}) as Record<string, string>;
const accountIds = new Set([...Object.keys(legacyApiKeys), ...Object.keys(legacySecrets)]);
if (accountIds.size === 0) return 0;
for (const accountId of accountIds) {
const existing = await getVaultSecret(accountId);
if (existing) continue;
const secret = legacySecrets[accountId] ?? (legacyApiKeys[accountId]
? { type: 'api_key' as const, accountId, apiKey: legacyApiKeys[accountId] }
: undefined);
if (secret) await setVaultSecret(secret);
}
store.set('providerSecrets', {});
store.set('apiKeys', {});
return accountIds.size;
}
const secretStore = new ElectronStoreSecretStore();
export function getSecretStore(): SecretStore {
+274 -34
View File
@@ -1,8 +1,13 @@
import { openSync, closeSync, fstatSync, readSync } from 'node:fs';
import { access } from 'node:fs/promises';
import { join } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { RuntimeManager } from '../runtime/manager';
import { stripAcpWorkingDirectoryPrefix } from '@shared/chat/session-title';
import { isOpenClawHeartbeatPollText } from '@shared/chat/openclaw-internal';
import type { RawMessage } from '@shared/chat/types';
import { getOpenClawConfigDir } from '../utils/paths';
import type { SessionTurnTimingCandidate } from '@shared/host-api/contract';
import { resolveOpenClawStateDir } from '../utils/paths';
import { logger } from '../utils/logger';
import {
removeSessionEntry,
@@ -20,15 +25,25 @@ type SessionSummary = {
sessionKey: string;
firstUserText: string | null;
lastTimestamp: number | null;
workspacePath: string | null;
heartbeatOnly?: boolean;
};
type TranscriptMessage = RawMessage;
type ParsedTranscriptLine = {
type?: string;
id?: unknown;
timestamp?: unknown;
message?: TranscriptMessage;
};
type TranscriptMessageRecord = {
id?: string;
timestamp?: unknown;
message: TranscriptMessage;
};
type SessionPayload = {
id?: unknown;
sessionKey?: unknown;
@@ -51,7 +66,8 @@ function extractMessageText(content: unknown): string {
}
function cleanSummaryUserText(text: string): string {
return text
const textAfterInitialPrefix = stripAcpWorkingDirectoryPrefix(text);
const cleaned = textAfterInitialPrefix
.replace(/^Sender\s*\([^)]*\)\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '')
.replace(/^Sender\s*\([^)]*\)\s*:\s*\{[\s\S]*?\}\s*/i, '')
.replace(/^Sender\s*\([^)]*\)\s*:[^\n]*(?:\n\s*)*/i, '')
@@ -66,10 +82,14 @@ function cleanSummaryUserText(text: string): string {
.replace(/^Conversation info\s*\([^)]*\):\s*\{[\s\S]*?\}\s*/i, '')
.replace(/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, '')
.trim();
const stripCwdExposedByCleanup = !textAfterInitialPrefix.startsWith('[Working directory: ')
&& cleaned.startsWith('[Working directory: ');
return (stripCwdExposedByCleanup ? stripAcpWorkingDirectoryPrefix(cleaned) : cleaned).trim();
}
function isInternalSummaryText(text: string): boolean {
if (!text) return true;
if (isOpenClawHeartbeatPollText(text)) return true;
if (/^\s*System\s*\(untrusted\)\s*:/i.test(text)) return true;
if (
/An async command you ran earlier has completed/i.test(text)
@@ -91,39 +111,188 @@ function normalizeTimestamp(value: unknown): number | null {
return value < 1e12 ? value * 1000 : value;
}
function parseMessageLine(line: string): TranscriptMessage | null {
try {
const entry = JSON.parse(line) as ParsedTranscriptLine;
if (entry.type !== 'message' || !entry.message || typeof entry.message !== 'object') {
return null;
function normalizeTranscriptTimestamp(value: unknown): number | null {
if (typeof value === 'number') return normalizeTimestamp(value);
if (typeof value !== 'string' || !value.trim()) return null;
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : null;
}
function transcriptRecordTimestamp(record: TranscriptMessageRecord): number | null {
return normalizeTranscriptTimestamp(record.timestamp)
?? normalizeTranscriptTimestamp(record.message.timestamp);
}
function normalizeTurnUserText(message: TranscriptMessage): string {
return stripAcpWorkingDirectoryPrefix(extractMessageText(message.content))
.replace(/\r\n/g, '\n')
.trim();
}
function isInternalInterSessionUser(message: TranscriptMessage): boolean {
const provenance = (message as TranscriptMessage & { provenance?: unknown }).provenance;
if (provenance && typeof provenance === 'object' && !Array.isArray(provenance)) {
const kind = (provenance as Record<string, unknown>).kind;
if (typeof kind === 'string' && kind.toLowerCase() === 'inter_session') return true;
}
return /^\[Inter-session message\]\s/.test(extractMessageText(message.content));
}
function extractTranscriptTurnTimings(records: TranscriptMessageRecord[]): SessionTurnTimingCandidate[] {
const turns: Array<{
normalizedUserText: string;
startedAt: number | null;
completedAt: number | null;
}> = [];
let current: (typeof turns)[number] | null = null;
for (const record of records) {
const role = typeof record.message.role === 'string' ? record.message.role.toLowerCase() : '';
if (role === 'user') {
if (isInternalInterSessionUser(record.message)) continue;
current = {
normalizedUserText: normalizeTurnUserText(record.message),
startedAt: transcriptRecordTimestamp(record),
completedAt: null,
};
turns.push(current);
continue;
}
return entry.message;
if (!current || (role !== 'assistant' && role !== 'toolresult' && role !== 'tool_result')) continue;
const timestamp = transcriptRecordTimestamp(record);
if (timestamp != null && (current.completedAt == null || timestamp > current.completedAt)) {
current.completedAt = timestamp;
}
}
const occurrences = new Map<string, number>();
const candidates = new Array<SessionTurnTimingCandidate | null>(turns.length).fill(null);
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index]!;
const userOccurrenceFromTail = (occurrences.get(turn.normalizedUserText) ?? 0) + 1;
occurrences.set(turn.normalizedUserText, userOccurrenceFromTail);
if (turn.startedAt == null || turn.completedAt == null || turn.completedAt < turn.startedAt) continue;
candidates[index] = {
normalizedUserText: turn.normalizedUserText,
userOccurrenceFromTail,
durationMs: turn.completedAt - turn.startedAt,
};
}
return candidates.filter((candidate): candidate is SessionTurnTimingCandidate => candidate != null);
}
type SqliteDatabaseLike = {
prepare: (sql: string) => {
get: (...params: unknown[]) => unknown;
};
};
function normalizeCwdValue(value: unknown): string | null {
const cwd = typeof value === 'string' ? value.trim() : '';
return cwd || null;
}
function parseRuntimeOptionsCwd(value: unknown): string | null {
if (typeof value !== 'string' || !value.trim()) return null;
try {
const parsed = JSON.parse(value) as unknown;
if (!parsed || typeof parsed !== 'object') return null;
return normalizeCwdValue((parsed as Record<string, unknown>).cwd);
} catch {
return null;
}
}
function parseRecentMessagesFromTailChunk(chunk: string, readStart: number, limit: number): TranscriptMessage[] {
function readAcpReplayCwd(db: SqliteDatabaseLike, sessionKey: string): string | null {
try {
const row = db.prepare(
'SELECT cwd FROM acp_replay_sessions WHERE session_key = ? ORDER BY updated_at DESC, session_id ASC LIMIT 1',
).get(sessionKey) as { cwd?: unknown } | undefined;
return normalizeCwdValue(row?.cwd);
} catch {
return null;
}
}
function readAcpRuntimeMetaCwd(db: SqliteDatabaseLike, sessionKey: string): string | null {
try {
const row = db.prepare('SELECT * FROM acp_sessions WHERE session_key = ?').get(sessionKey) as {
runtime_options_json?: unknown;
cwd?: unknown;
} | undefined;
return parseRuntimeOptionsCwd(row?.runtime_options_json) ?? normalizeCwdValue(row?.cwd);
} catch {
return null;
}
}
async function readOpenClawAcpSessionCwds(sessionKeys: string[]): Promise<Map<string, string>> {
const normalizedKeys = Array.from(new Set(sessionKeys.map((sessionKey) => sessionKey.trim()).filter(Boolean)));
const workspaceByKey = new Map<string, string>();
if (normalizedKeys.length === 0) return workspaceByKey;
const databasePath = join(resolveOpenClawStateDir(), 'state', 'openclaw.sqlite');
try {
await access(databasePath);
const sqliteSpecifier = 'node:sqlite';
const { DatabaseSync } = await import(/* @vite-ignore */ sqliteSpecifier);
const db = new DatabaseSync(databasePath, { readOnly: true });
try {
for (const sessionKey of normalizedKeys) {
const cwd = readAcpReplayCwd(db, sessionKey) ?? readAcpRuntimeMetaCwd(db, sessionKey);
if (cwd) workspaceByKey.set(sessionKey, cwd);
}
return workspaceByKey;
} finally {
db.close();
}
} catch {
return new Map();
}
}
function parseMessageRecordLine(line: string): TranscriptMessageRecord | null {
try {
const entry = JSON.parse(line) as ParsedTranscriptLine;
if (entry.type !== 'message' || !entry.message || typeof entry.message !== 'object') {
return null;
}
return {
...(typeof entry.id === 'string' ? { id: entry.id } : {}),
timestamp: entry.timestamp,
message: entry.message,
};
} catch {
return null;
}
}
function parseMessageLine(line: string): TranscriptMessage | null {
return parseMessageRecordLine(line)?.message ?? null;
}
function parseRecentRecordsFromTailChunk(chunk: string, readStart: number, limit: number): TranscriptMessageRecord[] {
const lines = chunk.split(/\r?\n/);
if (readStart > 0) lines.shift();
const collected: TranscriptMessage[] = [];
const collected: TranscriptMessageRecord[] = [];
let scanned = 0;
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index];
if (!line?.trim()) continue;
scanned += 1;
if (scanned > RECENT_TRANSCRIPT_MAX_SCAN_LINES) break;
const message = parseMessageLine(line);
if (message) {
collected.push(message);
const record = parseMessageRecordLine(line);
if (record) {
collected.push(record);
if (collected.length >= limit) break;
}
}
return collected.reverse();
}
function readRecentTranscriptMessages(transcriptPath: string, limit: number): TranscriptMessage[] {
function readRecentTranscriptRecords(transcriptPath: string, limit: number): TranscriptMessageRecord[] {
const boundedLimit = Math.max(1, Math.min(Math.floor(limit), 1000));
let fd: number | null = null;
try {
@@ -137,13 +306,13 @@ function readRecentTranscriptMessages(transcriptPath: string, limit: number): Tr
const readLen = size - readStart;
const buffer = Buffer.allocUnsafe(readLen);
readSync(fd, buffer, 0, readLen, readStart);
const messages = parseRecentMessagesFromTailChunk(buffer.toString('utf8'), readStart, boundedLimit);
const records = parseRecentRecordsFromTailChunk(buffer.toString('utf8'), readStart, boundedLimit);
if (
messages.length >= boundedLimit
records.length >= boundedLimit
|| readStart === 0
|| readBytes >= RECENT_TRANSCRIPT_MAX_READ_BYTES
) {
return messages;
return records;
}
readBytes = Math.min(size, readBytes * 2);
}
@@ -153,6 +322,10 @@ function readRecentTranscriptMessages(transcriptPath: string, limit: number): Tr
}
}
function readRecentTranscriptMessages(transcriptPath: string, limit: number): TranscriptMessage[] {
return readRecentTranscriptRecords(transcriptPath, limit).map((record) => record.message);
}
async function readAllTranscriptMessages(transcriptPath: string): Promise<TranscriptMessage[]> {
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(transcriptPath, 'utf8');
@@ -162,9 +335,14 @@ async function readAllTranscriptMessages(transcriptPath: string): Promise<Transc
});
}
function summarizeTranscriptMessages(sessionKey: string, messages: TranscriptMessage[]): SessionSummary {
function summarizeTranscriptMessages(
sessionKey: string,
messages: TranscriptMessage[],
workspacePath: string | null,
): SessionSummary {
let firstUserText: string | null = null;
let lastTimestamp: number | null = null;
let sawHeartbeatPollText = false;
for (const message of messages) {
const normalizedTs = normalizeTimestamp(message.timestamp);
@@ -173,13 +351,24 @@ function summarizeTranscriptMessages(sessionKey: string, messages: TranscriptMes
}
if (firstUserText == null && message.role === 'user') {
const text = cleanSummaryUserText(extractMessageText(message.content));
if (text && !isInternalSummaryText(text)) {
if (text && isInternalSummaryText(text)) {
if (isOpenClawHeartbeatPollText(text)) {
sawHeartbeatPollText = true;
}
} else if (text) {
firstUserText = text;
}
}
}
return { sessionKey, firstUserText, lastTimestamp };
const heartbeatOnly = firstUserText == null && sawHeartbeatPollText;
return {
sessionKey,
firstUserText,
lastTimestamp,
workspacePath,
...(heartbeatOnly ? { heartbeatOnly: true } : {}),
};
}
function parseSessionKey(sessionKey: string): { agentId: string; suffix: string } | null {
@@ -209,7 +398,7 @@ function getLimit(payload: unknown, fallback = 200): number {
async function readSessionsJson(agentId: string): Promise<Record<string, unknown>> {
const fsP = await import('node:fs/promises');
const sessionsJsonPath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json');
const sessionsJsonPath = join(resolveOpenClawStateDir(), 'agents', agentId, 'sessions', 'sessions.json');
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
return JSON.parse(raw) as Record<string, unknown>;
}
@@ -264,24 +453,24 @@ function resolveSessionTranscriptPathByKey(
return resolvedSrcPath ?? null;
}
async function loadSessionSummary(sessionKey: string): Promise<SessionSummary> {
async function loadSessionSummary(sessionKey: string, workspacePath: string | null): Promise<SessionSummary> {
const parsed = parseSessionKey(sessionKey);
if (!parsed) {
return { sessionKey, firstUserText: null, lastTimestamp: null };
return { sessionKey, firstUserText: null, lastTimestamp: null, workspacePath };
}
try {
const sessionsDir = join(getOpenClawConfigDir(), 'agents', parsed.agentId, 'sessions');
const sessionsDir = join(resolveOpenClawStateDir(), 'agents', parsed.agentId, 'sessions');
const sessionsJson = await readSessionsJson(parsed.agentId);
const transcriptPath = resolveSessionTranscriptPathByKey(sessionKey, sessionsDir, sessionsJson);
if (!transcriptPath) {
return { sessionKey, firstUserText: null, lastTimestamp: null };
return { sessionKey, firstUserText: null, lastTimestamp: null, workspacePath };
}
const messages = await readAllTranscriptMessages(transcriptPath);
return summarizeTranscriptMessages(sessionKey, messages);
return summarizeTranscriptMessages(sessionKey, messages, workspacePath);
} catch {
return { sessionKey, firstUserText: null, lastTimestamp: null };
return { sessionKey, firstUserText: null, lastTimestamp: null, workspacePath };
}
}
@@ -290,7 +479,7 @@ async function loadSessionTranscriptByKey(sessionKey: string, limit: number): Pr
if (!parsed) return null;
try {
const sessionsDir = join(getOpenClawConfigDir(), 'agents', parsed.agentId, 'sessions');
const sessionsDir = join(resolveOpenClawStateDir(), 'agents', parsed.agentId, 'sessions');
const sessionsJson = await readSessionsJson(parsed.agentId);
const transcriptPath = resolveSessionTranscriptPathByKey(sessionKey, sessionsDir, sessionsJson);
if (!transcriptPath) return null;
@@ -301,6 +490,28 @@ async function loadSessionTranscriptByKey(sessionKey: string, limit: number): Pr
}
}
async function loadSessionTurnTimingsByKey(
sessionKey: string,
limit: number,
): Promise<SessionTurnTimingCandidate[] | null> {
const parsed = parseSessionKey(sessionKey);
if (!parsed) return null;
try {
const sessionsDir = join(resolveOpenClawStateDir(), 'agents', parsed.agentId, 'sessions');
const sessionsJson = await readSessionsJson(parsed.agentId);
const transcriptPath = resolveSessionTranscriptPathByKey(sessionKey, sessionsDir, sessionsJson);
if (!transcriptPath) return null;
// ACP session/load is authoritative for history content, but its updates omit the
// original timestamps needed to calculate a whole-turn duration. Read only bounded
// transcript timing metadata here; this must never become a second history source.
return extractTranscriptTurnTimings(readRecentTranscriptRecords(transcriptPath, limit));
} catch {
return null;
}
}
async function deleteSession(sessionKey: string): Promise<{ success: boolean; error?: string }> {
if (!sessionKey || !sessionKey.startsWith('agent:')) {
return { success: false, error: `Invalid sessionKey: ${sessionKey}` };
@@ -314,7 +525,7 @@ async function deleteSession(sessionKey: string): Promise<{ success: boolean; er
return { success: false, error: `Invalid agentId: ${agentId}` };
}
const sessionsDir = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions');
const sessionsDir = join(resolveOpenClawStateDir(), 'agents', agentId, 'sessions');
const sessionsJsonPath = join(sessionsDir, 'sessions.json');
logger.info(`[session:delete] key=${sessionKey} agentId=${agentId}`);
logger.info(`[session:delete] sessionsJson=${sessionsJsonPath}`);
@@ -382,7 +593,7 @@ async function renameSession(sessionKey: string, label: string): Promise<{ succe
return { success: false, error: `Invalid agentId in sessionKey: ${agentId}` };
}
const sessionsJsonPath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json');
const sessionsJsonPath = join(resolveOpenClawStateDir(), 'agents', agentId, 'sessions', 'sessions.json');
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
const json = JSON.parse(raw) as Record<string, unknown>;
@@ -411,9 +622,15 @@ async function renameSession(sessionKey: string, label: string): Promise<{ succe
return { success: true };
}
export function createSessionsApi(): CompleteHostServiceRegistry['sessions'] {
export function createSessionsApi(runtimeManager?: RuntimeManager): CompleteHostServiceRegistry['sessions'] {
return {
delete: async (payload) => deleteSession(getSessionKey(payload)),
delete: async (payload) => {
const provider = runtimeManager?.getActiveProvider();
if (provider?.listCapabilities().sessions) {
return provider.deleteSession(payload);
}
return deleteSession(getSessionKey(payload));
},
rename: async (payload) => {
const body = isRecord(payload) ? payload as SessionPayload : {};
const sessionKey = getSessionKey(payload);
@@ -421,20 +638,35 @@ export function createSessionsApi(): CompleteHostServiceRegistry['sessions'] {
if (typeof label !== 'string') {
throw new Error('Label cannot be empty');
}
const provider = runtimeManager?.getActiveProvider();
if (provider?.listCapabilities().sessions) {
return provider.rpc('sessions.rename', { sessionKey, label }) as Promise<{ success: boolean; error?: string }>;
}
return renameSession(sessionKey, label);
},
summaries: async (payload) => {
const provider = runtimeManager?.getActiveProvider();
if (provider?.listCapabilities().sessions) {
return provider.listSessions(payload) as ReturnType<CompleteHostServiceRegistry['sessions']['summaries']>;
}
const body = isRecord(payload) ? payload as SessionPayload : {};
const sessionKeys = Array.isArray(body.sessionKeys)
? body.sessionKeys.filter((value): value is string => typeof value === 'string' && value.startsWith('agent:'))
: [];
if (sessionKeys.length === 0) return { success: true, summaries: [] };
const workspaceByKey = await readOpenClawAcpSessionCwds(sessionKeys);
return {
success: true,
summaries: await Promise.all(sessionKeys.map((sessionKey) => loadSessionSummary(sessionKey))),
summaries: await Promise.all(sessionKeys.map((sessionKey) => (
loadSessionSummary(sessionKey, workspaceByKey.get(sessionKey.trim()) ?? null)
))),
};
},
history: async (payload) => {
const provider = runtimeManager?.getActiveProvider();
if (provider?.listCapabilities().history) {
return provider.loadHistory(payload) as ReturnType<CompleteHostServiceRegistry['sessions']['history']>;
}
const body = isRecord(payload) ? payload as SessionPayload : {};
const limit = getLimit(payload);
@@ -454,7 +686,7 @@ export function createSessionsApi(): CompleteHostServiceRegistry['sessions'] {
}
try {
const transcriptPath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', `${sessionId}.jsonl`);
const transcriptPath = join(resolveOpenClawStateDir(), 'agents', agentId, 'sessions', `${sessionId}.jsonl`);
return { success: true, messages: readRecentTranscriptMessages(transcriptPath, limit) };
} catch (error) {
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
@@ -463,5 +695,13 @@ export function createSessionsApi(): CompleteHostServiceRegistry['sessions'] {
return { success: false, error: 'Failed to load transcript' };
}
},
turnTimings: async (payload) => {
const body = isRecord(payload) ? payload as SessionPayload : {};
const sessionKey = typeof body.sessionKey === 'string' ? body.sessionKey.trim() : '';
if (!sessionKey) return { success: false, error: 'sessionKey is required' };
const timings = await loadSessionTurnTimingsByKey(sessionKey, getLimit(payload, 1000));
if (!timings) return { success: false, error: 'Transcript not found' };
return { success: true, timings };
},
};
}
+12 -3
View File
@@ -1,5 +1,7 @@
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { GatewayManager } from '../gateway/manager';
import type { RuntimeManager } from '../runtime/manager';
import type { RuntimeKind } from '@shared/types/gateway';
import { syncLaunchAtStartupSettingFromStore } from '../main/launch-at-startup';
import { createMenu } from '../main/menu';
import { applyProxySettings } from '../main/proxy';
@@ -86,7 +88,11 @@ async function handleProxySettingsChange(gatewayManager: GatewayManager): Promis
async function runSettingsSideEffects(
gatewayManager: GatewayManager,
patch: Partial<AppSettings>,
runtimeManager?: RuntimeManager,
): Promise<void> {
if (typeof patch.runtimeKind === 'string' && runtimeManager) {
await runtimeManager.setActiveKind(patch.runtimeKind as RuntimeKind);
}
if (patchTouchesProxy(patch)) {
await handleProxySettingsChange(gatewayManager);
}
@@ -98,7 +104,10 @@ async function runSettingsSideEffects(
}
}
export function createSettingsApi(gatewayManager: GatewayManager): CompleteHostServiceRegistry['settings'] {
export function createSettingsApi(
gatewayManager: GatewayManager,
runtimeManager?: RuntimeManager,
): CompleteHostServiceRegistry['settings'] {
return {
getAll: () => getAllSettings(),
get: async (payload) => {
@@ -109,7 +118,7 @@ export function createSettingsApi(gatewayManager: GatewayManager): CompleteHostS
const body = payload as SetPayload | undefined;
const key = await requireSettingKey(body);
await setSetting(key as never, body?.value as never);
await runSettingsSideEffects(gatewayManager, { [key]: body?.value } as Partial<AppSettings>);
await runSettingsSideEffects(gatewayManager, { [key]: body?.value } as Partial<AppSettings>, runtimeManager);
return { success: true };
},
setMany: async (payload) => {
@@ -118,7 +127,7 @@ export function createSettingsApi(gatewayManager: GatewayManager): CompleteHostS
for (const [key, value] of entries) {
await setSetting(key, value as never);
}
await runSettingsSideEffects(gatewayManager, patch);
await runSettingsSideEffects(gatewayManager, patch, runtimeManager);
return { success: true };
},
reset: async () => {
+69 -5
View File
@@ -1,7 +1,12 @@
import type { GatewayManager } from '../gateway/manager';
import type { RuntimeManager } from '../runtime/manager';
import type { ClawHubService, ClawHubInstallParams, ClawHubSearchParams, ClawHubUninstallParams } from '../gateway/clawhub';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { join } from 'node:path';
import { readFile } from 'node:fs/promises';
import { getCcConnectCodexHomeDir, getCcConnectProviderProfilePath } from '../runtime/cc-connect-paths';
import { getAllSkillConfigs, getSkillConfig, updateSkillConfig, updateSkillConfigs } from '../utils/skill-config';
import { getOpenClawSkillsDir } from '../utils/paths';
import {
collectQuickAccessSkills,
filterEnabledQuickAccessSkills,
@@ -86,12 +91,50 @@ function getConfigUpdates(payload: unknown): NormalizedSkillConfigUpdate[] {
export function createSkillsApi({
clawHubService,
gatewayManager,
runtimeManager,
}: {
clawHubService: ClawHubService;
gatewayManager: GatewayManager;
runtimeManager?: RuntimeManager;
}): CompleteHostServiceRegistry['skills'] {
const runtimeSupportsSkills = () => runtimeManager?.listCapabilities().skills === true;
const refreshCcConnectSkills = async () => {
if (runtimeManager?.getActiveProvider().kind === 'cc-connect') {
await runtimeManager.rpc('skills.update', {});
}
};
return {
local: async () => ({ success: true, skills: await listLocalSkills() }),
target: async () => {
const sourceDir = getOpenClawSkillsDir();
const activeKind = await runtimeManager?.getActiveKind();
if (activeKind === 'cc-connect') {
const profile: { codexHomeDir?: unknown } = await readFile(getCcConnectProviderProfilePath(), 'utf8')
.then((content) => JSON.parse(content) as { codexHomeDir?: unknown })
.catch(() => ({} as { codexHomeDir?: unknown }));
const codexHomeDir = typeof profile.codexHomeDir === 'string'
? profile.codexHomeDir
: getCcConnectCodexHomeDir();
const runtimeDir = join(codexHomeDir, 'skills');
return {
success: true,
runtimeKind: 'cc-connect',
sourceDir,
openDir: runtimeDir,
runtimeDir,
manifestPath: join(runtimeDir, 'manifest.json'),
mirrorMode: 'runtime-mirror',
};
}
return {
success: true,
runtimeKind: 'openclaw',
sourceDir,
openDir: sourceDir,
runtimeDir: sourceDir,
mirrorMode: 'source',
};
},
configs: async () => getAllSkillConfigs(),
allConfigs: async () => getAllSkillConfigs(),
getConfig: async (payload) => {
@@ -100,11 +143,23 @@ export function createSkillsApi({
},
updateConfig: async (payload) => {
const { skillKey, ...updates } = getConfigUpdate(payload);
return updateSkillConfig(skillKey, updates);
const result = await updateSkillConfig(skillKey, updates);
await refreshCcConnectSkills();
return result;
},
updateConfigs: async (payload) => {
const result = await updateSkillConfigs(getConfigUpdates(payload));
await refreshCcConnectSkills();
return result;
},
status: async () => {
if (runtimeSupportsSkills()) return await runtimeManager!.rpc('skills.status');
return gatewayManager.rpc('skills.status');
},
update: async (payload) => {
if (runtimeSupportsSkills()) return await runtimeManager!.rpc('skills.update', isRecord(payload) ? payload : {});
return gatewayManager.rpc('skills.update', isRecord(payload) ? payload : {});
},
updateConfigs: async (payload) => updateSkillConfigs(getConfigUpdates(payload)),
status: async () => gatewayManager.rpc('skills.status'),
update: async (payload) => gatewayManager.rpc('skills.update', isRecord(payload) ? payload : {}),
quickAccess: async (payload) => {
const body = isRecord(payload) ? payload as QuickAccessPayload : {};
const [scannedSkills, configs] = await Promise.all([
@@ -114,7 +169,14 @@ export function createSkillsApi({
getAllSkillConfigs(),
]);
let runtimeSkills: QuickAccessRuntimeSkillStatus[] | undefined;
if (gatewayManager.getStatus().state === 'running') {
if (runtimeSupportsSkills()) {
try {
const runtimeStatus = await runtimeManager!.rpc<{ skills?: QuickAccessRuntimeSkillStatus[] }>('skills.status');
runtimeSkills = runtimeStatus.skills || [];
} catch {
runtimeSkills = undefined;
}
} else if (gatewayManager.getStatus().state === 'running') {
try {
const runtimeStatus = await gatewayManager.rpc<{ skills?: QuickAccessRuntimeSkillStatus[] }>('skills.status');
runtimeSkills = runtimeStatus.skills || [];
@@ -151,6 +213,7 @@ export function createSkillsApi({
clawhubInstall: async (payload) => {
try {
await clawHubService.install((isRecord(payload) ? payload : {}) as ClawHubInstallParams);
await refreshCcConnectSkills();
return { success: true };
} catch (error) {
return { success: false, error: errorMessage(error) };
@@ -159,6 +222,7 @@ export function createSkillsApi({
clawhubUninstall: async (payload) => {
try {
await clawHubService.uninstall((isRecord(payload) ? payload : {}) as ClawHubUninstallParams);
await refreshCcConnectSkills();
return { success: true };
} catch (error) {
return { success: false, error: errorMessage(error) };
+44 -3
View File
@@ -1,9 +1,13 @@
import { getRecentTokenUsageHistory } from '../utils/token-usage';
import type { TokenUsageHistoryEntry } from '../utils/token-usage-core';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { RuntimeManager } from '../runtime/manager';
import type { RuntimeKind } from '@shared/types/gateway';
import { isRecord } from './payload-utils';
import { toTokenUsageHistoryEntry } from '../runtime/usage';
type RecentTokenHistoryPayload = {
limit?: unknown;
runtimeKind?: unknown;
};
function getSafeLimit(payload: unknown): number | undefined {
@@ -20,8 +24,45 @@ function getSafeLimit(payload: unknown): number | undefined {
return undefined;
}
export function createUsageApi(): CompleteHostServiceRegistry['usage'] {
function getExplicitRuntimeKind(payload: unknown): RuntimeKind | undefined {
return isRecord(payload) && (payload.runtimeKind === 'openclaw' || payload.runtimeKind === 'cc-connect')
? payload.runtimeKind
: undefined;
}
function getActiveRuntimeKind(runtimeManager?: RuntimeManager): RuntimeKind | undefined {
return runtimeManager?.getActiveProvider().kind;
}
async function getRuntimeTokenHistory(
limit: number | undefined,
runtimeKind: RuntimeKind,
runtimeManager: RuntimeManager | undefined,
): Promise<TokenUsageHistoryEntry[]> {
const provider = runtimeManager?.getProvider(runtimeKind);
if (!provider) return [];
if (runtimeKind === 'cc-connect' && provider !== runtimeManager?.getActiveProvider()) return [];
const result = await provider.listUsage({ ...(limit !== undefined ? { limit } : {}) });
if (!result.success) return [];
const entries = result.records.map(toTokenUsageHistoryEntry);
entries.sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp));
return entries.slice(0, limit ?? entries.length);
}
export async function getRecentTokenHistoryForRuntime(
payload?: unknown,
runtimeManager?: RuntimeManager,
) {
const limit = getSafeLimit(payload);
const runtimeKind = getExplicitRuntimeKind(payload) ?? getActiveRuntimeKind(runtimeManager);
if (!runtimeKind) return [];
return getRuntimeTokenHistory(limit, runtimeKind, runtimeManager);
}
export function createUsageApi(runtimeManager?: RuntimeManager): CompleteHostServiceRegistry['usage'] {
return {
recentTokenHistory: async (payload) => getRecentTokenUsageHistory(getSafeLimit(payload)),
recentTokenHistory: async (payload) => {
return getRecentTokenHistoryForRuntime(payload, runtimeManager);
},
};
}
+69
View File
@@ -0,0 +1,69 @@
import { shell, type Session, type WebContents } from 'electron';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { WebBrowserGuestRegistry } from '../main/web-browser-policy';
import { normalizeWebBrowserTopLevelUrl } from '../../shared/web-browser';
export interface WebBrowserApiDependencies {
browserSession: Session;
registry: WebBrowserGuestRegistry;
openExternal?: (url: string) => Promise<void>;
}
function requireLiveGuest(registry: WebBrowserGuestRegistry): WebContents {
const guest = registry.current();
if (!guest) {
throw new Error('Web browser guest is unavailable');
}
return guest;
}
function requireAllowedUrl(url: string): string {
const normalizedUrl = normalizeWebBrowserTopLevelUrl(url);
if (!normalizedUrl) {
throw new Error('Web browser URL is not allowed');
}
return normalizedUrl;
}
function isAbortedLoad(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false;
const loadError = error as { code?: unknown; errno?: unknown };
return loadError.code === 'ERR_ABORTED' && loadError.errno === -3;
}
export function createWebBrowserApi(
dependencies: WebBrowserApiDependencies,
): CompleteHostServiceRegistry['webBrowser'] {
const { browserSession, registry } = dependencies;
const openExternal = dependencies.openExternal ?? ((url: string) => shell.openExternal(url));
return {
async navigate({ url }) {
const guest = requireLiveGuest(registry);
const allowedUrl = requireAllowedUrl(url);
try {
await guest.loadURL(allowedUrl);
} catch (error) {
if (!isAbortedLoad(error)) throw error;
}
},
async clearCookies() {
await browserSession.clearStorageData({ storages: ['cookies'] });
},
async clearSiteData() {
await Promise.all([
browserSession.clearCache(),
browserSession.clearStorageData({
storages: ['cachestorage', 'localstorage', 'indexdb', 'serviceworkers'],
}),
]);
},
async openExternal() {
const guest = requireLiveGuest(registry);
await openExternal(requireAllowedUrl(guest.getURL()));
},
};
}
@@ -0,0 +1,50 @@
export type ModelInputModality = 'text' | 'image';
/**
* Conservative context-window defaults for well-known model families, applied
* to custom-provider model rows that would otherwise carry no `contextWindow`.
*
* Why this matters: when a model row has neither `contextTokens` nor
* `contextWindow`, OpenClaw's embedded runner skips preemptive compaction and
* context-window guarding entirely, so long sessions only fail at the provider
* with "Context overflow: prompt too large" instead of being compacted early.
*/
const CUSTOM_MODEL_CONTEXT_WINDOW_RULES: Array<{ pattern: RegExp; contextWindow: number }> = [
{ pattern: /\bgpt-5/, contextWindow: 272_000 },
{ pattern: /\b(?:gpt-4\.1|gpt-4o|o[134])\b/, contextWindow: 128_000 },
{ pattern: /\bclaude\b|\bclaude-/, contextWindow: 200_000 },
{ pattern: /\bgemini\b/, contextWindow: 1_048_576 },
{ pattern: /\bkimi\b|moonshot/, contextWindow: 256_000 },
{ pattern: /minimax/, contextWindow: 204_800 },
{ pattern: /\bglm-5(?:\.|\b)/, contextWindow: 1_000_000 },
{ pattern: /\bglm-4/, contextWindow: 200_000 },
];
/** Safe floor for unknown custom models: high enough to avoid compaction spam. */
export const DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW = 131_072;
export function inferCustomModelContextWindow(modelId: string): number {
const normalized = modelId.trim().toLowerCase();
for (const rule of CUSTOM_MODEL_CONTEXT_WINDOW_RULES) {
if (rule.pattern.test(normalized)) return rule.contextWindow;
}
return DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW;
}
/**
* Mirrors OpenClaw 2026.5.20 custom-provider onboarding inference.
* Unknown models use the same conservative text-only fallback as non-interactive onboarding.
*/
export function inferCustomModelInputModalities(modelId: string): ModelInputModality[] {
const normalized = modelId.trim().toLowerCase();
const supportsImageInput = (
/\b(?:gpt-4o|gpt-4\.1|gpt-[5-9]|o[134])\b/.test(normalized)
|| /\bclaude-(?:3|4|sonnet|opus|haiku)\b/.test(normalized)
|| /\bgemini\b/.test(normalized)
|| /\b(?:qwen[\w.-]*-?vl|qwen-vl)\b/.test(normalized)
|| /\b(?:vision|llava|pixtral|internvl|mllama|minicpm-v|glm-4v)\b/.test(normalized)
|| /(?:^|[-_/])vl(?:[-_/]|$)/.test(normalized)
);
return supportsImageInput ? ['text', 'image'] : ['text'];
}
+119 -4
View File
@@ -15,9 +15,9 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
requiresApiKey: true,
category: 'official',
envVar: 'ANTHROPIC_API_KEY',
defaultModelId: 'claude-opus-4-6',
defaultModelId: 'claude-opus-4-8',
showModelId: true,
modelIdPlaceholder: 'claude-opus-4-6',
modelIdPlaceholder: 'claude-opus-4-8',
supportedAuthModes: ['api_key'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
@@ -69,8 +69,8 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
model: 'Multi-Model',
requiresApiKey: true,
showModelId: true,
modelIdPlaceholder: 'anthropic/claude-opus-4.6',
defaultModelId: 'anthropic/claude-opus-4.6',
modelIdPlaceholder: 'openai/gpt-5.6-sol',
defaultModelId: 'openai/gpt-5.6-sol',
category: 'compatible',
envVar: 'OPENROUTER_API_KEY',
supportedAuthModes: ['api_key'],
@@ -310,6 +310,121 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
],
},
},
{
// OpenClaw runtime key is always `zai` (CN + Global share one provider slot).
// Cross-checked with docs.openclaw.ai/providers/zai and docs.z.ai/devpack/tool/openclaw.
id: 'zai',
name: 'Z.AI (CN)',
icon: 'Z',
placeholder: 'your-z.ai-api-key',
model: 'GLM',
requiresApiKey: true,
defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4',
showBaseUrl: true,
showModelId: true,
modelIdPlaceholder: 'glm-5.2',
defaultModelId: 'glm-5.2',
apiKeyUrl: 'https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys',
category: 'official',
envVar: 'ZAI_API_KEY',
codePlanPresetBaseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
codePlanPresetModelId: 'glm-5.2',
codePlanDocsUrl: 'https://docs.bigmodel.cn/cn/coding-plan/quick-start',
supportedAuthModes: ['api_key'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
providerConfig: {
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
api: 'openai-completions',
apiKeyEnv: 'ZAI_API_KEY',
models: [
{
id: 'glm-5.2',
name: 'GLM-5.2',
reasoning: true,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 131072,
},
{
id: 'glm-5.1',
name: 'GLM-5.1',
reasoning: true,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 131072,
},
{
id: 'glm-4.7',
name: 'GLM-4.7',
reasoning: true,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 131072,
},
],
},
},
{
// Aliases to OpenClaw runtime key `zai` — mutually exclusive with CN in the UI.
id: 'zai-global',
name: 'Z.AI (Global)',
icon: 'Z',
placeholder: 'your-z.ai-api-key',
model: 'GLM',
requiresApiKey: true,
defaultBaseUrl: 'https://api.z.ai/api/paas/v4',
showBaseUrl: true,
showModelId: true,
modelIdPlaceholder: 'glm-5.2',
defaultModelId: 'glm-5.2',
apiKeyUrl: 'https://z.ai/manage-apikey',
category: 'official',
envVar: 'ZAI_API_KEY',
codePlanPresetBaseUrl: 'https://api.z.ai/api/coding/paas/v4',
codePlanPresetModelId: 'glm-5.2',
codePlanDocsUrl: 'https://docs.z.ai/devpack/quick-start',
supportedAuthModes: ['api_key'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
providerConfig: {
baseUrl: 'https://api.z.ai/api/paas/v4',
api: 'openai-completions',
apiKeyEnv: 'ZAI_API_KEY',
models: [
{
id: 'glm-5.2',
name: 'GLM-5.2',
reasoning: true,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 131072,
},
{
id: 'glm-5.1',
name: 'GLM-5.1',
reasoning: true,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 131072,
},
{
id: 'glm-4.7',
name: 'GLM-4.7',
reasoning: true,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 131072,
},
],
},
},
{
id: 'modelstudio',
name: 'Model Studio',
+20 -1
View File
@@ -10,6 +10,8 @@ export const PROVIDER_TYPES = [
'deepseek',
'minimax-portal',
'minimax-portal-cn',
'zai',
'zai-global',
'modelstudio',
'ollama',
'custom',
@@ -27,6 +29,8 @@ export const BUILTIN_PROVIDER_TYPES = [
'deepseek',
'minimax-portal',
'minimax-portal-cn',
'zai',
'zai-global',
'modelstudio',
'ollama',
] as const;
@@ -48,7 +52,7 @@ export const OLLAMA_PLACEHOLDER_API_KEY = 'ollama-local';
export const OPENCLAW_API_PROTOCOLS = [
'openai-completions',
'openai-responses',
'openai-codex-responses',
'openai-chatgpt-responses',
'anthropic-messages',
'google-generative-ai',
'github-copilot',
@@ -59,6 +63,20 @@ export const OPENCLAW_API_PROTOCOLS = [
export type OpenClawApiProtocol = (typeof OPENCLAW_API_PROTOCOLS)[number];
/** Legacy api values ClawX previously wrote that OpenClaw no longer accepts. */
export const LEGACY_OPENCLAW_API_PROTOCOL_MIGRATIONS = {
'openai-codex-responses': 'openai-chatgpt-responses',
} as const satisfies Record<string, OpenClawApiProtocol>;
export function normalizeOpenClawApiProtocol(api: unknown): OpenClawApiProtocol | undefined {
if (typeof api !== 'string') return undefined;
if ((OPENCLAW_API_PROTOCOLS as readonly string[]).includes(api)) {
return api as OpenClawApiProtocol;
}
const migrated = (LEGACY_OPENCLAW_API_PROTOCOL_MIGRATIONS as Record<string, OpenClawApiProtocol>)[api];
return migrated;
}
export class InvalidApiProtocolError extends Error {
constructor(public readonly api: unknown, public readonly providerKey?: string) {
super(
@@ -189,6 +207,7 @@ export type ProviderSecret =
accountId: string;
accessToken: string;
refreshToken: string;
idToken?: string;
expiresAt: number;
scopes?: string[];
email?: string;
+57 -12
View File
@@ -1,6 +1,7 @@
import { access, copyFile, mkdir, readdir, rm } from 'fs/promises';
import { constants } from 'fs';
import { join, normalize } from 'path';
import { app } from 'electron';
import { deleteAgentChannelAccounts, listConfiguredChannels, readOpenClawConfig, writeOpenClawConfig } from './channel-config';
import type { OpenClawConfig } from './channel-config';
import { withConfigLock } from './config-mutex';
@@ -8,11 +9,15 @@ import { expandPath, getOpenClawConfigDir } from './paths';
import * as logger from './logger';
import { toUiChannelType } from './channel-alias';
import { ensureClawXIdentityFile } from './openclaw-workspace';
import {
listCcConnectAgentPermissionModes,
listCcConnectAgentProviderBindings,
} from '../runtime/cc-connect-agent-bindings';
import { getClawXDataLayout, resolveClawXDataRoot } from './clawx-data-layout';
const MAIN_AGENT_ID = 'main';
const MAIN_AGENT_NAME = 'Main Agent';
const DEFAULT_ACCOUNT_ID = 'default';
const DEFAULT_WORKSPACE_PATH = '~/.openclaw/workspace';
const AGENT_BOOTSTRAP_FILES = [
'AGENTS.md',
'SOUL.md',
@@ -166,7 +171,13 @@ function getDefaultWorkspacePath(config: AgentConfigDocument): string {
: undefined);
return typeof defaults?.workspace === 'string' && defaults.workspace.trim()
? defaults.workspace
: DEFAULT_WORKSPACE_PATH;
: getClawXManagedWorkspacePath(MAIN_AGENT_ID);
}
function getClawXManagedWorkspacePath(agentId: string): string {
const safeAgentId = agentId.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-') || MAIN_AGENT_ID;
const layout = getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData')));
return join(layout.agentWorkspacesDir, safeAgentId);
}
function getDefaultAgentDirPath(agentId: string): string {
@@ -350,10 +361,8 @@ function trimTrailingSeparators(path: string): string {
}
function getManagedWorkspaceDirectory(agent: AgentListEntry): string | null {
if (agent.id === MAIN_AGENT_ID) return null;
const configuredWorkspace = expandPath(agent.workspace || `~/.openclaw/workspace-${agent.id}`);
const managedWorkspace = join(getOpenClawConfigDir(), `workspace-${agent.id}`);
const configuredWorkspace = expandPath(agent.workspace || getClawXManagedWorkspacePath(agent.id));
const managedWorkspace = getClawXManagedWorkspacePath(agent.id);
const normalizedConfigured = trimTrailingSeparators(normalize(configuredWorkspace));
const normalizedManaged = trimTrailingSeparators(normalize(managedWorkspace));
@@ -411,7 +420,7 @@ async function provisionAgentFilesystem(
const { entries } = normalizeAgentsConfig(config);
const mainEntry = entries.find((entry) => entry.id === MAIN_AGENT_ID) ?? createImplicitMainEntry(config);
const sourceWorkspace = expandPath(mainEntry.workspace || getDefaultWorkspacePath(config));
const targetWorkspace = expandPath(agent.workspace || `~/.openclaw/workspace-${agent.id}`);
const targetWorkspace = expandPath(agent.workspace || getClawXManagedWorkspacePath(agent.id));
const sourceAgentDir = expandPath(mainEntry.agentDir || getDefaultAgentDirPath(MAIN_AGENT_ID));
const targetAgentDir = expandPath(agent.agentDir || getDefaultAgentDirPath(agent.id));
const targetSessionsDir = join(getOpenClawConfigDir(), 'agents', agent.id, 'sessions');
@@ -465,6 +474,10 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedCha
const defaultAgentIdNorm = normalizeAgentIdForBinding(defaultAgentId);
const channelOwners: Record<string, string> = {};
const channelAccountOwners: Record<string, string> = {};
const [providerBindings, permissionModes] = await Promise.all([
listCcConnectAgentProviderBindings(),
listCcConnectAgentPermissionModes(),
]);
// Build per-agent channel lists from account-scoped bindings
const agentChannelSets = new Map<string, Set<string>>();
@@ -522,8 +535,10 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedCha
modelDisplay: modelLabel,
modelRef: explicitModelRef || defaultModelRef || null,
overrideModelRef: explicitModelRef,
providerAccountId: providerBindings[entry.id] ?? null,
permissionMode: permissionModes[entry.id] ?? 'full-auto',
inheritedModel,
workspace: entry.workspace || (entry.id === MAIN_AGENT_ID ? getDefaultWorkspacePath(config) : `~/.openclaw/workspace-${entry.id}`),
workspace: entry.workspace || getClawXManagedWorkspacePath(entry.id),
agentDir: entry.agentDir || getDefaultAgentDirPath(entry.id),
mainSessionKey: buildAgentMainSessionKey(config, entry.id),
channelTypes: configuredChannels
@@ -543,8 +558,16 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedCha
}
export async function listAgentsSnapshot(): Promise<AgentsSnapshot> {
const config = await readOpenClawConfig() as AgentConfigDocument;
return buildSnapshotFromConfig(config);
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
const { pruneStaleRuntimeAgentModelRefs } = await import('./openclaw-auth');
const modified = await pruneStaleRuntimeAgentModelRefs(config as unknown as Record<string, unknown>);
if (modified) {
await writeOpenClawConfig(config);
logger.info('Pruned stale runtime agent model refs from openclaw.json');
}
return buildSnapshotFromConfig(config);
});
}
export async function listAgentsSnapshotFromConfig(config: OpenClawConfig, configuredChannels?: string[]): Promise<AgentsSnapshot> {
@@ -599,7 +622,7 @@ export async function createAgent(
const newAgent: AgentListEntry = {
id: nextId,
name: normalizedName,
workspace: `~/.openclaw/workspace-${nextId}`,
workspace: getClawXManagedWorkspacePath(nextId),
agentDir: getDefaultAgentDirPath(nextId),
};
@@ -669,7 +692,29 @@ export async function updateAgentModel(agentId: string, modelRef: string | null)
if (!isValidModelRef(normalizedModelRef)) {
throw new Error('modelRef must be in "provider/model" format');
}
nextEntry.model = { primary: normalizedModelRef };
// Merge into the existing model block: replacing it wholesale discards
// hand-configured fields such as `fallbacks`.
const existingModel = entries[index].model;
const nextModel: AgentModelConfig = existingModel && typeof existingModel === 'object'
? { ...existingModel, primary: normalizedModelRef }
: { primary: normalizedModelRef };
// The OpenClaw runtime treats a per-agent model block without a
// `fallbacks` key as an EMPTY fallback override, which suppresses
// agents.defaults.model.fallbacks entirely. Inherit the defaults chain
// so switching models never silently disables failover.
if (!Array.isArray(nextModel.fallbacks)) {
const defaultsModel = agentsConfig.defaults?.model;
const defaultFallbacks = defaultsModel && typeof defaultsModel === 'object'
? (defaultsModel as AgentModelConfig).fallbacks
: undefined;
if (Array.isArray(defaultFallbacks)) {
const inherited = defaultFallbacks.filter((ref): ref is string => typeof ref === 'string' && ref.trim().length > 0);
if (inherited.length > 0) {
nextModel.fallbacks = inherited;
}
}
}
nextEntry.model = nextModel;
}
entries[index] = nextEntry;
+26 -45
View File
@@ -4,11 +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,
saveOAuthTokenToOpenClaw,
setOpenClawDefaultModel,
} 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
@@ -16,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-codex';
const OPENAI_RUNTIME_PROVIDER_ID = 'openai';
const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.5';
class BrowserOAuthManager extends EventEmitter {
export class BrowserOAuthManager extends EventEmitter {
private activeAccountId: string | null = null;
private activeLabel: string | null = null;
private active = false;
private mainWindow: BrowserWindow | null = null;
private pendingManualCodeResolve: ((value: string) => void) | null = null;
private pendingManualCodeReject: ((reason?: unknown) => void) | null = null;
private successHandler: ((payload: BrowserOAuthSuccessPayload) => Promise<void>) | null = null;
setWindow(window: BrowserWindow) {
this.mainWindow = window;
}
setSuccessHandler(
handler: ((payload: BrowserOAuthSuccessPayload) => Promise<void>) | null,
): void {
this.successHandler = handler;
}
async startFlow(
provider: BrowserOAuthProviderType,
options?: { accountId?: string; label?: string },
@@ -124,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);
@@ -139,12 +139,12 @@ class BrowserOAuthManager extends EventEmitter {
const oauthTokenEmail = typeof token.email === 'string' ? token.email : undefined;
const oauthTokenSubject = typeof token.accountId === 'string' ? token.accountId : undefined;
// OpenAI OAuth uses openai-codex/* runtime; existing openai/* refs are incompatible.
const normalizedExistingModel = (() => {
const value = existing?.model?.trim();
if (!value) return undefined;
if (value.startsWith('openai/')) return undefined;
if (value.startsWith('openai-codex/')) return value.split('/').pop();
if (value.startsWith('openai/') || value.startsWith('openai-codex/')) {
return value.split('/').pop();
}
return value.includes('/') ? value.split('/').pop() : value;
})();
@@ -174,40 +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,
});
const modelId = normalizedExistingModel || defaultModel;
const modelRef = `${runtimeProviderId}/${modelId}`;
const fallbackModelRefs = (nextAccount.fallbackModels ?? [])
.map((fallback) => fallback.trim())
.filter(Boolean)
.map((fallback) => (
fallback.startsWith(`${runtimeProviderId}/`)
? fallback
: `${runtimeProviderId}/${fallback}`
));
try {
await setOpenClawDefaultModel(runtimeProviderId, modelRef, fallbackModelRefs);
await ensureOpenClawProviderAgentRuntimePins();
logger.info(`[BrowserOAuth] Registered ${runtimeProviderId} in openclaw.json (default model: ${modelRef})`);
} catch (err) {
logger.warn('[BrowserOAuth] Failed to register OpenAI OAuth provider in openclaw.json:', err);
throw err;
}
this.emit('oauth:success', { provider: providerType, accountId: nextAccount.id });
const successPayload = { provider: providerType, accountId: nextAccount.id };
await this.successHandler?.(successPayload);
this.active = false;
this.activeAccountId = null;
this.activeLabel = null;
this.pendingManualCodeResolve = null;
this.pendingManualCodeReject = null;
logger.info(`[BrowserOAuth] Successfully completed OAuth for ${providerType}`);
this.emit('oauth:success', successPayload);
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:success', {
provider: providerType,
+147 -11
View File
@@ -12,6 +12,9 @@ import { getOpenClawResolvedDir } from './paths';
import * as logger from './logger';
import { proxyAwareFetch } from './proxy-fetch';
import { withConfigLock } from './config-mutex';
import { readClawXRuntimeConfig, writeClawXRuntimeConfig } from './clawx-runtime-config';
import { getChannelVaultSecrets, replaceChannelVaultSecrets } from '../services/secrets/credential-vault';
import { getSetting } from './store';
import {
OPENCLAW_WECHAT_CHANNEL_TYPE,
isWechatChannelType,
@@ -453,6 +456,95 @@ export interface OpenClawConfig {
[key: string]: unknown;
}
const CHANNEL_SECRET_FIELDS = new Set([
'accessToken',
'appPassword',
'appSecret',
'appToken',
'botSecret',
'botToken',
'callbackAesKey',
'callbackToken',
'channelAccessToken',
'channelSecret',
'channelToken',
'clientSecret',
'corpSecret',
'encryptKey',
'password',
'secret',
'serviceAccountKey',
'token',
]);
function cloneConfig(config: OpenClawConfig): OpenClawConfig {
return JSON.parse(JSON.stringify(config)) as OpenClawConfig;
}
function channelCredentialId(channelType: string, accountId: string): string {
return `${channelType}:${accountId}`;
}
function stripChannelSecrets(config: OpenClawConfig): {
config: OpenClawConfig;
secrets: Record<string, Record<string, string>>;
found: boolean;
} {
const sanitized = cloneConfig(config);
const secrets: Record<string, Record<string, string>> = {};
let found = false;
for (const [channelType, section] of Object.entries(sanitized.channels ?? {})) {
const accounts = section.accounts && typeof section.accounts === 'object'
? section.accounts as Record<string, ChannelConfigData>
: null;
const defaultAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim()
? section.defaultAccount.trim()
: 'default';
const entries: Array<[string, ChannelConfigData]> = [
[defaultAccountId, section],
...Object.entries(accounts ?? {}),
];
for (const [accountId, account] of entries) {
const accountSecrets: Record<string, string> = {};
for (const field of CHANNEL_SECRET_FIELDS) {
const value = account[field];
if (typeof value !== 'string' || !value) continue;
accountSecrets[field] = value;
delete account[field];
found = true;
}
if (Object.keys(accountSecrets).length > 0) {
const credentialId = channelCredentialId(channelType, accountId);
secrets[credentialId] = { ...(secrets[credentialId] ?? {}), ...accountSecrets };
}
}
}
return { config: sanitized, secrets, found };
}
function hydrateChannelSecrets(
config: OpenClawConfig,
secrets: Record<string, Record<string, string>>,
): OpenClawConfig {
const hydrated = cloneConfig(config);
for (const [channelType, section] of Object.entries(hydrated.channels ?? {})) {
const accounts = section.accounts && typeof section.accounts === 'object'
? section.accounts as Record<string, ChannelConfigData>
: null;
const defaultAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim()
? section.defaultAccount.trim()
: 'default';
const entries: Array<[string, ChannelConfigData]> = [
[defaultAccountId, section],
...Object.entries(accounts ?? {}),
];
for (const [accountId, account] of entries) {
Object.assign(account, secrets[channelCredentialId(channelType, accountId)] ?? {});
}
}
return hydrated;
}
// ── Config I/O ───────────────────────────────────────────────────
async function ensureConfigDir(): Promise<void> {
@@ -461,7 +553,7 @@ async function ensureConfigDir(): Promise<void> {
}
}
export async function readOpenClawConfig(): Promise<OpenClawConfig> {
async function readOpenClawCompatibilityConfig(): Promise<OpenClawConfig> {
await ensureConfigDir();
if (!(await fileExists(CONFIG_FILE))) {
@@ -478,9 +570,21 @@ export async function readOpenClawConfig(): Promise<OpenClawConfig> {
}
}
export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void> {
await ensureConfigDir();
export async function readOpenClawConfig(): Promise<OpenClawConfig> {
const config = await readClawXRuntimeConfig({
readOpenClawCompatibility: readOpenClawCompatibilityConfig,
openClawConfigPath: CONFIG_FILE,
});
const stripped = stripChannelSecrets(config);
if (stripped.found) {
await replaceChannelVaultSecrets(stripped.secrets);
await writeClawXRuntimeConfig(stripped.config);
return config;
}
return hydrateChannelSecrets(config, await getChannelVaultSecrets());
}
export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void> {
try {
// Enable graceful in-process reload authorization for SIGUSR1 flows.
const commands =
@@ -490,7 +594,12 @@ export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void>
commands.restart = true;
config.commands = commands;
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
const stripped = stripChannelSecrets(config);
await replaceChannelVaultSecrets(stripped.secrets);
await writeClawXRuntimeConfig(stripped.config);
if (await getSetting('runtimeKind').catch(() => 'openclaw') === 'openclaw') {
await writeOpenClawCompatibilityProjection(config);
}
} catch (error) {
logger.error('Failed to write OpenClaw config', error);
console.error('Failed to write OpenClaw config:', error);
@@ -498,6 +607,12 @@ export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void>
}
}
export async function writeOpenClawCompatibilityProjection(config?: OpenClawConfig): Promise<void> {
await ensureConfigDir();
const projected = config ?? await readOpenClawConfig();
await writeFile(CONFIG_FILE, JSON.stringify(projected, null, 2), { encoding: 'utf8', mode: 0o600 });
}
// ── Channel operations ───────────────────────────────────────────
async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: string): Promise<void> {
@@ -688,14 +803,26 @@ function transformChannelConfig(
}
}
if (channelType === 'feishu') {
const adminUsers = transformedConfig.adminUsers;
delete transformedConfig.adminUsers;
if (typeof adminUsers === 'string') {
const admins = adminUsers.split(',').map((value) => value.trim()).filter(Boolean);
transformedConfig.adminFrom = admins.length > 0
? admins
: existingAccountConfig.adminFrom;
}
}
if (channelType === 'feishu' || channelType === 'wecom') {
const existingDmPolicy = existingAccountConfig.dmPolicy === 'pairing' ? 'open' : existingAccountConfig.dmPolicy;
transformedConfig.dmPolicy = transformedConfig.dmPolicy ?? existingDmPolicy ?? 'open';
const hasExplicitAllowFrom = transformedConfig.allowFrom !== undefined;
let allowFrom = (transformedConfig.allowFrom ?? existingAccountConfig.allowFrom ?? ['*']) as string[];
if (!Array.isArray(allowFrom)) {
allowFrom = [allowFrom] as string[];
}
transformedConfig.dmPolicy = transformedConfig.dmPolicy
?? (hasExplicitAllowFrom && !allowFrom.includes('*') ? 'allowlist' : existingDmPolicy ?? 'open');
if (transformedConfig.dmPolicy === 'open' && !allowFrom.includes('*')) {
allowFrom = [...allowFrom, '*'];
@@ -751,6 +878,9 @@ function migrateLegacyChannelConfigToAccounts(
channelSection: ChannelConfigData,
defaultAccountId: string = DEFAULT_ACCOUNT_ID,
): void {
const targetAccountId = typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim()
? channelSection.defaultAccount.trim()
: defaultAccountId;
const legacyPayload = getLegacyChannelPayload(channelSection);
const legacyKeys = Object.keys(legacyPayload);
const existingAccounts = getChannelAccountsMap(channelSection);
@@ -758,15 +888,15 @@ function migrateLegacyChannelConfigToAccounts(
if (legacyKeys.length === 0) {
if (hasAccounts && typeof channelSection.defaultAccount !== 'string') {
channelSection.defaultAccount = defaultAccountId;
channelSection.defaultAccount = targetAccountId;
}
return;
}
const accounts = ensureChannelAccountsMap(channelSection);
const existingDefaultAccount = accounts[defaultAccountId] ?? {};
const existingDefaultAccount = accounts[targetAccountId] ?? {};
accounts[defaultAccountId] = {
accounts[targetAccountId] = {
...(channelSection.enabled !== undefined ? { enabled: channelSection.enabled } : {}),
...legacyPayload,
...existingDefaultAccount,
@@ -775,7 +905,7 @@ function migrateLegacyChannelConfigToAccounts(
channelSection.defaultAccount =
typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim()
? channelSection.defaultAccount
: defaultAccountId;
: targetAccountId;
for (const key of legacyKeys) {
delete channelSection[key];
@@ -852,7 +982,10 @@ export async function saveChannelConfig(
}
const channelSection = currentConfig.channels[resolvedChannelType];
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
const currentDefaultAccountId = typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim()
? channelSection.defaultAccount.trim()
: DEFAULT_ACCOUNT_ID;
migrateLegacyChannelConfigToAccounts(channelSection, currentDefaultAccountId);
// Guard: reject if this bot/app credential is already used by another account.
assertNoDuplicateCredential(resolvedChannelType, config, channelSection, resolvedAccountId);
@@ -984,6 +1117,9 @@ function extractFormValues(channelType: string, saved: ChannelConfigData): Recor
}
}
} else {
if (channelType === 'feishu' && Array.isArray(saved.adminFrom)) {
values.adminUsers = saved.adminFrom.join(', ');
}
for (const [key, value] of Object.entries(saved)) {
if (typeof value === 'string' && key !== 'enabled') {
values[key] = value;
+137
View File
@@ -0,0 +1,137 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
export const CLAWX_DATA_VERSION = 1;
export interface ClawXDataLayout {
root: string;
stateDir: string;
dataVersionPath: string;
migrationJournalPath: string;
locksDir: string;
writerLockPath: string;
appDir: string;
credentialsDir: string;
skillsDir: string;
workspacesDir: string;
agentWorkspacesDir: string;
runtimesDir: string;
ccConnectRuntimeDir: string;
openClawRuntimeDir: string;
electronUserDataDir: string;
logsDir: string;
backupsDir: string;
cacheDir: string;
}
export interface ClawXDataVersionFile {
schema: 'clawx-data';
version: number;
createdAt: string;
updatedAt: string;
}
function cleanOverride(value: string | undefined): string | undefined {
const cleaned = value?.trim();
return cleaned ? resolve(cleaned) : undefined;
}
export function resolveClawXDataRoot(
env: NodeJS.ProcessEnv = process.env,
electronUserDataFallback?: string,
): string {
const fallback = cleanOverride(electronUserDataFallback);
const fallbackRoot = fallback
&& basename(fallback) === 'electron'
&& basename(dirname(fallback)) === 'system'
? resolve(fallback, '..', '..')
: fallback;
return cleanOverride(env.CLAWX_DATA_HOME)
?? cleanOverride(env.CLAWX_USER_DATA_DIR)
?? fallbackRoot
?? join(homedir(), '.clawx');
}
export function getClawXDataLayout(
root = resolveClawXDataRoot(),
env: NodeJS.ProcessEnv = process.env,
): ClawXDataLayout {
const resolvedRoot = resolve(root);
const stateDir = join(resolvedRoot, 'state');
const locksDir = join(resolvedRoot, 'locks');
const runtimesDir = join(resolvedRoot, 'runtimes');
const workspacesDir = join(resolvedRoot, 'workspaces');
const explicitElectronUserData = cleanOverride(env.CLAWX_USER_DATA_DIR);
const flatCompatibility = Boolean(explicitElectronUserData && !cleanOverride(env.CLAWX_DATA_HOME));
return {
root: resolvedRoot,
stateDir,
dataVersionPath: join(stateDir, 'data-version.json'),
migrationJournalPath: join(stateDir, 'migration-journal.jsonl'),
locksDir,
writerLockPath: join(locksDir, 'writer.lock'),
appDir: flatCompatibility ? resolvedRoot : join(resolvedRoot, 'app'),
credentialsDir: join(resolvedRoot, 'credentials'),
skillsDir: join(resolvedRoot, 'skills'),
workspacesDir,
agentWorkspacesDir: join(workspacesDir, 'agents'),
runtimesDir,
ccConnectRuntimeDir: join(runtimesDir, 'cc-connect'),
openClawRuntimeDir: join(runtimesDir, 'openclaw'),
electronUserDataDir: explicitElectronUserData ?? join(resolvedRoot, 'system', 'electron'),
logsDir: join(resolvedRoot, 'logs'),
backupsDir: join(resolvedRoot, 'backups'),
cacheDir: join(resolvedRoot, 'cache'),
};
}
function writeJsonAtomic(path: string, value: unknown): void {
mkdirSync(dirname(path), { recursive: true });
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
renameSync(temporaryPath, path);
}
export function initializeClawXDataLayout(layout = getClawXDataLayout()): ClawXDataVersionFile {
for (const dir of [
layout.stateDir,
layout.locksDir,
layout.appDir,
layout.credentialsDir,
layout.skillsDir,
layout.agentWorkspacesDir,
layout.ccConnectRuntimeDir,
layout.openClawRuntimeDir,
layout.electronUserDataDir,
layout.logsDir,
layout.backupsDir,
layout.cacheDir,
]) {
mkdirSync(dir, { recursive: true });
}
if (existsSync(layout.dataVersionPath)) {
const current = JSON.parse(readFileSync(layout.dataVersionPath, 'utf8')) as Partial<ClawXDataVersionFile>;
if (current.schema !== 'clawx-data' || !Number.isInteger(current.version)) {
throw new Error(`Invalid ClawX data version file: ${layout.dataVersionPath}`);
}
if ((current.version ?? 0) > CLAWX_DATA_VERSION) {
throw new Error(
`ClawX data version ${current.version} is newer than supported version ${CLAWX_DATA_VERSION}; refusing to write`,
);
}
return current as ClawXDataVersionFile;
}
const now = new Date().toISOString();
const versionFile: ClawXDataVersionFile = {
schema: 'clawx-data',
version: CLAWX_DATA_VERSION,
createdAt: now,
updatedAt: now,
};
writeJsonAtomic(layout.dataVersionPath, versionFile);
return versionFile;
}
+100
View File
@@ -0,0 +1,100 @@
import { cp, lstat, mkdir, readFile, readdir, realpath, stat, writeFile } from 'node:fs/promises';
import { basename, dirname, join, resolve } from 'node:path';
import type { ClawXDataLayout } from './clawx-data-layout';
export interface ClawXLegacyMigrationResult {
skipped: boolean;
copied: string[];
source: string;
target: string;
}
const LEGACY_ELECTRON_PROFILE_PATHS = [
'Local Storage',
'IndexedDB',
join('Partitions', 'clawx-web-browser'),
] as const;
async function exists(path: string): Promise<boolean> {
return stat(path).then(() => true).catch(() => false);
}
async function copyIfMissing(source: string, target: string, copied: string[]): Promise<void> {
if (!(await exists(source))) return;
if (await exists(target)) {
const targetStat = await stat(target);
if (!targetStat.isDirectory() || (await readdir(target)).length > 0) return;
}
await mkdir(dirname(target), { recursive: true });
await cp(source, target, {
recursive: true,
errorOnExist: false,
force: false,
filter: async (sourcePath) => {
const entry = await lstat(sourcePath);
return entry.isDirectory() || entry.isFile() || entry.isSymbolicLink();
},
});
copied.push(target);
}
async function canonicalPath(path: string): Promise<string> {
return realpath(path).catch(() => resolve(path));
}
async function appendJournal(layout: ClawXDataLayout, record: Record<string, unknown>): Promise<void> {
await mkdir(layout.stateDir, { recursive: true });
const previous = await readFile(layout.migrationJournalPath, 'utf8').catch(() => '');
await writeFile(layout.migrationJournalPath, `${previous}${JSON.stringify(record)}\n`, {
encoding: 'utf8',
mode: 0o600,
});
}
export async function migrateLegacyClawXData(options: {
legacyElectronUserDataDir: string;
layout: ClawXDataLayout;
}): Promise<ClawXLegacyMigrationResult> {
const source = await canonicalPath(options.legacyElectronUserDataDir);
const target = await canonicalPath(options.layout.root);
const electronUserDataDir = await canonicalPath(options.layout.electronUserDataDir);
if (
source === electronUserDataDir
|| source === target
|| source.startsWith(`${target}/`)
) {
return { skipped: true, copied: [], source, target };
}
const copied: string[] = [];
for (const fileName of ['settings.json', 'clawx-providers.json']) {
await copyIfMissing(join(source, fileName), join(options.layout.appDir, fileName), copied);
}
for (const fileName of ['window-state.json', 'clawx-device-identity.json']) {
await copyIfMissing(join(source, fileName), join(options.layout.electronUserDataDir, fileName), copied);
}
for (const relativePath of LEGACY_ELECTRON_PROFILE_PATHS) {
await copyIfMissing(
join(source, relativePath),
join(options.layout.electronUserDataDir, relativePath),
copied,
);
}
await copyIfMissing(
join(source, 'runtimes', 'cc-connect'),
options.layout.ccConnectRuntimeDir,
copied,
);
await copyIfMissing(join(source, 'logs'), options.layout.logsDir, copied);
await appendJournal(options.layout, {
schema: 'clawx-data-migration',
version: 1,
migration: 'legacy-electron-user-data-import',
source,
target,
copied: copied.map((path) => basename(path)),
completedAt: new Date().toISOString(),
});
return { skipped: false, copied, source, target };
}
+71
View File
@@ -0,0 +1,71 @@
import { randomUUID } from 'node:crypto';
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { app } from 'electron';
import { getClawXDataLayout, resolveClawXDataRoot } from './clawx-data-layout';
type RuntimeConfigDocument<T> = {
schema: 'clawx-runtime-config';
version: 1;
importedFromOpenClawAt?: string;
updatedAt: string;
config: T;
};
function runtimeConfigPath(): string {
const layout = getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData')));
return join(layout.appDir, 'runtime-config.json');
}
async function writeAtomic(path: string, value: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
await chmod(temporaryPath, 0o600).catch(() => {});
await rename(temporaryPath, path);
await chmod(path, 0o600).catch(() => {});
}
async function readDocument<T>(): Promise<RuntimeConfigDocument<T> | null> {
try {
const parsed = JSON.parse(await readFile(runtimeConfigPath(), 'utf8')) as Partial<RuntimeConfigDocument<T>>;
if (parsed.schema === 'clawx-runtime-config' && parsed.version === 1 && parsed.config) {
return parsed as RuntimeConfigDocument<T>;
}
} catch {
// Missing canonical config is imported from the compatibility source.
}
return null;
}
export async function readClawXRuntimeConfig<T extends Record<string, unknown>>(options: {
readOpenClawCompatibility: () => Promise<T>;
openClawConfigPath: string;
}): Promise<T> {
const canonicalPath = runtimeConfigPath();
const document = await readDocument<T>();
if (document) return document.config;
const config = await options.readOpenClawCompatibility();
await writeAtomic(canonicalPath, {
schema: 'clawx-runtime-config',
version: 1,
importedFromOpenClawAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
config,
} satisfies RuntimeConfigDocument<T>);
return config;
}
export async function writeClawXRuntimeConfig<T extends Record<string, unknown>>(config: T): Promise<void> {
await writeAtomic(runtimeConfigPath(), {
schema: 'clawx-runtime-config',
version: 1,
updatedAt: new Date().toISOString(),
config,
} satisfies RuntimeConfigDocument<T>);
}
export function getClawXRuntimeConfigPath(): string {
return runtimeConfigPath();
}
+85 -31
View File
@@ -12,6 +12,19 @@ import { getUvMirrorEnv } from './uv-env';
/** Browser Control UI client id used in OpenClaw 2026.5.x connect frames. */
export const CONTROL_UI_BROWSER_CLIENT_ID = 'openclaw-control-ui';
/** ClawX Gateway WebSocket client id used in connect frames. */
export const GATEWAY_UI_CLIENT_ID = 'gateway-client';
/** OpenClaw embedded CLI client id (ACP bridge, doctor, etc.). */
export const OPENCLAW_CLI_CLIENT_ID = 'cli';
/** Loopback-only clients that ClawX auto-approves without user interaction. */
export const LOCAL_AUTO_APPROVE_CLIENT_IDS = new Set([
CONTROL_UI_BROWSER_CLIENT_ID,
GATEWAY_UI_CLIENT_ID,
OPENCLAW_CLI_CLIENT_ID,
]);
export type PendingDevicePairingRequest = {
requestId?: string;
clientId?: string;
@@ -46,6 +59,11 @@ export function isControlUiBrowserPairingRequest(request: PendingDevicePairingRe
return clientId === CONTROL_UI_BROWSER_CLIENT_ID;
}
export function isLocalLoopbackDeviceAutoApprovalRequest(request: PendingDevicePairingRequest): boolean {
const clientId = typeof request.clientId === 'string' ? request.clientId.trim() : '';
return LOCAL_AUTO_APPROVE_CLIENT_IDS.has(clientId);
}
function parseDevicePairingList(value: unknown): DevicePairingList {
const record = typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
return {
@@ -206,10 +224,10 @@ function sleep(ms: number, signal: { cancelled: boolean }): Promise<void> {
}
/**
* Approve pending Control UI browser pairing requests.
* Approve pending loopback device pairing / scope-upgrade requests for trusted local clients.
* Uses Gateway RPC when available; falls back to local pending.json + embedded CLI on Windows packaged builds.
*/
export async function approvePendingControlUiPairingRequests(
export async function approvePendingLocalDeviceRequests(
gateway: GatewayPairingRpcClient,
options?: { approvedRequestIds?: Set<string> },
): Promise<string[]> {
@@ -219,7 +237,7 @@ export async function approvePendingControlUiPairingRequests(
const approved: string[] = [];
for (const request of pending) {
if (!isControlUiBrowserPairingRequest(request)) continue;
if (!isLocalLoopbackDeviceAutoApprovalRequest(request)) continue;
const requestId = typeof request.requestId === 'string' ? request.requestId.trim() : '';
if (!requestId || approvedRequestIds.has(requestId)) continue;
@@ -230,11 +248,11 @@ export async function approvePendingControlUiPairingRequests(
approvedRequestIds.add(requestId);
approved.push(requestId);
logger.info(
`[control-ui] Auto-approved browser device pairing (requestId=${requestId}, mode=${request.clientMode ?? 'unknown'})`,
`[device-auto-approve] Auto-approved local device request (requestId=${requestId}, clientId=${request.clientId ?? 'unknown'}, mode=${request.clientMode ?? 'unknown'})`,
);
} catch (error) {
logger.warn(
`[control-ui] Failed to auto-approve pairing request ${requestId}: ${String(error)}`,
`[device-auto-approve] Failed to auto-approve request ${requestId}: ${String(error)}`,
);
}
}
@@ -242,26 +260,78 @@ export async function approvePendingControlUiPairingRequests(
return approved;
}
async function watchControlUiPairingApprovals(
/**
* Approve pending Control UI browser pairing requests.
* @deprecated Prefer approvePendingLocalDeviceRequests.
*/
export async function approvePendingControlUiPairingRequests(
gateway: GatewayPairingRpcClient,
options?: { approvedRequestIds?: Set<string> },
): Promise<string[]> {
return approvePendingLocalDeviceRequests(gateway, options);
}
async function watchLocalDeviceApprovals(
gateway: GatewayPairingRpcClient,
signal: { cancelled: boolean },
timeoutMs: number,
timeoutMs: number | null,
pollIntervalMs: number,
): Promise<void> {
const approvedRequestIds = new Set<string>();
const deadline = Date.now() + timeoutMs;
const deadline = timeoutMs == null ? null : Date.now() + timeoutMs;
while (!signal.cancelled && Date.now() < deadline) {
while (!signal.cancelled && (deadline == null || Date.now() < deadline)) {
try {
await approvePendingControlUiPairingRequests(gateway, { approvedRequestIds });
await approvePendingLocalDeviceRequests(gateway, { approvedRequestIds });
} catch (error) {
logger.debug(`[control-ui] Pairing poll error: ${String(error)}`);
logger.debug(`[device-auto-approve] Poll error: ${String(error)}`);
}
await sleep(pollIntervalMs, signal);
}
}
export function cancelLocalDeviceAutoApproval(): void {
activeWatcher?.cancel();
activeWatcher = null;
}
/**
* Poll for loopback device pairing / scope-upgrade requests and approve them locally.
* Safe to call repeatedly; only one watcher runs at a time.
*/
export function scheduleLocalDeviceAutoApproval(
gateway: GatewayPairingRpcClient,
options?: {
/** Omit or pass null to poll until cancelLocalDeviceAutoApproval() is called. */
timeoutMs?: number | null;
pollIntervalMs?: number;
},
): void {
activeWatcher?.cancel();
const signal = { cancelled: false };
const cancel = () => {
signal.cancelled = true;
};
activeWatcher = { cancel };
const timeoutMs = options?.timeoutMs === undefined
? null
: options.timeoutMs;
const pollIntervalMs = options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
void watchLocalDeviceApprovals(gateway, signal, timeoutMs, pollIntervalMs)
.catch((error) => {
logger.warn(`[device-auto-approve] Auto-approval watcher failed: ${String(error)}`);
})
.finally(() => {
if (activeWatcher?.cancel === cancel) {
activeWatcher = null;
}
});
}
/**
* Poll for Control UI browser pairing requests and approve them locally.
* Safe to call repeatedly; only one watcher runs at a time.
@@ -273,24 +343,8 @@ export function scheduleControlUiDeviceAutoApproval(
pollIntervalMs?: number;
},
): void {
activeWatcher?.cancel();
const signal = { cancelled: false };
const cancel = () => {
signal.cancelled = true;
};
activeWatcher = { cancel };
const timeoutMs = resolveWatchTimeoutMs(options?.timeoutMs);
const pollIntervalMs = options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
void watchControlUiPairingApprovals(gateway, signal, timeoutMs, pollIntervalMs)
.catch((error) => {
logger.warn(`[control-ui] Auto-approval watcher failed: ${String(error)}`);
})
.finally(() => {
if (activeWatcher?.cancel === cancel) {
activeWatcher = null;
}
});
scheduleLocalDeviceAutoApproval(gateway, {
timeoutMs: resolveWatchTimeoutMs(options?.timeoutMs),
pollIntervalMs: options?.pollIntervalMs,
});
}
+23 -3
View File
@@ -11,6 +11,7 @@ import { app } from 'electron';
import { join } from 'path';
import { existsSync, mkdirSync, appendFileSync } from 'fs';
import { appendFile, open, readdir, stat } from 'fs/promises';
import { getClawXDataLayout } from './clawx-data-layout';
/**
* Log levels
@@ -80,8 +81,27 @@ function flushBufferSync(): void {
writeBuffer = [];
}
// Ensure all buffered data reaches disk before the process exits.
process.on('exit', flushBufferSync);
type LoggerGlobalState = typeof globalThis & {
__clawxLoggerExitFlushers?: Set<() => void>;
__clawxLoggerExitHandlerRegistered?: boolean;
};
const loggerGlobalState = globalThis as LoggerGlobalState;
const loggerExitFlushers = loggerGlobalState.__clawxLoggerExitFlushers ?? new Set<() => void>();
loggerGlobalState.__clawxLoggerExitFlushers = loggerExitFlushers;
loggerExitFlushers.add(flushBufferSync);
// Ensure all buffered data reaches disk before the process exits. Vitest can
// reload this module many times, so keep one process listener and fan out to
// each module instance's buffer flusher.
if (!loggerGlobalState.__clawxLoggerExitHandlerRegistered) {
process.on('exit', () => {
for (const flush of loggerExitFlushers) {
flush();
}
});
loggerGlobalState.__clawxLoggerExitHandlerRegistered = true;
}
// ── Initialisation ───────────────────────────────────────────────
@@ -95,7 +115,7 @@ export function initLogger(): void {
currentLevel = LogLevel.INFO;
}
logDir = join(app.getPath('userData'), 'logs');
logDir = getClawXDataLayout().logsDir;
if (!existsSync(logDir)) {
mkdirSync(logDir, { recursive: true });
+5 -1
View File
@@ -26,6 +26,7 @@ const SUCCESS_HTML = `<!doctype html>
export interface OpenAICodexOAuthCredentials {
access: string;
refresh: string;
idToken?: string;
expires: number;
accountId: string;
email?: string;
@@ -219,7 +220,7 @@ function startLocalOAuthServer(state: string): Promise<OpenAICodexLocalServer |
async function exchangeAuthorizationCode(
code: string,
verifier: string,
): Promise<{ access: string; refresh: string; expires: number }> {
): Promise<{ access: string; refresh: string; idToken?: string; expires: number }> {
const response = await proxyAwareFetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -240,6 +241,7 @@ async function exchangeAuthorizationCode(
const json = await response.json() as {
access_token?: string;
refresh_token?: string;
id_token?: string;
expires_in?: number;
};
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== 'number') {
@@ -249,6 +251,7 @@ async function exchangeAuthorizationCode(
return {
access: json.access_token,
refresh: json.refresh_token,
idToken: typeof json.id_token === 'string' && json.id_token.trim() ? json.id_token.trim() : undefined,
expires: Date.now() + json.expires_in * 1000,
};
}
@@ -306,6 +309,7 @@ export async function loginOpenAICodexOAuth(options: {
return {
access: token.access,
refresh: token.refresh,
idToken: token.idToken,
expires: token.expires,
accountId,
email: getEmailFromAccessToken(token.access),
+297
View File
@@ -0,0 +1,297 @@
/**
* OpenClaw 2026.6+ persists agent auth in openclaw-agent.sqlite.
* ClawX historically wrote auth-profiles.json only; gateway runtime reads SQLite.
*/
import { chmodSync, existsSync, mkdirSync } from 'fs';
import { access, readFile } from 'fs/promises';
import { constants } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { DatabaseSync } from 'node:sqlite';
const AUTH_PROFILE_FILENAME = 'auth-profiles.json';
const AUTH_SQLITE_FILENAME = 'openclaw-agent.sqlite';
const PRIMARY_ROW_KEY = 'primary';
const SCHEMA_VERSION = 1;
const OPENCLAW_AGENT_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS schema_meta (
meta_key TEXT NOT NULL PRIMARY KEY,
role TEXT NOT NULL,
schema_version INTEGER NOT NULL,
agent_id TEXT,
app_version TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS cache_entries (
scope TEXT NOT NULL,
key TEXT NOT NULL,
value_json TEXT,
blob BLOB,
expires_at INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (scope, key)
);
CREATE INDEX IF NOT EXISTS idx_agent_cache_expiry
ON cache_entries(scope, expires_at, key)
WHERE expires_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_agent_cache_updated
ON cache_entries(scope, updated_at DESC, key);
CREATE TABLE IF NOT EXISTS auth_profile_store (
store_key TEXT NOT NULL PRIMARY KEY,
store_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS auth_profile_state (
state_key TEXT NOT NULL PRIMARY KEY,
state_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`;
export interface PersistedAuthProfileCredential {
type: string;
provider: string;
key?: string;
access?: string;
refresh?: string;
expires?: number;
email?: string;
projectId?: string;
[extra: string]: unknown;
}
export interface PersistedAuthProfilesStore {
version: number;
profiles: Record<string, PersistedAuthProfileCredential>;
order?: Record<string, string[]>;
lastGood?: Record<string, string>;
usageStats?: Record<string, unknown>;
}
function getAgentAuthDir(agentId: string): string {
return join(homedir(), '.openclaw', 'agents', agentId, 'agent');
}
export function getAuthProfilesJsonPath(agentId: string): string {
return join(getAgentAuthDir(agentId), AUTH_PROFILE_FILENAME);
}
export function getAuthProfilesSqlitePath(agentId: string): string {
return join(getAgentAuthDir(agentId), AUTH_SQLITE_FILENAME);
}
function ensureAgentAuthDir(agentId: string): void {
const dir = getAgentAuthDir(agentId);
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
function ensureDatabaseSchema(db: DatabaseSync, agentId: string): void {
db.exec(OPENCLAW_AGENT_SCHEMA_SQL);
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION};`);
const now = Date.now();
db.prepare(`
INSERT INTO schema_meta (
meta_key, role, schema_version, agent_id, app_version, created_at, updated_at
) VALUES (?, 'agent', ?, ?, NULL, ?, ?)
ON CONFLICT(meta_key) DO UPDATE SET
role = excluded.role,
schema_version = excluded.schema_version,
agent_id = excluded.agent_id,
updated_at = excluded.updated_at
`).run(PRIMARY_ROW_KEY, SCHEMA_VERSION, agentId, now, now);
}
function tightenDatabasePermissions(sqlitePath: string): void {
try {
if (process.platform !== 'win32') {
chmodSync(sqlitePath, 0o600);
for (const suffix of ['-wal', '-shm']) {
const sidecar = `${sqlitePath}${suffix}`;
if (existsSync(sidecar)) {
chmodSync(sidecar, 0o600);
}
}
}
} catch {
// Best-effort; Windows ACLs differ from POSIX modes.
}
}
function parseJsonCell(raw: string | null | undefined): Record<string, unknown> | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : null;
} catch {
return null;
}
}
function coerceAuthProfilesStore(raw: Record<string, unknown> | null): PersistedAuthProfilesStore | null {
if (!raw || typeof raw !== 'object') return null;
const profiles = raw.profiles;
if (!profiles || typeof profiles !== 'object') return null;
const version = typeof raw.version === 'number' ? raw.version : 1;
const store: PersistedAuthProfilesStore = {
version,
profiles: profiles as Record<string, PersistedAuthProfileCredential>,
};
if (raw.order && typeof raw.order === 'object') {
store.order = raw.order as Record<string, string[]>;
}
if (raw.lastGood && typeof raw.lastGood === 'object') {
store.lastGood = raw.lastGood as Record<string, string>;
}
if (raw.usageStats && typeof raw.usageStats === 'object') {
store.usageStats = raw.usageStats as Record<string, unknown>;
}
return store;
}
function buildSecretsPayload(store: PersistedAuthProfilesStore): Record<string, unknown> {
return {
version: store.version ?? 1,
profiles: store.profiles,
};
}
function buildStatePayload(store: PersistedAuthProfilesStore): Record<string, unknown> | null {
if (!store.order && !store.lastGood && !store.usageStats) {
return null;
}
return {
version: 1,
...(store.order ? { order: store.order } : {}),
...(store.lastGood ? { lastGood: store.lastGood } : {}),
...(store.usageStats ? { usageStats: store.usageStats } : {}),
};
}
function mergeStoreAndState(
secrets: Record<string, unknown> | null,
state: Record<string, unknown> | null,
): PersistedAuthProfilesStore | null {
const base = coerceAuthProfilesStore(secrets);
if (!base) return null;
if (!state) return base;
if (state.order && typeof state.order === 'object') {
base.order = state.order as Record<string, string[]>;
}
if (state.lastGood && typeof state.lastGood === 'object') {
base.lastGood = state.lastGood as Record<string, string>;
}
if (state.usageStats && typeof state.usageStats === 'object') {
base.usageStats = state.usageStats as Record<string, unknown>;
}
return base;
}
function hasPersistedProfiles(store: PersistedAuthProfilesStore | null | undefined): boolean {
return !!store && Object.keys(store.profiles).length > 0;
}
function openAgentDatabase(agentId: string, sqlitePath: string): DatabaseSync {
ensureAgentAuthDir(agentId);
const db = new DatabaseSync(sqlitePath);
db.exec('PRAGMA synchronous = NORMAL;');
db.exec('PRAGMA busy_timeout = 5000;');
db.exec('PRAGMA foreign_keys = ON;');
ensureDatabaseSchema(db, agentId);
return db;
}
export function readAuthProfilesFromSqlite(agentId: string): PersistedAuthProfilesStore | null {
const sqlitePath = getAuthProfilesSqlitePath(agentId);
if (!existsSync(sqlitePath)) {
return null;
}
const db = new DatabaseSync(sqlitePath, { readOnly: true });
try {
const storeRow = db.prepare(
'SELECT store_json FROM auth_profile_store WHERE store_key = ?',
).get(PRIMARY_ROW_KEY) as { store_json?: string } | undefined;
const stateRow = db.prepare(
'SELECT state_json FROM auth_profile_state WHERE state_key = ?',
).get(PRIMARY_ROW_KEY) as { state_json?: string } | undefined;
return mergeStoreAndState(
parseJsonCell(storeRow?.store_json),
parseJsonCell(stateRow?.state_json),
);
} catch (error) {
console.warn(`Failed to read auth profiles from SQLite (${sqlitePath}):`, error);
return null;
} finally {
db.close();
}
}
export function writeAuthProfilesToSqlite(
store: PersistedAuthProfilesStore,
agentId: string,
): void {
const sqlitePath = getAuthProfilesSqlitePath(agentId);
const db = openAgentDatabase(agentId, sqlitePath);
try {
const now = Date.now();
const secretsPayload = JSON.stringify(buildSecretsPayload(store));
db.prepare(`
INSERT INTO auth_profile_store (store_key, store_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(store_key) DO UPDATE SET
store_json = excluded.store_json,
updated_at = excluded.updated_at
`).run(PRIMARY_ROW_KEY, secretsPayload, now);
const statePayload = buildStatePayload(store);
if (statePayload) {
db.prepare(`
INSERT INTO auth_profile_state (state_key, state_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(state_key) DO UPDATE SET
state_json = excluded.state_json,
updated_at = excluded.updated_at
`).run(PRIMARY_ROW_KEY, JSON.stringify(statePayload), now);
} else {
db.prepare('DELETE FROM auth_profile_state WHERE state_key = ?').run(PRIMARY_ROW_KEY);
}
} finally {
db.close();
tightenDatabasePermissions(sqlitePath);
}
}
export async function readAuthProfilesJson(agentId: string): Promise<PersistedAuthProfilesStore | null> {
const jsonPath = getAuthProfilesJsonPath(agentId);
try {
await access(jsonPath, constants.F_OK);
const raw = JSON.parse(await readFile(jsonPath, 'utf-8')) as Record<string, unknown>;
return coerceAuthProfilesStore(raw);
} catch {
return null;
}
}
export async function migrateAuthProfilesJsonToSqliteIfNeeded(agentId: string): Promise<boolean> {
const sqliteStore = readAuthProfilesFromSqlite(agentId);
if (hasPersistedProfiles(sqliteStore)) {
return false;
}
const jsonStore = await readAuthProfilesJson(agentId);
if (!hasPersistedProfiles(jsonStore)) {
return false;
}
writeAuthProfilesToSqlite(jsonStore!, agentId);
console.log(
`[auth-sync] Migrated auth-profiles.json to SQLite for agent "${agentId}"`,
);
return true;
}
+644 -47
View File
@@ -1,7 +1,8 @@
/**
* OpenClaw Auth Profiles Utility
* Writes API keys to configured OpenClaw agent auth-profiles.json files
* so the OpenClaw Gateway can load them for AI provider calls.
* Writes API keys to OpenClaw agent auth storage (SQLite primary since 2026.6+,
* with auth-profiles.json kept for migration compatibility) so the Gateway can
* load them for AI provider calls.
*
* All file I/O is asynchronous (fs/promises) to avoid blocking the
* Electron main thread. On Windows + NTFS + Defender the synchronous
@@ -28,16 +29,25 @@ import {
} from './provider-keys';
import { normalizePiAiModelCost, type PiAiModelCostRates } from '../shared/pi-ai-model-cost';
import { withConfigLock } from './config-mutex';
import { ensureMemorySearchDisabledDefault, hasUserMemorySearchConfig } from './openclaw-memory-search';
import { PORTS } from './config';
import { getSetting } from './store';
import {
OPENCLAW_API_PROTOCOLS,
assertValidApiProtocol,
normalizeOpenClawApiProtocol,
} from '../shared/providers/types';
import { inferCustomModelContextWindow, inferCustomModelInputModalities } from '../shared/providers/model-capabilities';
import {
CLAWX_OPENAI_IMAGE_DEFAULT_MODEL,
CLAWX_OPENAI_IMAGE_PROVIDER_KEY,
} from './openclaw-image-relay-constants';
import {
migrateAuthProfilesJsonToSqliteIfNeeded,
readAuthProfilesFromSqlite,
readAuthProfilesJson,
writeAuthProfilesToSqlite,
type PersistedAuthProfilesStore,
} from './openclaw-auth-sqlite';
const AUTH_STORE_VERSION = 1;
const AUTH_PROFILE_FILENAME = 'auth-profiles.json';
@@ -321,14 +331,10 @@ interface OAuthProfileEntry {
expires: number;
email?: string;
projectId?: string;
accountId?: string;
}
interface AuthProfilesStore {
version: number;
profiles: Record<string, AuthProfileEntry | OAuthProfileEntry>;
order?: Record<string, string[]>;
lastGood?: Record<string, string>;
}
type AuthProfilesStore = PersistedAuthProfilesStore;
function removeProfilesForProvider(store: AuthProfilesStore, provider: string): boolean {
const removedProfileIds = new Set<string>();
@@ -413,22 +419,42 @@ function getAuthProfilesPath(agentId = 'main'): string {
}
async function readAuthProfiles(agentId = 'main'): Promise<AuthProfilesStore> {
const filePath = getAuthProfilesPath(agentId);
try {
const data = await readJsonFile<AuthProfilesStore>(filePath);
if (data?.version && data.profiles && typeof data.profiles === 'object') {
return data;
}
} catch (error) {
console.warn('Failed to read auth-profiles.json, creating fresh store:', error);
const sqliteStore = readAuthProfilesFromSqlite(agentId);
if (sqliteStore?.profiles && Object.keys(sqliteStore.profiles).length > 0) {
return sqliteStore;
}
const jsonStore = await readAuthProfilesJson(agentId);
if (jsonStore?.profiles && Object.keys(jsonStore.profiles).length > 0) {
try {
writeAuthProfilesToSqlite(jsonStore, agentId);
console.log(`[auth-sync] Backfilled SQLite auth store from JSON for agent "${agentId}"`);
} catch (error) {
console.warn(`Failed to backfill SQLite auth store for agent "${agentId}":`, error);
}
return jsonStore;
}
return { version: AUTH_STORE_VERSION, profiles: {} };
}
async function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): Promise<void> {
writeAuthProfilesToSqlite(store, agentId);
await writeJsonFile(getAuthProfilesPath(agentId), store);
}
/** Migrate legacy JSON-only auth profiles into SQLite for all configured agents. */
export async function migrateAllAgentAuthProfilesToSqlite(): Promise<void> {
const agentIds = await discoverAgentIds();
for (const agentId of agentIds) {
try {
await migrateAuthProfilesJsonToSqliteIfNeeded(agentId);
} catch (error) {
console.warn(`Failed to migrate auth profiles to SQLite for agent "${agentId}":`, error);
}
}
}
function getApiKeyFromAuthProfilesStore(
store: AuthProfilesStore,
provider: string,
@@ -497,6 +523,8 @@ async function discoverAgentIds(): Promise<string[]> {
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const;
const VALID_COMPACTION_MODES = new Set(['default', 'safeguard']);
/** Matches OpenClaw's 200k+ context-window recommendation (see computeContextAwareReserveTokensFloor). */
const DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR = 50_000;
const BUILTIN_CHANNEL_IDS = new Set([
'discord',
'telegram',
@@ -621,6 +649,7 @@ function normalizeAuthProfileProviderKey(provider: string): string {
function addProvidersFromProfileEntries(
profiles: Record<string, unknown> | undefined,
target: Set<string>,
options?: { includeRawKeys?: boolean },
): void {
if (!profiles || typeof profiles !== 'object') {
return;
@@ -631,17 +660,28 @@ function addProvidersFromProfileEntries(
? ((profile as Record<string, unknown>).provider as string)
: undefined;
if (!provider) continue;
target.add(normalizeAuthProfileProviderKey(provider));
const normalized = normalizeAuthProfileProviderKey(provider);
target.add(normalized);
// The raw runtime key (e.g. "openai-codex") matters for active-provider
// checks: filterActiveProviderKeysForUi() and the OAuth account matching
// in ProviderService.listAccounts() both key off it. Newer OpenClaw
// versions no longer keep explicit models.providers/plugins entries for
// these providers, so the auth profile is the only remaining signal.
if (options?.includeRawKeys && provider !== normalized) {
target.add(provider);
}
}
}
async function getProvidersFromAuthProfileStores(): Promise<Set<string>> {
async function getProvidersFromAuthProfileStores(
options?: { includeRawKeys?: boolean },
): Promise<Set<string>> {
const providers = new Set<string>();
const agentIds = await discoverAgentIds();
for (const agentId of agentIds) {
const store = await readAuthProfiles(agentId);
addProvidersFromProfileEntries(store.profiles, providers);
addProvidersFromProfileEntries(store.profiles, providers, options);
}
return providers;
@@ -674,9 +714,13 @@ async function collectActiveProviderIdsFromConfig(config: Record<string, unknown
}
const auth = config.auth as Record<string, unknown> | undefined;
addProvidersFromProfileEntries(auth?.profiles as Record<string, unknown> | undefined, activeProviders);
addProvidersFromProfileEntries(
auth?.profiles as Record<string, unknown> | undefined,
activeProviders,
{ includeRawKeys: true },
);
const authProfileProviders = await getProvidersFromAuthProfileStores();
const authProfileProviders = await getProvidersFromAuthProfileStores({ includeRawKeys: true });
for (const provider of authProfileProviders) {
activeProviders.add(provider);
}
@@ -806,6 +850,86 @@ function normalizeAgentsDefaultsCompactionMode(config: Record<string, unknown>):
}
}
/**
* Seed `agents.defaults.compaction.mode = "safeguard"` when the user has no
* compaction config at all, so long sessions are compacted before they hit the
* provider's context limit. Never touches an existing compaction object.
*/
function ensureCompactionSafeguardDefault(config: Record<string, unknown>): boolean {
const agents = (config.agents && typeof config.agents === 'object'
? config.agents as Record<string, unknown>
: {});
const defaults = (agents.defaults && typeof agents.defaults === 'object'
? agents.defaults as Record<string, unknown>
: {});
if (defaults.compaction !== undefined) return false;
defaults.compaction = {
mode: 'safeguard',
reserveTokensFloor: DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR,
};
agents.defaults = defaults;
config.agents = agents;
return true;
}
/**
* Backfill `reserveTokensFloor` on compaction configs that ClawX or OpenClaw
* seeded without one. OpenClaw's built-in default (20k) is too low once
* contextWindow backfill activates safeguard compaction on 200k+ models.
*/
function backfillCompactionReserveTokensFloor(config: Record<string, unknown>): boolean {
const agents = (config.agents && typeof config.agents === 'object'
? config.agents as Record<string, unknown>
: null);
if (!agents) return false;
const defaults = (agents.defaults && typeof agents.defaults === 'object'
? agents.defaults as Record<string, unknown>
: null);
if (!defaults) return false;
const compaction = (defaults.compaction && typeof defaults.compaction === 'object'
? defaults.compaction as Record<string, unknown>
: null);
if (!compaction || compaction.reserveTokensFloor !== undefined) return false;
compaction.reserveTokensFloor = DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR;
defaults.compaction = compaction;
agents.defaults = defaults;
config.agents = agents;
return true;
}
/**
* Self-heal helper: walk `models.providers.custom-*` entries and fill in an
* inferred `contextWindow` on model rows that have neither `contextWindow`
* nor `contextTokens`. Rows written by older ClawX versions only carried
* `{ id, name, input }`, which disables OpenClaw's preemptive compaction and
* context-window guard for custom providers.
*
* Deliberately scoped to `custom-` keys: registry providers own their
* metadata, and small local models (ollama) must not inherit a large window.
*/
function backfillCustomProviderModelContextWindows(config: Record<string, unknown>): string[] {
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
const backfilled: string[] = [];
for (const [providerKey, entry] of Object.entries(providers)) {
if (!providerKey.startsWith('custom-') || !isPlainRecord(entry)) continue;
const rows = Array.isArray(entry.models) ? entry.models : [];
for (const row of rows) {
if (!isPlainRecord(row) || typeof row.id !== 'string' || !row.id) continue;
if (typeof row.contextWindow === 'number' || typeof row.contextTokens === 'number') continue;
row.contextWindow = inferCustomModelContextWindow(row.id);
backfilled.push(`${providerKey}/${row.id}`);
}
}
return backfilled;
}
async function writeOpenClawJson(config: Record<string, unknown>): Promise<void> {
normalizeAgentsDefaultsCompactionMode(config);
@@ -828,7 +952,14 @@ async function writeOpenClawJson(config: Record<string, unknown>): Promise<void>
*/
export async function saveOAuthTokenToOpenClaw(
provider: string,
token: { access: string; refresh: string; expires: number; email?: string; projectId?: string },
token: {
access: string;
refresh: string;
expires: number;
email?: string;
projectId?: string;
accountId?: string;
},
agentId?: string
): Promise<void> {
const agentIds = agentId ? [agentId] : await discoverAgentIds();
@@ -846,6 +977,7 @@ export async function saveOAuthTokenToOpenClaw(
expires: token.expires,
email: token.email,
projectId: token.projectId,
accountId: token.accountId ?? token.projectId,
};
if (!store.order) store.order = {};
@@ -945,6 +1077,123 @@ export async function removeProviderKeyFromOpenClaw(
/**
* Remove a provider completely from OpenClaw (delete config, disable plugins, delete keys)
*/
function getModelRefProviderKey(modelRef: string): string | null {
const separatorIndex = modelRef.indexOf('/');
if (separatorIndex <= 0 || separatorIndex >= modelRef.length - 1) {
return null;
}
return modelRef.slice(0, separatorIndex);
}
function removeProviderPrefixFromModelConfig(
modelCfg: Record<string, unknown>,
prefix: string,
): boolean {
let modified = false;
if (typeof modelCfg.primary === 'string' && modelCfg.primary.startsWith(prefix)) {
delete modelCfg.primary;
modified = true;
}
if (Array.isArray(modelCfg.fallbacks)) {
const filtered = (modelCfg.fallbacks as string[]).filter((fallback) => !fallback.startsWith(prefix));
if (filtered.length !== modelCfg.fallbacks.length) {
modelCfg.fallbacks = filtered.length > 0 ? filtered : undefined;
modified = true;
}
}
return modified;
}
function deleteModelConfigIfEmpty(parent: Record<string, unknown>): void {
const modelCfg = parent.model;
if (!isPlainRecord(modelCfg)) return;
const hasPrimary = typeof modelCfg.primary === 'string' && modelCfg.primary.trim();
const hasFallbacks = Array.isArray(modelCfg.fallbacks) && modelCfg.fallbacks.length > 0;
if (!hasPrimary && !hasFallbacks) {
delete parent.model;
}
}
const RUNTIME_GENERATED_PROVIDER_KEY = /^(custom|ollama)-[a-z0-9]+$/i;
function isRuntimeGeneratedProviderKey(providerKey: string): boolean {
return RUNTIME_GENERATED_PROVIDER_KEY.test(providerKey);
}
function pruneStaleRuntimeModelConfig(
modelCfg: Record<string, unknown>,
activeProviders: Set<string>,
context: string,
): boolean {
let modified = false;
const primary = typeof modelCfg.primary === 'string' ? modelCfg.primary.trim() : '';
if (primary) {
const providerKey = getModelRefProviderKey(primary);
if (
providerKey
&& isRuntimeGeneratedProviderKey(providerKey)
&& !activeProviders.has(providerKey)
) {
delete modelCfg.primary;
modified = true;
console.log(`Removed stale runtime model ref "${primary}" from ${context}`);
}
}
if (Array.isArray(modelCfg.fallbacks)) {
const filtered = (modelCfg.fallbacks as string[]).filter((fallback) => {
const providerKey = getModelRefProviderKey(fallback);
if (!providerKey) return true;
if (!isRuntimeGeneratedProviderKey(providerKey)) return true;
return activeProviders.has(providerKey);
});
if (filtered.length !== modelCfg.fallbacks.length) {
modelCfg.fallbacks = filtered.length > 0 ? filtered : undefined;
modified = true;
}
}
return modified;
}
/**
* Drop agent model refs that point at deleted custom/ollama runtime providers.
* Built-in providers are left intact because they may still resolve via auth/env.
*/
export async function pruneStaleRuntimeAgentModelRefs(config: Record<string, unknown>): Promise<boolean> {
const activeProviders = await getActiveOpenClawProviders();
const agents = config.agents;
if (!isPlainRecord(agents)) return false;
let modified = false;
const agentDefaults = agents.defaults;
if (isPlainRecord(agentDefaults) && isPlainRecord(agentDefaults.model)) {
if (pruneStaleRuntimeModelConfig(agentDefaults.model, activeProviders, 'agents.defaults.model')) {
deleteModelConfigIfEmpty(agentDefaults);
modified = true;
}
}
if (Array.isArray(agents.list)) {
for (const entry of agents.list) {
if (!isPlainRecord(entry) || !isPlainRecord(entry.model)) continue;
const agentId = typeof entry.id === 'string' ? entry.id : 'unknown';
if (pruneStaleRuntimeModelConfig(entry.model, activeProviders, `agent "${agentId}" model override`)) {
deleteModelConfigIfEmpty(entry);
modified = true;
}
}
}
return modified;
}
export async function removeProviderFromOpenClaw(provider: string): Promise<void> {
// 1. Remove from auth-profiles.json.
// We must also remove entries whose raw `provider` field maps to this UI
@@ -1032,29 +1281,32 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
}
}
// Clean up agents.defaults.model references that point to the deleted provider.
// Clean up agent model references that point to the deleted provider.
// Model refs use the format "providerType/modelId", e.g. "openai/gpt-4".
// Leaving stale refs causes the Gateway to report "Unknown model" errors.
const agents = config.agents as Record<string, unknown> | undefined;
const providerPrefix = `${provider}/`;
const agentDefaults = (agents?.defaults && typeof agents.defaults === 'object'
? agents.defaults as Record<string, unknown>
: null);
if (agentDefaults?.model && typeof agentDefaults.model === 'object') {
const modelCfg = agentDefaults.model as Record<string, unknown>;
const prefix = `${provider}/`;
if (typeof modelCfg.primary === 'string' && modelCfg.primary.startsWith(prefix)) {
delete modelCfg.primary;
if (removeProviderPrefixFromModelConfig(modelCfg, providerPrefix)) {
deleteModelConfigIfEmpty(agentDefaults);
modified = true;
console.log(`Removed deleted provider "${provider}" from agents.defaults.model.primary`);
console.log(`Removed deleted provider "${provider}" from agents.defaults.model`);
}
}
if (Array.isArray(modelCfg.fallbacks)) {
const filtered = (modelCfg.fallbacks as string[]).filter((fb) => !fb.startsWith(prefix));
if (filtered.length !== modelCfg.fallbacks.length) {
modelCfg.fallbacks = filtered.length > 0 ? filtered : undefined;
const agentList = agents?.list;
if (Array.isArray(agentList)) {
for (const entry of agentList) {
if (!isPlainRecord(entry) || !isPlainRecord(entry.model)) continue;
const agentId = typeof entry.id === 'string' ? entry.id : 'unknown';
if (removeProviderPrefixFromModelConfig(entry.model, providerPrefix)) {
deleteModelConfigIfEmpty(entry);
modified = true;
console.log(`Removed deleted provider "${provider}" from agents.defaults.model.fallbacks`);
console.log(`Removed deleted provider "${provider}" from agent "${agentId}" model override`);
}
}
}
@@ -1079,6 +1331,118 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
*
* Returns the list of pruned provider keys for logging.
*/
function repairLegacyApiProtocolEntriesInConfig(config: Record<string, unknown>): string[] {
const migrated: string[] = [];
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
for (const [key, entry] of Object.entries(providers)) {
if (!isPlainRecord(entry)) continue;
const entryObj = entry as Record<string, unknown>;
const api = entryObj.api;
const normalized = normalizeOpenClawApiProtocol(api);
if (normalized && normalized !== api) {
entryObj.api = normalized;
migrated.push(key);
}
}
return migrated;
}
/** ChatGPT/Codex OAuth must not use the Platform API base URL. */
export const OPENAI_CODEX_OAUTH_BASE_URL = 'https://chatgpt.com/backend-api/codex';
function isOpenAiPlatformBaseUrl(baseUrl: unknown): boolean {
if (typeof baseUrl !== 'string') return false;
return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(baseUrl.trim());
}
function resolveOpenAiCodexOAuthBaseUrl(baseUrl: string, api: string): string {
if (normalizeOpenClawApiProtocol(api) !== 'openai-chatgpt-responses') {
return baseUrl;
}
if (isOpenAiPlatformBaseUrl(baseUrl)) {
return OPENAI_CODEX_OAUTH_BASE_URL;
}
return baseUrl;
}
function repairOpenAiCodexOAuthProviderEntriesInConfig(config: Record<string, unknown>): string[] {
const repaired: string[] = [];
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
for (const [key, entry] of Object.entries(providers)) {
if (!isPlainRecord(entry)) continue;
const entryObj = entry as Record<string, unknown>;
const api = normalizeOpenClawApiProtocol(entryObj.api);
if (api !== 'openai-chatgpt-responses') continue;
if (!isOpenAiPlatformBaseUrl(entryObj.baseUrl)) continue;
entryObj.baseUrl = OPENAI_CODEX_OAUTH_BASE_URL;
repaired.push(key);
}
return repaired;
}
function rewriteOpenAiCodexModelRef(modelRef: unknown): string | undefined {
if (typeof modelRef !== 'string') return undefined;
return modelRef.replace(/^openai-codex\//, 'openai/');
}
/** Move legacy OAuth runtime config from `openai-codex` to canonical `openai`. */
function migrateOpenAiCodexOAuthRuntimeToOpenAiInConfig(config: Record<string, unknown>): string[] {
const migrated: string[] = [];
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
const codexEntry = providers['openai-codex'];
if (isPlainRecord(codexEntry)) {
const codexApi = normalizeOpenClawApiProtocol(codexEntry.api);
if (codexApi === 'openai-chatgpt-responses') {
const existingOpenAi = isPlainRecord(providers.openai) ? providers.openai as Record<string, unknown> : {};
providers.openai = {
...existingOpenAi,
...codexEntry,
baseUrl: OPENAI_CODEX_OAUTH_BASE_URL,
api: 'openai-chatgpt-responses',
agentRuntime: isPlainRecord(existingOpenAi.agentRuntime)
? existingOpenAi.agentRuntime
: { id: 'pi' },
};
delete providers['openai-codex'];
migrated.push('openai-codex->openai');
}
}
const agents = (config.agents || {}) as Record<string, unknown>;
const defaults = (agents.defaults || {}) as Record<string, unknown>;
const modelDefaults = (defaults.model || {}) as Record<string, unknown>;
const primary = rewriteOpenAiCodexModelRef(modelDefaults.primary);
if (primary && primary !== modelDefaults.primary) {
modelDefaults.primary = primary;
migrated.push('default-model-ref');
}
if (Array.isArray(modelDefaults.fallbacks)) {
const fallbacks = modelDefaults.fallbacks as unknown[];
const nextFallbacks = fallbacks.map((fallback) => rewriteOpenAiCodexModelRef(fallback) ?? fallback);
if (nextFallbacks.some((fallback, index) => fallback !== fallbacks[index])) {
modelDefaults.fallbacks = nextFallbacks;
migrated.push('default-model-fallbacks');
}
}
if (migrated.length > 0) {
defaults.model = modelDefaults;
agents.defaults = defaults;
config.agents = agents;
models.providers = providers;
config.models = models;
}
return migrated;
}
export async function pruneInvalidApiProviderEntries(): Promise<string[]> {
const removed: string[] = [];
await withConfigLock(async () => {
@@ -1087,9 +1451,24 @@ export async function pruneInvalidApiProviderEntries(): Promise<string[]> {
const providers = (models.providers || {}) as Record<string, unknown>;
let modified = false;
const migrated = repairLegacyApiProtocolEntriesInConfig(config);
if (migrated.length > 0) {
modified = true;
}
const repairedCodexBaseUrls = repairOpenAiCodexOAuthProviderEntriesInConfig(config);
if (repairedCodexBaseUrls.length > 0) {
modified = true;
}
const migratedCodexRuntime = migrateOpenAiCodexOAuthRuntimeToOpenAiInConfig(config);
if (migratedCodexRuntime.length > 0) {
modified = true;
}
for (const [key, entry] of Object.entries(providers)) {
const api = isPlainRecord(entry) ? (entry as Record<string, unknown>).api : undefined;
if (typeof api !== 'string' || !(OPENCLAW_API_PROTOCOLS as readonly string[]).includes(api)) {
if (!normalizeOpenClawApiProtocol(api)) {
delete providers[key];
removed.push(key);
modified = true;
@@ -1167,19 +1546,31 @@ export async function setOpenClawDefaultModel(
});
console.log(`Configured models.providers.${provider} with baseUrl=${providerCfg.baseUrl}, model=${modelId}`);
} else if (provider === 'openai-codex') {
// OAuth Codex is not in the UI registry but still needs an explicit provider
// entry with a pinned embedded runtime (see OPENCLAW_PROVIDER_PINNED_AGENT_RUNTIME).
upsertOpenClawProviderEntry(config, provider, {
// Legacy runtime key: OpenClaw Codex hooks only apply to canonical `openai`.
const oauthModel = model.replace(/^openai-codex\//, 'openai/');
const oauthFallbacks = fallbackModels.map((fallback) => fallback.replace(/^openai-codex\//, 'openai/'));
defaults.model = {
primary: oauthModel,
fallbacks: oauthFallbacks,
};
agents.defaults = defaults;
config.agents = agents;
upsertOpenClawProviderEntry(config, 'openai', {
baseUrl: OPENAI_CODEX_OAUTH_PROVIDER_CONFIG.baseUrl,
api: OPENAI_CODEX_OAUTH_PROVIDER_CONFIG.api,
modelIds: [modelId, ...fallbackModelIds],
mergeExistingModels: true,
});
if (isOpenClawOAuthPluginProviderKey(provider)) {
ensureOAuthPluginEnabled(config, provider);
const modelsConfig = (config.models || {}) as Record<string, unknown>;
const providerEntries = (modelsConfig.providers || {}) as Record<string, unknown>;
if (providerEntries['openai-codex']) {
delete providerEntries['openai-codex'];
modelsConfig.providers = providerEntries;
config.models = modelsConfig;
}
console.log(
`Configured models.providers.${provider} for OAuth (api=${OPENAI_CODEX_OAUTH_PROVIDER_CONFIG.api})`,
`Configured models.providers.openai for OAuth (api=${OPENAI_CODEX_OAUTH_PROVIDER_CONFIG.api})`,
);
} else {
// Built-in provider: remove any stale models.providers entry
@@ -1221,6 +1612,7 @@ type ProviderEntryBuildOptions = {
modelIds?: string[];
includeRegistryModels?: boolean;
mergeExistingModels?: boolean;
inferRuntimeModelInputs?: boolean;
};
function normalizeModelRef(provider: string, modelOverride?: string): string | undefined {
@@ -1451,8 +1843,8 @@ const OPENCLAW_PROVIDER_PINNED_AGENT_RUNTIME: Record<string, string> = {
/** Runtime models.providers entry for OpenAI Codex OAuth accounts. */
export const OPENAI_CODEX_OAUTH_PROVIDER_CONFIG = {
baseUrl: 'https://api.openai.com/v1',
api: 'openai-codex-responses' as const,
baseUrl: OPENAI_CODEX_OAUTH_BASE_URL,
api: 'openai-chatgpt-responses' as const,
};
function applyPinnedAgentRuntime(
@@ -1515,7 +1907,18 @@ function upsertOpenClawProviderEntry(
const registryModels = options.includeRegistryModels
? ((getProviderConfig(provider)?.models ?? []).map((m) => ({ ...m })) as Array<Record<string, unknown>>)
: [];
const runtimeModels = (options.modelIds ?? []).map((id) => ({ id, name: id }));
const runtimeModels = (options.modelIds ?? []).map((id) => ({
id,
name: id,
...(options.inferRuntimeModelInputs
? {
input: inferCustomModelInputModalities(id),
// Without an explicit contextWindow OpenClaw cannot budget compaction
// for custom providers and long sessions die with context overflow.
contextWindow: inferCustomModelContextWindow(id),
}
: {}),
}));
let mergedModels = mergeProviderModels(registryModels, existingModels, runtimeModels);
if (options.api === 'anthropic-messages') {
mergedModels = mergedModels.map((model) => ensureAnthropicMessagesModelEntry(model, provider, existingProvider));
@@ -1523,7 +1926,7 @@ function upsertOpenClawProviderEntry(
const nextProvider: Record<string, unknown> = {
...existingProvider,
baseUrl: options.baseUrl,
baseUrl: resolveOpenAiCodexOAuthBaseUrl(options.baseUrl, options.api),
api: options.api,
models: mergedModels,
};
@@ -1686,6 +2089,8 @@ export async function syncProviderConfigToOpenClaw(
apiKeyEnv: override.apiKeyEnv,
headers: override.headers,
modelIds: modelId ? [modelId] : [],
mergeExistingModels: true,
inferRuntimeModelInputs: true,
});
}
@@ -1875,6 +2280,8 @@ export async function setOpenClawDefaultModelWithOverride(
headers: override.headers,
authHeader: override.authHeader,
modelIds: [modelId, ...fallbackModelIds],
mergeExistingModels: true,
inferRuntimeModelInputs: true,
});
}
@@ -1940,10 +2347,16 @@ export async function getActiveOpenClawProviders(): Promise<Set<string>> {
// 4. auth.profiles — OAuth/device-token based providers may exist only in
// auth-profiles without explicit models.providers entries yet.
// Raw keys (e.g. "openai-codex") are included so downstream logic can
// distinguish OAuth runtime providers from their UI alias ("openai").
const auth = config.auth as Record<string, unknown> | undefined;
addProvidersFromProfileEntries(auth?.profiles as Record<string, unknown> | undefined, activeProviders);
addProvidersFromProfileEntries(
auth?.profiles as Record<string, unknown> | undefined,
activeProviders,
{ includeRawKeys: true },
);
const authProfileProviders = await getProvidersFromAuthProfileStores();
const authProfileProviders = await getProvidersFromAuthProfileStores({ includeRawKeys: true });
for (const provider of authProfileProviders) {
activeProviders.add(provider);
}
@@ -2290,6 +2703,35 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
modified = true;
}
// ── Compaction safeguard default ──
if (ensureCompactionSafeguardDefault(config)) {
modified = true;
console.log(`[batch-sync] Seeded agents.defaults.compaction.mode=safeguard reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`);
} else if (backfillCompactionReserveTokensFloor(config)) {
modified = true;
console.log(`[batch-sync] Backfilled agents.defaults.compaction.reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`);
}
// ── Memory search default ──
// OpenClaw defaults to the openai embedding provider; without a key that
// yields doctor errors and a broken memory_search tool. Seed enabled=false
// only when the user has no memorySearch config anywhere AND no OpenAI key
// (i.e. the default embedding model is unusable). Existing user config is
// never modified.
if (!hasUserMemorySearchConfig(config)
&& !(await getProviderApiKeyFromOpenClaw('openai'))
&& ensureMemorySearchDisabledDefault(config)) {
modified = true;
console.log('[batch-sync] Seeded agents.defaults.memorySearch.enabled=false (no embedding provider configured)');
}
// ── Custom provider contextWindow backfill ──
const backfilledContextWindows = backfillCustomProviderModelContextWindows(config);
if (backfilledContextWindows.length > 0) {
modified = true;
console.log(`[batch-sync] Backfilled contextWindow for custom provider models: ${backfilledContextWindows.join(', ')}`);
}
if (modified) {
await writeOpenClawJson(config);
console.log('Synced gateway token, browser config, web_fetch SSRF policy, and session idle to openclaw.json');
@@ -2345,6 +2787,15 @@ async function updateModelsJsonProviderEntriesForAgents(
const mergedModels = (entry.models ?? []).map((m) => {
const prev = existingModels.find((e) => e.id === m.id);
const base = prev ? { ...prev, id: m.id, name: m.name } : { ...m };
// Custom-provider rows need an explicit contextWindow so the embedded
// runner can budget compaction (see backfillCustomProviderModelContextWindows).
if (
providerType.startsWith('custom-')
&& typeof base.contextWindow !== 'number'
&& typeof base.contextTokens !== 'number'
) {
base.contextWindow = inferCustomModelContextWindow(m.id);
}
return {
...base,
cost: normalizePiAiModelCost((base as { cost?: unknown }).cost),
@@ -2408,6 +2859,25 @@ export async function updateSingleAgentModelProvider(
* unknown or future config issues, the reactive auto-repair mechanism
* (`runOpenClawDoctorRepair`) runs `openclaw doctor --fix` as a fallback.
*/
const SKILL_WORKSHOP_TOOL_DENY_ENTRY = 'skill_workshop';
const SKILL_CREATOR_SKILL_KEY = 'skill-creator';
function normalizeToolDenyList(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === 'string')
: [];
}
function ensureToolDenyIncludes(
deny: string[],
entry: string,
): { deny: string[]; modified: boolean } {
if (deny.includes(entry)) {
return { deny, modified: false };
}
return { deny: [...deny, entry], modified: true };
}
export async function sanitizeOpenClawConfig(): Promise<void> {
return withConfigLock(async () => {
// Skip sanitization if the config file does not exist yet.
@@ -2591,6 +3061,22 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
toolsModified = true;
}
// OpenClaw 6.5+ routes durable skill edits through the Skill Workshop tool.
// ClawX keeps direct skill-creator authoring instead, so deny the workshop
// tool even under tools.profile="full".
const denyResult = ensureToolDenyIncludes(
normalizeToolDenyList(toolsConfig.deny),
SKILL_WORKSHOP_TOOL_DENY_ENTRY,
);
if (denyResult.modified) {
toolsConfig.deny = denyResult.deny;
toolsModified = true;
console.log('[sanitize] Added "skill_workshop" to tools.deny for ClawX desktop');
} else if (!Array.isArray(toolsConfig.deny) || toolsConfig.deny.length !== denyResult.deny.length) {
toolsConfig.deny = denyResult.deny;
toolsModified = true;
}
// ── tools.exec approvals (OpenClaw 3.28+) ──────────────────────
// ClawX is a local desktop app where the user is the trusted operator.
// Exec approval prompts add unnecessary friction in this context, so we
@@ -2611,6 +3097,99 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
modified = true;
}
// ── session.dmScope ─────────────────────────────────────────────
// OpenClaw defaults DM session routing to "main" (all channels share
// agent:main:main), which makes ClawX sidebar conflate feishu, dingtalk,
// and other channel DMs into one entry. Set "per-channel-peer" so each
// channel+peer gets its own session key (agent:main:feishu:direct:ou_xxx),
// letting the sidebar show them as separate conversations with channel badges.
const sessionConfig = (
config.session && typeof config.session === 'object' && !Array.isArray(config.session)
? { ...(config.session as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
if (sessionConfig.dmScope !== 'per-channel-peer' && sessionConfig.dmScope !== 'per-account-channel-peer') {
sessionConfig.dmScope = 'per-channel-peer';
config.session = sessionConfig;
modified = true;
console.log('[sanitize] Set session.dmScope="per-channel-peer" so channel DMs appear as separate sessions in ClawX');
}
// ── Skill Workshop hard-disable (OpenClaw 6.10+) ─────────────────
const gateway = (
config.gateway && typeof config.gateway === 'object'
? { ...(config.gateway as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
const gatewayTools = (
gateway.tools && typeof gateway.tools === 'object'
? { ...(gateway.tools as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
const gatewayDenyResult = ensureToolDenyIncludes(
normalizeToolDenyList(gatewayTools.deny),
SKILL_WORKSHOP_TOOL_DENY_ENTRY,
);
let gatewayModified = gatewayDenyResult.modified;
if (gatewayDenyResult.modified) {
gatewayTools.deny = gatewayDenyResult.deny;
console.log('[sanitize] Added "skill_workshop" to gateway.tools.deny for ClawX desktop');
} else if (!Array.isArray(gatewayTools.deny) || gatewayTools.deny.length !== gatewayDenyResult.deny.length) {
gatewayTools.deny = gatewayDenyResult.deny;
gatewayModified = true;
}
if (gatewayModified) {
gateway.tools = gatewayTools;
config.gateway = gateway;
modified = true;
}
let skillsObj = (
config.skills && typeof config.skills === 'object' && !Array.isArray(config.skills)
? { ...(config.skills as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
let skillsModified = false;
const workshop = (
skillsObj.workshop && typeof skillsObj.workshop === 'object'
? { ...(skillsObj.workshop as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
const autonomous = (
workshop.autonomous && typeof workshop.autonomous === 'object'
? { ...(workshop.autonomous as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
if (autonomous.enabled !== false) {
autonomous.enabled = false;
workshop.autonomous = autonomous;
skillsObj.workshop = workshop;
skillsModified = true;
console.log('[sanitize] Disabled skills.workshop.autonomous for ClawX desktop');
}
const skillEntries = (
skillsObj.entries && typeof skillsObj.entries === 'object' && !Array.isArray(skillsObj.entries)
? { ...(skillsObj.entries as Record<string, unknown>) }
: {}
) as Record<string, Record<string, unknown>>;
const skillCreatorEntry = skillEntries[SKILL_CREATOR_SKILL_KEY] || {};
if (skillCreatorEntry.enabled !== true) {
skillEntries[SKILL_CREATOR_SKILL_KEY] = {
...skillCreatorEntry,
enabled: true,
};
skillsObj.entries = skillEntries;
skillsModified = true;
console.log('[sanitize] Enabled bundled skill-creator for direct skill authoring in ClawX desktop');
}
if (skillsModified) {
config.skills = skillsObj;
modified = true;
}
// ── plugins.entries.feishu cleanup ──────────────────────────────
// Normalize feishu plugin ids dynamically based on installed manifest.
// Different environments may report either "openclaw-lark" or
@@ -3079,6 +3658,24 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
}
}
const migratedApiProtocols = repairLegacyApiProtocolEntriesInConfig(config);
if (migratedApiProtocols.length > 0) {
modified = true;
console.log(`[sanitize] Migrated legacy models.providers api protocol for: ${migratedApiProtocols.join(', ')}`);
}
const repairedCodexBaseUrls = repairOpenAiCodexOAuthProviderEntriesInConfig(config);
if (repairedCodexBaseUrls.length > 0) {
modified = true;
console.log(`[sanitize] Repaired OpenAI Codex OAuth baseUrl for: ${repairedCodexBaseUrls.join(', ')}`);
}
const migratedCodexRuntime = migrateOpenAiCodexOAuthRuntimeToOpenAiInConfig(config);
if (migratedCodexRuntime.length > 0) {
modified = true;
console.log(`[sanitize] Migrated legacy OpenAI Codex OAuth runtime: ${migratedCodexRuntime.join(', ')}`);
}
const pinnedProviderRuntimes = applyOpenClawProviderAgentRuntimePinsToConfig(config);
if (pinnedProviderRuntimes.length > 0) {
modified = true;
+170 -14
View File
@@ -11,9 +11,9 @@ import {
symlinkSync,
unlinkSync,
} from 'node:fs';
import { spawn } from 'node:child_process';
import { spawn, type ForkOptions } from 'node:child_process';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
import { delimiter, join, dirname } from 'node:path';
import { getOpenClawDir, getOpenClawEntryPath } from './paths';
import { logger } from './logger';
@@ -98,6 +98,158 @@ export function getOpenClawCliCommand(): string {
return `node ${quoteForPosix(entryPath)}`;
}
export type OpenClawCliSpawnSpec = {
command: string;
args: string[];
env?: NodeJS.ProcessEnv;
shell?: boolean;
};
type OpenClawEmbeddedForkOptions = ForkOptions & { windowsHide?: boolean };
export type OpenClawEmbeddedForkSpec = {
modulePath: string;
args: string[];
options: OpenClawEmbeddedForkOptions;
};
function fileExists(path: string): boolean {
return existsSync(path);
}
function getWindowsCmdWrapperSpawnSpec(cmdPath: string): OpenClawCliSpawnSpec {
return {
command: process.env.ComSpec || 'cmd.exe',
args: ['/d', '/s', '/c', `"${cmdPath}"`],
};
}
function getExecutableFromPath(command: string): string | null {
const pathEnv = process.env.PATH || '';
for (const dir of pathEnv.split(delimiter)) {
if (!dir) continue;
const candidate = join(dir, command);
if (fileExists(candidate)) return candidate;
}
return null;
}
function getDevNodeExecPath(): string | null {
const nodeCommand = process.platform === 'win32' ? 'node.exe' : 'node';
return getExecutableFromPath(nodeCommand);
}
export function getOpenClawCliSpawnSpec(): OpenClawCliSpawnSpec {
const entryPath = getOpenClawEntryPath();
const platform = process.platform;
if (platform === 'darwin' || platform === 'linux') {
const localBinPath = join(homedir(), '.local', 'bin', 'openclaw');
if (fileExists(localBinPath)) {
return { command: localBinPath, args: [] };
}
}
if (platform === 'linux' && fileExists('/usr/local/bin/openclaw')) {
return { command: '/usr/local/bin/openclaw', args: [] };
}
if (!app.isPackaged) {
const openclawDir = getOpenClawDir();
const nodeModulesDir = dirname(openclawDir);
const binName = platform === 'win32' ? 'openclaw.cmd' : 'openclaw';
const binPath = join(nodeModulesDir, '.bin', binName);
if (fileExists(binPath)) {
if (platform === 'win32') {
return getWindowsCmdWrapperSpawnSpec(binPath);
}
return { command: binPath, args: [], shell: false };
}
}
const packagedWrapper = getPackagedCliWrapperPath();
if (packagedWrapper) {
if (platform === 'win32') {
return getWindowsCmdWrapperSpawnSpec(packagedWrapper);
}
return { command: packagedWrapper, args: [], shell: false };
}
if (app.isPackaged) {
if (platform === 'win32') {
const bundledNode = getPackagedWindowsNodePath();
if (bundledNode) {
return { command: bundledNode, args: [entryPath] };
}
}
return {
command: process.execPath,
args: [entryPath],
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
};
}
return { command: 'node', args: [entryPath] };
}
function getOpenClawEmbeddedExecPath(): { execPath: string; electronRunAsNode: boolean } {
if (!app.isPackaged) {
const nodeExecPath = getDevNodeExecPath();
if (nodeExecPath) return { execPath: nodeExecPath, electronRunAsNode: false };
if (process.versions?.electron) {
throw new Error('Node executable not found on PATH for embedded OpenClaw launch');
}
}
if (app.isPackaged && process.platform === 'win32') {
const bundledNode = getPackagedWindowsNodePath();
if (bundledNode) return { execPath: bundledNode, electronRunAsNode: false };
}
if (app.isPackaged && process.platform === 'darwin') {
const helperPath = getPackagedMacOSHelperPath();
if (!helperPath) {
throw new Error('ClawX Helper executable not found for embedded OpenClaw launch');
}
return { execPath: helperPath, electronRunAsNode: true };
}
return { execPath: process.execPath, electronRunAsNode: Boolean(process.versions?.electron) };
}
export function getOpenClawEmbeddedForkSpec(args: string[] = []): OpenClawEmbeddedForkSpec {
const { execPath, electronRunAsNode } = getOpenClawEmbeddedExecPath();
const env: NodeJS.ProcessEnv = {
...process.env,
OPENCLAW_NO_RESPAWN: '1',
OPENCLAW_EMBEDDED_IN: 'ClawX',
OPENCLAW_EXEC_SHELL_SNAPSHOT: '0',
};
if (electronRunAsNode) {
env.ELECTRON_RUN_AS_NODE = '1';
} else {
delete env.ELECTRON_RUN_AS_NODE;
}
return {
modulePath: getOpenClawEntryPath(),
args,
options: {
cwd: getOpenClawDir(),
env,
execPath,
execArgv: [],
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
windowsHide: true,
},
};
}
// ── Packaged CLI wrapper path ────────────────────────────────────────────────
function getPackagedCliWrapperPath(): string | null {
@@ -120,6 +272,20 @@ function getWindowsPowerShellPath(): string {
return join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
}
function getPackagedMacOSHelperPath(): string | null {
if (process.platform !== 'darwin' || !app.isPackaged) return null;
const appName = app.getName();
const helperName = `${appName} Helper`;
const helperPath = join(
dirname(process.execPath),
'../Frameworks',
`${helperName}.app`,
'Contents/MacOS',
helperName,
);
return existsSync(helperPath) ? helperPath : null;
}
// ── macOS / Linux install ────────────────────────────────────────────────────
function getCliTargetPath(): string {
@@ -328,18 +494,8 @@ export async function autoInstallCliIfNeeded(
// ── Completion helpers ───────────────────────────────────────────────────────
function getNodeExecForCli(): string {
if (process.platform === 'darwin' && app.isPackaged) {
const appName = app.getName();
const helperName = `${appName} Helper`;
const helperPath = join(
dirname(process.execPath),
'../Frameworks',
`${helperName}.app`,
'Contents/MacOS',
helperName,
);
if (existsSync(helperPath)) return helperPath;
}
const helperPath = getPackagedMacOSHelperPath();
if (helperPath) return helperPath;
return process.execPath;
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Memory search default seeding for openclaw.json.
*
* OpenClaw enables semantic memory search by default with the `openai`
* embedding provider, so a user without an OpenAI key gets doctor errors and
* a broken memory_search tool. ClawX seeds `agents.defaults.memorySearch =
* { enabled: false }` at Gateway prelaunch — but only when the user has no
* memorySearch config anywhere (global defaults or per-agent overrides).
* Existing user config is never modified.
*/
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
/**
* True when the user manages memorySearch themselves: either
* `agents.defaults.memorySearch` or any `agents.list[].memorySearch` exists.
*/
export function hasUserMemorySearchConfig(config: Record<string, unknown>): boolean {
const agents = isRecord(config.agents) ? config.agents : undefined;
if (!agents) return false;
const defaults = isRecord(agents.defaults) ? agents.defaults : undefined;
if (defaults && defaults.memorySearch !== undefined) return true;
const list = Array.isArray(agents.list) ? agents.list : [];
return list.some((entry) => isRecord(entry) && entry.memorySearch !== undefined);
}
/**
* Seed `agents.defaults.memorySearch = { enabled: false }` when the user has
* no memorySearch config at all. Mutates `config` in place and returns true
* when a change was made. Never touches existing memorySearch objects.
*/
export function ensureMemorySearchDisabledDefault(config: Record<string, unknown>): boolean {
if (hasUserMemorySearchConfig(config)) return false;
const agents = (isRecord(config.agents) ? config.agents : {}) as Record<string, unknown>;
const defaults = (isRecord(agents.defaults) ? agents.defaults : {}) as Record<string, unknown>;
defaults.memorySearch = { enabled: false };
agents.defaults = defaults;
config.agents = agents;
return true;
}
+180
View File
@@ -0,0 +1,180 @@
import { chmod, copyFile, lstat, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
import { basename, dirname, join, relative, resolve } from 'node:path';
import { resolveOpenClawConfigPath, resolveOpenClawStateDir } from './paths';
const UPGRADE_ID = 'openclaw-2026.7.1';
const SNAPSHOT_DIR_MODE = 0o700;
const SNAPSHOT_FILE_MODE = 0o600;
const AGENT_AUTH_BASENAMES = new Set([
'auth-profiles.json',
'openclaw-agent.sqlite',
'openclaw-agent.sqlite-wal',
'openclaw-agent.sqlite-shm',
]);
export type OpenClawUpgradeSnapshotResult = {
status: 'created' | 'exists';
snapshotDir: string;
files: string[];
};
export type OpenClawUpgradeSnapshotCleanupResult = {
status: 'removed' | 'missing';
snapshotDir: string;
};
type SnapshotOptions = {
stateDir?: string;
configPath?: string;
};
function resolveSnapshotDir(stateDir: string): string {
return join(stateDir, 'backups', `clawx-${UPGRADE_ID}-pre-migration`);
}
async function isCopyableRegularFile(path: string): Promise<boolean> {
try {
const info = await lstat(path);
return info.isFile();
} catch {
return false;
}
}
async function snapshotMarkerExists(markerPath: string): Promise<boolean> {
try {
return (await stat(markerPath)).isFile();
} catch {
return false;
}
}
async function copyFileIfPresent(source: string, destination: string, copied: string[]): Promise<void> {
if (!await isCopyableRegularFile(source)) return;
await mkdir(dirname(destination), { recursive: true, mode: SNAPSHOT_DIR_MODE });
await copyFile(source, destination);
await chmod(destination, SNAPSHOT_FILE_MODE);
copied.push(destination);
}
async function copyTree(
sourceRoot: string,
destinationRoot: string,
copied: string[],
includeFile: (name: string) => boolean,
): Promise<void> {
let entries;
try {
entries = await readdir(sourceRoot, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isSymbolicLink()) continue;
const source = join(sourceRoot, entry.name);
const destination = join(destinationRoot, entry.name);
if (entry.isDirectory()) {
await mkdir(destination, { recursive: true, mode: SNAPSHOT_DIR_MODE });
await copyTree(source, destination, copied, includeFile);
} else if (entry.isFile() && includeFile(entry.name)) {
await copyFileIfPresent(source, destination, copied);
}
}
}
/**
* Creates a one-time pre-migration snapshot before ClawX first starts the
* OpenClaw 2026.7.1 Gateway. SQLite databases are copied together with their
* WAL/SHM sidecars; channel credentials under `credentials/` are intentionally
* excluded because this migration does not rewrite them.
*/
export async function ensureOpenClaw2026_7_1UpgradeSnapshot(
options: SnapshotOptions = {},
): Promise<OpenClawUpgradeSnapshotResult> {
const stateDir = resolve(options.stateDir ?? resolveOpenClawStateDir());
const configPath = resolve(options.configPath ?? resolveOpenClawConfigPath());
const snapshotDir = resolveSnapshotDir(stateDir);
const markerPath = join(snapshotDir, 'snapshot.json');
if (await snapshotMarkerExists(markerPath)) {
try {
const marker = JSON.parse(await readFile(markerPath, 'utf8')) as { files?: unknown };
return {
status: 'exists',
snapshotDir,
files: Array.isArray(marker.files)
? marker.files.filter((value): value is string => typeof value === 'string')
: [],
};
} catch {
// Replace malformed/incomplete snapshots below.
}
}
const tempDir = `${snapshotDir}.tmp-${process.pid}-${Date.now()}`;
const copiedDestinations: string[] = [];
await rm(tempDir, { recursive: true, force: true });
await mkdir(tempDir, { recursive: true, mode: SNAPSHOT_DIR_MODE });
try {
await copyFileIfPresent(configPath, join(tempDir, 'config', basename(configPath)), copiedDestinations);
for (const databasePath of [
join(stateDir, 'openclaw.sqlite'),
join(stateDir, 'state', 'openclaw.sqlite'),
]) {
const relativeDatabase = relative(stateDir, databasePath);
for (const suffix of ['', '-wal', '-shm']) {
await copyFileIfPresent(
`${databasePath}${suffix}`,
join(tempDir, 'state-files', `${relativeDatabase}${suffix}`),
copiedDestinations,
);
}
}
await copyTree(
join(stateDir, 'agents'),
join(tempDir, 'agents'),
copiedDestinations,
(name) => AGENT_AUTH_BASENAMES.has(name),
);
const files = copiedDestinations.map((path) => relative(tempDir, path)).sort();
await writeFile(join(tempDir, 'snapshot.json'), `${JSON.stringify({
upgrade: UPGRADE_ID,
createdAt: new Date().toISOString(),
configPath,
stateDir,
files,
}, null, 2)}\n`, { encoding: 'utf8', mode: SNAPSHOT_FILE_MODE });
await rm(snapshotDir, { recursive: true, force: true });
await mkdir(dirname(snapshotDir), { recursive: true, mode: SNAPSHOT_DIR_MODE });
await rename(tempDir, snapshotDir);
return { status: 'created', snapshotDir, files };
} catch (error) {
await rm(tempDir, { recursive: true, force: true });
throw error;
}
}
/**
* Removes the one-time OpenClaw 2026.7.1 pre-migration snapshot after Gateway
* startup succeeds so duplicated config/auth/SQLite secrets do not linger.
*/
export async function removeOpenClaw2026_7_1UpgradeSnapshot(
options: SnapshotOptions = {},
): Promise<OpenClawUpgradeSnapshotCleanupResult> {
const stateDir = resolve(options.stateDir ?? resolveOpenClawStateDir());
const snapshotDir = resolveSnapshotDir(stateDir);
const markerPath = join(snapshotDir, 'snapshot.json');
if (!await snapshotMarkerExists(markerPath)) {
return { status: 'missing', snapshotDir };
}
await rm(snapshotDir, { recursive: true, force: true });
return { status: 'removed', snapshotDir };
}
+19 -4
View File
@@ -3,9 +3,10 @@
* Cross-platform path resolution helpers
*/
import { createRequire } from 'node:module';
import { join } from 'path';
import { dirname, join, resolve } from 'path';
import { homedir } from 'os';
import { existsSync, mkdirSync, readFileSync, realpathSync } from 'fs';
import { getClawXDataLayout, resolveClawXDataRoot } from './clawx-data-layout';
const require = createRequire(import.meta.url);
@@ -54,6 +55,20 @@ export function getOpenClawConfigDir(): string {
return join(homedir(), '.openclaw');
}
export function resolveOpenClawStateDir(env: NodeJS.ProcessEnv = process.env): string {
const configured = env.OPENCLAW_STATE_DIR?.trim();
return resolve(expandPath(configured || join(homedir(), '.openclaw')));
}
export function resolveOpenClawConfigPath(env: NodeJS.ProcessEnv = process.env): string {
const configured = env.OPENCLAW_CONFIG_PATH?.trim();
return resolve(expandPath(configured || join(resolveOpenClawStateDir(env), 'openclaw.json')));
}
export function resolveOpenClawConfigDir(env: NodeJS.ProcessEnv = process.env): string {
return dirname(resolveOpenClawConfigPath(env));
}
/**
* Get OpenClaw skills directory
*/
@@ -65,21 +80,21 @@ export function getOpenClawSkillsDir(): string {
* Get ClawX config directory
*/
export function getClawXConfigDir(): string {
return join(homedir(), '.clawx');
return resolveClawXDataRoot();
}
/**
* Get ClawX logs directory
*/
export function getLogsDir(): string {
return join(getElectronApp().getPath('userData'), 'logs');
return getClawXDataLayout().logsDir;
}
/**
* Get ClawX data directory
*/
export function getDataDir(): string {
return getElectronApp().getPath('userData');
return resolveClawXDataRoot();
}
/**
+230
View File
@@ -0,0 +1,230 @@
/**
* Persist ClawX-managed plugin install records into OpenClaw's SQLite
* installed_plugin_index store (openclaw.sqlite).
*
* OpenClaw 2026.6+ reads trusted install metadata from SQLite at runtime,
* not from transient plugins.installs in openclaw.json.
*/
import { existsSync, mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { logger } from './logger';
const INSTALLED_PLUGIN_INDEX_KEY = 'installed-plugin-index';
const INSTALLED_PLUGIN_INDEX_WARNING = 'DO NOT EDIT. This file is generated by OpenClaw from plugin manifests, install records, and config policy. Use `openclaw plugins registry --refresh`, `openclaw plugins install/update/uninstall`, or `openclaw plugins enable/disable` instead.';
const INSTALLED_PLUGIN_INDEX_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS installed_plugin_index (
index_key TEXT NOT NULL PRIMARY KEY,
version INTEGER NOT NULL,
host_contract_version TEXT NOT NULL,
compat_registry_version TEXT NOT NULL,
migration_version INTEGER NOT NULL,
policy_hash TEXT NOT NULL,
generated_at_ms INTEGER NOT NULL,
refresh_reason TEXT,
install_records_json TEXT NOT NULL,
plugins_json TEXT NOT NULL,
diagnostics_json TEXT NOT NULL,
warning TEXT,
updated_at_ms INTEGER NOT NULL
);
`;
function resolveOpenClawStateDir(): string {
return process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw');
}
function resolveOpenClawStateSqlitePath(): string {
// OpenClaw 2026.7.1 moved the shared state database under state/.
// Writing the legacy root-level database leaves Gateway migrations reading
// stale plugin records from the canonical database.
return join(resolveOpenClawStateDir(), 'state', 'openclaw.sqlite');
}
function parseInstallRecordsJson(raw: unknown): Record<string, Record<string, unknown>> {
if (!raw || typeof raw !== 'string') {
return {};
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {};
}
const records: Record<string, Record<string, unknown>> = {};
for (const [pluginId, record] of Object.entries(parsed as Record<string, unknown>)) {
if (record && typeof record === 'object' && !Array.isArray(record)) {
records[pluginId] = record as Record<string, unknown>;
}
}
return records;
} catch {
return {};
}
}
function installRecordsMatch(
left: Record<string, unknown>,
right: Record<string, unknown>,
): boolean {
const keys = ['source', 'spec', 'installPath', 'version', 'resolvedName', 'resolvedVersion', 'resolvedSpec'] as const;
return keys.every((key) => left[key] === right[key]);
}
function openStateDatabase(sqlitePath: string): DatabaseSync {
const db = new DatabaseSync(sqlitePath);
db.exec(INSTALLED_PLUGIN_INDEX_TABLE_SQL);
return db;
}
/**
* Upsert trusted install records into openclaw.sqlite.
* ClawX-authored records win over stale SQLite entries for the same plugin id.
*/
export function upsertPluginInstallRecordsIntoSqlite(
records: Record<string, Record<string, unknown>>,
): boolean {
if (Object.keys(records).length === 0) {
return false;
}
ensureOpenClawStateDirExists();
const sqlitePath = resolveOpenClawStateSqlitePath();
mkdirSync(join(resolveOpenClawStateDir(), 'state'), { recursive: true });
let db: DatabaseSync | null = null;
try {
db = openStateDatabase(sqlitePath);
const row = db.prepare(`
SELECT install_records_json
FROM installed_plugin_index
WHERE index_key = ?
`).get(INSTALLED_PLUGIN_INDEX_KEY) as { install_records_json?: string } | undefined;
const now = Date.now();
let merged: Record<string, Record<string, unknown>>;
let changed = false;
if (row) {
const existing = parseInstallRecordsJson(row.install_records_json);
merged = { ...existing };
for (const [pluginId, record] of Object.entries(records)) {
const current = merged[pluginId];
if (current && installRecordsMatch(current, record)) {
continue;
}
merged[pluginId] = record;
changed = true;
}
if (!changed) {
return false;
}
db.prepare(`
UPDATE installed_plugin_index
SET install_records_json = ?,
updated_at_ms = ?,
generated_at_ms = ?
WHERE index_key = ?
`).run(JSON.stringify(merged), now, now, INSTALLED_PLUGIN_INDEX_KEY);
} else {
merged = { ...records };
changed = true;
db.prepare(`
INSERT INTO installed_plugin_index (
index_key, version, host_contract_version, compat_registry_version,
migration_version, policy_hash, generated_at_ms, refresh_reason,
install_records_json, plugins_json, diagnostics_json, warning, updated_at_ms
) VALUES (
?, 1, 'clawx-managed', 'clawx-managed',
1, 'clawx-managed', ?, 'source-changed',
?, '[]', '[]', ?, ?
)
`).run(
INSTALLED_PLUGIN_INDEX_KEY,
now,
JSON.stringify(merged),
INSTALLED_PLUGIN_INDEX_WARNING,
now,
);
}
if (changed) {
logger.info(`[plugin] Persisted trusted install metadata to SQLite for: ${Object.keys(records).join(', ')}`);
}
return changed;
} catch (error) {
logger.warn('[plugin] Failed to persist trusted install metadata to SQLite:', error);
return false;
} finally {
db?.close();
}
}
/**
* Remove install records that must remain ClawX-managed rather than updated
* from their raw upstream npm package. Also clean the legacy root-level DB
* previously written by ClawX before OpenClaw 2026.7.1 moved state to state/.
*/
export function removePluginInstallRecordsFromSqlite(pluginIds: string[]): boolean {
if (pluginIds.length === 0) return false;
const stateDir = resolveOpenClawStateDir();
const sqlitePaths = [
resolveOpenClawStateSqlitePath(),
join(stateDir, 'openclaw.sqlite'),
];
let changed = false;
for (const sqlitePath of sqlitePaths) {
if (!existsSync(sqlitePath)) continue;
let db: DatabaseSync | null = null;
try {
db = openStateDatabase(sqlitePath);
const row = db.prepare(`
SELECT install_records_json
FROM installed_plugin_index
WHERE index_key = ?
`).get(INSTALLED_PLUGIN_INDEX_KEY) as { install_records_json?: string } | undefined;
if (!row) continue;
const records = parseInstallRecordsJson(row.install_records_json);
let databaseChanged = false;
for (const pluginId of pluginIds) {
if (Object.hasOwn(records, pluginId)) {
delete records[pluginId];
databaseChanged = true;
}
}
if (!databaseChanged) continue;
const now = Date.now();
db.prepare(`
UPDATE installed_plugin_index
SET install_records_json = ?,
updated_at_ms = ?,
generated_at_ms = ?
WHERE index_key = ?
`).run(JSON.stringify(records), now, now, INSTALLED_PLUGIN_INDEX_KEY);
changed = true;
} catch (error) {
logger.warn(`[plugin] Failed to remove install metadata from ${sqlitePath}:`, error);
} finally {
db?.close();
}
}
if (changed) {
logger.info(`[plugin] Removed managed install metadata from SQLite for: ${pluginIds.join(', ')}`);
}
return changed;
}
/** Ensure ~/.openclaw exists before first config write in fresh installs. */
export function ensureOpenClawStateDirExists(): void {
const stateDir = resolveOpenClawStateDir();
if (!existsSync(stateDir)) {
mkdirSync(stateDir, { recursive: true });
}
}
+400 -26
View File
@@ -7,11 +7,17 @@
*/
import { app } from 'electron';
import path from 'node:path';
import { existsSync, cpSync, copyFileSync, statSync, mkdirSync, rmSync, readFileSync, writeFileSync, readdirSync, realpathSync } from 'node:fs';
import { existsSync, cpSync, copyFileSync, statSync, lstatSync, mkdirSync, rmSync, readFileSync, writeFileSync, readdirSync, realpathSync, symlinkSync, unlinkSync } from 'node:fs';
import { readdir, stat, copyFile, mkdir } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { logger } from './logger';
import { getOpenClawResolvedDir } from './paths';
import {
upsertPluginInstallRecordsIntoSqlite,
removePluginInstallRecordsFromSqlite,
ensureOpenClawStateDirExists,
} from './plugin-install-index';
function normalizeFsPathForWindows(filePath: string): string {
if (process.platform !== 'win32') return filePath;
@@ -121,7 +127,7 @@ const MANIFEST_ID_FIXES: Record<string, string> = {
/**
* After a plugin has been copied to ~/.openclaw/extensions/<dir>, fix any
* known manifest-ID mismatches so the Gateway can load the plugin.
* Also patches package.json fields that the Gateway uses as "entry hints".
* Also keeps package.json npm metadata usable by OpenClaw's repair planner.
*/
export function fixupPluginManifest(targetDir: string): void {
// 1. Fix openclaw.plugin.json id
@@ -130,45 +136,64 @@ export function fixupPluginManifest(targetDir: string): void {
const raw = readFileSync(fsPath(manifestPath), 'utf-8');
const manifest = JSON.parse(raw);
const oldId = manifest.id as string | undefined;
let modified = false;
if (oldId && MANIFEST_ID_FIXES[oldId]) {
const newId = MANIFEST_ID_FIXES[oldId];
manifest.id = newId;
writeFileSync(fsPath(manifestPath), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
modified = true;
logger.info(`[plugin] Fixed manifest ID: ${oldId}${newId}`);
}
// OpenClaw 2026.7.1 treats configured channel plugins without a static
// channelConfigs descriptor as stale/missing and invokes its npm repair
// flow. The WeCom package has no descriptor upstream, so provide a
// permissive schema that preserves ClawX's existing channel config fields.
if (manifest.id === 'wecom' && !manifest.channelConfigs?.wecom) {
manifest.channelConfigs = {
...(manifest.channelConfigs ?? {}),
wecom: {
schema: {
type: 'object',
additionalProperties: true,
},
},
};
modified = true;
logger.info('[plugin] Added WeCom channelConfigs compatibility descriptor');
}
if (modified) {
writeFileSync(fsPath(manifestPath), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
}
} catch {
// manifest may not exist yet — ignore
}
// 2. Fix package.json fields that Gateway uses as "entry hints"
// 2. Keep package.json package-manager metadata valid
const pkgPath = join(targetDir, 'package.json');
try {
const raw = readFileSync(fsPath(pkgPath), 'utf-8');
const pkg = JSON.parse(raw);
let modified = false;
// Check if the package name contains a legacy ID that needs fixing
for (const [oldId, newId] of Object.entries(MANIFEST_ID_FIXES)) {
if (typeof pkg.name === 'string' && pkg.name.includes(oldId)) {
pkg.name = pkg.name.replace(oldId, newId);
modified = true;
}
const install = pkg.openclaw?.install;
if (install) {
if (typeof install.npmSpec === 'string' && install.npmSpec.includes(oldId)) {
install.npmSpec = install.npmSpec.replace(oldId, newId);
modified = true;
}
if (typeof install.localPath === 'string' && install.localPath.includes(oldId)) {
install.localPath = install.localPath.replace(oldId, newId);
modified = true;
}
}
// Keep the real upstream npm package name/spec even though ClawX patches
// the effective plugin id. Rewriting these to the non-existent
// `@wecom/wecom` package makes OpenClaw's repair planner fail before the
// Gateway starts. Restore metadata previously rewritten by older ClawX
// compatibility code.
if (pkg.name === '@wecom/wecom') {
pkg.name = '@wecom/wecom-openclaw-plugin';
modified = true;
}
const install = pkg.openclaw?.install;
if (install?.npmSpec === '@wecom/wecom') {
install.npmSpec = '@wecom/wecom-openclaw-plugin';
modified = true;
}
if (modified) {
writeFileSync(fsPath(pkgPath), JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
logger.info(`[plugin] Fixed package.json entry hints in ${targetDir}`);
logger.info(`[plugin] Restored package.json npm metadata in ${targetDir}`);
}
} catch {
// ignore
@@ -238,7 +263,348 @@ const PLUGIN_NPM_NAMES: Record<string, string> = {
'openclaw-weixin': '@tencent-weixin/openclaw-weixin',
};
// ── Version helper ───────────────────────────────────────────────────────────
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
/**
* Channel plugins whose ClawX-managed mirrors need synchronized install
* metadata. OpenClaw 2026.6+ reads these records from SQLite for trust checks;
* OpenClaw 2026.7.1 also uses them to decide whether startup migrations should
* update an installed plugin.
*/
type TrustedOfficialExtensionPlugin = {
npmName: string;
/** Effective manifest/config id when it differs from the mirror directory. */
pluginId?: string;
/** Path records keep OpenClaw from replacing a ClawX-patched mirror. */
recordSource?: 'npm' | 'path';
legacyPluginIds?: string[];
};
const TRUSTED_OFFICIAL_EXTENSION_PLUGINS: Record<string, TrustedOfficialExtensionPlugin> = {
dingtalk: { npmName: '@soimy/dingtalk' },
// WeCom intentionally runs under ClawX's legacy-compatible `wecom` id even
// though the upstream package manifest still declares
// `wecom-openclaw-plugin`. Keep it path-owned so startup migration does not
// replace the compatibility-patched mirror with the raw npm package.
wecom: {
npmName: '@wecom/wecom-openclaw-plugin',
recordSource: 'path',
legacyPluginIds: ['wecom-openclaw-plugin'],
},
// @larksuite/openclaw-lark 2026.7.9 declares ./dist/index.js as `main`, but
// publishes its runtime entry as ./index.js. OpenClaw 2026.7.1 rejects old
// managed npm records during its post-core smoke check. Make ClawX's complete
// mirror the canonical path-owned payload instead.
'feishu-openclaw-plugin': {
npmName: '@larksuite/openclaw-lark',
pluginId: 'openclaw-lark',
recordSource: 'path',
legacyPluginIds: ['feishu-openclaw-plugin', 'feishu'],
},
whatsapp: { npmName: '@openclaw/whatsapp' },
discord: { npmName: '@openclaw/discord' },
qqbot: { npmName: '@openclaw/qqbot' },
'openclaw-weixin': { npmName: '@tencent-weixin/openclaw-weixin' },
'clawx-openai-image': {
npmName: 'clawx-openai-image-plugin',
recordSource: 'path',
},
};
type TrustedOfficialPluginInstallRecord = Record<string, unknown> & {
source: 'npm' | 'path';
spec: string;
installPath: string;
version: string;
installedAt: string;
};
/** Store plain paths for OpenClaw install-record matching (no Windows \\?\ prefix). */
function normalizePluginInstallPathForRecord(targetDir: string): string | null {
try {
const resolved = realpathSync(targetDir);
return path.normalize(resolved);
} catch {
return path.normalize(targetDir);
}
}
function buildTrustedOfficialPluginInstallRecord(
pluginDirName: string,
targetDir: string,
): { pluginId: string; record: TrustedOfficialPluginInstallRecord } | null {
const definition = TRUSTED_OFFICIAL_EXTENSION_PLUGINS[pluginDirName];
if (!definition) return null;
const version = readPluginVersion(join(targetDir, 'package.json'));
const installPath = normalizePluginInstallPathForRecord(targetDir);
if (!version || !installPath) return null;
const pluginId = definition.pluginId ?? pluginDirName;
const installedAt = new Date().toISOString();
if (definition.recordSource === 'path') {
return {
pluginId,
record: {
source: 'path',
spec: targetDir,
sourcePath: targetDir,
installPath,
version,
installedAt,
},
};
}
return {
pluginId,
record: {
source: 'npm',
spec: definition.npmName,
installPath,
version,
resolvedName: definition.npmName,
resolvedVersion: version,
resolvedSpec: `${definition.npmName}@${version}`,
installedAt,
},
};
}
function pluginInstallRecordIds(pluginDirName: string): string[] {
const definition = TRUSTED_OFFICIAL_EXTENSION_PLUGINS[pluginDirName];
return [...new Set([
pluginDirName,
definition?.pluginId,
...(definition?.legacyPluginIds ?? []),
].filter((value): value is string => Boolean(value)))];
}
function removeLegacyPluginInstallMetadataFromConfig(pluginIds: string[]): boolean {
if (!existsSync(fsPath(OPENCLAW_CONFIG_PATH))) return false;
const raw = readFileSync(fsPath(OPENCLAW_CONFIG_PATH), 'utf-8');
const config = JSON.parse(raw) as Record<string, unknown>;
const plugins = config.plugins;
if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return false;
const pluginsRecord = plugins as Record<string, unknown>;
const installs = pluginsRecord.installs;
if (!installs || typeof installs !== 'object' || Array.isArray(installs)) return false;
const installsRecord = installs as Record<string, unknown>;
const removedIds = pluginIds.filter((pluginId) => Object.hasOwn(installsRecord, pluginId));
if (removedIds.length === 0) return false;
for (const pluginId of removedIds) {
delete installsRecord[pluginId];
}
if (Object.keys(installsRecord).length === 0) {
delete pluginsRecord.installs;
}
writeFileSync(
fsPath(OPENCLAW_CONFIG_PATH),
`${JSON.stringify(config, null, 2)}\n`,
'utf-8',
);
logger.info(`[plugin] Removed legacy config install metadata for: ${removedIds.join(', ')}`);
return true;
}
function canonicalComparablePath(filePath: string): string {
let resolved: string;
try {
resolved = realpathSync(fsPath(filePath));
} catch {
resolved = path.resolve(filePath);
}
const withoutLongPathPrefix = resolved.replace(/^\\\\\?\\UNC\\/i, '\\\\').replace(/^\\\\\?\\/i, '');
return process.platform === 'win32' ? withoutLongPathPrefix.toLowerCase() : withoutLongPathPrefix;
}
/**
* Materialized mirrors live outside the bundled OpenClaw package tree, so
* Node's normal package lookup cannot resolve their declared `openclaw` peer.
* OpenClaw 2026.7.1 also audits this exact link before reporting Gateway ready.
*/
export function repairPluginOpenClawPeerLink(
targetDir: string,
openclawDir = getOpenClawResolvedDir(),
): boolean {
let packageJson: Record<string, unknown>;
try {
packageJson = JSON.parse(readFileSync(fsPath(join(targetDir, 'package.json')), 'utf-8')) as Record<string, unknown>;
} catch {
return false;
}
const peerDependencies = packageJson.peerDependencies;
if (
!peerDependencies
|| typeof peerDependencies !== 'object'
|| Array.isArray(peerDependencies)
|| typeof (peerDependencies as Record<string, unknown>).openclaw !== 'string'
) {
return true;
}
if (!existsSync(fsPath(join(openclawDir, 'package.json')))) {
logger.warn(`[plugin] Cannot link OpenClaw peer for ${targetDir}: runtime package missing at ${openclawDir}`);
return false;
}
const nodeModulesDir = join(targetDir, 'node_modules');
const linkPath = join(nodeModulesDir, 'openclaw');
try {
mkdirSync(fsPath(nodeModulesDir), { recursive: true });
const nodeModulesStat = lstatSync(fsPath(nodeModulesDir));
if (!nodeModulesStat.isDirectory() || nodeModulesStat.isSymbolicLink()) {
logger.warn(`[plugin] Cannot link OpenClaw peer because ${nodeModulesDir} is not a real directory`);
return false;
}
try {
if (canonicalComparablePath(linkPath) === canonicalComparablePath(openclawDir)) {
return true;
}
} catch {
// Fall through to lstat/creation for a missing or broken link.
}
let existing: ReturnType<typeof lstatSync> | null = null;
try {
existing = lstatSync(fsPath(linkPath));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
if (existing) {
if (existing.isSymbolicLink()) {
unlinkSync(fsPath(linkPath));
} else if (existing.isDirectory()) {
let existingPackageName: unknown;
try {
existingPackageName = JSON.parse(
readFileSync(fsPath(join(linkPath, 'package.json')), 'utf-8'),
).name;
} catch {
existingPackageName = null;
}
if (existingPackageName !== 'openclaw') {
logger.warn(`[plugin] Cannot replace non-OpenClaw peer directory at ${linkPath}`);
return false;
}
rmSync(fsPath(linkPath), { recursive: true, force: true });
} else {
logger.warn(`[plugin] Cannot replace non-directory OpenClaw peer at ${linkPath}`);
return false;
}
}
symlinkSync(openclawDir, fsPath(linkPath), 'junction');
if (canonicalComparablePath(linkPath) !== canonicalComparablePath(openclawDir)) {
logger.warn(`[plugin] OpenClaw peer link audit failed after creating ${linkPath}`);
return false;
}
logger.info(`[plugin] Linked OpenClaw peer: ${linkPath}${openclawDir}`);
return true;
} catch (error) {
logger.warn(`[plugin] Failed to link OpenClaw peer for ${targetDir}:`, error);
return false;
}
}
function persistTrustedOfficialPluginInstallRecordsToSqlite(
records: Record<string, Record<string, unknown>>,
): boolean {
return upsertPluginInstallRecordsIntoSqlite(records);
}
/**
* Persist a ClawX-mirrored plugin install record in OpenClaw's canonical SQLite
* index. OpenClaw 2026.7.1 treats config-level plugins.installs as legacy
* migration input, so remove that transient copy instead of recreating it.
* Safe to call repeatedly; no-ops when metadata is already current.
*/
export function syncTrustedOfficialPluginInstallRecord(
pluginDirName: string,
targetDir: string,
): boolean {
const expected = buildTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
if (!expected) return false;
if (!existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) {
return false;
}
// Repair this even when install metadata already matches. A copied plugin's
// node_modules intentionally excludes host peers, and OpenClaw's migration
// smoke check runs before the Gateway can supply any runtime fallback.
repairPluginOpenClawPeerLink(targetDir);
const recordIds = pluginInstallRecordIds(pluginDirName);
let jsonChanged = false;
try {
ensureOpenClawStateDirExists();
jsonChanged = removeLegacyPluginInstallMetadataFromConfig(recordIds);
} catch (error) {
// Keep the canonical SQLite repair available even if legacy config cleanup
// cannot be completed in this pass.
logger.warn(`[plugin] Failed to remove legacy install metadata for ${pluginDirName}:`, error);
}
// Remove aliases left by older ClawX/OpenClaw ownership conventions, but do
// not delete the canonical id first: upsert can replace npm/path ownership
// atomically without creating a missing-record window.
const staleRecordIds = recordIds.filter((pluginId) => pluginId !== expected.pluginId);
const removedLegacyRecord = removePluginInstallRecordsFromSqlite(staleRecordIds);
const sqliteChanged = persistTrustedOfficialPluginInstallRecordsToSqlite({
[expected.pluginId]: expected.record,
});
return jsonChanged || removedLegacyRecord || sqliteChanged;
}
/**
* Remove metadata for a ClawX mirror that is no longer configured. This must
* run even when its extension directory is already missing: stale records are
* themselves enough to fail OpenClaw's post-core payload smoke check.
*/
export function removeTrustedOfficialPluginInstallRecord(pluginDirName: string): boolean {
const recordIds = pluginInstallRecordIds(pluginDirName);
if (recordIds.length === 0) return false;
let jsonChanged = false;
try {
jsonChanged = removeLegacyPluginInstallMetadataFromConfig(recordIds);
} catch (error) {
logger.warn(`[plugin] Failed to remove stale config install metadata for ${pluginDirName}:`, error);
}
const sqliteChanged = removePluginInstallRecordsFromSqlite(recordIds);
return jsonChanged || sqliteChanged;
}
/** Repair managed install metadata and host peer links for all mirrors on disk. */
export function repairTrustedOfficialPluginInstallRecords(): void {
for (const pluginDirName of Object.keys(TRUSTED_OFFICIAL_EXTENSION_PLUGINS)) {
const targetDir = join(homedir(), '.openclaw', 'extensions', pluginDirName);
if (!existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) {
continue;
}
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
}
}
export function resolvePluginNpmPackagePath(npmName: string): string | null {
const candidateRoots = app.isPackaged
? [app.getAppPath(), process.resourcesPath]
: [app.getAppPath(), process.cwd(), join(app.getAppPath(), '..')];
for (const root of candidateRoots) {
const npmPkgPath = join(root, 'node_modules', ...npmName.split('/'));
if (existsSync(fsPath(join(npmPkgPath, 'openclaw.plugin.json')))) {
return npmPkgPath;
}
}
return null;
}
function readPluginVersion(pkgJsonPath: string): string | null {
try {
@@ -373,10 +739,14 @@ export function ensurePluginInstalled(
// If already installed, check whether an upgrade is available
if (existsSync(fsPath(targetManifest))) {
if (!sourceDir) return { installed: true }; // no bundled source to compare, keep existing
if (!sourceDir) {
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
return { installed: true }; // no bundled source to compare, keep existing
}
const installedVersion = readPluginVersion(targetPkgJson);
const sourceVersion = readPluginVersion(join(sourceDir, 'package.json'));
if (!sourceVersion || !installedVersion || sourceVersion === installedVersion) {
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
return { installed: true }; // same version or unable to compare
}
// Version differs — fall through to overwrite install
@@ -400,6 +770,7 @@ export function ensurePluginInstalled(
return { installed: false, warning: `Failed to install ${pluginLabel} plugin mirror (manifest missing).` };
}
fixupPluginManifest(targetDir);
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
logger.info(`Installed ${pluginLabel} plugin from bundled mirror: ${sourceDir}`);
return { installed: true };
} catch (error) {
@@ -434,8 +805,8 @@ export function ensurePluginInstalled(
if (!app.isPackaged) {
const npmName = PLUGIN_NPM_NAMES[pluginDirName];
if (npmName) {
const npmPkgPath = join(process.cwd(), 'node_modules', ...npmName.split('/'));
if (existsSync(fsPath(join(npmPkgPath, 'openclaw.plugin.json')))) {
const npmPkgPath = resolvePluginNpmPackagePath(npmName);
if (npmPkgPath && existsSync(fsPath(join(npmPkgPath, 'openclaw.plugin.json')))) {
const installedVersion = existsSync(fsPath(targetPkgJson)) ? readPluginVersion(targetPkgJson) : null;
const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json'));
if (sourceVersion && (!installedVersion || sourceVersion !== installedVersion)) {
@@ -448,6 +819,7 @@ export function ensurePluginInstalled(
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
fixupPluginManifest(targetDir);
if (existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) {
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
return { installed: true };
}
} catch (err) {
@@ -465,6 +837,7 @@ export function ensurePluginInstalled(
);
}
} else if (existsSync(fsPath(targetManifest))) {
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
return { installed: true }; // same version, already installed
}
}
@@ -575,4 +948,5 @@ export async function ensureAllBundledPluginsInstalled(): Promise<void> {
logger.warn(`[plugin] Failed to install/upgrade ${label} plugin:`, error);
}
}
repairTrustedOfficialPluginInstallRecords();
}
+16 -13
View File
@@ -3,12 +3,14 @@ const MULTI_INSTANCE_PROVIDER_TYPES = new Set(['custom', 'ollama']);
export const OPENCLAW_PROVIDER_KEY_MINIMAX = 'minimax-portal';
export const OPENCLAW_PROVIDER_KEY_MOONSHOT = 'moonshot';
export const OPENCLAW_PROVIDER_KEY_MOONSHOT_GLOBAL = 'moonshot-global';
export const OPENAI_CODEX_RUNTIME_PROVIDER_KEY = 'openai-codex';
/** OpenClaw Z.AI runtime provider id (CN + Global share this key). */
export const OPENCLAW_PROVIDER_KEY_ZAI = 'zai';
/** OpenClaw Codex OAuth runtime provider id (canonical `openai`, not legacy `openai-codex`). */
export const OPENAI_CODEX_RUNTIME_PROVIDER_KEY = 'openai';
export const CLAWX_OPENAI_IMAGE_PROVIDER_KEY = 'clawx-openai-image';
export const OAUTH_PROVIDER_TYPES = ['minimax-portal', 'minimax-portal-cn'] as const;
export const OPENCLAW_OAUTH_PLUGIN_PROVIDER_KEYS = [
OPENCLAW_PROVIDER_KEY_MINIMAX,
OPENAI_CODEX_RUNTIME_PROVIDER_KEY,
] as const;
const OAUTH_PROVIDER_TYPE_SET = new Set<string>(OAUTH_PROVIDER_TYPES);
@@ -19,6 +21,8 @@ const HIDDEN_PROVIDER_KEYS_FOR_UI = new Set<string>([
const PROVIDER_KEY_ALIASES: Record<string, string> = {
'minimax-portal-cn': OPENCLAW_PROVIDER_KEY_MINIMAX,
// OpenClaw's built-in Z.AI provider is always `zai` (see docs.openclaw.ai/providers/zai).
'zai-global': OPENCLAW_PROVIDER_KEY_ZAI,
};
export function getOpenClawProviderKeyForType(type: string, providerId: string): string {
@@ -41,7 +45,7 @@ export function getOpenClawProviderKeyForType(type: string, providerId: string):
/**
* Resolve the OpenClaw runtime provider key for a saved account.
* Browser OAuth for OpenAI is stored under vendorId `openai` but runs as `openai-codex`.
* Browser OAuth for OpenAI is stored under vendorId `openai` and runs as `openai`.
*/
export function resolveOpenClawProviderKey(account: {
vendorId: string;
@@ -65,23 +69,18 @@ export function getAliasSourceTypes(openClawKey: string): string[] {
}
/**
* OpenAI Codex OAuth uses runtime key `openai-codex` while API keys use `openai`.
* When only OAuth is configured, hide the redundant bare `openai` active slot so the
* UI does not show a second "OpenAI • API key (missing)" row.
* Legacy OpenClaw builds wrote OAuth under runtime key `openai-codex`. Hide that
* stale slot when the canonical `openai` OAuth entry is active.
*/
export function filterActiveProviderKeysForUi(
activeKeys: Iterable<string>,
options?: { hasConfiguredOpenAiApiKey?: boolean },
): string[] {
const keys = Array.from(activeKeys).filter((key) => !HIDDEN_PROVIDER_KEYS_FOR_UI.has(key));
const active = new Set(keys);
if (!active.has('openai') || !active.has(OPENAI_CODEX_RUNTIME_PROVIDER_KEY)) {
return keys;
if (keys.includes('openai') && keys.includes('openai-codex') && !options?.hasConfiguredOpenAiApiKey) {
return keys.filter((key) => key !== 'openai-codex');
}
if (options?.hasConfiguredOpenAiApiKey) {
return keys;
}
return keys.filter((key) => key !== 'openai');
return keys;
}
export function isOAuthProviderType(type: string): boolean {
@@ -92,6 +91,10 @@ export function isMiniMaxProviderType(type: string): boolean {
return type === OPENCLAW_PROVIDER_KEY_MINIMAX || type === 'minimax-portal-cn';
}
export function isZaiProviderType(type: string): boolean {
return type === OPENCLAW_PROVIDER_KEY_ZAI || type === 'zai-global';
}
export function getOAuthProviderTargetKey(type: string): string | undefined {
if (!isOAuthProviderType(type)) return undefined;
return OPENCLAW_PROVIDER_KEY_MINIMAX;
+44
View File
@@ -0,0 +1,44 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { RuntimeKind } from '@shared/types/gateway';
import { getCcConnectManagedDir } from '../runtime/cc-connect-paths';
type RuntimeStatusReader = {
getStatus?: () => { runtimeKind?: RuntimeKind };
};
function getActiveRuntimeKind(runtimeManager?: RuntimeStatusReader | null): RuntimeKind {
try {
return runtimeManager?.getStatus?.().runtimeKind === 'cc-connect' ? 'cc-connect' : 'openclaw';
} catch {
return 'openclaw';
}
}
export function getOpenClawMediaDir(): string {
return join(homedir(), '.openclaw', 'media');
}
export function getCcConnectMediaDir(): string {
return join(getCcConnectManagedDir(), 'media');
}
export function getRuntimeMediaDir(runtimeManager?: RuntimeStatusReader | null): string {
return getActiveRuntimeKind(runtimeManager) === 'cc-connect'
? getCcConnectMediaDir()
: getOpenClawMediaDir();
}
export function getRuntimeOutboundMediaDir(runtimeManager?: RuntimeStatusReader | null): string {
return join(getRuntimeMediaDir(runtimeManager), 'outbound');
}
export function getRuntimeOutgoingMediaRecordDirs(runtimeManager?: RuntimeStatusReader | null): string[] {
const active = getRuntimeMediaDir(runtimeManager);
const ccConnect = getCcConnectMediaDir();
const fallback = active === ccConnect ? getOpenClawMediaDir() : ccConnect;
return [
join(active, 'outgoing', 'records'),
join(fallback, 'outgoing', 'records'),
];
}
+15 -16
View File
@@ -35,10 +35,6 @@ import { getOpenClawProviderKeyForType } from './provider-keys';
export async function storeApiKey(providerId: string, apiKey: string): Promise<boolean> {
try {
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
keys[providerId] = apiKey;
s.set('apiKeys', keys);
await setProviderSecret({
type: 'api_key',
accountId: providerId,
@@ -65,9 +61,7 @@ export async function getApiKey(providerId: string): Promise<string | null> {
return secret.apiKey ?? null;
}
const s = await getClawXProviderStore();
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
return keys[providerId] || null;
return null;
} catch (error) {
console.error('Failed to retrieve API key:', error);
return null;
@@ -80,10 +74,6 @@ export async function getApiKey(providerId: string): Promise<string | null> {
export async function deleteApiKey(providerId: string): Promise<boolean> {
try {
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
delete keys[providerId];
s.set('apiKeys', keys);
await deleteProviderSecret(providerId);
return true;
} catch (error) {
@@ -102,9 +92,7 @@ export async function hasApiKey(providerId: string): Promise<boolean> {
return true;
}
const s = await getClawXProviderStore();
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
return providerId in keys;
return secret?.type === 'local' && Boolean(secret.apiKey);
}
/**
@@ -113,8 +101,19 @@ export async function hasApiKey(providerId: string): Promise<boolean> {
export async function listStoredKeyIds(): Promise<string[]> {
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
return Object.keys(keys);
const legacyKeys = (s.get('apiKeys') || {}) as Record<string, string>;
const accountIds = new Set([
...Object.keys(legacyKeys),
...(await listProviderAccounts()).map((account) => account.id),
]);
const stored: string[] = [];
for (const accountId of accountIds) {
const secret = await getProviderSecret(accountId);
if (secret?.type === 'api_key' || (secret?.type === 'local' && secret.apiKey)) {
stored.push(accountId);
}
}
return stored.sort();
}
// ==================== Provider Configuration ====================
+12
View File
@@ -6,6 +6,9 @@
import { randomBytes } from 'crypto';
import { app } from 'electron';
import { resolveSupportedLanguage } from '@shared/language';
import { DEFAULT_WORKSPACE_CWD } from '@shared/workspace';
import type { RuntimeKind } from '@shared/types/gateway';
import { getClawXDataLayout, resolveClawXDataRoot } from './clawx-data-layout';
// Lazy-load electron-store (ESM module)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -33,6 +36,7 @@ export interface AppSettings {
// Gateway
gatewayAutoStart: boolean;
runtimeKind: RuntimeKind;
gatewayPort: number;
gatewayToken: string;
proxyEnabled: boolean;
@@ -51,6 +55,9 @@ export interface AppSettings {
// UI State
sidebarCollapsed: boolean;
devModeUnlocked: boolean;
chatWorkspacePath: string;
recentWorkspacePaths: string[];
workspaceLabels: Record<string, string>;
// Presets
selectedBundles: string[];
@@ -84,6 +91,7 @@ function createDefaultSettings(): AppSettings {
// Gateway
gatewayAutoStart: true,
runtimeKind: 'openclaw',
gatewayPort: 18789,
gatewayToken: generateToken(),
proxyEnabled: false,
@@ -102,6 +110,9 @@ function createDefaultSettings(): AppSettings {
// UI State
sidebarCollapsed: false,
devModeUnlocked: false,
chatWorkspacePath: DEFAULT_WORKSPACE_CWD,
recentWorkspacePaths: [DEFAULT_WORKSPACE_CWD],
workspaceLabels: {},
// Presets
selectedBundles: ['productivity', 'developer'],
@@ -118,6 +129,7 @@ async function getSettingsStore() {
const Store = (await import('electron-store')).default;
settingsStoreInstance = new Store<AppSettings>({
name: 'settings',
cwd: getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData'))).appDir,
defaults: createDefaultSettings(),
});
}
+234 -54
View File
@@ -1,19 +1,32 @@
export interface TokenUsageHistoryEntry {
runtimeKind?: 'openclaw' | 'cc-connect';
timestamp: string;
sessionId: string;
runtimeSessionId?: string;
agentId: string;
providerAccountId?: string;
model?: string;
provider?: string;
content?: string;
turnId?: string;
usageStatus: 'available' | 'missing' | 'error';
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
reasoningTokens?: number;
totalTokens: number;
costUsd?: number;
}
type UsageParseContext = {
sessionId: string;
agentId: string;
runtimeKind?: TokenUsageHistoryEntry['runtimeKind'];
model?: string;
provider?: string;
};
export function extractSessionIdFromTranscriptFileName(fileName: string): string | undefined {
if (!fileName.endsWith('.jsonl') && !fileName.includes('.jsonl.reset.')) return undefined;
return fileName
@@ -37,6 +50,8 @@ interface TranscriptUsageShape {
total_tokens?: number;
cache_read?: number;
cache_write?: number;
cachedInputTokens?: number;
cached_input_tokens?: number;
prompt_tokens?: number;
completion_tokens?: number;
cache_read_tokens?: number;
@@ -54,9 +69,24 @@ interface TranscriptUsageShape {
cacheReadTokenCount?: number;
cacheReadTokens?: number;
cache_write_token_count?: number;
cost?: {
reasoningTokens?: number;
reasoning_tokens?: number;
reasoningOutputTokens?: number;
reasoning_output_tokens?: number;
cost?: number | string | {
total?: number;
usd?: number;
total_usd?: number;
totalUsd?: number;
amount?: number;
};
costUsd?: number;
cost_usd?: number;
costUSD?: number;
totalCost?: number;
total_cost?: number;
totalCostUsd?: number;
total_cost_usd?: number;
}
type UsageRecordStatus = 'available' | 'missing' | 'error';
@@ -66,6 +96,7 @@ interface ParsedUsageTokens {
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
reasoningTokens?: number;
totalTokens: number;
costUsd?: number;
usageStatus: UsageRecordStatus;
@@ -96,6 +127,32 @@ function firstUsageNumber(usage: TranscriptUsageShape | undefined, candidates: s
return undefined;
}
function parseUsageCostUsd(usage: TranscriptUsageShape): number | undefined {
const direct = firstUsageNumber(usage, [
'costUsd',
'cost_usd',
'costUSD',
'totalCostUsd',
'total_cost_usd',
'totalCost',
'total_cost',
'cost',
]);
if (direct !== undefined) return direct;
if (usage.cost && typeof usage.cost === 'object' && !Array.isArray(usage.cost)) {
return firstUsageNumber(usage.cost as TranscriptUsageShape, [
'total',
'usd',
'total_usd',
'totalUsd',
'amount',
]);
}
return undefined;
}
function parseUsageFromShape(usage: unknown): ParsedUsageTokens | undefined {
if (usage === undefined) {
return undefined;
@@ -141,6 +198,8 @@ function parseUsageFromShape(usage: unknown): ParsedUsageTokens | undefined {
'cache_read_tokens',
'cacheReadTokenCount',
'cache_read_token_count',
'cachedInputTokens',
'cached_input_tokens',
]);
const cacheWriteTokens = firstUsageNumber(usageShape, [
'cacheWrite',
@@ -157,14 +216,21 @@ function parseUsageFromShape(usage: unknown): ParsedUsageTokens | undefined {
'totalTokenCount',
'total_token_count',
]);
const reasoningTokens = firstUsageNumber(usageShape, [
'reasoningTokens',
'reasoning_tokens',
'reasoningOutputTokens',
'reasoning_output_tokens',
]);
const hasUsageValue =
inputTokens !== undefined
|| outputTokens !== undefined
|| cacheReadTokens !== undefined
|| cacheWriteTokens !== undefined
|| reasoningTokens !== undefined
|| explicitTotalTokens !== undefined
|| normalizeUsageNumber(usageShape.cost?.total) !== undefined;
|| parseUsageCostUsd(usageShape) !== undefined;
if (!hasUsageValue) {
return {
@@ -180,8 +246,6 @@ function parseUsageFromShape(usage: unknown): ParsedUsageTokens | undefined {
const totalTokens = explicitTotalTokens ?? (
(inputTokens ?? 0)
+ (outputTokens ?? 0)
+ (cacheReadTokens ?? 0)
+ (cacheWriteTokens ?? 0)
);
return {
@@ -190,8 +254,9 @@ function parseUsageFromShape(usage: unknown): ParsedUsageTokens | undefined {
outputTokens: outputTokens ?? 0,
cacheReadTokens: cacheReadTokens ?? 0,
cacheWriteTokens: cacheWriteTokens ?? 0,
...(reasoningTokens !== undefined ? { reasoningTokens } : {}),
totalTokens,
costUsd: normalizeUsageNumber(usageShape.cost?.total),
costUsd: parseUsageCostUsd(usageShape),
};
}
@@ -214,8 +279,25 @@ interface TranscriptLineShape {
};
};
};
payload?: {
type?: string;
model?: string;
model_provider?: string;
info?: {
last_token_usage?: TranscriptUsageShape;
total_token_usage?: TranscriptUsageShape;
};
};
}
type UsageMessageShape = NonNullable<TranscriptLineShape['message']> & {
id?: string;
timestamp?: string | number;
created_at?: string | number;
createdAt?: string | number;
content?: unknown;
};
function normalizeUsageContent(value: unknown): string | undefined {
if (typeof value === 'string') {
const trimmed = value.trim();
@@ -257,13 +339,155 @@ function normalizeUsageContent(value: unknown): string | undefined {
return undefined;
}
function normalizeUsageTimestamp(value: unknown): string | undefined {
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : value;
}
if (typeof value === 'number' && Number.isFinite(value)) {
return new Date(value < 1e12 ? value * 1000 : value).toISOString();
}
return undefined;
}
function usageEntryFromMessage(
message: UsageMessageShape | undefined,
timestamp: string | undefined,
context: UsageParseContext,
): TokenUsageHistoryEntry | null {
if (!message || !timestamp) return null;
if (message.role === 'assistant' && 'usage' in message) {
const usage = parseUsageFromShape(message.usage);
if (!usage) return null;
const contentText = normalizeUsageContent((message as Record<string, unknown>).content);
return {
timestamp,
...(context.runtimeKind ? { runtimeKind: context.runtimeKind } : {}),
sessionId: context.sessionId,
agentId: context.agentId,
model: message.model ?? message.modelRef,
provider: message.provider,
...(contentText ? { content: contentText } : {}),
...(message.id ? { turnId: message.id } : {}),
...usage,
};
}
if (message.role !== 'toolResult' && message.role !== 'toolresult') {
return null;
}
const details = message.details;
if (!details || !('usage' in details)) {
return null;
}
const usage = parseUsageFromShape(details.usage);
if (!usage) return null;
const provider = details.provider ?? details.externalContent?.provider ?? message.provider;
const model = details.model ?? message.model ?? message.modelRef;
const contentText = normalizeUsageContent(details.content)
?? normalizeUsageContent((message as Record<string, unknown>).content);
return {
timestamp,
...(context.runtimeKind ? { runtimeKind: context.runtimeKind } : {}),
sessionId: context.sessionId,
agentId: context.agentId,
model,
provider,
...(contentText ? { content: contentText } : {}),
...(message.id ? { turnId: message.id } : {}),
...usage,
};
}
function usageEntryFromCodexTokenCount(
record: TranscriptLineShape,
timestamp: string | undefined,
context: UsageParseContext,
): TokenUsageHistoryEntry | null {
if (!timestamp || record.type !== 'event_msg' || record.payload?.type !== 'token_count') {
return null;
}
const usage = parseUsageFromShape(record.payload.info?.last_token_usage ?? record.payload.info?.total_token_usage);
if (!usage || usage.usageStatus !== 'available') {
return null;
}
return {
timestamp,
...(context.runtimeKind ? { runtimeKind: context.runtimeKind } : {}),
sessionId: context.sessionId,
agentId: context.agentId,
...(context.model ? { model: context.model } : {}),
provider: context.provider ?? 'codex',
...usage,
};
}
function usageContextWithJsonlMetadata(lines: string[], context: UsageParseContext): UsageParseContext {
let model = context.model;
let provider = context.provider;
for (const line of lines) {
let parsed: TranscriptLineShape;
try {
parsed = JSON.parse(line) as TranscriptLineShape;
} catch {
continue;
}
if (parsed.type !== 'session_meta' && parsed.type !== 'turn_context') continue;
const payload = parsed.payload as Record<string, unknown> | undefined;
if (!payload || typeof payload !== 'object') continue;
if (!model && typeof payload.model === 'string' && payload.model.trim()) {
model = payload.model.trim();
}
if (!provider && typeof payload.model_provider === 'string' && payload.model_provider.trim()) {
provider = payload.model_provider.trim();
}
if (model && provider) break;
}
return {
...context,
...(model ? { model } : {}),
...(provider ? { provider } : {}),
};
}
export function parseUsageEntriesFromMessages(
messages: unknown[],
context: UsageParseContext,
limit?: number,
): TokenUsageHistoryEntry[] {
const entries: TokenUsageHistoryEntry[] = [];
const maxEntries = typeof limit === 'number' && Number.isFinite(limit)
? Math.max(Math.floor(limit), 0)
: Number.POSITIVE_INFINITY;
for (let i = messages.length - 1; i >= 0 && entries.length < maxEntries; i -= 1) {
const item = messages[i];
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
const message = item as UsageMessageShape;
const timestamp = normalizeUsageTimestamp(message.timestamp ?? message.created_at ?? message.createdAt);
const entry = usageEntryFromMessage(message, timestamp, context);
if (entry) entries.push(entry);
}
return entries;
}
export function parseUsageEntriesFromJsonl(
content: string,
context: { sessionId: string; agentId: string },
context: UsageParseContext,
limit?: number,
): TokenUsageHistoryEntry[] {
const entries: TokenUsageHistoryEntry[] = [];
const lines = content.split(/\r?\n/).filter(Boolean);
const enrichedContext = usageContextWithJsonlMetadata(lines, context);
const maxEntries = typeof limit === 'number' && Number.isFinite(limit)
? Math.max(Math.floor(limit), 0)
: Number.POSITIVE_INFINITY;
@@ -276,54 +500,10 @@ export function parseUsageEntriesFromJsonl(
continue;
}
const message = parsed.message;
if (!message || !parsed.timestamp) {
continue;
}
if (message.role === 'assistant' && 'usage' in message) {
const usage = parseUsageFromShape(message.usage);
if (!usage) continue;
const contentText = normalizeUsageContent((message as Record<string, unknown>).content);
entries.push({
timestamp: parsed.timestamp,
sessionId: context.sessionId,
agentId: context.agentId,
model: message.model ?? message.modelRef,
provider: message.provider,
...(contentText ? { content: contentText } : {}),
...usage,
});
continue;
}
if (message.role !== 'toolResult') {
continue;
}
const details = message.details;
if (!details || !('usage' in details)) {
continue;
}
const usage = parseUsageFromShape(details.usage);
if (!usage) continue;
const provider = details.provider ?? details.externalContent?.provider ?? message.provider;
const model = details.model ?? message.model ?? message.modelRef;
const contentText = normalizeUsageContent(details.content)
?? normalizeUsageContent((message as Record<string, unknown>).content);
entries.push({
timestamp: parsed.timestamp,
sessionId: context.sessionId,
agentId: context.agentId,
model,
provider,
...(contentText ? { content: contentText } : {}),
...usage,
});
const timestamp = normalizeUsageTimestamp(parsed.timestamp);
const entry = usageEntryFromMessage(parsed.message, timestamp, enrichedContext)
?? usageEntryFromCodexTokenCount(parsed, timestamp, enrichedContext);
if (entry) entries.push(entry);
}
return entries;
+37 -8
View File
@@ -7,6 +7,7 @@ import {
parseUsageEntriesFromJsonl,
type TokenUsageHistoryEntry,
} from './token-usage-core';
import type { RuntimeKind } from '@shared/types/gateway';
import { listConfiguredAgentIds } from './agent-config';
export {
@@ -15,6 +16,14 @@ export {
type TokenUsageHistoryEntry,
} from './token-usage-core';
type RecentUsageSourceFile = {
filePath: string;
sessionId: string;
agentId: string;
mtimeMs: number;
source: 'openclaw-jsonl';
};
async function listAgentIdsWithSessionDirs(): Promise<string[]> {
const openclawDir = getOpenClawConfigDir();
const agentsDir = join(openclawDir, 'agents');
@@ -48,13 +57,13 @@ async function listAgentIdsWithSessionDirs(): Promise<string[]> {
return [...agentIds];
}
async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }>> {
async function listRecentSessionFiles(): Promise<RecentUsageSourceFile[]> {
const openclawDir = getOpenClawConfigDir();
const agentsDir = join(openclawDir, 'agents');
try {
const agentEntries = await listAgentIdsWithSessionDirs();
const files: Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }> = [];
const files: RecentUsageSourceFile[] = [];
for (const agentId of agentEntries) {
const sessionsDir = join(agentsDir, agentId, 'sessions');
@@ -72,6 +81,7 @@ async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessi
sessionId,
agentId,
mtimeMs: fileStat.mtimeMs,
source: 'openclaw-jsonl',
});
} catch {
continue;
@@ -89,21 +99,40 @@ async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessi
}
}
export async function getRecentTokenUsageHistory(limit?: number): Promise<TokenUsageHistoryEntry[]> {
const files = await listRecentSessionFiles();
export type TokenUsageHistoryOptions = {
limit?: number;
runtimeKind?: RuntimeKind;
};
function normalizeTokenUsageOptions(input?: number | TokenUsageHistoryOptions): TokenUsageHistoryOptions {
if (typeof input === 'number') return { limit: input };
return input ?? {};
}
function matchesRuntimeKind(file: RecentUsageSourceFile, runtimeKind?: RuntimeKind): boolean {
if (!runtimeKind) return true;
return runtimeKind === 'openclaw' && file.source === 'openclaw-jsonl';
}
export async function getRecentTokenUsageHistory(input?: number | TokenUsageHistoryOptions): Promise<TokenUsageHistoryEntry[]> {
const options = normalizeTokenUsageOptions(input);
if (options.runtimeKind === 'cc-connect') return [];
const files = (await listRecentSessionFiles())
.filter((file) => matchesRuntimeKind(file, options.runtimeKind))
.sort((a, b) => b.mtimeMs - a.mtimeMs);
const results: TokenUsageHistoryEntry[] = [];
const maxEntries = typeof limit === 'number' && Number.isFinite(limit)
? Math.max(Math.floor(limit), 0)
const maxEntries = typeof options.limit === 'number' && Number.isFinite(options.limit)
? Math.max(Math.floor(options.limit), 0)
: Number.POSITIVE_INFINITY;
for (const file of files) {
if (results.length >= maxEntries) break;
try {
const content = await readFile(file.filePath, 'utf8');
const entries = parseUsageEntriesFromJsonl(content, {
sessionId: file.sessionId,
agentId: file.agentId,
}, Number.isFinite(maxEntries) ? maxEntries - results.length : undefined);
runtimeKind: 'openclaw',
});
results.push(...entries);
} catch (error) {
logger.debug(`Failed to read token usage transcript ${file.filePath}:`, error);
@@ -0,0 +1,172 @@
# ACP Attachment Access Control And Open With
Status: authoritative durable architecture and security reference, reviewed 2026-07-23.
Related scenarios: `acp-chat-experience`, `acp-file-activity`, `gateway-backend-communication`
Related rules: `attachment-access-safety`, `session-workspace-authority`, `tool-derived-file-safety`, `renderer-main-boundary`, `backend-communication-boundary`
Related tasks: `acp-media-attachments`, `acp-attachment-open-with`, `unify-acp-file-cards`
## Trust Boundaries And Ownership
Renderer owns attachment parsing, timeline projection, presentation, and click routing. Electron Main owns ACP session and relative-path context, filesystem and URI validation, scoped reads, operating-system handler discovery, and native open/reveal actions. Renderer-provided URIs, metadata, staging ids, transcript message ids, attachment references, and selected handler ids are untrusted inputs.
An attachment source reference conceptually identifies the active ACP session key, generation, original URI, and optional Main-issued staging or transcript evidence. A resolved local or remote reference repeats that routing identity. These references, Renderer-visible attachment ids, opaque file identities, and handler ids are not bearer capabilities. This Harness reference is authoritative for the durable architecture and security invariants; `shared/host-api/contract.ts` and `src/lib/acp/timeline-types.ts` define the exact current serializable fields, result unions, error values, and operation signatures.
Renderer reaches attachment operations only through `src/lib/host-api.ts` and the typed Host API. The Main-owned `files` service exposes resolution, bounded text and binary reads, click-initiated open, compatible-handler listing, selected-handler open, and reveal. A successful resolution supplies display metadata, a non-sensitive opaque identity, and an attachment-scoped target, but it does not authorize a later read, list, open, or reveal.
The attachment Open With contract consists of `listAttachmentOpenHandlers(ref)`, `openAttachmentWith({ ref, handlerId })`, and `revealAttachment(ref)`. Renderer receives only platform, stable public handler identity, bounded display name, optional bounded PNG icon data URL, and default status. Main accepts no Renderer-provided canonical file path, executable or application path, association input, bundle path, icon-source path, command line, command template, helper source, or child-process environment addition.
## Grant Lifecycle
ACP session load or creation is the only operation that establishes attachment session and relative-path context. Main canonicalizes the selected workspace root and execution cwd, verifies that both are directories and cwd is contained by the workspace, and commits the context only after the ACP operation succeeds. A failed load restores the prior ACP state and prior context. Switching the active session or advancing generation replaces that context.
Every attachment operation looks up the one active Main-owned context by exact session key and generation. Attachment, preview, list, selected-handler open, and reveal payloads cannot provide or replace the execution cwd. Each operation independently resolves the original ref and checks the active session and generation. Generation is a revocation and race token used together with session identity; it is not a globally monotonic credential.
Listing never grants a durable capability. Application-specific open freshly enumerates current handlers without icons, verifies exact membership of the submitted public handler id, then calls an attachment-owned revalidation callback. That callback resolves the original ref again, requires an existing regular local file, and rechecks generation immediately before native invocation. The action is rejected if the association key changed. Reveal likewise re-resolves and revalidates immediately before `shell.showItemInFolder()`.
## Local Resolution And Special Scopes
An accepted absolute, home-relative, `file:`, or execution-cwd-relative reference may resolve to any existing regular local file, including a file outside the active workspace or managed OpenClaw directories. The target is canonicalized before use. The local `scope` returned to Renderer is classification metadata for existing UI behavior, not an authorization root:
- `workspace`: the canonical target is inside the active ACP workspace root. Relative references resolve from the registered execution cwd.
- `openclaw-media`: the canonical target is outside the workspace. This legacy scope name does not imply containment under an OpenClaw media root.
- `staging`: when a staging id is supplied, it must match the exact canonical file in the Main-owned staging record. The same file may also resolve from an explicit path without claiming staging identity.
- `remote`: a normalized HTTP or HTTPS URL without embedded credentials. Remote references remain session/generation scoped and are revalidated immediately before external open.
Gateway outgoing media remains a record-bound special case, not a general local URL alias. Main validates the outgoing attachment id, requires the URL session key and managed record `sessionKey` to equal the active ACP session key, requires the record attachment id to match, and resolves the record's original file through a managed media root. If both transcript evidence and the record carry a message id, they must agree. The literal `global` session key follows exact equality and is never a wildcard.
## Path And URI Hardening
Main applies syntax checks before ownership checks and authorization again before each side effect. Current defenses include:
- Reject empty, NUL-containing, traversal-bearing, unknown-scheme, UNC, network-share, and overlong source references. The source-reference bound is 4096 characters.
- Decode percent-encoded input once through platform URL handling, then reject encoded traversal or NUL content as well.
- Accept `file:` URLs only with an empty authority or local `localhost` authority; reject remote authorities and credentials.
- Accept only HTTP and HTTPS remote URLs, require a host, reject credentials, and use platform URL normalization for identity and open.
- Resolve home-relative, absolute, Windows-drive, and execution-cwd-relative local references without treating a Renderer-provided path as an authorization root.
- Require an existing regular file and canonicalize the target. Symlink targets and files outside the workspace are allowed after canonical resolution.
Scoped reads open the canonical file without following a final symlink where the platform supports it, verify that the handle is a regular file, recheck the active generation, and read through that handle. Local system open re-resolves immediately before `shell.openPath`; remote open revalidates the normalized URL and active generation before `shell.openExternal`. A prior resolve, handler list, cache entry, or stable identity alone never authorizes a later side effect.
## Opaque Identity And Safe Labels
After authorization, Main returns an opaque hash derived from the canonical local target or normalized remote URL. Renderer uses that value for turn-scoped deduplication and diagnostics, but it must not expose a sensitive host path or be treated as access authority.
Display labels come from approved metadata or a decoded basename. Main reduces labels to a basename, removes control and bidirectional-formatting characters, collapses whitespace to one line, applies the current length bound, and falls back to a generic attachment label. Available attachment cards separately show the decoded local path or normalized remote URL represented by the explicit source reference; unavailable cards remain basename-only. Main-owned staging metadata may provide the original user-selected display path.
## Preview And Shared File Card
The shared Renderer classifier in `src/lib/file-preview-capabilities.ts` decides whether a session-valid local attachment fits an existing inline viewer and its size cap. Supported text/code, HTML, CSV, image, PDF, spreadsheet, and supported Office targets use the right-side Preview panel. Unsupported, known binary, audio/video, archive, other office-document, or over-limit local targets use the system application only after a user click. HTTP and HTTPS targets open externally only after a user click.
Every attachment preview carries an attachment-scoped file reference. Preview components and rich viewers use the attachment text or binary read operations and must not fall back to a naked path or general workspace read. Attachment previews omit trusted workspace-browser reveal or folder actions.
The later shared-card implementation supersedes the original attachment-local card/menu ownership. `src/pages/Chat/AcpFileCard.tsx` now owns the common `AcpFileCard` shell and target-aware `AcpFileOpenWith` menu for distinct `attachment` and `workspace` references. This sharing is presentation only: attachment authorization remains session/generation scoped, while tool-derived file activity uses independently validated workspace-scoped operations. The two reference types must never be converted into each other. Eligible local HTML menus put an action first that submits the already-present local file URI to the existing right-side Web Browser, equivalent to the user entering that URI in its address bar; this browser navigation is separate from native attachment operations.
For attachments, Open With is eligible only when tone is `assistant`, access is `available`, the target is `local`, and `attachmentOpenMode(...)` is `preview`. User, pending, unavailable, remote, and system-open-only attachments do not show it. The primary sibling button retains the translated `Preview <filename>` accessible name and preview behavior. The compact secondary sibling is never nested inside the primary button and must not activate preview.
Created and modified ACP file-activity rows may use the same shared menu with `WorkspaceFileRef`; deleted rows never do. Their Preview and Changes actions remain separate from Open With. Workspace operations follow the authority in `harness/reference/openclaw-file-activity.md`, not attachment grants.
## Lazy Menu Lifecycle
Discovery is user-initiated and starts only when an eligible menu opens. On every closed-to-open transition, the shared component clears old rows and starts a new request on macOS or Windows. A disabled localized loading row occupies only the application section; reveal is immediately available. Linux skips the discovery call. Closing, unmounting, changing target, or starting a newer request invalidates prior asynchronous results so a stale response cannot populate another file's menu.
Valid rows put enumerated defaults first and locale-sort the remainder with `Intl.Collator(i18n.language)`. Rows use the Main-provided 20-pixel PNG icon only after Renderer validates the expected bounded data URL shape; missing, malformed, oversized, or image-load-failed icons use the generic application icon. The application section is not truncated; the bounded-height Radix menu scrolls and retains keyboard navigation, Enter activation, Escape/outside-click dismissal, and trigger focus restoration. Reveal is the final item and is labeled for Finder on macOS, File Explorer on Windows, or the file manager on Linux.
Discovery is requested again on a later open. Main's cache may satisfy that request, but Renderer owns no authorization or native metadata cache. Only a failed user-selected application open or reveal may show a localized non-blocking toast.
## Native Helper Bounds
Native helper output is untrusted and every record is independently schema-validated. The following limits are security invariants in both the Main adapter and static Windows helper where applicable:
- Handler display name: at most 256 UTF-16 code units.
- Private native handler identity/public input: at most 512 UTF-16 code units before platform-specific public-id validation.
- Native file, bundle, executable, and icon-source path: at most 4096 UTF-16 code units.
- Icon PNG data URL: at most 64 KiB, with macOS base64 syntax and PNG signature validation before IPC.
- Process lifetime: five seconds for non-interactive discovery and the interactive Windows protocol.
- Aggregate or `execFile` output: at most 1 MiB.
- Windows stdin protocol: exactly one JSON line no longer than 8192 characters after ready.
- Presentation cache: five minutes and at most 128 association entries.
Control characters invalidate bounded names, ids, and paths. Helpers run through explicit executables and positional argument arrays with `shell: false`, hidden Windows process UI, and a minimal allowlisted Main-owned environment. Payloads, dependencies, Renderer inputs, and inherited arbitrary variables cannot add environment entries. Process failures are reduced to bounded reason codes; command arguments, private output, and native errors are not logged.
## macOS Static JXA Adapter
macOS uses `/usr/bin/osascript -l JavaScript` with a static JXA program owned in `electron/services/attachment-open-with.ts`. File paths and the icon mode are positional arguments after `--`; no Renderer or file value is interpolated into source. The program imports Foundation and AppKit and uses `NSWorkspace`/Launch Services to convert the Main-owned canonical path to a file URL, enumerate registered application URLs, and identify the default application URL.
Each valid enumerated row carries a private bundle identifier, localized display name, and bundle path. The public `handlerId` is the bundle identifier, but bundle and icon-source paths remain private to Main. Listing renders each bundle's `NSWorkspace.iconForFile()` image as a 32-point PNG in JXA; one icon failure omits only that icon, and macOS does not use Electron bundle-icon fallback.
Opening performs a fresh icon-free JXA enumeration, matches the submitted bundle identifier, calls attachment revalidation, rejects an association-key change, then invokes `/usr/bin/open` with `['-a', freshBundlePath, revalidatedPath]`. No caller can select an arbitrary application path.
## Windows Static PowerShell/C#/COM Adapter
Windows uses the checked-in static bundled `resources/scripts/attachment-open-with.ps1` helper. Development resolves it from the app resources tree and packaged builds resolve the copied resource under `process.resourcesPath`; Windows CI/native smoke and release packaging checks protect source validity and packaged-resource identity. Main starts `powershell.exe` with `-NoLogo`, `-NoProfile`, `-NonInteractive`, `-ExecutionPolicy Bypass`, `-File`, helper path, mode, and data as separate arguments. It never builds or executes PowerShell/registry command templates.
The helper's static embedded C# uses documented Shell APIs: `SHAssocEnumHandlers`, `AssocQueryString`, `IAssocHandler`, `IEnumAssocHandlers`, `IShellItem`, and Shell data-object binding. Listing derives the association from the Main-owned path, enumerates desktop and packaged handlers, reads localized UI names and icon/executable metadata when available, and marks an enumerated row default only when strict normalized executable identity matches `AssocQueryString`. Main requests Windows icons from validated private icon/executable paths through `app.getFileIcon()`; icon absence or failure does not invalidate a handler.
Windows never exposes native handler identity. Both Node and the helper derive the public opaque id as lower-case hex `SHA-256(UTF8("win32\0" + nativeIdentity))`; action input must be exactly 64 lower-case hexadecimal characters. Private identity-to-public-id relationships stay inside Main/helper memory and list cache records.
For action-time validation, Main spawns `prepare-open` with the initial pre-authorized canonical path and opaque id as separate arguments. The helper derives that path's association, enumerates exactly once, recomputes ids, retains only the matching `IAssocHandler` COM object, emits exactly one `{"ready":true}` line, and waits. Only after ready does Main re-resolve the original attachment ref and generation. If revalidation succeeds and the association key is unchanged, Main sends exactly one bounded line containing only `{ "command": "invoke", "path": <post-ready canonical path> }`. The helper rechecks the association, creates a Shell data object for that path, invokes the retained handler, and releases COM objects. Timeout, output overflow, EOF, malformed/repeated protocol, cancellation, revalidation failure, stdin failure, association change, or non-zero helper exit prevents invocation.
## Successful Empty Result On Linux
Linux intentionally has no application discovery or application-specific open in this scope. `listAttachmentOpenHandlers` still authorizes and resolves the attachment, then returns `{ ok: true, platform: 'linux', handlers: [] }` without starting a helper. The shared menu therefore contains only attachment-scoped reveal. Application-specific open is unsupported rather than falling back to a default application.
## Normalization And Presentation Cache
Main validates rows independently, removes duplicate public identities, carries a default flag across duplicates, and keeps valid enumerated default rows before other operating-system rows. Renderer locale-sorts non-default rows. Current code normalizes only rows returned by enumeration; it does not synthesize a handler that a separate default-association query names but enumeration omitted.
The durable guarded requirement is that any future default-handler insertion must use complete validated metadata from a platform API, preserve exact fresh-membership and invocation checks, and remain representable without exposing private identity or paths. It is a follow-up, not implemented behavior. Tests and documentation must not claim insertion until the adapter actually supplies and validates that row.
Main caches normalized list metadata, converted icons, and private list records for five minutes by platform plus lower-case file-association key; extensionless files use the lower-case basename. Expired entries are pruned, growth is bounded to 128 entries, and oldest entries are evicted. This discovery cache is presentation-only. Attachment authorization is never cached, failures grant nothing, and action-time validation always uses a fresh icon-free enumeration rather than the cache.
## Failure And Privacy Semantics
An invalid, stale, missing, unsafe, remote-for-local-operation, or non-file reference becomes an unavailable/error result. It cannot be previewed or opened, but it does not suppress assistant prose or independently valid attachments. A valid existing file does not become unavailable merely because it is outside the workspace. Read failures remain inside Preview; local or remote open failures use the localized non-blocking Chat error path.
Helper startup, timeout, output, parsing, schema, association, application metadata, and icon failures must not reject attachment-card rendering. Whole discovery failure becomes an empty application section with no toast, banner, or failure row. One invalid handler is omitted; one invalid icon affects only that row. Reveal remains available and primary preview remains unchanged. Only a failed action explicitly requested by selecting an application or reveal may surface a concise localized toast.
Logs and traces must not contain transcript bodies, file content, credentials, canonical file paths, application or bundle paths, icon-source paths, native handler identities, helper source/output, command lines, or icon data. Optional open-with tracing may use only the existing opaque attachment identity and bounded allowlisted fields such as source kind or reason code.
## Non-Goals
- Application discovery or application-specific open on Linux.
- Open With on user, remote, unavailable, pending, system-open-only, or non-preview attachment cards.
- Changing preview format classification or file-size limits.
- Persisting associations or changing an operating-system default.
- Download, export, copy, or system chooser actions in this menu.
- Renderer access to canonical/native paths, private identities, helper source, or command templates.
- Converting workspace file-activity evidence into attachment authority.
- Generalizing the card/menu beyond ACP attachment and file-activity surfaces.
## Rejected Alternatives
### Native N-API Addon
A native addon could call AppKit, Launch Services, and Windows Shell APIs directly. It was rejected because architecture-specific compilation, signing, packaging, and release maintenance are disproportionate to this feature.
### Registry Or Application-Directory Parsing
Scanning macOS application directories or parsing Windows Registry command strings was rejected because it misses packaged applications, can report handlers that cannot open the file, and introduces unsafe command-template parsing. Windows must use Shell association COM APIs.
### System Open-With Dialog Only
Delegating entirely to the operating-system chooser was rejected because it does not provide the required in-card application list, icons, and independently available reveal action.
### Renderer-Owned Discovery Or Paths
Renderer-side helper execution, raw executable selection, and generic-shell reveal were rejected because they bypass attachment-scoped authorization, expose private host paths, and make stale-session revocation unenforceable.
## Validation Anchors
- `tests/unit/attachment-open-with.test.ts`: schema normalization, 256/512/4096 bounds, control rejection, SHA-256 opacity, stable deduplication/default-first ordering, five-minute/128-entry cache, 64 KiB icons, static JXA arguments, shell-free sanitized process options, five seconds/1 MiB bounds, Linux no-process behavior, fresh macOS membership, and Windows ready/revalidate/invoke protocol.
- `tests/unit/attachment-open-with-native.test.ts`: real platform-gated static JXA and PowerShell/C#/COM smoke coverage plus packaged helper resolution.
- `tests/unit/attachment-access.test.ts`: typed per-operation attachment authorization, stale generation and remote rejection, action-time re-resolution, forged handler rejection, association-race prevention, scoped reveal, and sensitive diagnostic exclusion.
- `tests/unit/files-api-workspace.test.ts`: the distinct workspace-scoped operations consumed by the later shared `AcpFileCard` implementation.
- `tests/unit/host-api-facade.test.ts` and `tests/unit/host-services.test.ts`: typed `files` facade/service routes and absence of a legacy direct IPC path.
- `tests/unit/acp-chat-components.test.tsx`: exact attachment and file-activity eligibility, sibling controls, lazy loading, stale-request invalidation, locale ordering, icon fallback, silent discovery failure, platform reveal labels, explicit-action errors, and keyboard menu behavior.
- `tests/unit/artifact-panel.test.tsx`, `tests/unit/file-preview-body.test.tsx`, and `tests/unit/rich-file-viewers.test.tsx`: unchanged attachment-scoped preview behavior.
- `tests/e2e/chat-acp-attachments.spec.ts`: attachment card click routing, typed list/open/reveal requests, platform branches, Linux reveal-only behavior, and failure isolation.
- `tests/e2e/chat-file-changes.spec.ts`: later `AcpFileCard` workspace-target reuse and deleted-row exclusion without conflating workspace and attachment grants.
- `.github/workflows/check.yml` and `.github/workflows/release.yml`: Windows native bridge execution and packaged helper presence/hash checks.
+101
View File
@@ -0,0 +1,101 @@
# ACP Chat Architecture And Timeline
Status: current architecture reference, reviewed 2026-07-15.
Related scenario: `acp-chat-experience`
Related rules: `acp-chat-state-and-history`, `attachment-access-safety`, `renderer-main-boundary`
Related tasks: `acp-native-chat`, `acp-media-attachments`, `filter-openclaw-heartbeat-session`
## Ownership
Electron Main owns the reusable `openclaw acp` child process, ACP SDK connection, stdio lifecycle, typed host operations, permission responses, and routing envelopes. Renderer owns ACP semantic reduction and presentation. Main must not translate ordinary text, thought, tool, permission, plan, or media updates into a second ClawX Chat protocol.
The normal flow is:
```text
Chat UI -> host-api -> Main ACP service -> openclaw acp
session/update -> Main routing envelope -> Renderer reducer -> timeline -> React
```
Gateway remains responsible for non-Chat capabilities. Restricted Gateway host-event evidence may supplement asynchronous image-generation completion, but it is not a source for ordinary Chat messages or tool history.
## Identity And Race Protection
Renderer-visible session identity is the OpenClaw Gateway session key. Main may hold a different ACP session id returned by `newSession`; it rewrites downstream routing to the matching Gateway session key. Loads on the shared ACP connection are serialized. A routing envelope carries the session key and the Main-owned generation token for the matching load or live prompt. Renderer uses a separate local request sequence to reject stale load completions; preparing a local-only session must not advance the ACP generation. Renderer ignores updates, permission requests, and asynchronous hydration results whose session or generation matches neither the selected session nor a retained live prompt. Generation is an in-memory race token rather than a durable sequence; Main may restore the previous value when a load fails, so code must compare it together with session and current-operation state rather than assume global monotonicity.
While `session/prompt` is pending, Main retains a bounded session-id routing context and Renderer retains that prompt's reduced timeline and original client-observed turn start in memory. This lets another page or conversation be viewed without dropping the original stream or resetting elapsed time. Returning to the live conversation reactivates its existing ACP context and restores the memory snapshot without calling `session/load`; updates received during the handoff are still generation-filtered. Prompt settlement releases both live contexts, after which returning uses ordinary ACP replay plus bounded timing metadata. This is live operation state, not a second history ledger, and it is never persisted.
`messageId` and `toolCallId` are opaque identities within one loaded timeline. They are not durable UI identities across loads. Timeline sequence values and DOM anchors are also local to the active snapshot.
## History Authority
ACP `session/load` replay is the primary source of Chat history. ClawX does not persist an ACP ledger, reduced timeline, replay cache, or reconstructed tool history. Full structured replay can restore tools and file activity; transcript-only fallback must not invent them.
OpenClaw emits replay through ordinary `session/update` notifications and completes the replay before `session/load` returns. Main collects those raw notifications for the active load generation and returns them with the load result instead of forwarding them incrementally. Renderer temporarily groups generation-matching host events that arrive during the IPC result handoff, then runs the normal reducer over the combined batch and publishes the resulting timeline in one state update. This is an in-flight transaction buffer only, not a history cache; after load, live updates continue through the normal host-event route. Permission requests are accepted only after the current loaded session starts a prompt, preventing load-time or handoff requests from creating invisible waiters.
There are exactly two approved transcript-derived content supplements. ClawX may recover asynchronous image-generation completions with proven `image_generate` context, and it may recover explicit line-leading assistant `MEDIA:` attachment directives omitted by OpenClaw ACP. Both are bounded, marked, memory-only projections. Separately, Main may extract metadata-only whole-turn timing because ACP replay omits original timestamps. Renderer can attach that timing only to an unambiguously matched ACP turn; it cannot reconstruct ordinary assistant text, thoughts, tool cards, plans, permissions, file activity, or missing turns. See `harness/reference/acp-generated-media-and-diagnostics.md#bounded-transcript-exceptions` for the content compatibility grammar and timing boundary.
## Timeline Model
The Renderer keeps an in-memory `AcpTimelineSnapshot` with ordered item ids, item records, open message segments, tool and permission state, and ACP metadata. The exact TypeScript types in `src/lib/acp/` are authoritative; the stable conceptual item kinds are:
```ts
type TimelineItem =
| MessageSegmentItem
| ThoughtItem
| ToolCallItem
| PermissionItem
| PlanItem;
type MessageSegmentItem = {
kind: 'message-segment';
id: string;
role: 'user' | 'assistant';
messageId: string;
segmentIndex: number;
parts: RenderPart[];
};
```
The reducer preserves first-seen ACP order and patches existing items in place. Interleaving a process block with assistant text closes the current segment; later text for that message creates another segment. Replay and live updates use the same reducer path. Optimistic user segments are allowed and are coalesced with the ACP user echo.
UI-only state such as card expansion, scroll position, selected artifact, composer draft, copy feedback, and lightbox state stays outside the reducer.
## Display Grouping
The protocol timeline remains flat. `src/lib/acp/timeline-groups.ts` derives display groups at render time:
- A user item starts or extends a user group.
- All non-user items between user boundaries form one assistant turn.
- Assistant-side items before the first user item still form a visible assistant turn.
- Grouping never infers ownership from `messageId`, `toolCallId`, `_meta`, or synthetic persisted turn ids.
An assistant turn has one identity column and one copy action. Copy includes textual assistant segments and excludes tool output. Its footer may show localized whole-turn metadata: live duration runs from optimistic send until prompt settlement, while historical duration runs from the matched transcript user record to the latest assistant or tool-result record before the next real user. Missing or ambiguous timing stays hidden. Tool cards render inline in original order, preserve preformatted whitespace, auto-collapse one second after live completion, respect manual override, and start collapsed when historical and completed.
## Attachments
Standard ACP `resource_link` and URI-backed `resource` content is the preferred attachment path. OpenClaw ACP currently projects assistant text and thought content but can omit assistant media while removing `MEDIA:` directives from the visible live reply. Until upstream emits standard resource content, the bounded explicit-`MEDIA:` supplement may add a marked compatibility attachment to the matching turn without manufacturing an ACP event.
Standard `resource_link` mapping preserves its URI, name, title fallback, MIME type, and size when supplied. A URI-backed embedded `resource` uses the same model and metadata precedence; embedded content without a usable URI becomes unavailable rather than entering an unrelated unsupported-content path. Exact TypeScript models remain authoritative.
Renderer keeps attachment references and compatibility projections in the active in-memory timeline. Main owns ACP session and relative-path context and resolves, reads, and opens every attachment against the exact session and generation. Existing regular files may resolve outside the active workspace, but a prior resolution is not reusable authorization and Renderer cannot supply a replacement execution cwd. Native ACP evidence wins when it resolves to the same identity as compatibility evidence. The complete authorization and URI boundary is documented in `harness/reference/acp-attachment-access-control.md`.
Assistant grouping lifts attachments from message, thought, and tool-output segments into one ordered turn list after all prose and process items and before file activity. This prevents an early resource block from appearing above later assistant prose. User grouping similarly renders all prose before ordered attachments. User-selected images render as Main-generated thumbnails whose hover overlay identifies the file. Other available attachment cards show the filename followed by the muted, truncating path represented by their explicit source reference; unavailable attachments remain basename-only.
Available attachment cards contain a primary semantic action with keyboard activation, an accessible action and safe filename, standard focus visibility, and the established hover state. Eligible local assistant Preview cards may also contain a compact secondary Open With sibling button; controls are never nested and secondary interaction cannot activate Preview. Pending and unavailable rows remain announced but disabled. Supported session-valid local files use the Preview panel, other local files use the system application only after a click, and HTTP or HTTPS attachments open externally only after a click. One malformed or unavailable attachment cannot suppress prose or sibling attachments. Image-generation completion remains an inline-image experience. It shares transcript coordination and opaque resolved identities with attachment recovery but is not converted into an attachment card.
## Chat Behaviors
- The primary Chat view does not render the legacy Execution Graph.
- A recoverable initial `reply was never sent` load failure may leave an empty new-chat page usable; prompt failures remain visible.
- The working indicator follows the same sending state as the Stop action and supports reduced motion.
- The question directory is derived only from active user message segments. Duplicate text remains separate, titles use the first non-empty Markdown part, and textless entries use a localized fallback. Fewer than two questions disables navigation. Selection scrolls smoothly to the current-snapshot anchor; a missing anchor is a safe no-op. The UI caps the directory at 300 recent entries and reports the hidden count when older entries are omitted.
- Heartbeat-only desktop sessions are hidden only when the exact OpenClaw heartbeat sentinel is present and there is no real user content. A title such as `ClawX` or `main` is never sufficient. The guard applies to list, startup selection, refresh, and cached summary hydration without deleting OpenClaw history.
## Validation Anchors
Key tests live in `tests/unit/acp-*.test.*`, `tests/unit/acp-timeline-groups.test.ts`, `tests/unit/attachment-access.test.ts`, `tests/unit/chat-question-directory.test.tsx`, `tests/e2e/chat-acp-inline-timeline.spec.ts`, and `tests/e2e/chat-acp-attachments.spec.ts`.
This reference consolidates the former ACP native Chat, Chat polish, turn grouping, and question-directory design documents. Later implementation decisions supersede the original no-optimistic-message rule, the assumption that ACP id always equals Gateway session key, and segment-level assistant copy controls.
@@ -0,0 +1,87 @@
# ACP Generated Media And Diagnostics
Status: current compatibility reference, reviewed 2026-07-15.
Related scenario: `acp-chat-experience`
Related rules: `acp-chat-state-and-history`, `acp-compatibility-content-safety`, `attachment-access-safety`, `diagnostics-trace-safety`
Related tasks: `acp-image-generation-compatibility`, `acp-historical-transcript-supplement`, `acp-media-attachments`, `acp-debug-trace-channel`, `acp-whole-turn-duration`
## Preferred And Compatibility Paths
Standard ACP image, `resource_link`, and URI-backed `resource` content blocks are preferred and render directly. OpenClaw ACP currently projects assistant text and thought content but can omit assistant media, while Gateway processing removes `MEDIA:` directives from the visible live reply. ClawX handles those gaps through two bounded in-memory compatibility exceptions. Neither revives the legacy Chat renderer nor represents synthetic data as a native ACP event.
## Bounded Transcript Exceptions
This section is the durable rationale referenced by the transcript supplement entry point. The two content exceptions are:
1. Image-generation completion with proven `image_generate` context. Trusted structured runtime evidence or approved transcript evidence may restore the completion caption, failure explanation, and media as the existing inline-image experience.
2. General attachment recovery from an explicit line-leading assistant `MEDIA:` directive outside fenced code blocks. This exception does not require image-generation context, but it recovers only the attachment reference, never the surrounding assistant message.
Both content exceptions use one bounded transcript fetch coordinator, keep projected state in memory, require exact active session and generation identity, and reject stale or ambiguous evidence. Existing-session load reads at most 1000 recent transcript messages. An ordinary successful live prompt performs one immediate read and one retry 1500 milliseconds later. Only an `image_generate` task recorded for that same live prompt extends the coordinator through bounded backoff while waiting for its completion artifact; accepted completion, invalidation, or retry-window exhaustion stops it. These exceptions must be removed when the distributed OpenClaw ACP adapter emits the equivalent standard content.
The same historical coordinator may request metadata-only whole-turn timing from Main. This is necessary because ACP `session/load` supplies replay content and status but not the original timestamps needed to calculate duration. Main derives candidates from bounded transcript JSONL envelopes, and Renderer aligns them with the same normalized user text and duplicate occurrence-from-tail rule. Timing can annotate only an ACP-created turn and never recovers transcript content.
Transcript supplementation must not recover or reconstruct ordinary assistant messages, thoughts, tools, plans, permissions, file activity, or a parallel Chat history. Bare paths, inline prose paths, unknown URI schemes, incidental tool paths, and directives inside fenced code blocks are not general attachments.
### Image-Generation Completion
The projector:
1. Detects a recent image-generation task start from ACP tool evidence.
2. Accepts completion text and media only from bounded, trusted fields or approved historical context.
3. Requires the active session and generation to remain unchanged.
4. Resolves local paths or Gateway media through `hostApi.media.thumbnails` in Main.
5. Inserts a marked synthetic assistant segment after the associated tool when possible.
6. Deduplicates repeated evidence and keeps all state in memory.
Accepted live evidence includes structured media fields such as `mediaUrl`, `mediaUrls`, nested `sourceReply` media, assistant media attachments, OpenClaw ACP tool output explicitly associated with the internal UI sink, and final Gateway assistant replies whose `image_generate:<task-id>:ok|error` run matches the recorded task. For a correlated `message` tool delivery, `sourceReply.text` is the authoritative visible caption or failure explanation. A task-correlated final assistant reply may also provide the authoritative caption or text-only failure explanation. Arbitrary prose, unrelated runs or tools, failed delivery attempts, and unscoped local paths are rejected.
When trusted source-reply text exists, it is preserved whether or not media is present. If no source-reply text exists, successful media uses the localized generic caption; partial or failed thumbnail hydration uses the existing localized fallback. Raw `MEDIA:` paths are never displayed.
### Explicit MEDIA Attachments
The general attachment extractor considers normalized assistant roles only. After optional leading whitespace, a whole line must start with the case-insensitive `MEDIA:` token and contain exactly one reference. Single- or double-quoted references may contain spaces and must close with the same quote; unquoted references cannot contain whitespace. One accepted line produces one candidate, and multiple lines retain transcript order. The current source-reference bound is `4096` characters.
Accepted reference forms are absolute POSIX paths, Windows drive paths, `file://` URIs, `~/` paths, paths relative to the registered execution cwd, and HTTP or HTTPS URLs. Relative paths are accepted only when execution cwd is available. Unknown URI schemes, malformed URLs or quotes, empty references, Markdown/list wrappers, inline prose, ordinary bare paths, and wrapped references are rejected. Markdown backtick and tilde fences follow the delimiter character and opening length; all content remains ignored until a valid close with the same delimiter and at least that length. The parser does not render the raw directive or surrounding transcript prose.
Transcript and ACP messages are partitioned by real user boundaries; leading orphan assistant content is ineligible. OpenClaw ACP does not project assistant `MEDIA:` attachments, so ClawX must read this bounded transcript supplement. To align it without parsing user-authored marker text, each ACP user segment retains only the ordered, binary-free text blocks produced by OpenClaw's prompt flattening: text and embedded text remain text, `resource_link` becomes OpenClaw's escaped `[Resource link]` form, and image/audio/blob data is omitted. User matching then removes only the known OpenClaw working-directory envelope and normalizes line endings and surrounding whitespace; it does not use broad fuzzy matching or globally strip resource markers. Because transcript history is a bounded suffix and cross-source message ids are not durable, alignment proceeds newest-to-oldest with the tuple of normalized flattened user text and duplicate occurrence from the tail. Attachment-only empty text remains eligible under the same real-user boundary and occurrence rules. A live supplement additionally requires the optimistic ACP user identity and restricts extraction to that current turn. Missing, duplicate, or ambiguous anchors are skipped instead of assigned by ordinal offset or nearest-turn guesswork.
Every asynchronous result is valid only for the same active session key, ACP generation, supplement operation, current attempt, and, for live recovery, user-turn identity. Session changes, new loads or prompts, cancellation/invalidation, and the delayed retry superseding the immediate attempt prevent stale mutation.
Candidates already proven to be image-generation completions remain inline images and are suppressed from the paperclip-card path. General attachments are deduplicated only within a conversation turn by the opaque identity returned after Main authorization. Deduplication spans immediate and delayed reads, repeated history loads, native ACP resources, general compatibility evidence, and generated-image evidence. Native ACP attachment evidence wins regardless of arrival order; an equivalent inline generated image suppresses the paperclip card. An unavailable result does not reserve a resolved identity, so a stable candidate from the delayed read can replace the same synthetic projection and upgrade it to available.
Every standard or compatibility attachment reference is resolved through Main's session-scoped attachment boundary. Main derives the execution cwd from the successful ACP load, checks the exact session and generation, permits existing regular files outside the active workspace, and re-resolves each preview read or open. HTTP and HTTPS references are revalidated before external open; outgoing media URLs retain their managed-record binding. See `harness/reference/acp-attachment-access-control.md`.
Image generation and general attachments share transcript fetch coordination and opaque resolved media identities only. Generated images remain inline; general attachments render as paperclip rows after assistant prose.
## Historical Evidence
After successful `loadSession` for an existing session, the store may call:
```ts
hostApi.sessions.history({ sessionKey, limit: 1000 });
```
A pure image-generation extractor scans messages in transcript order. It first records an `image_generate` start from a tool result, then accepts a later internal-UI `message` tool source reply or assistant completion associated with that task. OpenClaw's runtime-generated inter-session completion trigger remains part of the originating user turn rather than starting a new end-user turn. Assistant media captions have their `MEDIA:` directives removed before display, and a task-correlated text-only assistant reply may restore a failure explanation. A message-tool reply or image completion without preceding task context is rejected. Separately, the general attachment extractor may accept explicit assistant `MEDIA:` directives without image-generation context under the restrictions above. Read failure, no accepted evidence, duplicate evidence, or a stale generation leaves the ACP timeline unchanged.
These are the only transcript-derived Chat content supplements. Metadata-only whole-turn timing is also permitted, but it must not become a general recovery mechanism for missing turns, tool cards, file activity, plans, permissions, thoughts, or ordinary messages.
## Rejected Compatibility Alternatives
Main does not manufacture ACP `agent_message_chunk` resource events from transcript evidence because that would misrepresent compatibility data as native protocol replay. The ACP page does not reuse legacy Chat path extraction or rendering because that would restore competing history authorities. Standard-ACP-only behavior is insufficient while the distributed adapter omits assistant media, but the exception remains removable when upstream emits standard resources. Bare-path or broad prose extraction is rejected because false positives would widen the local-file trust surface.
## Trace Channel
Main owns one memory-only ACP trace ring buffer. The current implementation keeps 500 chronological entries with monotonic sequence numbers and ISO timestamps. `diagnostics.acpTrace()` returns a snapshot; Renderer records compact projection decisions through `diagnostics.recordAcpTrace()`.
Main records bridge lifecycle and summarized upstream/downstream routing. Renderer records reason-coded compatibility decisions such as start detection, rejection, dedupe, thumbnail result, stale drop, and append. Recording is best-effort and must never alter Chat behavior.
Before storage, Main validates and sanitizes all entries. The sanitizer removes secret-like keys and bearer/API-key values, truncates long strings, and bounds arrays and nesting. Call sites must submit summaries and must not include transcript bodies, binary media, or full ACP notifications; the generic sanitizer is defense in depth and cannot identify every semantically sensitive short value. Renderer payloads are untrusted. The trace is not persisted and has no user-visible UI.
## Validation Anchors
Key tests are `tests/unit/acp-image-generation-compat.test.ts`, `tests/unit/acp-media-attachments.test.ts`, `tests/unit/acp-chat-store.test.ts`, `tests/unit/attachment-access.test.ts`, `tests/unit/acp-trace.test.ts`, `tests/unit/acp-chat-service.test.ts`, `tests/e2e/chat-acp-attachments.spec.ts`, and the generated-media cases in `tests/e2e/chat-acp-inline-timeline.spec.ts` and `tests/e2e/chat-run-state-events.spec.ts`.
This reference consolidates the former image-generation completion, debug trace, and historical transcript supplement designs.
@@ -0,0 +1,78 @@
# Chat Workspace And Navigation
Status: current workspace reference, reviewed 2026-07-23.
Related scenario: `chat-workspace-and-navigation`
Related rules: `session-workspace-authority`, `sidebar-session-attention-authority`, `ui-i18n-design-tokens`, `office-preview-safety`, `web-browser-security-and-lifecycle`
Related tasks: `chat-workspace-context`, `sidebar-session-attention`, `office-document-preview`, `web-browser`
## Workspace Authority
OpenClaw's persisted ACP `cwd` is authoritative for a bound session. The global workspace is only the default for a new or not-yet-bound session. Resolution is:
1. Recoverable session `workspacePath` projected from OpenClaw ACP metadata.
2. Global workspace for a new or local unbound session.
3. `~/.openclaw/workspace` when a historical session has no recoverable cwd.
The effective workspace is shared by ACP load/prompt, the composer, sidebar grouping, the right-side workspace browser, and tool-derived file activity. A bound session is read-only in the composer and is not moved when the global selection changes. Missing or unreadable bound paths show unavailable/error state instead of silently changing roots.
ClawX persists global and recent workspace selections plus custom display labels through Main-owned settings APIs. On editable new or unbound chats, the composer menu shows the canonical default once, followed by deduplicated non-default workspaces from the most-recent list and known session paths, then the native folder picker. Recent entries stay first; all entries use custom display labels when available, keep the full path as hover text, and update only the global selection until first send binds the session. Custom labels are keyed by canonical path and never replace path identity or ACP cwd authority. Renderer session state may mirror the bound path for UI coordination, but must not become a competing persistent session-to-path authority. Targeted `@agent` sends intentionally use the target agent workspace and remain an explicit branch. Navigation records that workspace on the target session placeholder before reactive loading; a newly targeted agent's first send creates its main ACP session and shares one load identity with the prompt so navigation cannot supersede delivery.
## First Send And Titles
First send initializes the ACP session with the selected cwd and then marks the local session as created/bound. A fresh session generated at cold start to replace hidden heartbeat history is marked as the same kind of local placeholder; it cannot appear as a normal empty session or bypass first-send creation. Gateway event and canonical-list reconciliation preserve the local `createdLocally` marker until acknowledgement, even when OpenClaw already reports the same key with the ACP bridge display name. The acknowledgement atomically restores a raced-away placeholder when necessary, clears the marker, and seeds a missing automatic sidebar title from the raw first prompt. The newly visible row therefore never falls back to the bridge client identity while transcript title hydration catches up. Existing explicit or cached labels win. ACP keeps `_meta.prefixCwd: true`; disabling cwd injection would break OpenClaw context. Automatic titles instead normalize away one leading `[Working directory: ...]` envelope and subsequent whitespace.
Normalization applies to automatic sources such as Gateway-derived title and Main transcript summary. It never changes an explicit user label, never removes a non-leading marker, and treats the exact truncated envelope form as a missing title so a better summary can replace it. OpenClaw's synthetic `<first 8 session UUID characters> (YYYY-MM-DD)` fallback is also treated as missing only when it matches the row's full session id; Main's transcript summary then supplies the first user prompt. Opening and closing rename mode without changing the value must not persist any displayed fallback as an explicit label.
## Sidebar Navigation
Sessions are grouped by workspace, not by date bucket. The default workspace sorts first and other workspace labels use natural ordering. Within a group, activity sorts descending using:
1. Hydrated session last activity.
2. Summary `updatedAt`.
3. Timestamp parsed from the session key.
4. Zero.
Each group initially displays five sessions and loads five more at a time. Collapse and visible-count state are per workspace and in memory. Relative time and ordering use the same timestamp; actions replace the timestamp on hover or keyboard focus.
Non-default workspace headers expose a rename action on hover or keyboard focus. A custom name updates both the sidebar group and the composer workspace chip; the header and chip keep the full filesystem path in their title text.
Sidebar validates distinct non-default group paths through Main. A confirmed unavailable group shows a warning badge and destructive delete action; available, unresolved, and default groups do not. One confirmation hard-deletes the group's sessions sequentially across agents. Successful sessions disappear together, failed sessions remain for retry, and workspace recents/labels are removed only after the full group succeeds.
## Sidebar Session Attention
OpenClaw Gateway session rows are the sole authority for sidebar run state. ClawX subscribes to `sessions.changed`, reconciles exact session keys into the existing session catalog, and uses canonical `sessions.list` snapshots for startup and reconnect recovery. ACP prompts, ACP timeline events, and Gateway agent runtime events do not provide a second status source.
The trailing row content has strict `busy > unread > timeago` precedence. A Gateway-active row shows the localized busy indicator. An observed busy-to-idle transition shows the localized unread indicator until the conversation is opened, after which the relative activity time returns.
Read state follows visible Chat integration rather than the retained current-session key. Chat marks its session visible on mount and on each session-key change, clears visibility on unmount, and treats completion for that visible session as read. Routes such as Settings may retain the current key, but completion there remains unread. The sidebar click path also marks the session read synchronously before navigating to Chat.
The versioned attention store persists only exact-key `observedBusy` and `unread` state. This allows a later idle canonical snapshot to recover completion when ClawX previously observed the run as busy, including across an app restart. A run that starts and finishes while ClawX is fully offline cannot be inferred and must not create unread state. Run-scoped cron keys also cannot drive base-row attention because the bundled Gateway does not expose a recoverable canonical relationship.
The complete projection, persistence, list/event ordering, failure recovery, and future `sessions.patch({ unread: false })` migration are documented in `harness/reference/sidebar-session-attention.md`.
## Workspace Browser And Web Browser
The right panel tabs are Workspace, Preview, Changes, and Web Browser. Workspace keeps the existing store tab value `browser`; the unrelated Electron Web Browser uses `web-browser`. The Workspace tree uses `react-arborist`, includes hidden files, uses relative path as node identity, and remains read-only: no edit, drag/drop, or multi-select. Agent and path tags replace the older `Workspace - agent` header. Home is compacted to `~`, the path's final segment remains visible, and the full value is available as a title.
File icons come only from trusted bundled assets. Selecting a file preserves the existing preview behavior and backend boundary.
The Web Browser is a fixed fourth tab with one persistent Electron guest. `ArtifactTab` keeps `browser` and `web-browser` distinct; `WebBrowserAnchor` marks the panel body while the route-stable `WebBrowserHost` mounted by `MainLayout` owns the live guest. The stable panel selectors are `artifact-panel-tabs`, `artifact-panel-tab-web-browser`, and `web-browser-anchor`; the global surface selectors are `web-browser-host` and `web-browser-webview`. Its session, security, lifecycle, permission, popup, download, proxy, and data-clearing contract is documented separately in `harness/reference/web-browser.md`.
## Office Document Preview
The Workspace and Preview surfaces support read-only `.docx` and `.pptx` files; legacy `.doc` and `.ppt` files remain system-open-only. Extension is authoritative, and compressed DOCX/PPTX input is limited to 20 MB before Renderer parsing. Scoped workspace and attachment references use only their authorized Host API read route and never fall back to a naked path. Workspace Browser intentionally retains its existing Host-validated absolute-path read flow.
DOCX generated content is isolated and its links are non-interactive. PPTX renders one slide at a time, and kept-mounted artifact surfaces conditionally mount it so the shared Electron Renderer has a single mounted PPTX viewer. Cleanup releases ClawX-owned resources and invokes public `destroy()` exactly once, while the reviewed dependency-owned retained-resource limitation remains accepted. Exact security and lifecycle requirements are in `harness/specs/rules/office-preview-safety.md`; dependency choices, rendering decisions, user-visible limitations, and future hardening are in `harness/reference/office-document-preview.md`.
## Question Navigation
The Chat question directory belongs to the active ACP timeline rather than workspace persistence. Its current behavior is documented in `harness/reference/acp-chat.md`.
## Validation Anchors
Key tests include `tests/unit/workspace-context.test.ts`, `tests/unit/session-title.test.ts`, `tests/unit/session-buckets.test.ts`, `tests/unit/sidebar-session-buckets.test.ts`, `tests/unit/use-new-chat-action.test.tsx`, `tests/unit/chat-store-session-label-fetch.test.ts`, `tests/unit/workspace-browser-body.test.tsx`, `tests/unit/office-file-viewers.test.tsx`, `tests/unit/chat-acp-page.test.tsx`, `tests/unit/artifact-panel-store.test.ts`, `tests/unit/artifact-panel.test.tsx`, `tests/unit/main-layout.test.tsx`, `tests/e2e/chat-workspace-context.spec.ts` including inherited/recent/known-workspace selection and synthetic-title replacement coverage, `tests/e2e/chat-new-session-date.spec.ts`, `tests/e2e/chat-acp-inline-timeline.spec.ts`, `tests/e2e/chat-question-directory.spec.ts`, `tests/e2e/chat-sidebar-session-attention.spec.ts`, `tests/e2e/office-document-preview.spec.ts`, and the three final Web Browser E2E specs linked from `harness/reference/web-browser.md`.
This reference consolidates the former workspace sidebar, chat workspace context, sidebar workspace UI, and ACP working-directory title designs. The later flat activity-sorted sidebar supersedes the earlier recency buckets.
@@ -0,0 +1,191 @@
# Office Document Preview
Status: implemented contract, reviewed 2026-07-23.
Related scenarios: `chat-workspace-and-navigation`, `acp-chat-experience`, `acp-file-activity`
Related rule: `office-preview-safety`
Related task: `office-document-preview`
## Format And Limit Contract
Inline Office preview is extension-authoritative. Only the OOXML extensions below enter an Office parser:
| Extension | MIME mapping | Preview kind | Parser | Compressed input limit |
| --- | --- | --- | --- | --- |
| `.docx` | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` | `docx` | `docx-preview` | 20 MB (`20 * 1024 * 1024` bytes) |
| `.pptx` | `application/vnd.openxmlformats-officedocument.presentationml.presentation` | `pptx` | `pptxviewjs@1.1.9` | 20 MB (`20 * 1024 * 1024` bytes) |
The extension wins when MIME conflicts. MIME alone never opts an unknown, missing, `.doc`, or `.ppt` extension into an OOXML parser. Legacy `.doc` and `.ppt` remain system-open-only. Office previews are read-only and do not expose Source or Diff tabs.
The shared discriminated limit contract is exact:
| Preview target | Maximum accepted input |
| --- | --- |
| Text | 2 MB (`2 * 1024 * 1024` bytes) |
| DOCX or PPTX rich preview | 20 MB (`20 * 1024 * 1024` bytes) |
| Image, PDF, or sheet rich preview | 50 MB (`50 * 1024 * 1024` bytes) |
The maximum itself is accepted and one byte above it is rejected. The Office limit applies to compressed input before parsing. Known over-limit files do not mount a viewer or import a parser. Unknown-size files use the same value as the Host API read `maxBytes`, so a race-time size increase returns `tooLarge` without transferring parser input.
## Dependencies And Loading
- `docx-preview` converts DOCX bytes to the generated HTML and CSS used for page preview. Its incomplete Word layout model is an accepted fidelity limitation.
- Exactly `pptxviewjs@1.1.9` parses PPTX packages and paints one slide at a time to Canvas. The version is pinned because the reviewed global-state, scheduler, cleanup, and accepted-retention decisions are specific to 1.1.9.
- `jszip` satisfies the PPTX parser's peer dependency and is the ZIP implementation used by the selected Office parsing dependencies.
- `chart.js` supplies `chart.js/auto`, which the selected `pptxviewjs` ESM build imports to paint supported embedded charts.
`FilePreviewBody` and `WorkspaceBrowserBody` load both Office viewer components with React `lazy()`. Each viewer dynamically imports its parser only after an authorized, in-limit binary read succeeds. This keeps the viewers, `jszip`, `chart.js`, and parser code out of the synchronous chat entry path and ensures rejected input cannot trigger parser initialization.
## Read Authority
Each viewer selects exactly one existing binary route and passes the 20 MB maximum:
| Target authority | Read route |
| --- | --- |
| Ordinary local path, including Workspace Browser's already validated absolute path | `readBinaryFile(filePath, { maxBytes })` |
| Explicit `WorkspaceFileRef` | `readWorkspaceBinary({ ...workspaceFileRef, maxBytes })` |
| Explicit `AttachmentFileRef` | `readAttachmentBinary(attachmentFileRef, maxBytes)` |
A target containing both scoped reference types is invalid and fails before any read. A scoped target never retries through `filePath`, another scope, or a naked-path API. Workspace Browser deliberately retains its existing Host-validated absolute-path route; other workspace-derived file activity enters Preview with its `WorkspaceFileRef`. Remote attachments do not enter preview and no preview-specific download flow exists.
Document bytes remain inside the existing Renderer/Main Host API boundary. The Renderer passes `Uint8Array` to parser APIs and does not use direct filesystem access, document `fetch()`, parser URL loaders, temporary files, uploads, Gateway endpoints, or a Main-process or external conversion service.
## Over-Limit Authority Routing
Over-limit behavior follows the target's authority rather than the display surface:
| Target | Behavior |
| --- | --- |
| Ordinary local Preview target | Show the too-large/direct-open surface with confirmed system open and reveal actions. |
| Workspace Browser validated absolute path | Show the same confirmed direct-open and reveal actions. |
| Local authorized attachment known to be over limit | `attachmentOpenMode()` chooses the existing scoped `hostApi.files.openAttachment()` system-open flow. |
| Remote attachment | Keep the existing scoped system-open flow; do not preview or download for preview. |
| `WorkspaceFileRef` Preview target | Show `tooLarge` without naked-path shell actions. |
| Scoped attachment whose bounded read detects a race-time size increase | Show `tooLarge` without falling back to a naked path. |
This distinction is required: ordinary paths own local shell authority, while scoped references retain only their scoped Host API authority.
## DOCX Rendering
`DocxViewer` creates a new detached body container and style container for each target generation. It invokes `renderAsync()` while both are disconnected, then appends the current generation's completed containers to an open Shadow Root on the React-owned host. Generated document CSS and DOM therefore cannot rewrite ClawX layout, and stale reads or renders cannot replace the selected document.
The render options are exact:
```ts
{
className: 'clawx-docx',
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
ignoreFonts: false,
breakPages: true,
ignoreLastRenderedPageBreak: false,
renderHeaders: true,
renderFooters: true,
renderFootnotes: true,
renderEndnotes: true,
renderChanges: false,
renderComments: false,
renderAltChunks: false,
useBase64URL: true,
experimental: false,
debug: false,
}
```
`renderAltChunks: false` prevents embedded HTML parts from entering the preview. Comments and tracked changes are disabled. `useBase64URL: true` avoids library-created Blob URL lifetime and keeps generated resources releasable with the Shadow Root containers.
Capturing `click` and `auxclick` listeners on the Shadow Root prevent the default action of every generated anchor. Hash, HTTP(S), file, and custom-protocol links are all non-interactive; DOCX preview cannot navigate the ClawX window or invoke shell authority.
Pages remain centered, vertical, authored-size paper sheets. A `ResizeObserver` resets body CSS `zoom` to `1`, measures the widest `section.clawx-docx`, and applies `Math.min(1, host.clientWidth / widestPageWidth)`. Chromium CSS zoom scales dimensions and flow together. The viewer scales down to fit but never enlarges above authored size.
On target replacement or unmount, ClawX disconnects the observer, removes generated style/body containers, clears their children, and drops its direct byte and DOM references. An uncancellable render may finish after cleanup, but generation checks prevent it from attaching stale content.
## PPTX Rendering And Scheduling
`PptxViewer` uses one React-owned Canvas keyed by target identity. Unlike DOCX resources, the initial PPTX Canvas is mounted when `pptxviewjs` renders into it; it is not first rendered detached. Target identity, committed-generation checks, and latest-request checks prevent stale work from publishing, while React key replacement or unmount detaches the obsolete Canvas.
The `PPTXViewer` constructor receives exactly these behaviorally significant options:
```ts
{
canvas,
enableThumbnails: false,
slideSizeMode: 'fit',
backgroundColor: '#ffffff',
autoChartRerenderDelayMs: 0,
}
```
Before initial rendering, the viewer waits for positive container width and height for at most 60 checks, using at most 59 animation-frame waits. It applies the current container width and height as Canvas CSS dimensions before every render. `slideSizeMode: 'fit'` preserves the source aspect ratio within the centered preview surface.
A module-level promise queue serializes all `pptxviewjs` operations across lifecycles: construction, `loadFile()`, slide-count access, initial render, restored-position render, navigation, chart refresh, resize render, and `destroy()`. Each render request carries its generation, request identity, latest slide, and latest measured size; work made obsolete before execution is skipped. A failed current render terminates that lifecycle, while a rejected obsolete render cannot replace or fail the new target.
The presentation is parsed once per mounted target. Initial rendering always paints slide index zero. If the owning surface has a stored index for the same target identity, the viewer clamps it to the loaded slide range and then renders it. Position display is one-based, navigation is disabled at boundaries and while rendering, and `onSlideIndexChange` fires only after a successful current render.
The `ResizeObserver` uses a 100 ms trailing debounce. The callback does not render directly; after the debounce it re-reads dimensions and queues a current-slide refresh. Setting `autoChartRerenderDelayMs: 0` disables the dependency's uncancellable delayed chart render. The global `chartRenderingComplete` listener coalesces events while a refresh is pending and submits refreshes through the same serialized scheduler. Cleanup removes the listener and observer and cancels owned timers and animation frames.
## Single PPTX Instance
`pptxviewjs@1.1.9` stores presentation chart and ZIP state in Renderer globals including `window.currentProcessor` and `window.currentZipData`. At most one `PptxViewer` may therefore be mounted in the shared Electron Renderer. This is a correctness requirement, not a performance preference: concurrent decks could resolve chart or package data from the wrong presentation.
Workspace and Preview surfaces remain mounted to preserve surrounding UI state, but each conditionally mounts its PPTX child only while its artifact tab is active. CSS-only hiding is insufficient. A development assertion rejects a second concurrent viewer. The owning Workspace and Preview surfaces retain slide positions in maps keyed by target identity; switching away destroys and unmounts the viewer, and switching back reparses the deck and restores the clamped position.
Cleanup queues the active instance's public `destroy()` exactly once after preceding dependency work. ClawX removes all listeners, observers, timers, animation frames, queued request references, and its direct instance and Canvas ownership. The dependency limitations below mean this does not claim full internal reclamation.
## States And Errors
Both viewers expose four lifecycle states:
- `loading`: authorized read, lazy parser import, parse, or initial render is in progress.
- `ready`: DOCX pages or the current PPTX slide and controls are visible.
- `tooLarge`: preflight or the bounded Host API read rejects input above 20 MB.
- `error`: authority validation, read, empty input, parse, layout sizing, or render failed.
Errors use localized format-specific generic messages and never show parser exceptions, paths from parser errors, or stack traces. Corrupt, malformed, encrypted, password-protected, empty, and otherwise unsupported OOXML inputs may fail into `error`. There is no automatic retry loop. Reselecting or reopening a target creates a new load.
## Non-Goals
- Legacy `.doc` or `.ppt` parsing or conversion.
- Editing, saving, comments, tracked changes, Word search, or table-of-contents tooling.
- Pixel-identical Microsoft Word or PowerPoint layout.
- DOCX link opening or application-window navigation from generated content.
- PPTX thumbnails, keyboard shortcuts, animation, transitions, media playback, fullscreen, presenter mode, or automatic slide shows.
- Remote-attachment downloading for preview.
- Main-process, server, cloud, or external-service conversion.
- Changes to existing PDF, spreadsheet, image, HTML, Markdown, source, or diff behavior.
## Rejected Alternatives
- MIME-driven Office parser selection was rejected because legacy or unknown extensions must not enter an OOXML parser.
- Main-process or cloud conversion, temporary-file conversion, parser URL loading, and Renderer `fetch()` were rejected to preserve existing authority and data boundaries.
- DOCX light-DOM rendering and interactive generated links were rejected because document CSS and navigation must not gain application authority.
- Transform-only DOCX scaling was rejected because transformed pages can overlap later flow; Chromium CSS zoom scales layout dimensions with content.
- Keeping multiple PPTX viewers mounted under CSS `hidden` was rejected because shared dependency globals can cross-contaminate presentations.
- Independent initial, navigation, chart, and resize render paths were rejected because uncancelled operations can race. One serialized scheduler owns every dependency operation and render source.
- The library's delayed chart rerender was rejected in favor of an owned, coalesced event refresh. Patching `pptxviewjs` internals was also rejected; the reviewed public API and accepted limitation remain explicit.
## Accepted Risks And Limitations
- Both ZIP-based parsers run in the Renderer and can briefly occupy the UI thread on complex in-limit files.
- A 20 MB compressed cap reduces but does not eliminate ZIP expansion or high peak-memory risk.
- `docx-preview` is not Word's pagination engine; wrapping, page breaks, fonts, and layout can differ.
- `pptxviewjs` has incomplete PowerPoint fidelity for uncommon shapes, fonts, animations, transitions, and media.
- Embedded fonts depend on available platform fonts and fallbacks.
- Public `destroy()` in `pptxviewjs@1.1.9` does not fully clear internal caches, URLs, delayed work, or chart-related globals. Repeated presentation switches may retain dependency-owned memory until the Renderer exits. The single-instance rule prevents concurrent cross-presentation corruption but does not eliminate this accepted retention risk.
## Future Hardening
The current 20 MB check is a product performance guard, not a complete malicious-ZIP boundary. Future ZIP hardening may add pre-parse entry-count, expansion-ratio, and XML-complexity budgets. Such checks must preserve the same target authority routes and fail before either parser receives bytes; they are not implemented or implied by the current release.
## Validation Anchors
Format classification and limits are anchored by `shared/file-preview/limits.ts`, `src/lib/generated-files.ts`, `src/lib/file-preview-capabilities.ts`, `tests/unit/generated-files.test.ts`, and `tests/unit/open-file-utils.test.ts`.
Renderer authority, DOCX isolation/options/links/zoom, PPTX construction/sizing/scheduling/chart behavior/restoration/cleanup, stale-generation handling, and generic errors are anchored by `src/components/file-preview/DocxViewer.tsx`, `src/components/file-preview/PptxViewer.tsx`, and `tests/unit/office-file-viewers.test.tsx`.
Surface preflight, authority-specific fallback, conditional mounting, and position ownership are anchored by `src/components/file-preview/FilePreviewBody.tsx`, `src/components/file-preview/WorkspaceBrowserBody.tsx`, `src/components/file-preview/ArtifactPanel.tsx`, `src/pages/Chat/AcpTurnFileActivity.tsx`, `src/pages/Chat/AcpAttachmentPart.tsx`, `tests/unit/file-preview-body.test.tsx`, `tests/unit/workspace-browser-body.test.tsx`, `tests/unit/artifact-panel.test.tsx`, and `tests/unit/acp-chat-components.test.tsx`.
`tests/e2e/office-document-preview.spec.ts` uses real deterministic DOCX/PPTX packages to anchor Shadow Root page rendering, Canvas pixels, chart completion, slide navigation, per-target position restoration, constrained-panel resizing, the single-mounted-viewer invariant, Host API read routes, and absence of legacy direct IPC.

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