Compare commits

..
Author SHA1 Message Date
Haze b47bfbd66a 0.3.12-alpha.0 2026-04-24 17:20:59 +08:00
HazeandClaude Opus 4 efa76b37d3 fix(gateway): fall back to junction when symlink unavailable on Windows
ensureExtensionDepsResolvable called symlinkSync without a type argument
and without path normalization. On Windows without Developer Mode or
admin rights, plain symlinkSync throws EPERM; the failure was silently
swallowed, leaving extension-owned packages unresolvable from shared
dist/ chunks and breaking gateway startup.

Extract the link logic into electron/gateway/fs-link.ts:
- linkDirSafe prefers junction on Windows (works without elevation),
  falls back to a plain dir symlink only if junction creation fails
  (e.g. cross-volume).
- normalizeFsPath centralizes the \\?\ extended-length + UNC prefixing
  that was previously an inline helper in config-sync.ts.

Also drop the now-redundant inline fsPath helper in config-sync.ts and
replace the two bare symlinkSync calls with linkDirSafe.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-04-24 17:11:52 +08:00
Haze 6bacbd964d perf(gateway): shorten gateway.ready fallback timeout from 30s to 5s
The fallback exists as a safety net for the server-side gateway.ready
event. In practice OpenClaw's plugin bootstrap can push the real event
well past 30s (observed: handshake completes, then 30s tick by, then the
fallback fires with no event having arrived). That long tail kept the
stale gating code blocking UI state for the full 30s.

Step 1 moved sessions.list off the gatewayReady gate, so this value now
only matters as a belt-and-braces signal for any future consumer. 5s is
long enough to preserve "event wins when it actually fires on a healthy
boot" while avoiding a multi-second stall whenever the server is slow.

Updated gateway-ready-fallback.test.ts to advance timers around the new
boundary.
2026-04-24 17:04:10 +08:00
Haze a3d5b0555f perf(gateway): gate sessions.list on state=running instead of gatewayReady
The maybeLoadSessions() guard previously waited for status.gatewayReady
to become true, which is driven by the server-side gateway.ready event
and backed by a 30s fallback timer in GatewayManager. In practice,
OpenClaw's plugin bootstrap often exceeds that window, so the fallback
fired and users stared at the loading state for ~30s after the WS
handshake had already completed.

sessions.list is a plain RPC — it needs the handshake to be done, not
plugins to be up. Gate it on state === 'running' so the session list is
fetched immediately after handshake completion. Existing throttling via
LOAD_SESSIONS_MIN_INTERVAL_MS still prevents spam on state flaps.
2026-04-24 17:03:05 +08:00
572 changed files with 20500 additions and 64845 deletions
-22
View File
@@ -25,11 +25,6 @@ jobs:
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 --frozen-lockfile
@@ -45,18 +40,6 @@ jobs:
- name: Run tests
run: pnpm run test
- name: Run harness checks
run: pnpm run harness:ci
- name: Upload harness artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: harness-artifacts
path: artifacts/harness
if-no-files-found: warn
retention-days: 7
build:
runs-on: windows-latest
env:
@@ -74,11 +57,6 @@ jobs:
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 --frozen-lockfile
-5
View File
@@ -35,11 +35,6 @@ jobs:
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 --frozen-lockfile
+4 -96
View File
@@ -23,8 +23,6 @@ jobs:
- windows-latest
env:
CI: 'true'
# Linux runners cannot use Electron's setuid chrome-sandbox; harmless on macOS/Windows.
ELECTRON_DISABLE_SANDBOX: '1'
steps:
- name: Checkout code
@@ -39,105 +37,15 @@ jobs:
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
shell: bash
run: |
echo "side-effects-cache=false" >> .npmrc
pnpm install --frozen-lockfile
# electron/install.js and extract-zip both leave dist half-extracted on CI
# (114MB zip downloads OK; only LICENSE + LICENSES.chromium.html land in dist).
# Download with @electron/get, extract with the OS unzip tool instead.
- name: Install Electron binary (Unix)
if: runner.os != 'Windows'
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" 2>/dev/null || stat -f%z "$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
if [ "$(uname -s)" = "Darwin" ]; then
PLATFORM_PATH='Electron.app/Contents/MacOS/Electron'
test -f "$ELECTRON_DIR/dist/Electron.app/Contents/MacOS/Electron"
else
PLATFORM_PATH='electron'
test -f "$ELECTRON_DIR/dist/electron"
chmod +x "$ELECTRON_DIR/dist/electron"
fi
printf '%s' "$PLATFORM_PATH" > "$ELECTRON_DIR/path.txt"
echo "path.txt: $(cat "$ELECTRON_DIR/path.txt")"
- name: Install Electron binary (Windows)
if: runner.os == 'Windows'
shell: pwsh
env:
force_no_cache: 'true'
run: |
$ErrorActionPreference = 'Stop'
Remove-Item Env:ELECTRON_SKIP_BINARY_DOWNLOAD -ErrorAction SilentlyContinue
$electronDir = node -p "require('path').dirname(require.resolve('electron/package.json'))"
Write-Host "Electron package dir: $electronDir"
Remove-Item -Recurse -Force "$electronDir\dist", "$electronDir\path.txt" -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path "$electronDir\dist" | Out-Null
Push-Location $electronDir
try {
$zip = 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); });"
} finally {
Pop-Location
}
$zipSize = (Get-Item -LiteralPath $zip).Length
Write-Host "Downloaded zip: $zip ($zipSize bytes)"
Expand-Archive -LiteralPath $zip -DestinationPath "$electronDir\dist" -Force
$entryCount = (Get-ChildItem -LiteralPath "$electronDir\dist").Count
Write-Host "Extracted top-level entries: $entryCount"
$typeDef = Join-Path $electronDir 'dist\electron.d.ts'
if (Test-Path -LiteralPath $typeDef) {
Move-Item -LiteralPath $typeDef -Destination (Join-Path $electronDir 'electron.d.ts') -Force
}
$exe = Join-Path $electronDir 'dist\electron.exe'
if (-not (Test-Path -LiteralPath $exe)) {
throw "electron.exe missing after extract: $exe"
}
Set-Content -LiteralPath (Join-Path $electronDir 'path.txt') -Value 'electron.exe' -NoNewline
Write-Host "path.txt: electron.exe"
- name: Verify Electron binary
run: pnpm exec electron --version
run: pnpm install --frozen-lockfile
- name: Generate extension bridge
run: pnpm run ext:bridge
- name: Rebuild Electron binary
run: pnpm rebuild electron
- name: Run Electron E2E on Linux
if: runner.os == 'Linux'
run: xvfb-run -a pnpm run test:e2e
-54
View File
@@ -1,54 +0,0 @@
name: Harness
on:
workflow_dispatch:
pull_request:
branches:
- main
paths:
- 'harness/**'
- 'tests/unit/harness-specs.test.ts'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.github/workflows/harness.yml'
jobs:
harness:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: '1'
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- 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 --frozen-lockfile
- name: Run harness CI checks
run: pnpm run harness:ci
- name: Upload harness artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: harness-artifacts
path: artifacts/harness
if-no-files-found: warn
retention-days: 7
-8
View File
@@ -43,20 +43,12 @@ jobs:
restore-keys: |
${{ runner.os }}-pnpm-store-
- 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: Download uv binaries for Windows
run: pnpm run uv:download:win
- name: Download agent-browser binaries for Windows
run: pnpm run agent-browser:download:win
- name: Build Windows package (no publish)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-17
View File
@@ -57,11 +57,6 @@ jobs:
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
@@ -77,18 +72,6 @@ jobs:
if: matrix.platform == 'linux'
run: pnpm run uv:download:linux
- name: Download agent-browser binaries for macOS
if: matrix.platform == 'mac'
run: pnpm run agent-browser:download:mac
- name: Download agent-browser binaries for Windows
if: matrix.platform == 'win'
run: pnpm run agent-browser:download:win
- name: Download agent-browser binaries for Linux
if: matrix.platform == 'linux'
run: pnpm run agent-browser:download:linux
# macOS specific steps
- name: Free disk space (macOS)
-8
View File
@@ -31,20 +31,12 @@ jobs:
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: Download uv binaries for Windows
run: pnpm run uv:download:win
- name: Download agent-browser binaries for Windows
run: pnpm run agent-browser:download:win
- name: Build Windows
run: pnpm run package:win
-11
View File
@@ -42,9 +42,6 @@ coverage/
playwright-report/
test-results/
# Local session transcript fixtures (may contain private conversation data)
tests/fixtures/transcripts/
# Cache
.cache/
.turbo/
@@ -66,7 +63,6 @@ resources/bin
build/
artifacts/
.delivery/
docs/pr-session-notes-*.md
.cursor/
@@ -77,10 +73,3 @@ package-lock.json
# Generated extension bridges (created by scripts/generate-ext-bridge.mjs)
electron/extensions/_ext-bridge.generated.ts
src/extensions/_ext-bridge.generated.ts
# Local playground artifacts
playground/
# ClawX-biz bridge workspace artifacts
resources/enterprise-skills/
resources/openclaw-plugins/skillshub/
+1 -5
View File
@@ -33,11 +33,10 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
- **Gateway startup**: When running `pnpm dev`, the OpenClaw Gateway process starts automatically on port 18789. It takes ~10-30 seconds to become ready. Gateway readiness is not required for UI development—the app functions without it (shows "connecting" state).
- **No database**: The app uses `electron-store` (JSON files) and OS keychain. No database setup is needed.
- **AI Provider keys**: Actual AI chat requires at least one provider API key configured via Settings > AI Providers. The app is fully navigable and testable without keys.
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, scans both configured agents and any runtime agent directories found on disk, and treats normal, `.deleted.jsonl`, and `.jsonl.reset.*` transcripts as valid history sources. It extracts assistant/tool usage records with `message.usage` and aggregates fields such as input/output/cache/total tokens and cost from those structured records. Note: "Delete conversation" in the sidebar is a hard delete — the Main process unlinks `<id>.jsonl` plus any leftover `<id>.deleted.jsonl` and `<id>.jsonl.reset.*` siblings, *and* OpenClaw's trajectory artefacts (`<id>.trajectory.jsonl` flight recorder + `<id>.trajectory-path.json` pointer); when the pointer references a runtime file outside the agent's `sessions/` folder (the `OPENCLAW_TRAJECTORY_DIR` override), that off-disk file is unlinked too. Deleted conversations stop contributing to this chart — use a fresh session if you want history retained.
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, scans both configured agents and any runtime agent directories found on disk, and treats normal, `.deleted.jsonl`, and `.jsonl.reset.*` transcripts as valid history sources. It extracts assistant/tool usage records with `message.usage` and aggregates fields such as input/output/cache/total tokens and cost from those structured records.
- **Models page aggregation**: The 7-day/30-day filters are relative rolling windows, not calendar-month buckets. When grouped by time, the chart should keep all day buckets in the selected window; only model grouping is intentionally capped to the top entries.
- **OpenClaw Doctor in UI**: In Settings > Advanced > Developer, the app exposes both `Run Doctor` (`openclaw doctor --json`) and `Run Doctor Fix` (`openclaw doctor --fix --yes --non-interactive`) through the host-api. Renderer code should call the host route, not spawn CLI processes directly.
- **UI change validation**: Any user-visible UI change should include or update an Electron E2E spec in the same PR so the interaction is covered by Playwright.
- **i18n & styling conventions**: New user-facing features must (1) route all text through `react-i18next` with full locale coverage (`en` / `zh` / `ja` / `ru` under `shared/i18n/locales/<lang>/<ns>.json`) — never hardcode display strings, and (2) use the design tokens and substitution rules documented in `src/styles/globals.css` (surfaces `bg-surface-modal` / `bg-surface-input`, selected state `bg-black/5 dark:bg-white/10`, status colours `text-X-700 dark:text-X-400`, page H1/H2 `font-serif font-normal tracking-tight`, etc.) — see the *Component conventions* block in `globals.css` for the full substitution table.
- **Renderer/Main API boundary (important)**:
- Renderer must use `src/lib/host-api.ts` and `src/lib/api-client.ts` as the single entry for backend calls.
- Do not add new direct `window.electron.ipcRenderer.invoke(...)` calls in pages/components; expose them through host-api/api-client instead.
@@ -45,6 +44,3 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
- Transport policy is Main-owned and fixed as `WS -> HTTP -> IPC fallback`; renderer should not implement protocol switching UI/business logic.
- **Comms-change checklist**: If your change touches communication paths (gateway events, runtime send/receive, delivery, or fallback), run `pnpm run comms:replay` and `pnpm run comms:compare` before pushing.
- **Doc sync rule**: After any functional or architecture change, review `README.md`, `README.zh-CN.md`, and `README.ja-JP.md` for required updates; if behavior/flows/interfaces changed, update docs in the same PR/commit.
- **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.
+47 -63
View File
@@ -43,8 +43,6 @@
ClawXはベストプラクティスのモデルプロバイダーが事前設定されており、Windowsおよび多言語設定をネイティブにサポートしています。もちろん、**設定 → 詳細設定 → 開発者モード**から高度な設定を微調整することもできます。
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">完全なエンタープライズ版、専用のサービスサポート、または御社のビジネスシナリオに合わせた導入支援が必要な場合は、<a href="mailto:public@valuecell.ai">public@valuecell.ai</a> までお問い合わせください。</strong></p>
---
## スクリーンショット
@@ -83,7 +81,6 @@ AIエージェントの構築にコマンドラインの習得は不要である
| 複雑なCLIセットアップ | ワンクリックインストールとガイド付きセットアップウィザード |
| 設定ファイル | リアルタイムバリデーション付きのビジュアル設定 |
| プロセス管理 | ゲートウェイライフサイクルの自動管理 |
| アプリ更新 | 起動時に更新を確認し、ダウンロードやインストール前に通知 |
| 複数のAIプロバイダー | 統合プロバイダー設定パネル |
| スキル/プラグインのインストール | 組み込みのスキルマーケットプレイスと管理機能 |
@@ -93,8 +90,6 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
私たちはアップストリームのOpenClawプロジェクトとの厳密な整合性を維持することにコミットしており、公式リリースが提供する最新の機能、安定性の改善、エコシステムの互換性に常にアクセスできることを保証します。
開発者モードを有効にすると、サイドバーにはネイティブの Dreams ページも表示され、ClawX 内で OpenClaw の記憶レビュー、夢日記、基本メンテナンス操作を扱えます。詳細な診断が必要な場合は、そのページから完全版の OpenClaw Dreams UI も開けます。
---
## 機能
@@ -104,7 +99,6 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
### 💬 インテリジェントチャットインターフェース
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリング(GitHub 風テーブルや KaTeX による LaTeX 数式 `$インライン$``$$ブロック$$``\(インライン\)``\[ブロック\]` を含む)に加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
コンポーザーから挿入した Skill は `/skill-name` 形式のチップとして表示され、チップをクリックすると右側のプレビュー側欄でその Skill の `SKILL.md` を開けます。
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
各 Agent は `provider/model` の実行時設定を個別に上書きできます。上書きしていない Agent は引き続きグローバルの既定モデルを継承します。
@@ -116,22 +110,20 @@ ClawX には Tencent 公式の個人 WeChat チャンネルプラグインも同
### ⏰ Cronベースの自動化
AIタスクを自動的に実行するようスケジュール設定できます。トリガーを定義し、間隔を設定することで、手動介入なしにAIエージェントを24時間稼働させることができます。
定期タスク画面では外部配信を「送信アカウント」と「受信先ターゲット」の 2 段階セレクターで設定できるようになりました。対応チャネルでは、受信先候補をチャネルのディレクトリ機能や既知セッション履歴から自動検出するため、`jobs.json` を手で編集する必要はありません。タスクのメッセージ入力欄でも、メインのチャット入力と同じインライン `/skill` トークン記法でスキルを挿入できるようになりました(選択中のエージェントに応じて読み込み)。スケジュールされたプロンプトから直接スキルを起動できます。スケジュール選択は**繰り返し**と**1回のみ**のタブに分かれました。繰り返しは毎時・毎日・平日・毎週・カスタム(生の cron)の頻度を時刻/曜日コントロール付きで選べ、1回のみは選択した日付(曜日を表示)と時刻に一度だけ実行します。1回のみのタスクは未来の時刻を指定する必要があり、実行後はランタイムにより自動的に削除されます。
定期タスク画面では外部配信を「送信アカウント」と「受信先ターゲット」の 2 段階セレクターで設定できるようになりました。対応チャネルでは、受信先候補をチャネルのディレクトリ機能や既知セッション履歴から自動検出するため、`jobs.json` を手で編集する必要はありません。
### 🧩 拡張可能なスキルシステム
事前構築されたスキルでAIエージェントを拡張できます。統合 Skills ページはローカル優先で、管理ディレクトリや workspace のスキルをスキャンし、Gateway に依存せず有効/無効を切り替えられます。エンタープライズ拡張がある場合は、その拡張が提供する marketplace も表示できます。
事前構築されたスキルでAIエージェントを拡張できます。統合スキルパネルからスキルの閲覧、インストール、管理が可能です。パッケージマネージャーは不要です。
ClawX はドキュメント処理スキル(`pdf``xlsx``docx``pptx`)もフル内容で同梱し、起動時に管理スキルディレクトリ(既定 `~/.openclaw/skills`)へ自動配備し、初回インストール時に既定で有効化します。追加の同梱スキル(`find-skills``self-improving-agent``tavily-search`)も既定で有効化されますが、必要な API キーが未設定の場合は OpenClaw が実行時に設定エラーを表示します。
Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、workspace、追加スキルディレクトリ)から検出されたスキルを表示でき、各スキルの実際のパスを確認して実フォルダを直接開けます。OpenClaw 同梱の bundled skill については、コミュニティ版ではパッケージにも表示にも `skill-creator` のみを残し、dev 起動時と packaged 起動時の両方で他の bundled skill を物理的に削除します。さらに、削除済み bundled skill の古い `openclaw.json` エントリも一緒に掃除します。
Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、workspace、追加スキルディレクトリ)から検出されたスキルを表示でき、各スキルの実際のパスを確認して実フォルダを直接開けます。
主な検索スキルで必要な環境変数:
- `TAVILY_API_KEY`: `tavily-search` 用(上流ランタイムで OAuth 対応の場合あり)
### 🔐 セキュアなプロバイダー統合
複数のAIプロバイダー(OpenAI、Anthropicなど)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。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 と同等の画像入力推論を適用し、不明なモデルはテキスト専用として扱います。
互換ゲートウェイで `/models` が認証以外の理由で使えない場合、ClawX は API キー検証時に軽量な `/chat/completions` または `/responses` プローブへ自動フォールバックします。
### 🌙 アダプティブテーマ
@@ -140,9 +132,6 @@ OpenAI-compatible ゲートウェイを **Custom プロバイダー** で使う
### 🚀 自動起動設定
**設定 → 通用** から **システム起動時に自動起動** を有効化すると、ログイン後に ClawX が自動的に起動します。
### 🔔 更新通知
ClawX は起動時に新しいバージョンを自動確認できます。更新が見つかるとアプリ内通知を表示し、ダウンロードやインストールはユーザーが選択した後にのみ実行されます。
---
## はじめに
@@ -218,59 +207,58 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ
ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を採用しています。Renderer は単一クライアント抽象を呼び出し、プロトコル選択とライフサイクルは Main が管理します:
```
┌────────────────────────────────────────────────────────────────────┐
ClawX デスクトップアプリ
┌──────────────────────────────────────────────────────────────┐
│ │ Electron メインプロセス │ │
│ │ • ウィンドウ&アプリケーションライフサイクル管理 │ │
│ │ • ゲートウェイプロセスの監視 │ │
│ │ • システム統合(トレイ、通知、キーチェーン) │ │
│ │ • 自動アップデートオーケストレーション │
└──────────────────────────────────────────────────────────────┘
│ │
│ IPC(権威ある制御プレーン)
┌──────────────────────────────────────────────────────────────┐
│ │ React レンダラープロセス │ │
│ │ • モダンなコンポーネントベースUI(React 19) │ │
│ │ • Zustandによるステート管理 │ │
│ │ • 統一 host-api/api-client 呼び出し │ │
│ • リッチなMarkdownレンダリング │
│ └──────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬─────────────────────────────────────┘
│ 型付き IPC リクエスト
┌─────────────────────────────────────────────────────────────────┐
│ Main Host Services と Gateway Manager │
│ │
│ • host:invoke 型付きサービスディスパッチ │
│ • 設定、ファイル、セッション、スキル、プロバイダー、診断サービス │
│ • Main が Gateway WebSocket とプロセス監視を所有 │
```┌─────────────────────────────────────────────────────────────────┐
│ ClawX デスクトップアプリ │
┌────────────────────────────────────────────────────────────┐
│ Electron メインプロセス │
│ │ • ウィンドウ&アプリケーションライフサイクル管理 │ │
│ │ • ゲートウェイプロセスの監視 │ │
│ │ • システム統合(トレイ、通知、キーチェーン) │ │
│ │ • 自動アップデートオーケストレーション │ │
│ └────────────────────────────────────────────────────────────┘
│ │ IPC(権威ある制御プレーン)
┌────────────────────────────────────────────────────────────┐
│ React レンダラープロセス │
│ │ • モダンなコンポーネントベースUI(React 19) │ │
│ │ • Zustandによるステート管理 │ │
│ │ • 統一 host-api/api-client 呼び出し │ │
│ │ • リッチなMarkdownレンダリング │ │
└────────────────────────────────────────────────────────────┘
└──────────────────────────────┬──────────────────────────────────┘
│ Main 所有 WebSocket
│ Main管理のトランスポート戦略
│(WS優先、HTTP次点、IPCフォールバック)
┌─────────────────────────────────────────────────────────────────┐
│ Host API と Main プロキシ層 │
│ │
│ • hostapi:fetchMainプロキシ、CORS回避) │
│ • gateway:httpProxyRendererはGateway HTTPに直アクセスしない) │
│ • 統一エラーマッピングとリトライ/バックオフ │
└──────────────────────────────┬──────────────────────────────────┘
│ WS / HTTP / IPC フォールバック
┌─────────────────────────────────────────────────────────────────┐
│ OpenClaw ゲートウェイ │
│ • AIエージェントランタイムとオーケストレーション
│ • メッセージチャネル管理
│ • スキル/プラグイン実行環境
│ • プロバイダー抽象化レイヤー
│ • AIエージェントランタイムとオーケストレーション │
│ • メッセージチャネル管理 │
│ • スキル/プラグイン実行環境 │
│ • プロバイダー抽象化レイヤー │
└─────────────────────────────────────────────────────────────────┘
```
### 設計原則
- **プロセス分離**: AIランタイムは別プロセスで動作し、重い計算処理中でもUIの応答性を確保します
- **フロントエンド呼び出しの単一入口**: Renderer は host-api/api-client を通じて呼び出し、下位プロトコルに依存しません
- **Mainによるトランスポート制御**: Gateway WebSocket は Electron Main のみが所有し、Renderer は型付き IPC で Main と通信します
- **拡張 IPC コントリビューション**: Main プロセス拡張は HTTP route ではなく、型付き IPC レジストリを通じて host-api action を提供します
- **Mainによるトランスポート制御**: WS/HTTP の選択と IPC フォールバックを Main で一元管理します
- **グレースフルリカバリ**: 再接続・タイムアウト・バックオフで一時的障害を自動処理します
- **セキュアストレージ**: APIキーや機密データは、OSのネイティブセキュアストレージ機構を活用します
- **CORSセーフ設計**: Renderer はローカル Gateway や Host API HTTP エンドポイントを直接呼び出しません
- **CORSセーフ設計**: ローカルHTTPはMainプロキシ経由とし、Renderer側CORS問題を回避します
### プロセスモデルと Gateway トラブルシューティング
@@ -278,7 +266,6 @@ ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を
- 単一起動保護は Electron のロックに加え、ローカルのプロセスロックファイルも併用し、デスクトップ IPC / セッションバスが不安定な環境でも重複起動を防ぎます。
- ローリングアップグレード中に旧版/新版が混在すると、単一起動保護の挙動が非対称になる場合があります。安定運用のため、デスクトップクライアントは可能な限り同一バージョンへ揃えてください。
- ただし OpenClaw Gateway の待受は常に**単一**であるべきです。`127.0.0.1:18789` を Listen しているプロセスは1つだけです。
- Gateway の readiness は `system-presence``health``status` などの OpenClaw コア信号を基準にし、memory、Dreams、チャネルの失敗はグローバルな Gateway 障害ではなく capability degradation として表示します。
- Listen プロセスの確認例:
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
- Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen`
@@ -306,19 +293,16 @@ AI を開発ワークフローに統合できます。エージェントを使
### 前提条件
- **Node.js**: 22.19以上(LTS推奨)
- **Node.js**: 22以上(LTS推奨)
- **パッケージマネージャー**: pnpm 9以上(推奨)またはnpm
- **LinuxUbuntu/Debian**: Electron を実行する前に、必要なシステムライブラリをインストールしてください:
```bash
sudo apt-get install -y libnss3 libgtk-3-0 libxss1 libxtst6 libatspi2.0-0 libnotify4 xdg-utils
```
Ubuntu 24.04以降では、一部のパッケージに `t64` サフィックスが付いています。上記コマンドを実行すると `apt` が自動的に適切なバリアントを選択します。
### プロジェクト構成
```ClawX/
├── electron/ # Electron メインプロセス
│ ├── services/ # 型付き Host API、Provider/Secrets/ランタイムサービス
│ ├── api/ # メイン側 API ルーターとハンドラー
│ │ └── routes/ # RPC/HTTP プロキシのルートモジュール
│ ├── services/ # Provider/Secrets/ランタイムサービス
│ │ ├── providers/ # provider/account モデル同期ロジック
│ │ └── secrets/ # OS キーチェーンと秘密情報管理
│ ├── shared/ # 共通 Provider スキーマ/定数
@@ -344,7 +328,7 @@ AI を開発ワークフローに統合できます。エージェントを使
```bash
# 開発
pnpm run init # 依存関係のインストール + バンドルバイナリ(uv、agent-browserのダウンロード
pnpm run init # 依存関係のインストール + uvのダウンロード
pnpm dev # ホットリロードで起動(不足時は同梱スキルを自動準備)
# コード品質
+38 -54
View File
@@ -43,8 +43,6 @@ Whether you're automating workflows, managing AI-powered channels, or scheduling
ClawX comes pre-configured with best-practice model providers and natively supports Windows as well as multi-language settings. Of course, you can also fine-tune advanced configurations via **Settings → Advanced → Developer Mode**.
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">For a full enterprise edition, dedicated service support, or tailored deployment guidance for your business scenario, contact us at <a href="mailto:public@valuecell.ai">public@valuecell.ai</a>.</strong></p>
---
## Screenshot
@@ -83,9 +81,8 @@ Building AI agents shouldn't require mastering the command line. ClawX was desig
| Complex CLI setup | One-click installation with guided setup wizard |
| Configuration files | Visual settings with real-time validation |
| Process management | Automatic gateway lifecycle management |
| App updates | Startup update checks with a prompt before downloading or installing |
| Multiple AI providers | Unified provider configuration panel |
| Skill/plugin installation | Local-first skill management with optional extension-provided marketplace |
| Skill/plugin installation | Built-in skill marketplace and management |
### OpenClaw Inside
@@ -93,8 +90,6 @@ ClawX is built directly upon the official **OpenClaw** core. Instead of requirin
We are committed to maintaining strict alignment with the upstream OpenClaw project, ensuring that you always have access to the latest capabilities, stability improvements, and ecosystem compatibility provided by the official releases.
When Developer Mode is enabled, the sidebar also provides a native Dreams page for OpenClaw memory review, dream diary inspection, and basic maintenance actions. The full upstream OpenClaw Dreams UI remains available from that page when deeper diagnostics are needed.
---
## Features
@@ -104,7 +99,6 @@ Complete the entire setup—from installation to your first AI interaction—thr
### 💬 Intelligent Chat Interface
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, rich content rendering with Markdown (including GitHub-flavored tables and KaTeX-powered LaTeX math: `$inline$`, `$$block$$`, `\(inline\)`, and `\[block\]`), and direct `@agent` routing in the main composer for multi-agent setups.
Skills you insert from the composer appear as `/skill-name` chips; click a chip to open the preview sidebar and read that skill's `SKILL.md`.
When you target another agent with `@agent`, ClawX switches into that agent's own conversation context directly instead of relaying through the default agent. Agent workspaces stay separate by default, and stronger isolation depends on OpenClaw sandbox settings.
Each agent can also override its own `provider/model` runtime setting; agents without overrides continue inheriting the global default model.
@@ -116,13 +110,13 @@ ClawX now also bundles Tencent's official personal WeChat channel plugin, so you
### ⏰ Cron-Based Automation
Schedule AI tasks to run automatically. Define triggers, set intervals, and let your AI agents work around the clock without manual intervention.
The Cron page now lets you configure external delivery directly in the task form with separate sender-account and recipient-target selectors. For supported channels, recipient targets are discovered automatically from channel directories or known session history, so you no longer need to edit `jobs.json` by hand. The task message field also supports inserting skills with the same inline `/skill` token syntax as the main chat composer (scoped to the selected agent), so scheduled prompts can trigger skills directly. The schedule picker is split into **Recurring** and **Once** tabs: Recurring offers Hourly, Daily, Weekdays, Weekly, and Custom (raw cron) frequencies with inline time/weekday controls, while Once runs the task a single time at a chosen date (with weekday shown) and time. One-time tasks must be scheduled for a future moment and are automatically removed by the runtime once they finish.
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.
### 🧩 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.
Extend your AI agents with pre-built skills. Browse, install, and manage skills through the integrated skill panel—no package managers required.
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.
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.
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.
Environment variables for bundled search skills:
- `TAVILY_API_KEY` for `tavily-search` (OAuth may also be supported by upstream skill runtime)
@@ -130,9 +124,7 @@ Environment variables for bundled search skills:
### 🔐 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.
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.
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
@@ -141,9 +133,6 @@ Light mode, dark mode, or system-synchronized themes. ClawX adapts to your prefe
### 🚀 Startup Launch Control
In **Settings → General**, you can enable **Launch at system startup** so ClawX starts automatically after login.
### 🔔 Update Prompts
ClawX can automatically check for new versions on startup. When an update is available, it shows an in-app prompt; downloading and installing only happen after you choose the action.
---
## Getting Started
@@ -222,59 +211,58 @@ 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:
```
┌──────────────────────────────────────────────────────────────────┐
```┌─────────────────────────────────────────────────────────────────┐
│ ClawX Desktop App │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Electron Main Process │ │
│ │ Electron Main Process │ │
│ │ • Window & application lifecycle management │ │
│ │ • Gateway process supervision │ │
│ │ • System integration (tray, notifications, keychain) │ │
│ │ • Auto-update orchestration │ │
│ │ • Gateway process supervision │ │
│ │ • System integration (tray, notifications, keychain) │ │
│ │ • Auto-update orchestration │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ IPC (authoritative control plane) │
│ ▼ │
│ │
│ │ IPC (authoritative control plane)
│ ▼
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ React Renderer Process │ │
│ │ • Modern component-based UI (React 19) │ │
│ │ • State management with Zustand │ │
│ │ • Unified host-api/api-client calls │ │
│ │ • Rich Markdown rendering │ │
│ │ React Renderer Process │ │
│ │ • Modern component-based UI (React 19) │ │
│ │ • State management with Zustand │ │
│ │ • Unified host-api/api-client calls │ │
│ │ • Rich Markdown rendering │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────────
└──────────────────────────────┬──────────────────────────────────┘
Typed IPC requests
Main-owned transport strategy
│ (WS first, HTTP then IPC fallback)
─────────────────────────────────────────────────────────────────┐
Main Host Services & Gateway Manager
┌─────────────────────────────────────────────────────────────────┐
Host API & Main Process Proxies
│ │
│ • host:invoke typed service dispatcher
│ • Settings, files, sessions, skills, providers, diagnostics
│ • Main-owned Gateway WebSocket and process supervision
└──────────────────────────────┬──────────────────────────────────
│ • hostapi:fetch (Main proxy, avoids CORS in dev/prod)
│ • gateway:httpProxy (Renderer never calls Gateway HTTP direct)
│ • Unified error mapping & retry/backoff
└──────────────────────────────┬──────────────────────────────────┘
Main-owned WebSocket
WS / HTTP / IPC fallback
─────────────────────────────────────────────────────────────────┐
┌─────────────────────────────────────────────────────────────────┐
│ OpenClaw Gateway │
│ │
│ • AI agent runtime and orchestration
│ • AI agent runtime and orchestration │
│ • Message channel management │
│ • Skill/plugin execution environment
│ • Skill/plugin execution environment │
│ • Provider abstraction layer │
─────────────────────────────────────────────────────────────────┘
└─────────────────────────────────────────────────────────────────┘
```
### Design Principles
- **Process Isolation**: The AI runtime operates in a separate process, ensuring UI responsiveness even during heavy computation
- **Single Entry for Frontend Calls**: Renderer requests go through host-api/api-client; protocol details are hidden behind a stable interface
- **Main-Process Transport Ownership**: Electron Main owns the Gateway WebSocket; 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
- **Main-Process Transport Ownership**: Electron Main controls WS/HTTP usage and fallback to IPC for reliability
- **Graceful Recovery**: Built-in reconnect, timeout, and backoff logic handles transient failures automatically
- **Secure Storage**: API keys and sensitive data leverage the operating system's native secure storage mechanisms
- **CORS-Safe by Design**: The renderer does not call local Gateway or Host API HTTP endpoints directly
- **CORS-Safe by Design**: Local HTTP access is proxied by Main, preventing renderer-side CORS issues
### Process Model & Gateway Troubleshooting
@@ -282,7 +270,6 @@ ClawX employs a **dual-process architecture** with a unified host API layer. The
- Single-instance protection uses Electron's lock plus a local process-file lock fallback, preventing duplicate app launch in environments where desktop IPC/session bus is unstable.
- During rolling upgrades, mixed old/new app versions can still have asymmetric protection behavior. For best reliability, upgrade all desktop clients to the same version.
- The OpenClaw Gateway listener should still be **single-owner**: only one process should listen on `127.0.0.1:18789`.
- Gateway readiness is based on OpenClaw core signals such as `system-presence`, `health`, and `status`; memory, Dreams, or channel failures are shown as capability degradation instead of global Gateway failure.
- To verify the active listener:
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
- Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen`
@@ -310,19 +297,16 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro
### Prerequisites
- **Node.js**: 22.19+ (LTS recommended)
- **Node.js**: 22+ (LTS recommended)
- **Package Manager**: pnpm 9+ (recommended) or npm
- **Linux (Ubuntu/Debian)**: Install required system libraries before running Electron:
```bash
sudo apt-get install -y libnss3 libgtk-3-0 libxss1 libxtst6 libatspi2.0-0 libnotify4 xdg-utils
```
On Ubuntu 24.04+, some packages use a `t64` suffix; run the above command and `apt` will automatically select the correct variant.
### Project Structure
```ClawX/
├── electron/ # Electron Main Process
│ ├── services/ # Typed host APIs, provider, secrets and runtime services
│ ├── api/ # Main-side API router and handlers
│ │ └── routes/ # RPC/HTTP proxy route modules
│ ├── services/ # Provider, secrets and runtime services
│ │ ├── providers/ # Provider/account model sync logic
│ │ └── secrets/ # OS keychain and secret storage
│ ├── shared/ # Shared provider schemas/constants
@@ -348,7 +332,7 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro
```bash
# Development
pnpm run init # Install dependencies + download bundled binaries (uv, agent-browser)
pnpm run init # Install dependencies + download uv
pnpm dev # Start with hot reload (auto-prepares bundled skills if missing)
# Quality
+19 -22
View File
@@ -43,8 +43,6 @@
ClawX поставляется с предустановленными лучшими практиками для провайдеров моделей и нативно поддерживает Windows, а также многоязычные настройки. Вы можете тонко настроить расширенные параметры через **Настройки → Дополнительно → Режим разработчика**.
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">Для получения полной корпоративной версии, специализированной поддержки или индивидуального сопровождения внедрения под ваш бизнес-сценарий, свяжитесь с нами по адресу <a href="mailto:public@valuecell.ai">public@valuecell.ai</a>.</strong></p>
---
## Скриншоты
@@ -102,7 +100,6 @@ ClawX построен непосредственно на официально
### 💬 Интеллектуальный интерфейс чата
Общайтесь с AI-агентами через современный чат. Поддержка нескольких контекстов разговора, истории сообщений, рендеринга Markdown (включая таблицы GitHub-flavored и математические формулы LaTeX через KaTeX: `$строчные$`, `$$блочные$$`, `\(строчные\)` и `\[блочные\]`) и прямая маршрутизация через `@agent` в главном поле ввода для мультиагентных конфигураций.
Навыки, вставляемые из поля ввода, отображаются как чипы `/skill-name`; нажмите на чип, чтобы открыть боковую панель предпросмотра и прочитать `SKILL.md` соответствующего навыка.
При выборе другого агента через `@agent` ClawX переключается непосредственно в контекст этого агента вместо ретрансляции через агента по умолчанию. Рабочие пространства агентов по умолчанию разделены, но более строгая изоляция зависит от настроек песочницы OpenClaw.
Каждый агент может переопределить свои настройки `provider/model`; агенты без переопределения продолжают наследовать глобальную модель по умолчанию.
@@ -114,7 +111,7 @@ ClawX также включает официальный плагин лично
### ⏰ Автоматизация по расписанию
Планируйте автоматический запуск AI-задач. Определяйте триггеры, устанавливайте интервалы и позволяйте AI-агентам работать круглосуточно без ручного вмешательства.
На странице Cron теперь можно настроить внешнюю доставку непосредственно в форме задачи с отдельными селекторами учётной записи отправителя и цели получателя. Для поддерживаемых каналов цели получателей автоматически обнаруживаются из каталогов каналов или известной истории сессий, поэтому больше не нужно редактировать `jobs.json` вручную. Поле сообщения задачи также поддерживает вставку навыков с помощью того же синтаксиса встроенных токенов `/skill`, что и в основном окне чата (с учётом выбранного агента), поэтому запланированные подсказки могут запускать навыки напрямую. Выбор расписания разделён на вкладки **Повтор** и **Однократно**: повтор предлагает частоты «Ежечасно», «Ежедневно», «По будням», «Еженедельно» и «Свой» (произвольный cron) со встроенными элементами выбора времени/дня недели, а однократно запускает задачу один раз в выбранную дату (с показом дня недели) и время. Однократные задачи должны быть запланированы на будущее и автоматически удаляются средой выполнения после завершения.
На странице Cron теперь можно настроить внешнюю доставку непосредственно в форме задачи с отдельными селекторами учётной записи отправителя и цели получателя. Для поддерживаемых каналов цели получателей автоматически обнаруживаются из каталогов каналов или известной истории сессий, поэтому больше не нужно редактировать `jobs.json` вручную.
### 🧩 Расширяемая система навыков
Расширяйте возможности AI-агентов готовыми навыками. Просматривайте, устанавливайте и управляйте навыками через встроенную панель — менеджеры пакетов не нужны.
@@ -218,43 +215,43 @@ ClawX использует **двухпроцессную архитектуру
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Главный процесс Electron │ │
│ │ • Управление жизненным циклом окна и приложения │ │
│ │ • Наблюдение за процессом шлюза │ │
│ │ • Управление жизненным циклом окна и приложения │ │
│ │ • Наблюдение за процессом шлюза │ │
│ │ • Интеграция с системой (трей, уведомления, связка ключей)│ │
│ │ • Оркестрация автообновлений │ │
│ │ • Оркестрация автообновлений │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ IPC (авторитетная плоскость управления) │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Процесс рендерера React │ │
│ │ • Современный UI на компонентах (React 19) │ │
│ │ • Управление состоянием с Zustand │ │
│ │ • Унифицированные вызовы host-api/api-client │ │
│ │ • Рендеринг Markdown │ │
│ │ • Современный UI на компонентах (React 19) │ │
│ │ • Управление состоянием с Zustand │ │
│ │ • Унифицированные вызовы host-api/api-client │ │
│ │ • Рендеринг Markdown │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────────┘
Стратегия транспорта, управляемая Main
(Сначала WS, затем HTTP, затем IPC)
┌─────────────────────────────────────────────────────────────────
│ Host API и прокси-уровень Main
│ • hostapi:fetch (прокси Main, избегает CORS в dev/prod)
┌─────────────────────────────────────────────────────────────────┐
│ Host API и прокси-уровень Main │
│ │
│ • hostapi:fetch (прокси Main, избегает CORS в dev/prod) │
│ • gateway:httpProxy (Рендерер не вызывает Gateway HTTP напрямую)│
│ • Унифицированное отображение ошибок и повторные попытки
└──────────────────────────────┬──────────────────────────────────
│ • Унифицированное отображение ошибок и повторные попытки │
└──────────────────────────────┬──────────────────────────────────┘
Резерв WS / HTTP / IPC
┌─────────────────────────────────────────────────────────────────┐
│ Шлюз OpenClaw │
│ │
│ • Среда выполнения AI-агентов и оркестрация
│ • Управление каналами сообщений
│ • Среда выполнения навыков/плагинов
│ • Уровень абстракции провайдеров
│ • Среда выполнения AI-агентов и оркестрация │
│ • Управление каналами сообщений │
│ • Среда выполнения навыков/плагинов │
│ • Уровень абстракции провайдеров │
└─────────────────────────────────────────────────────────────────┘
```
@@ -300,7 +297,7 @@ ClawX использует **двухпроцессную архитектуру
### Требования
- **Node.js**: 22.19+ (рекомендуется LTS)
- **Node.js**: 22+ (рекомендуется LTS)
- **Менеджер пакетов**: pnpm 9+ (рекомендуется) или npm
### Структура проекта
+41 -57
View File
@@ -43,8 +43,6 @@
ClawX 预置了最佳实践的模型供应商配置,原生支持 Windows 平台以及多语言设置。当然,你也可以通过 **设置 → 高级 → 开发者模式** 来进行精细的高级配置。
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">如需完整的企业版、专属服务支持或面向您业务场景的定制化落地辅导,请联系 <a href="mailto:public@valuecell.ai">public@valuecell.ai</a>。</strong></p>
---
## 截图预览
@@ -84,7 +82,6 @@ ClawX 预置了最佳实践的模型供应商配置,原生支持 Windows 平
| 复杂的命令行配置 | 一键安装,配合引导式设置向导 |
| 手动编辑配置文件 | 可视化设置界面,实时校验 |
| 进程管理繁琐 | 自动管理网关生命周期 |
| 应用更新 | 启动时检查新版本,并在下载或安装前提示确认 |
| 多 AI 供应商切换 | 统一的供应商配置面板 |
| 技能/插件安装复杂 | 内置技能市场与管理界面 |
@@ -94,8 +91,6 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
我们致力于与上游 OpenClaw 项目保持严格同步,确保你始终可以使用官方发布的最新功能、稳定性改进和生态兼容性。
打开开发者模式后,侧边栏还会提供原生 Dreams 页面,可在 ClawX 内查看 OpenClaw 记忆回顾、梦境日记,并执行基础维护操作;需要更深诊断时仍可从该页面打开完整 OpenClaw Dreams UI。
---
## 功能特性
@@ -105,7 +100,6 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
### 💬 智能聊天界面
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、Markdown 富文本渲染(包括 GitHub 风格表格以及由 KaTeX 渲染的 LaTeX 数学公式:`$行内$``$$块级$$``\(行内\)``\[块级\]`),以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。
从输入框插入的技能会以 `/技能名` 卡片形式显示;点击卡片可在右侧预览栏打开并阅读该技能的 `SKILL.md`
当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。
@@ -117,22 +111,20 @@ ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Chan
### ⏰ 定时任务自动化
调度 AI 任务自动执行。定义触发器、设置时间间隔,让 AI 智能体 7×24 小时不间断工作。
现在定时任务页面已经可以直接配置外部投递,统一拆成“发送账号”和“接收目标”两个下拉选择。对于已支持的通道,接收目标会从通道目录能力或已知会话历史中自动发现,不需要再手动修改 `jobs.json`任务的消息输入框也支持像主对话框那样以内联 `/skill` 令牌的方式插入技能(按所选智能体范围加载),让定时提示词可以直接触发技能。调度选择器现在分为**周期**和**单次**两个选项卡:周期支持每小时、每天、工作日、每周、自定义(原始 cron)等频率,并内置时间/星期选择;单次则在所选日期(显示星期)和时间执行一次。单次任务必须设置为未来时间,并会在执行完成后由运行时自动清除。
现在定时任务页面已经可以直接配置外部投递,统一拆成“发送账号”和“接收目标”两个下拉选择。对于已支持的通道,接收目标会从通道目录能力或已知会话历史中自动发现,不需要再手动修改 `jobs.json`
### 🧩 可扩展技能系统
通过预构建的技能扩展 AI 智能体的能力。集成的 Skills 页面采用“本地优先”方式:会扫描托管目录与 workspace 技能目录,并且无需依赖 Gateway 即可启用或停用技能;在企业扩展接管时,也可以显示扩展提供的 marketplace
通过预构建的技能扩展 AI 智能体的能力。集成的技能面板中浏览、安装和管理技能——无需包管理器
ClawX 还会内置预装完整的文档处理技能(`pdf``xlsx``docx``pptx`),在启动时自动部署到托管技能目录(默认 `~/.openclaw/skills`),并在首次安装时默认启用。额外预装技能(`find-skills``self-improving-agent``tavily-search`)也会默认启用;若缺少必需的 API Key,OpenClaw 会在运行时给出配置错误提示。
Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、workspace、额外技能目录),并显示每个技能的实际路径,便于直接打开真实安装位置。对于 OpenClaw 自带的 bundled skills,社区版现在在打包产物里只保留并展示 `skill-creator`;开发模式和打包版启动时都会直接清理其它 bundled skill,同时把这些已删除 bundled skill 在 `openclaw.json` 中残留的旧配置一并移除。
Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、workspace、额外技能目录),并显示每个技能的实际路径,便于直接打开真实安装位置。
重点搜索技能所需环境变量:
- `TAVILY_API_KEY`:用于 `tavily-search`(上游运行时也可能支持 OAuth
### 🔐 安全的供应商集成
连接多个 AI 供应商(OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。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 一致的图片输入能力推断;未知模型默认按纯文本模型处理。
如果兼容网关的 `/models` 因非鉴权原因不可用,ClawX 会在校验 API Key 时自动降级为轻量的 `/chat/completions``/responses` 探测。
### 🌙 自适应主题
@@ -141,9 +133,6 @@ Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、wor
### 🚀 开机启动控制
**设置 → 通用** 中,你可以开启 **开机自动启动**,让 ClawX 在系统登录后自动启动。
### 🔔 更新提示
ClawX 可以在启动时自动检查新版本。发现更新后会显示应用内提示;只有在你选择操作后,才会下载或安装更新。
---
## 快速上手
@@ -222,47 +211,47 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问
ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用统一客户端抽象,协议选择与进程生命周期由 Electron 主进程统一管理:
```
┌───────────────────────────────────────────────────────────────────┐
ClawX 桌面应用
│ ┌─────────────────────────────────────────────────────────────┐ │
```┌─────────────────────────────────────────────────────────────────┐
│ ClawX 桌面应用 │
┌────────────────────────────────────────────────────────────┐
│ │ Electron 主进程 │ │
│ │ • 窗口与应用生命周期管理 │ │
│ │ • 窗口与应用生命周期管理 │ │
│ │ • 网关进程监控 │ │
│ │ • 系统集成(托盘、通知、密钥链) │ │
│ │ • 自动更新编排 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ IPC (权威控制面) │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ React 渲染进程 │ │
│ │ • 现代组件化 UI(React 19 │ │
│ │ • Zustand 状态管理 │ │
│ │ • 统一 host-api/api-client 调用 │ │
│ │ • Markdown 富文本渲染 │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬───────────────────────────────────┘
│ 类型化 IPC 请求
┌─────────────────────────────────────────────────────────────────┐
主进程 Host Services 与 Gateway Manager
│ • host:invoke 类型化服务分发
│ • 设置、文件、会话、技能、供应商、诊断服务
• 主进程持有 Gateway WebSocket 并负责进程监控
│ │ │
│ IPC(权威控制面)
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ React 渲染进程 │ │
• 现代组件化 UIReact 19
│ • Zustand 状态管理
│ • 统一 host-api/api-client 调用
│ • Markdown 富文本渲染
└────────────────────────────────────────────────────────────┘
└──────────────────────────────┬──────────────────────────────────┘
│ 主进程持有 WebSocket
│ 主进程统一传输策略
│(WS 优先,HTTP 次之,IPC 回退)
┌─────────────────────────────────────────────────────────────────┐
OpenClaw 网关
│ • AI 智能体运行时与编排
Host API 与主进程代理层
│ • hostapi:fetch(主进程代理,规避开发/生产 CORS)
│ • gateway:httpProxy(渲染进程不直连 Gateway HTTP
│ • 统一错误映射与重试/退避策略 │
└──────────────────────────────┬──────────────────────────────────┘
│ WS / HTTP / IPC 回退
┌─────────────────────────────────────────────────────────────────┐
│ OpenClaw 网关 │
│ │
│ • AI 智能体运行时与编排 │
│ • 消息频道管理 │
│ • 技能/插件执行环境
│ • 技能/插件执行环境 │
│ • 供应商抽象层 │
└─────────────────────────────────────────────────────────────────┘
```
@@ -270,11 +259,10 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
- **进程隔离**:AI 运行时在独立进程中运行,确保即使在高负载计算期间 UI 也能保持响应
- **前端调用单一入口**:渲染层统一走 host-api/api-client,不感知底层协议细节
- **主进程掌控传输策略**Gateway WebSocket 只由 Electron Main 持有,渲染进程通过类型化 IPC 调用 Main
- **扩展 IPC 贡献点**:主进程扩展通过类型化 IPC 注册表贡献 host-api action,而不是挂载 HTTP route
- **主进程掌控传输策略**WS/HTTP 选择与 IPC 回退在主进程集中处理,提升稳定性
- **优雅恢复**:内置重连、超时、退避逻辑,自动处理瞬时故障
- **安全存储**:API 密钥和敏感数据利用操作系统原生的安全存储机制
- **CORS 安全**渲染进程不直接请求本地 Gateway 或 Host API HTTP 端点
- **CORS 安全**本地 HTTP 请求由主进程代理,避免渲染进程跨域问题
### 进程模型与 Gateway 排障
@@ -282,7 +270,6 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
- 单实例保护同时使用 Electron 自带锁与本地进程文件锁回退机制,可在桌面会话总线异常时避免重复启动。
- 滚动升级期间若新旧版本混跑,单实例保护仍可能出现不对称行为。为保证稳定性,建议桌面客户端尽量统一升级到同一版本。
- 但 OpenClaw Gateway 监听应始终保持**单实例**:`127.0.0.1:18789` 只能有一个监听者。
- Gateway readiness 以 OpenClaw 的 `system-presence``health``status` 等核心信号为准;memory、Dreams 或频道失败会显示为能力降级,而不是全局 Gateway 故障。
- 可用以下命令确认监听进程:
- macOS/Linux`lsof -nP -iTCP:18789 -sTCP:LISTEN`
- WindowsPowerShell):`Get-NetTCPConnection -LocalPort 18789 -State Listen`
@@ -310,19 +297,16 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
### 前置要求
- **Node.js**22.19+(推荐 LTS 版本)
- **Node.js**22+(推荐 LTS 版本)
- **包管理器**pnpm 9+(推荐)或 npm
- **LinuxUbuntu/Debian**:运行 Electron 前,请先安装所需系统库:
```bash
sudo apt-get install -y libnss3 libgtk-3-0 libxss1 libxtst6 libatspi2.0-0 libnotify4 xdg-utils
```
在 Ubuntu 24.04+ 上,部分软件包使用 `t64` 后缀,运行上述命令后 `apt` 会自动选择正确版本。
### 项目结构
```ClawX/
├── electron/ # Electron 主进程
│ ├── services/ # 类型化 Host API、Provider、Secrets 与运行时服务
│ ├── api/ # 主进程 API 路由与处理器
│ │ └── routes/ # RPC/HTTP 代理路由模块
│ ├── services/ # Provider、Secrets 与运行时服务
│ │ ├── providers/ # Provider/account 模型同步逻辑
│ │ └── secrets/ # 系统钥匙串与密钥存储
│ ├── shared/ # 共享 Provider schema/常量
@@ -348,7 +332,7 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
```bash
# 开发
pnpm run init # 安装依赖并下载捆绑二进制(uv、agent-browser
pnpm run init # 安装依赖并下载 uv
pnpm dev # 以热重载模式启动(若缺失会自动准备预装技能包)
# 代码质量
+1
View File
@@ -1,6 +1,7 @@
{
"extensions": {
"main": [
"builtin/clawhub-marketplace",
"builtin/diagnostics"
],
"renderer": []
+11
View File
@@ -0,0 +1,11 @@
import type { BrowserWindow } from 'electron';
import type { GatewayManager } from '../gateway/manager';
import type { ClawHubService } from '../gateway/clawhub';
import type { HostEventBus } from './event-bus';
export interface HostApiContext {
gatewayManager: GatewayManager;
clawHubService: ClawHubService;
eventBus: HostEventBus;
mainWindow: BrowserWindow | null;
}
+36
View File
@@ -0,0 +1,36 @@
import type { ServerResponse } from 'http';
type EventPayload = unknown;
export class HostEventBus {
private readonly clients = new Set<ServerResponse>();
addSseClient(res: ServerResponse): void {
this.clients.add(res);
res.on('close', () => {
this.clients.delete(res);
});
}
emit(eventName: string, payload: EventPayload): void {
const message = `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`;
for (const client of this.clients) {
try {
client.write(message);
} catch {
this.clients.delete(client);
}
}
}
closeAll(): void {
for (const client of this.clients) {
try {
client.end();
} catch {
// Ignore individual client close failures.
}
}
this.clients.clear();
}
}
+73
View File
@@ -0,0 +1,73 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { PORTS } from '../utils/config';
/**
* Allowed CORS origins — only the Electron renderer (Vite dev or production)
* and the OpenClaw Gateway are permitted to make cross-origin requests.
*/
const ALLOWED_ORIGINS = new Set([
`http://127.0.0.1:${PORTS.CLAWX_DEV}`,
`http://localhost:${PORTS.CLAWX_DEV}`,
`http://127.0.0.1:${PORTS.OPENCLAW_GATEWAY}`,
`http://localhost:${PORTS.OPENCLAW_GATEWAY}`,
]);
export async function parseJsonBody<T>(req: IncomingMessage): Promise<T> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const raw = Buffer.concat(chunks).toString('utf8').trim();
if (!raw) {
return {} as T;
}
return JSON.parse(raw) as T;
}
/**
* Validate that mutation requests (POST/PUT/DELETE) carry a JSON Content-Type.
* This prevents "simple request" CSRF where the browser skips the preflight
* when Content-Type is text/plain or application/x-www-form-urlencoded.
*/
export function requireJsonContentType(req: IncomingMessage): boolean {
if (req.method === 'GET' || req.method === 'OPTIONS' || req.method === 'HEAD') {
return true;
}
// Requests without a body (content-length 0 or absent) are safe — CSRF
// "simple request" attacks rely on sending a crafted body.
const contentLength = req.headers['content-length'];
if (contentLength === '0' || contentLength === undefined) {
return true;
}
const ct = req.headers['content-type'] || '';
return ct.includes('application/json');
}
export function setCorsHeaders(res: ServerResponse, origin?: string): void {
// Only reflect the Origin header back if it is in the allow-list.
// Omitting the header for unknown origins causes the browser to block
// the response — this is the intended behavior for untrusted callers.
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
export function sendJson(res: ServerResponse, statusCode: number, payload: unknown): void {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(payload));
}
export function sendNoContent(res: ServerResponse): void {
res.statusCode = 204;
res.end();
}
export function sendText(res: ServerResponse, statusCode: number, text: string): void {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end(text);
}
+253
View File
@@ -0,0 +1,253 @@
import type { IncomingMessage, ServerResponse } from 'http';
import {
assignChannelToAgent,
clearChannelBinding,
createAgent,
deleteAgentConfig,
listAgentsSnapshot,
removeAgentWorkspaceDirectory,
resolveAccountIdForAgent,
updateAgentModel,
updateAgentName,
} from '../../utils/agent-config';
import { deleteChannelAccountConfig } from '../../utils/channel-config';
import { syncAgentModelOverrideToRuntime, syncAllProviderAuthToRuntime } from '../../services/providers/provider-runtime-sync';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import { ensureClawXContext } from '../../utils/openclaw-workspace';
function scheduleGatewayReload(ctx: HostApiContext, reason: string): void {
if (ctx.gatewayManager.getStatus().state !== 'stopped') {
ctx.gatewayManager.debouncedReload();
return;
}
void reason;
}
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
/**
* Force a full Gateway process restart after agent deletion.
*
* A SIGUSR1 in-process reload is NOT sufficient here: channel plugins
* (e.g. Feishu) maintain long-lived WebSocket connections to external
* services and do not disconnect accounts that were removed from the
* config during an in-process reload. The only reliable way to drop
* stale bot connections is to kill the Gateway process entirely and
* spawn a fresh one that reads the updated openclaw.json from scratch.
*/
export async function restartGatewayForAgentDeletion(ctx: HostApiContext): Promise<void> {
try {
// Capture the PID of the running Gateway BEFORE stop() clears it.
const status = ctx.gatewayManager.getStatus();
const pid = status.pid;
const port = status.port;
console.log('[agents] Triggering Gateway restart (kill+respawn) after agent deletion', { pid, port });
// Force-kill the Gateway process by PID. The manager's stop() only
// kills "owned" processes; if the manager connected to an already-
// running Gateway (ownsProcess=false), stop() simply closes the WS
// and the old process stays alive with its stale channel connections.
if (pid) {
try {
if (process.platform === 'win32') {
await execAsync(`taskkill /F /PID ${pid} /T`);
} else {
process.kill(pid, 'SIGTERM');
// Give it a moment to die
await new Promise((resolve) => setTimeout(resolve, 500));
try { process.kill(pid, 0); process.kill(pid, 'SIGKILL'); } catch { /* already dead */ }
}
} catch {
// process already gone that's fine
}
} else if (port) {
// If we don't know the PID (e.g. connected to an orphaned Gateway from
// a previous pnpm dev run), forcefully kill whatever is on the port.
try {
if (process.platform === 'darwin' || process.platform === 'linux') {
// MUST use -sTCP:LISTEN. Otherwise lsof returns the client process (ClawX itself)
// that has an ESTABLISHED WebSocket connection to the port, causing us to kill ourselves.
const { stdout } = await execAsync(`lsof -t -i :${port} -sTCP:LISTEN`);
const pids = stdout.trim().split('\n').filter(Boolean);
for (const p of pids) {
try { process.kill(parseInt(p, 10), 'SIGTERM'); } catch { /* ignore */ }
}
await new Promise((resolve) => setTimeout(resolve, 500));
for (const p of pids) {
try { process.kill(parseInt(p, 10), 'SIGKILL'); } catch { /* ignore */ }
}
} else if (process.platform === 'win32') {
// Find PID listening on the port
const { stdout } = await execAsync(`netstat -ano | findstr :${port}`);
const lines = stdout.trim().split('\n');
const pids = new Set<string>();
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 5 && parts[1].endsWith(`:${port}`) && parts[3] === 'LISTENING') {
pids.add(parts[4]);
}
}
for (const p of pids) {
try { await execAsync(`taskkill /F /PID ${p} /T`); } catch { /* ignore */ }
}
}
} catch {
// Port might not be bound or command failed; ignore
}
}
await ctx.gatewayManager.restart();
console.log('[agents] Gateway restart completed after agent deletion');
} catch (err) {
console.warn('[agents] Gateway restart after agent deletion failed:', err);
}
}
export async function handleAgentRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/agents' && req.method === 'GET') {
sendJson(res, 200, { success: true, ...(await listAgentsSnapshot()) });
return true;
}
if (url.pathname === '/api/agents' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ name: string; inheritWorkspace?: boolean }>(req);
const snapshot = await createAgent(body.name, { inheritWorkspace: body.inheritWorkspace });
// Sync provider API keys to the new agent's auth-profiles.json so the
// embedded runner can authenticate with LLM providers when messages
// arrive via channel bots (e.g. Feishu). Without this, the copied
// auth-profiles.json may contain a stale key → 401 from the LLM.
syncAllProviderAuthToRuntime().catch((err) => {
console.warn('[agents] Failed to sync provider auth after agent creation:', err);
});
scheduleGatewayReload(ctx, 'create-agent');
// Ensure newly provisioned workspaces get ClawX context merge/cleanup
// even when gateway status events do not fire (e.g. in-process reload).
void ensureClawXContext().catch((err) => {
console.warn('[agents] Failed to ensure ClawX context after agent creation:', err);
});
sendJson(res, 200, { success: true, ...snapshot });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/agents/') && req.method === 'PUT') {
const suffix = url.pathname.slice('/api/agents/'.length);
const parts = suffix.split('/').filter(Boolean);
if (parts.length === 1) {
try {
const body = await parseJsonBody<{ name: string }>(req);
const agentId = decodeURIComponent(parts[0]);
const snapshot = await updateAgentName(agentId, body.name);
scheduleGatewayReload(ctx, 'update-agent');
sendJson(res, 200, { success: true, ...snapshot });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (parts.length === 2 && parts[1] === 'model') {
try {
const body = await parseJsonBody<{ modelRef?: string | null }>(req);
const agentId = decodeURIComponent(parts[0]);
const snapshot = await updateAgentModel(agentId, body.modelRef ?? null);
try {
await syncAllProviderAuthToRuntime();
// Ensure this agent's runtime model registry reflects the new model override.
await syncAgentModelOverrideToRuntime(agentId);
} catch (syncError) {
console.warn('[agents] Failed to sync runtime after updating agent model:', syncError);
}
scheduleGatewayReload(ctx, 'update-agent-model');
sendJson(res, 200, { success: true, ...snapshot });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (parts.length === 3 && parts[1] === 'channels') {
try {
const agentId = decodeURIComponent(parts[0]);
const channelType = decodeURIComponent(parts[2]);
const snapshot = await assignChannelToAgent(agentId, channelType);
scheduleGatewayReload(ctx, 'assign-channel');
sendJson(res, 200, { success: true, ...snapshot });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
}
if (url.pathname.startsWith('/api/agents/') && req.method === 'DELETE') {
const suffix = url.pathname.slice('/api/agents/'.length);
const parts = suffix.split('/').filter(Boolean);
if (parts.length === 1) {
try {
const agentId = decodeURIComponent(parts[0]);
const { snapshot, removedEntry } = await deleteAgentConfig(agentId);
// Await reload synchronously BEFORE responding to the client.
// This ensures the Feishu plugin has disconnected the deleted bot
// before the UI shows "delete success" and the user tries chatting.
await restartGatewayForAgentDeletion(ctx);
// Delete workspace after reload so the new config is already live.
await removeAgentWorkspaceDirectory(removedEntry).catch((err) => {
console.warn('[agents] Failed to remove workspace after agent deletion:', err);
});
sendJson(res, 200, { success: true, ...snapshot });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (parts.length === 3 && parts[1] === 'channels') {
try {
const agentId = decodeURIComponent(parts[0]);
const channelType = decodeURIComponent(parts[2]);
const ownerId = agentId.trim().toLowerCase();
const snapshotBefore = await listAgentsSnapshot();
const ownedAccountIds = Object.entries(snapshotBefore.channelAccountOwners)
.filter(([channelAccountKey, owner]) => {
if (owner !== ownerId) return false;
return channelAccountKey.startsWith(`${channelType}:`);
})
.map(([channelAccountKey]) => channelAccountKey.slice(channelAccountKey.indexOf(':') + 1));
// Backward compatibility for legacy agentId->accountId mapping.
if (ownedAccountIds.length === 0) {
const legacyAccountId = resolveAccountIdForAgent(agentId);
if (snapshotBefore.channelAccountOwners[`${channelType}:${legacyAccountId}`] === ownerId) {
ownedAccountIds.push(legacyAccountId);
}
}
for (const accountId of ownedAccountIds) {
await deleteChannelAccountConfig(channelType, accountId);
await clearChannelBinding(channelType, accountId);
}
const snapshot = await listAgentsSnapshot();
scheduleGatewayReload(ctx, 'remove-agent-channel');
sendJson(res, 200, { success: true, ...snapshot });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
}
return false;
}
+37
View File
@@ -0,0 +1,37 @@
import type { IncomingMessage, ServerResponse } from 'http';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../../utils/openclaw-doctor';
export async function handleAppRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/events' && req.method === 'GET') {
// CORS headers are already set by the server middleware.
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
});
res.write(': connected\n\n');
ctx.eventBus.addSseClient(res);
// Send a current-state snapshot immediately so renderer subscribers do not
// miss lifecycle transitions that happened before the SSE connection opened.
res.write(`event: gateway:status\ndata: ${JSON.stringify(ctx.gatewayManager.getStatus())}\n\n`);
return true;
}
if (url.pathname === '/api/app/openclaw-doctor' && req.method === 'POST') {
const body = await parseJsonBody<{ mode?: 'diagnose' | 'fix' }>(req);
const mode = body.mode === 'fix' ? 'fix' : 'diagnose';
sendJson(res, 200, mode === 'fix' ? await runOpenClawDoctorFix() : await runOpenClawDoctor());
return true;
}
// OPTIONS is handled by the server middleware; no route-level handler needed.
return false;
}
File diff suppressed because it is too large Load Diff
+695
View File
@@ -0,0 +1,695 @@
import { readFile } from 'node:fs/promises';
import type { IncomingMessage, ServerResponse } from 'http';
import { join } from 'node:path';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import { getOpenClawConfigDir } from '../../utils/paths';
import { resolveAccountIdFromSessionHistory } from '../../utils/session-util';
import { toOpenClawChannelType, toUiChannelType } from '../../utils/channel-alias';
import { resolveAgentIdFromChannel } from '../../utils/agent-config';
/**
* Find agentId from session history by delivery "to" address.
* Efficiently searches only agent session directories for matching deliveryContext.to.
*/
interface GatewayCronJob {
id: string;
name: string;
description?: string;
enabled: boolean;
createdAtMs: number;
updatedAtMs: number;
schedule: { kind: string; expr?: string; everyMs?: number; at?: string; tz?: string };
payload: { kind: string; message?: string; text?: string };
delivery?: { mode: string; channel?: string; to?: string; accountId?: string };
sessionTarget?: string;
state: {
nextRunAtMs?: number;
runningAtMs?: number;
lastRunAtMs?: number;
lastStatus?: string;
lastError?: string;
lastDurationMs?: number;
};
}
interface CronRunLogEntry {
jobId?: string;
action?: string;
status?: string;
error?: string;
summary?: string;
sessionId?: string;
sessionKey?: string;
ts?: number;
runAtMs?: number;
durationMs?: number;
model?: string;
provider?: string;
}
interface CronSessionKeyParts {
agentId: string;
jobId: string;
runSessionId?: string;
}
interface CronSessionFallbackMessage {
id: string;
role: 'assistant' | 'system';
content: string;
timestamp: number;
isError?: boolean;
}
function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
if (!sessionKey.startsWith('agent:')) return null;
const parts = sessionKey.split(':');
if (parts.length < 4 || parts[2] !== 'cron') return null;
const agentId = parts[1] || 'main';
const jobId = parts[3];
if (!jobId) return null;
if (parts.length === 4) {
return { agentId, jobId };
}
if (parts.length === 6 && parts[4] === 'run' && parts[5]) {
return { agentId, jobId, runSessionId: parts[5] };
}
return null;
}
function normalizeTimestampMs(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value < 1e12 ? value * 1000 : value;
}
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return undefined;
}
function formatDuration(durationMs: number | undefined): string | null {
if (!durationMs || !Number.isFinite(durationMs)) return null;
if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
if (durationMs < 10_000) return `${(durationMs / 1000).toFixed(1)}s`;
return `${Math.round(durationMs / 1000)}s`;
}
function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSessionFallbackMessage | null {
const timestamp = normalizeTimestampMs(entry.ts) ?? normalizeTimestampMs(entry.runAtMs);
if (!timestamp) return null;
const status = typeof entry.status === 'string' ? entry.status.toLowerCase() : '';
const summary = typeof entry.summary === 'string' ? entry.summary.trim() : '';
const error = typeof entry.error === 'string' ? entry.error.trim() : '';
let content = summary || error;
if (!content) {
content = status === 'error'
? 'Scheduled task failed.'
: 'Scheduled task completed.';
}
if (status === 'error' && !content.toLowerCase().startsWith('run failed:')) {
content = `Run failed: ${content}`;
}
const meta: string[] = [];
const duration = formatDuration(entry.durationMs);
if (duration) meta.push(`Duration: ${duration}`);
if (entry.provider && entry.model) {
meta.push(`Model: ${entry.provider}/${entry.model}`);
} else if (entry.model) {
meta.push(`Model: ${entry.model}`);
}
if (meta.length > 0) {
content = `${content}\n\n${meta.join(' | ')}`;
}
return {
id: `cron-run-${entry.sessionId ?? entry.ts ?? index}`,
role: status === 'error' ? 'system' : 'assistant',
content,
timestamp,
...(status === 'error' ? { isError: true } : {}),
};
}
async function readCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
const logPath = join(getOpenClawConfigDir(), 'cron', 'runs', `${jobId}.jsonl`);
const raw = await readFile(logPath, 'utf8').catch(() => '');
if (!raw.trim()) return [];
const entries: CronRunLogEntry[] = [];
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const entry = JSON.parse(trimmed) as CronRunLogEntry;
if (!entry || entry.jobId !== jobId) continue;
if (entry.action && entry.action !== 'finished') continue;
entries.push(entry);
} catch {
// Ignore malformed log lines so one bad entry does not hide the rest.
}
}
return entries;
}
async function readSessionStoreEntry(
agentId: string,
sessionKey: string,
): Promise<Record<string, unknown> | undefined> {
const storePath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json');
const raw = await readFile(storePath, 'utf8').catch(() => '');
if (!raw.trim()) return undefined;
try {
const store = JSON.parse(raw) as Record<string, unknown>;
const directEntry = store[sessionKey];
if (directEntry && typeof directEntry === 'object') {
return directEntry as Record<string, unknown>;
}
const sessions = (store as { sessions?: unknown }).sessions;
if (Array.isArray(sessions)) {
const arrayEntry = sessions.find((entry) => {
if (!entry || typeof entry !== 'object') return false;
const record = entry as Record<string, unknown>;
return record.key === sessionKey || record.sessionKey === sessionKey;
});
if (arrayEntry && typeof arrayEntry === 'object') {
return arrayEntry as Record<string, unknown>;
}
}
} catch {
return undefined;
}
return undefined;
}
export function buildCronSessionFallbackMessages(params: {
sessionKey: string;
job?: Pick<GatewayCronJob, 'name' | 'payload' | 'state'>;
runs: CronRunLogEntry[];
sessionEntry?: { label?: string; updatedAt?: number };
limit?: number;
}): CronSessionFallbackMessage[] {
const parsed = parseCronSessionKey(params.sessionKey);
if (!parsed) return [];
const matchingRuns = params.runs
.filter((entry) => {
if (!parsed.runSessionId) return true;
return entry.sessionId === parsed.runSessionId
|| entry.sessionKey === `${params.sessionKey}`;
})
.sort((a, b) => {
const left = normalizeTimestampMs(a.ts) ?? normalizeTimestampMs(a.runAtMs) ?? 0;
const right = normalizeTimestampMs(b.ts) ?? normalizeTimestampMs(b.runAtMs) ?? 0;
return left - right;
});
const messages: CronSessionFallbackMessage[] = [];
const prompt = params.job?.payload?.message || params.job?.payload?.text || '';
const taskName = params.job?.name?.trim()
|| params.sessionEntry?.label?.replace(/^Cron:\s*/, '').trim()
|| '';
const firstRelevantTimestamp = matchingRuns.length > 0
? (normalizeTimestampMs(matchingRuns[0]?.runAtMs) ?? normalizeTimestampMs(matchingRuns[0]?.ts))
: (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'),
timestamp: Math.max(0, (firstRelevantTimestamp ?? Date.now()) - 1),
});
}
matchingRuns.forEach((entry, index) => {
const message = buildCronRunMessage(entry, index);
if (message) messages.push(message);
});
if (matchingRuns.length === 0) {
const runningAt = normalizeTimestampMs(params.job?.state?.runningAtMs);
if (runningAt) {
messages.push({
id: `cron-running-${parsed.jobId}`,
role: 'system',
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(),
});
}
}
const limit = typeof params.limit === 'number' && Number.isFinite(params.limit)
? Math.max(1, Math.floor(params.limit))
: messages.length;
return messages.slice(-limit);
}
type JsonRecord = Record<string, unknown>;
type GatewayCronDelivery = NonNullable<GatewayCronJob['delivery']>;
function getUnsupportedCronDeliveryError(_channel: string | undefined): string | null {
// Channel support is gated by the frontend whitelist (TESTED_CRON_DELIVERY_CHANNELS).
// No per-channel backend blocks are needed.
return null;
}
function normalizeCronDelivery(
rawDelivery: unknown,
fallbackMode: GatewayCronDelivery['mode'] = 'none',
): GatewayCronDelivery {
if (!rawDelivery || typeof rawDelivery !== 'object') {
return { mode: fallbackMode };
}
const delivery = rawDelivery as JsonRecord;
const mode = typeof delivery.mode === 'string' && delivery.mode.trim()
? delivery.mode.trim()
: fallbackMode;
const channel = typeof delivery.channel === 'string' && delivery.channel.trim()
? toOpenClawChannelType(delivery.channel.trim())
: undefined;
const to = typeof delivery.to === 'string' && delivery.to.trim()
? delivery.to.trim()
: undefined;
const accountId = typeof delivery.accountId === 'string' && delivery.accountId.trim()
? delivery.accountId.trim()
: undefined;
if (mode === 'announce' && !channel) {
return { mode: 'none' };
}
return {
mode,
...(channel ? { channel } : {}),
...(to ? { to } : {}),
...(accountId ? { accountId } : {}),
};
}
function normalizeCronDeliveryPatch(rawDelivery: unknown): Record<string, unknown> {
if (!rawDelivery || typeof rawDelivery !== 'object') {
return {};
}
const delivery = rawDelivery as JsonRecord;
const patch: Record<string, unknown> = {};
if ('mode' in delivery) {
patch.mode = typeof delivery.mode === 'string' && delivery.mode.trim()
? delivery.mode.trim()
: 'none';
}
if ('channel' in delivery) {
patch.channel = typeof delivery.channel === 'string' && delivery.channel.trim()
? toOpenClawChannelType(delivery.channel.trim())
: '';
}
if ('to' in delivery) {
patch.to = typeof delivery.to === 'string' ? delivery.to : '';
}
if ('accountId' in delivery) {
patch.accountId = typeof delivery.accountId === 'string' ? delivery.accountId : '';
}
return patch;
}
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 (typeof patch.message === 'string') {
patch.payload = { kind: 'agentTurn', message: patch.message };
delete patch.message;
}
if ('delivery' in patch) {
patch.delivery = normalizeCronDeliveryPatch(patch.delivery);
}
if ('agentId' in patch) {
const agentId = typeof patch.agentId === 'string' && patch.agentId.trim()
? patch.agentId.trim()
: 'main';
patch.agentId = agentId;
// Keep sessionTarget as isolated when agentId changes
}
return patch;
}
function transformCronJob(job: GatewayCronJob) {
const message = job.payload?.message || job.payload?.text || '';
const gatewayDelivery = normalizeCronDelivery(job.delivery);
const channelType = gatewayDelivery.channel ? toUiChannelType(gatewayDelivery.channel) : undefined;
const delivery = channelType
? { ...gatewayDelivery, channel: channelType }
: gatewayDelivery;
const target = channelType
? {
channelType,
channelId: delivery.accountId || gatewayDelivery.channel,
channelName: channelType,
recipient: delivery.to,
}
: undefined;
const lastRun = job.state?.lastRunAtMs
? {
time: new Date(job.state.lastRunAtMs).toISOString(),
success: job.state.lastStatus === 'ok',
error: job.state.lastError,
duration: job.state.lastDurationMs,
}
: undefined;
const nextRun = job.state?.nextRunAtMs
? new Date(job.state.nextRunAtMs).toISOString()
: undefined;
// Parse agentId from the job's agentId field
const agentId = (job as unknown as { agentId?: string }).agentId || 'main';
return {
id: job.id,
name: job.name,
message,
schedule: job.schedule,
delivery,
target,
enabled: job.enabled,
createdAt: new Date(job.createdAtMs).toISOString(),
updatedAt: new Date(job.updatedAtMs).toISOString(),
lastRun,
nextRun,
agentId,
};
}
export async function handleCronRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/cron/session-history' && req.method === 'GET') {
const sessionKey = url.searchParams.get('sessionKey')?.trim() || '';
const parsedSession = parseCronSessionKey(sessionKey);
if (!parsedSession) {
sendJson(res, 400, { success: false, error: `Invalid cron sessionKey: ${sessionKey}` });
return true;
}
const rawLimit = Number(url.searchParams.get('limit') || '200');
const limit = Number.isFinite(rawLimit)
? Math.min(Math.max(Math.floor(rawLimit), 1), 200)
: 200;
try {
const [jobsResult, runs, sessionEntry] = await Promise.all([
ctx.gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000)
.catch(() => ({ jobs: [] as GatewayCronJob[] })),
readCronRunLog(parsedSession.jobId),
readSessionStoreEntry(parsedSession.agentId, sessionKey),
]);
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
const job = jobs.find((item) => item.id === parsedSession.jobId);
const messages = buildCronSessionFallbackMessages({
sessionKey,
job,
runs,
sessionEntry: sessionEntry ? {
label: typeof sessionEntry.label === 'string' ? sessionEntry.label : undefined,
updatedAt: normalizeTimestampMs(sessionEntry.updatedAt),
} : undefined,
limit,
});
sendJson(res, 200, { messages });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/cron/jobs' && req.method === 'GET') {
try {
let jobs: GatewayCronJob[] = [];
let usedFallback = false;
try {
// 8s timeout — fail fast when Gateway is busy with AI tasks.
const result = await ctx.gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000);
const data = result as { jobs?: GatewayCronJob[] };
jobs = data?.jobs ?? (Array.isArray(result) ? result as GatewayCronJob[] : []);
// DEBUG: log name and agentId for each job
console.debug('Fetched cron jobs from Gateway:');
for (const job of jobs) {
const jobAgentId = (job as unknown as { agentId?: string }).agentId;
const deliveryInfo = job.delivery ? `delivery={mode:${job.delivery.mode}, channel:${job.delivery.channel || '(none)'}, accountId:${job.delivery.accountId || '(none)'}, to:${job.delivery.to || '(none)'}}` : 'delivery=(none)';
console.debug(` - name: "${job.name}", agentId: "${jobAgentId || '(undefined)'}", ${deliveryInfo}, sessionTarget: "${job.sessionTarget || '(none)'}", payload.kind: "${job.payload?.kind || '(none)'}"`);
}
} catch {
// Fallback: read cron.json directly when Gateway RPC fails/times out.
try {
const cronJsonPath = join(getOpenClawConfigDir(), 'cron', 'cron.json');
const raw = await readFile(cronJsonPath, 'utf-8');
const parsed = JSON.parse(raw);
const fileJobs = Array.isArray(parsed) ? parsed : (parsed?.jobs ?? []);
jobs = fileJobs as GatewayCronJob[];
usedFallback = true;
} catch {
// No fallback data available either
}
}
// Run repair in background — don't block the response.
if (!usedFallback && jobs.length > 0) {
// Repair 1: delivery channel missing
const jobsToRepairDelivery = jobs.filter((job) => {
const isIsolatedAgent =
(job.sessionTarget === 'isolated' || !job.sessionTarget) &&
job.payload?.kind === 'agentTurn';
return (
isIsolatedAgent &&
job.delivery?.mode === 'announce' &&
!job.delivery?.channel
);
});
if (jobsToRepairDelivery.length > 0) {
// Fire-and-forget: repair in background
void (async () => {
for (const job of jobsToRepairDelivery) {
try {
await ctx.gatewayManager.rpc('cron.update', {
id: job.id,
patch: { delivery: { mode: 'none' } },
});
} catch {
// ignore per-job repair failure
}
}
})();
// Optimistically fix the response data
for (const job of jobsToRepairDelivery) {
job.delivery = { mode: 'none' };
if (job.state?.lastError?.includes('Channel is required')) {
job.state.lastError = undefined;
job.state.lastStatus = 'ok';
}
}
}
// Repair 2: agentId is undefined for jobs with announce delivery
// Only repair undefined -> inferred agent, NOT main -> inferred agent
const jobsToRepairAgent = jobs.filter((job) => {
const jobAgentId = (job as unknown as { agentId?: string }).agentId;
return (
(job.sessionTarget === 'isolated' || !job.sessionTarget) &&
job.payload?.kind === 'agentTurn' &&
job.delivery?.mode === 'announce' &&
job.delivery?.channel &&
jobAgentId === undefined // Only repair when agentId is completely undefined
);
});
if (jobsToRepairAgent.length > 0) {
console.debug(`Found ${jobsToRepairAgent.length} jobs needing agent repair:`);
for (const job of jobsToRepairAgent) {
console.debug(` - Job "${job.name}" (id: ${job.id}): current agentId="${(job as unknown as { agentId?: string }).agentId || '(undefined)'}", channel="${job.delivery?.channel}", accountId="${job.delivery?.accountId || '(none)'}"`);
}
// Fire-and-forget: repair in background
void (async () => {
for (const job of jobsToRepairAgent) {
try {
const channel = toOpenClawChannelType(job.delivery!.channel!);
const accountId = job.delivery!.accountId;
const toAddress = job.delivery!.to;
// Try 1: resolve from channel + accountId binding
let correctAgentId = await resolveAgentIdFromChannel(channel, accountId);
// If no accountId, try to resolve it from session history using "to" address, then get agentId
let resolvedAccountId: string | null = null;
if (!correctAgentId && !accountId && toAddress) {
console.debug(`No binding found for channel="${channel}", accountId="${accountId || '(none)'}", trying session history for to="${toAddress}"`);
resolvedAccountId = await resolveAccountIdFromSessionHistory(toAddress, channel);
if (resolvedAccountId) {
console.debug(`Resolved accountId="${resolvedAccountId}" from session history, now resolving agentId`);
correctAgentId = await resolveAgentIdFromChannel(channel, resolvedAccountId);
}
}
if (correctAgentId) {
console.debug(`Repairing job "${job.name}": agentId "${(job as unknown as { agentId?: string }).agentId || '(undefined)'}" -> "${correctAgentId}"`);
// When accountId was resolved via to address, include it in the patch
const patch: Record<string, unknown> = { agentId: correctAgentId };
if (resolvedAccountId && !accountId) {
patch.delivery = { accountId: resolvedAccountId };
}
await ctx.gatewayManager.rpc('cron.update', { id: job.id, patch });
// Update the local job object so response reflects correct agentId
(job as unknown as { agentId: string }).agentId = correctAgentId;
if (resolvedAccountId && !accountId && job.delivery) {
job.delivery.accountId = resolvedAccountId;
}
} else {
console.warn(`Could not resolve agent for job "${job.name}": channel="${channel}", accountId="${accountId || '(none)'}", to="${toAddress || '(none)'}"`);
}
} catch (error) {
console.error(`Failed to repair agent for job "${job.name}":`, error);
}
}
})();
}
}
sendJson(res, 200, jobs.map((job) => ({ ...transformCronJob(job), ...(usedFallback ? { _fromFallback: true } : {}) })));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/cron/jobs' && req.method === 'POST') {
try {
const input = await parseJsonBody<{
name: string;
message: string;
schedule: string;
delivery?: GatewayCronDelivery;
enabled?: boolean;
agentId?: string;
}>(req);
const agentId = typeof input.agentId === 'string' && input.agentId.trim()
? input.agentId.trim()
: 'main';
// DEBUG: log the input and resolved agentId
console.debug(`Creating cron job: name="${input.name}", input.agentId="${input.agentId || '(not provided)'}", resolved agentId="${agentId}"`);
const delivery = normalizeCronDelivery(input.delivery);
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(delivery.channel);
if (delivery.mode === 'announce' && unsupportedDeliveryError) {
sendJson(res, 400, { success: false, error: unsupportedDeliveryError });
return true;
}
const result = await ctx.gatewayManager.rpc('cron.add', {
name: input.name,
schedule: { kind: 'cron', expr: input.schedule },
payload: { kind: 'agentTurn', message: input.message },
enabled: input.enabled ?? true,
wakeMode: 'next-heartbeat',
sessionTarget: 'isolated',
agentId,
delivery,
});
sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/cron/jobs/') && req.method === 'PUT') {
try {
const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length));
const input = await parseJsonBody<Record<string, unknown>>(req);
const patch = buildCronUpdatePatch(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') {
sendJson(res, 400, { success: false, error: unsupportedDeliveryError });
return true;
}
const result = await ctx.gatewayManager.rpc('cron.update', { id, patch });
sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/cron/jobs/') && req.method === 'DELETE') {
try {
const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length));
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.remove', { id }));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/cron/toggle' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ id: string; enabled: boolean }>(req);
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.update', { id: body.id, patch: { enabled: body.enabled } }));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/cron/trigger' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ id: string }>(req);
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.run', { id: body.id, mode: 'force' }));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
@@ -1,18 +1,15 @@
import { open } from 'node:fs/promises';
import { join } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { GatewayManager } from '../gateway/manager';
import { logger } from '../utils/logger';
import { getOpenClawConfigDir } from '../utils/paths';
import { buildGatewayHealthSummary } from '../utils/gateway-health';
import { buildChannelAccountsView, getChannelStatusDiagnostics } from './channels-api';
import type { IncomingMessage, ServerResponse } from 'http';
import { logger } from '../../utils/logger';
import { getOpenClawConfigDir } from '../../utils/paths';
import { buildGatewayHealthSummary } from '../../utils/gateway-health';
import type { HostApiContext } from '../context';
import { sendJson } from '../route-utils';
import { buildChannelAccountsView, getChannelStatusDiagnostics } from './channels';
const DEFAULT_TAIL_LINES = 200;
type DiagnosticsApiContext = {
gatewayManager: GatewayManager;
};
async function readTail(filePath: string, tailLines = DEFAULT_TAIL_LINES): Promise<string> {
const safeTailLines = Math.max(1, Math.floor(tailLines));
try {
@@ -45,31 +42,32 @@ async function readTail(filePath: string, tailLines = DEFAULT_TAIL_LINES): Promi
}
}
export function createDiagnosticsApi(ctx: DiagnosticsApiContext): CompleteHostServiceRegistry['diagnostics'] {
return {
gatewaySnapshot: async () => {
export async function handleDiagnosticsRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/diagnostics/gateway-snapshot' && req.method === 'GET') {
try {
const { channels } = await buildChannelAccountsView(ctx, { probe: false });
const diagnostics = ctx.gatewayManager.getDiagnostics?.() ?? {
consecutiveHeartbeatMisses: 0,
consecutiveRpcFailures: 0,
};
const channelStatusDiagnostics = getChannelStatusDiagnostics();
const gatewayStatus = ctx.gatewayManager.getStatus();
const gatewaySummary = buildGatewayHealthSummary({
status: gatewayStatus,
diagnostics,
lastChannelsStatusOkAt: channelStatusDiagnostics.lastChannelsStatusOkAt,
lastChannelsStatusFailureAt: channelStatusDiagnostics.lastChannelsStatusFailureAt,
});
const gateway = {
...gatewayStatus,
...gatewaySummary,
capabilities: typeof ctx.gatewayManager.getCapabilitySnapshot === 'function'
? ctx.gatewayManager.getCapabilitySnapshot(gatewaySummary)
: undefined,
...ctx.gatewayManager.getStatus(),
...buildGatewayHealthSummary({
status: ctx.gatewayManager.getStatus(),
diagnostics,
lastChannelsStatusOkAt: channelStatusDiagnostics.lastChannelsStatusOkAt,
lastChannelsStatusFailureAt: channelStatusDiagnostics.lastChannelsStatusFailureAt,
platform: process.platform,
}),
};
const openClawDir = getOpenClawConfigDir();
return {
sendJson(res, 200, {
capturedAt: Date.now(),
platform: process.platform,
gateway,
@@ -77,7 +75,12 @@ export function createDiagnosticsApi(ctx: DiagnosticsApiContext): CompleteHostSe
clawxLogTail: await logger.readLogFile(DEFAULT_TAIL_LINES),
gatewayLogTail: await readTail(join(openClawDir, 'logs', 'gateway.log')),
gatewayErrLogTail: await readTail(join(openClawDir, 'logs', 'gateway.err.log')),
};
},
};
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
+200
View File
@@ -0,0 +1,200 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { dialog, nativeImage } from 'electron';
import crypto from 'node:crypto';
import { extname, join } from 'node:path';
import { homedir } from 'node:os';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
const EXT_MIME_MAP: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
'.mkv': 'video/x-matroska',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
'.pdf': 'application/pdf',
'.zip': 'application/zip',
'.gz': 'application/gzip',
'.tar': 'application/x-tar',
'.7z': 'application/x-7z-compressed',
'.rar': 'application/vnd.rar',
'.json': 'application/json',
'.xml': 'application/xml',
'.csv': 'text/csv',
'.txt': 'text/plain',
'.md': 'text/markdown',
'.html': 'text/html',
'.css': 'text/css',
'.js': 'text/javascript',
'.ts': 'text/typescript',
'.py': 'text/x-python',
};
function getMimeType(ext: string): string {
return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream';
}
function mimeToExt(mimeType: string): string {
for (const [ext, mime] of Object.entries(EXT_MIME_MAP)) {
if (mime === mimeType) return ext;
}
return '';
}
const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound');
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
try {
const img = nativeImage.createFromPath(filePath);
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')}`;
}
const { readFile } = await import('node:fs/promises');
const buf = await readFile(filePath);
return `data:${mimeType};base64,${buf.toString('base64')}`;
} catch {
return null;
}
}
export async function handleFileRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/files/stage-paths' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ filePaths: string[] }>(req);
const fsP = await import('node:fs/promises');
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
const results = [];
for (const filePath of body.filePaths) {
const id = crypto.randomUUID();
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 fileName = filePath.split(/[\\/]/).pop() || 'file';
const preview = mimeType.startsWith('image/')
? await generateImagePreview(stagedPath, mimeType)
: null;
results.push({ id, fileName, mimeType, fileSize: s.size, stagedPath, preview });
}
sendJson(res, 200, results);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/files/stage-buffer' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ base64: string; fileName: string; mimeType: string }>(req);
const fsP = await import('node:fs/promises');
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
const id = crypto.randomUUID();
const ext = extname(body.fileName) || mimeToExt(body.mimeType);
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
const buffer = Buffer.from(body.base64, 'base64');
await fsP.writeFile(stagedPath, buffer);
const mimeType = body.mimeType || getMimeType(ext);
const preview = mimeType.startsWith('image/')
? await generateImagePreview(stagedPath, mimeType)
: null;
sendJson(res, 200, {
id,
fileName: body.fileName,
mimeType,
fileSize: buffer.length,
stagedPath,
preview,
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/files/thumbnails' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ paths: Array<{ filePath: string; mimeType: string }> }>(req);
const fsP = await import('node:fs/promises');
const results: Record<string, { preview: string | null; fileSize: number }> = {};
for (const { filePath, mimeType } of body.paths) {
try {
const s = await fsP.stat(filePath);
const preview = mimeType.startsWith('image/')
? await generateImagePreview(filePath, mimeType)
: null;
results[filePath] = { preview, fileSize: s.size };
} catch {
results[filePath] = { preview: null, fileSize: 0 };
}
}
sendJson(res, 200, results);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/files/save-image' && req.method === 'POST') {
try {
const body = await parseJsonBody<{
base64?: string;
mimeType?: string;
filePath?: string;
defaultFileName: string;
}>(req);
const ext = body.defaultFileName.includes('.')
? body.defaultFileName.split('.').pop()!
: (body.mimeType?.split('/')[1] || 'png');
const result = await dialog.showSaveDialog({
defaultPath: join(homedir(), 'Downloads', body.defaultFileName),
filters: [
{ name: 'Images', extensions: [ext, 'png', 'jpg', 'jpeg', 'webp', 'gif'] },
{ name: 'All Files', extensions: ['*'] },
],
});
if (result.canceled || !result.filePath) {
sendJson(res, 200, { success: false });
return true;
}
const fsP = await import('node:fs/promises');
if (body.filePath) {
await fsP.copyFile(body.filePath, result.filePath);
} else if (body.base64) {
await fsP.writeFile(result.filePath, Buffer.from(body.base64, 'base64'));
} else {
sendJson(res, 400, { success: false, error: 'No image data provided' });
return true;
}
sendJson(res, 200, { success: true, savedPath: result.filePath });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
+130
View File
@@ -0,0 +1,130 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { PORTS } from '../../utils/config';
import { buildOpenClawControlUiUrl } from '../../utils/openclaw-control-ui';
import { getSetting } from '../../utils/store';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
export async function handleGatewayRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/app/gateway-info' && req.method === 'GET') {
const status = ctx.gatewayManager.getStatus();
const token = await getSetting('gatewayToken');
const port = status.port || PORTS.OPENCLAW_GATEWAY;
sendJson(res, 200, {
wsUrl: `ws://127.0.0.1:${port}/ws`,
token,
port,
});
return true;
}
if (url.pathname === '/api/gateway/status' && req.method === 'GET') {
sendJson(res, 200, ctx.gatewayManager.getStatus());
return true;
}
if (url.pathname === '/api/gateway/health' && req.method === 'GET') {
const health = await ctx.gatewayManager.checkHealth();
sendJson(res, 200, health);
return true;
}
if (url.pathname === '/api/gateway/start' && req.method === 'POST') {
try {
await ctx.gatewayManager.start();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/gateway/stop' && req.method === 'POST') {
try {
await ctx.gatewayManager.stop();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/gateway/restart' && req.method === 'POST') {
try {
await ctx.gatewayManager.restart();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/gateway/control-ui' && req.method === 'GET') {
try {
const status = ctx.gatewayManager.getStatus();
const token = await getSetting('gatewayToken');
const port = status.port || PORTS.OPENCLAW_GATEWAY;
const urlValue = buildOpenClawControlUiUrl(port, token);
sendJson(res, 200, { success: true, url: urlValue, token, port });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/chat/send-with-media' && req.method === 'POST') {
try {
const body = await parseJsonBody<{
sessionKey: string;
message: string;
deliver?: boolean;
idempotencyKey: string;
media?: Array<{ filePath: string; mimeType: string; fileName: string }>;
}>(req);
const VISION_MIME_TYPES = new Set([
'image/png', 'image/jpeg', 'image/bmp', 'image/webp',
]);
const imageAttachments: Array<{ content: string; mimeType: string; fileName: string }> = [];
const fileReferences: string[] = [];
if (body.media && body.media.length > 0) {
const fsP = await import('node:fs/promises');
for (const m of body.media) {
fileReferences.push(`[media attached: ${m.filePath} (${m.mimeType}) | ${m.filePath}]`);
if (VISION_MIME_TYPES.has(m.mimeType)) {
const fileBuffer = await fsP.readFile(m.filePath);
imageAttachments.push({
content: fileBuffer.toString('base64'),
mimeType: m.mimeType,
fileName: m.fileName,
});
}
}
}
const message = fileReferences.length > 0
? [body.message, ...fileReferences].filter(Boolean).join('\n')
: body.message;
const rpcParams: Record<string, unknown> = {
sessionKey: body.sessionKey,
message,
deliver: body.deliver ?? false,
idempotencyKey: body.idempotencyKey,
};
if (imageAttachments.length > 0) {
rpcParams.attachments = imageAttachments;
}
const result = await ctx.gatewayManager.rpc('chat.send', rpcParams, 120000);
sendJson(res, 200, { success: true, result });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
+29
View File
@@ -0,0 +1,29 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { logger } from '../../utils/logger';
import type { HostApiContext } from '../context';
import { sendJson } from '../route-utils';
export async function handleLogRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/logs' && req.method === 'GET') {
const tailLines = Number(url.searchParams.get('tailLines') || '100');
sendJson(res, 200, { content: await logger.readLogFile(Number.isFinite(tailLines) ? tailLines : 100) });
return true;
}
if (url.pathname === '/api/logs/dir' && req.method === 'GET') {
sendJson(res, 200, { dir: logger.getLogDir() });
return true;
}
if (url.pathname === '/api/logs/files' && req.method === 'GET') {
sendJson(res, 200, { files: await logger.listLogFiles() });
return true;
}
return false;
}
+354
View File
@@ -0,0 +1,354 @@
import type { IncomingMessage, ServerResponse } from 'http';
import {
type ProviderConfig,
} from '../../utils/secure-storage';
import {
getProviderConfig,
} from '../../utils/provider-registry';
import { deviceOAuthManager, type OAuthProviderType } from '../../utils/device-oauth';
import { browserOAuthManager, type BrowserOAuthProviderType } from '../../utils/browser-oauth';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import {
syncDefaultProviderToRuntime,
syncDeletedProviderApiKeyToRuntime,
syncDeletedProviderToRuntime,
syncProviderApiKeyToRuntime,
syncSavedProviderToRuntime,
syncUpdatedProviderToRuntime,
} from '../../services/providers/provider-runtime-sync';
import { validateApiKeyWithProvider } from '../../services/providers/provider-validation';
import { getProviderService } from '../../services/providers/provider-service';
import { providerAccountToConfig } from '../../services/providers/provider-store';
import type { ProviderAccount } from '../../shared/providers/types';
import { logger } from '../../utils/logger';
const legacyProviderRoutesWarned = new Set<string>();
function hasObjectChanges<T extends Record<string, unknown>>(
existing: T,
patch: Partial<T> | undefined,
): boolean {
if (!patch) return false;
const keys = Object.keys(patch) as Array<keyof T>;
if (keys.length === 0) return false;
return keys.some((key) => JSON.stringify(existing[key]) !== JSON.stringify(patch[key]));
}
export async function handleProviderRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
const providerService = getProviderService();
const logLegacyProviderRoute = (route: string): void => {
if (legacyProviderRoutesWarned.has(route)) return;
legacyProviderRoutesWarned.add(route);
logger.warn(
`[provider-migration] Legacy HTTP route "${route}" is deprecated. Prefer /api/provider-accounts endpoints.`,
);
};
if (url.pathname === '/api/provider-vendors' && req.method === 'GET') {
sendJson(res, 200, await providerService.listVendors());
return true;
}
if (url.pathname === '/api/provider-accounts' && req.method === 'GET') {
sendJson(res, 200, await providerService.listAccounts());
return true;
}
if (url.pathname === '/api/provider-accounts' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ account: ProviderAccount; apiKey?: string }>(req);
const account = await providerService.createAccount(body.account, body.apiKey);
await syncSavedProviderToRuntime(providerAccountToConfig(account), body.apiKey, ctx.gatewayManager);
sendJson(res, 200, { success: true, account });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/provider-accounts/default' && req.method === 'GET') {
sendJson(res, 200, { accountId: await providerService.getDefaultAccountId() ?? null });
return true;
}
if (url.pathname === '/api/provider-accounts/default' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{ accountId: string }>(req);
const currentDefault = await providerService.getDefaultAccountId();
if (currentDefault === body.accountId) {
sendJson(res, 200, { success: true, noChange: true });
return true;
}
await providerService.setDefaultAccount(body.accountId);
await syncDefaultProviderToRuntime(body.accountId, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'GET') {
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
sendJson(res, 200, await providerService.getAccount(accountId));
return true;
}
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'PUT') {
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
try {
const body = await parseJsonBody<{ updates: Partial<ProviderAccount>; apiKey?: string }>(req);
const existing = await providerService.getAccount(accountId);
if (!existing) {
sendJson(res, 404, { success: false, error: 'Provider account not found' });
return true;
}
const hasPatchChanges = hasObjectChanges(existing as unknown as Record<string, unknown>, body.updates);
if (!hasPatchChanges && body.apiKey === undefined) {
sendJson(res, 200, { success: true, noChange: true, account: existing });
return true;
}
const nextAccount = await providerService.updateAccount(accountId, body.updates, body.apiKey);
await syncUpdatedProviderToRuntime(providerAccountToConfig(nextAccount), body.apiKey, ctx.gatewayManager);
sendJson(res, 200, { success: true, account: nextAccount });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'DELETE') {
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
try {
const existing = await providerService.getAccount(accountId);
const runtimeProviderKey = existing?.authMode === 'oauth_browser'
? (existing.vendorId === 'google'
? 'google-gemini-cli'
: (existing.vendorId === 'openai' ? 'openai-codex' : undefined))
: undefined;
if (url.searchParams.get('apiKeyOnly') === '1') {
await providerService.deleteLegacyProviderApiKey(accountId);
await syncDeletedProviderApiKeyToRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
runtimeProviderKey,
);
sendJson(res, 200, { success: true });
return true;
}
await providerService.deleteAccount(accountId);
await syncDeletedProviderToRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
ctx.gatewayManager,
runtimeProviderKey,
);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers' && req.method === 'GET') {
logLegacyProviderRoute('GET /api/providers');
sendJson(res, 200, await providerService.listLegacyProvidersWithKeyInfo());
return true;
}
if (url.pathname === '/api/providers/default' && req.method === 'GET') {
logLegacyProviderRoute('GET /api/providers/default');
sendJson(res, 200, { providerId: await providerService.getDefaultLegacyProvider() ?? null });
return true;
}
if (url.pathname === '/api/providers/default' && req.method === 'PUT') {
logLegacyProviderRoute('PUT /api/providers/default');
try {
const body = await parseJsonBody<{ providerId: string }>(req);
const currentDefault = await providerService.getDefaultLegacyProvider();
if (currentDefault === body.providerId) {
sendJson(res, 200, { success: true, noChange: true });
return true;
}
await providerService.setDefaultLegacyProvider(body.providerId);
await syncDefaultProviderToRuntime(body.providerId, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers/validate' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers/validate');
try {
const body = await parseJsonBody<{ providerId: string; apiKey: string; options?: { baseUrl?: string; apiProtocol?: string } }>(req);
const provider = await providerService.getLegacyProvider(body.providerId);
const providerType = provider?.type || body.providerId;
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
const resolvedBaseUrl = body.options?.baseUrl || provider?.baseUrl || registryBaseUrl;
const resolvedProtocol = body.options?.apiProtocol || provider?.apiProtocol;
sendJson(res, 200, await validateApiKeyWithProvider(providerType, body.apiKey, { baseUrl: resolvedBaseUrl, apiProtocol: resolvedProtocol }));
} catch (error) {
sendJson(res, 500, { valid: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers/oauth/start' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers/oauth/start');
try {
const body = await parseJsonBody<{
provider: OAuthProviderType | BrowserOAuthProviderType;
region?: 'global' | 'cn';
accountId?: string;
label?: string;
}>(req);
if (body.provider === 'google' || body.provider === 'openai') {
await browserOAuthManager.startFlow(body.provider, {
accountId: body.accountId,
label: body.label,
});
} else {
await deviceOAuthManager.startFlow(body.provider, body.region, {
accountId: body.accountId,
label: body.label,
});
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers/oauth/cancel' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers/oauth/cancel');
try {
await deviceOAuthManager.stopFlow();
await browserOAuthManager.stopFlow();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers/oauth/submit' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers/oauth/submit');
try {
const body = await parseJsonBody<{ code: string }>(req);
const accepted = browserOAuthManager.submitManualCode(body.code || '');
if (!accepted) {
sendJson(res, 400, { success: false, error: 'No active manual OAuth input pending' });
return true;
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers');
try {
const body = await parseJsonBody<{ config: ProviderConfig; apiKey?: string }>(req);
const config = body.config;
await providerService.saveLegacyProvider(config);
if (body.apiKey !== undefined) {
const trimmedKey = body.apiKey.trim();
if (trimmedKey) {
await providerService.setLegacyProviderApiKey(config.id, trimmedKey);
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
}
}
await syncSavedProviderToRuntime(config, body.apiKey, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/providers/') && req.method === 'GET') {
logLegacyProviderRoute('GET /api/providers/:id');
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
if (providerId.endsWith('/api-key')) {
const actualId = providerId.slice(0, -('/api-key'.length));
sendJson(res, 200, { apiKey: await providerService.getLegacyProviderApiKey(actualId) });
return true;
}
if (providerId.endsWith('/has-api-key')) {
const actualId = providerId.slice(0, -('/has-api-key'.length));
sendJson(res, 200, { hasKey: await providerService.hasLegacyProviderApiKey(actualId) });
return true;
}
sendJson(res, 200, await providerService.getLegacyProvider(providerId));
return true;
}
if (url.pathname.startsWith('/api/providers/') && req.method === 'PUT') {
logLegacyProviderRoute('PUT /api/providers/:id');
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
try {
const body = await parseJsonBody<{ updates: Partial<ProviderConfig>; apiKey?: string }>(req);
const existing = await providerService.getLegacyProvider(providerId);
if (!existing) {
sendJson(res, 404, { success: false, error: 'Provider not found' });
return true;
}
const hasPatchChanges = hasObjectChanges(existing as unknown as Record<string, unknown>, body.updates);
if (!hasPatchChanges && body.apiKey === undefined) {
sendJson(res, 200, { success: true, noChange: true });
return true;
}
const nextConfig: ProviderConfig = { ...existing, ...body.updates, updatedAt: new Date().toISOString() };
await providerService.saveLegacyProvider(nextConfig);
if (body.apiKey !== undefined) {
const trimmedKey = body.apiKey.trim();
if (trimmedKey) {
await providerService.setLegacyProviderApiKey(providerId, trimmedKey);
await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey);
} else {
await providerService.deleteLegacyProviderApiKey(providerId);
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
}
}
await syncUpdatedProviderToRuntime(nextConfig, body.apiKey, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/providers/') && req.method === 'DELETE') {
logLegacyProviderRoute('DELETE /api/providers/:id');
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
try {
const existing = await providerService.getLegacyProvider(providerId);
if (url.searchParams.get('apiKeyOnly') === '1') {
await providerService.deleteLegacyProviderApiKey(providerId);
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
sendJson(res, 200, { success: true });
return true;
}
await providerService.deleteLegacyProvider(providerId);
await syncDeletedProviderToRuntime(existing, providerId, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
+135
View File
@@ -0,0 +1,135 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { join } from 'node:path';
import { getOpenClawConfigDir } from '../../utils/paths';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
const SAFE_SESSION_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
export async function handleSessionRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/sessions/transcript' && req.method === 'GET') {
try {
const agentId = url.searchParams.get('agentId')?.trim() || '';
const sessionId = url.searchParams.get('sessionId')?.trim() || '';
if (!agentId || !sessionId) {
sendJson(res, 400, { success: false, error: 'agentId and sessionId are required' });
return true;
}
if (!SAFE_SESSION_SEGMENT.test(agentId) || !SAFE_SESSION_SEGMENT.test(sessionId)) {
sendJson(res, 400, { success: false, error: 'Invalid transcript identifier' });
return true;
}
const transcriptPath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', `${sessionId}.jsonl`);
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(transcriptPath, 'utf8');
const lines = raw.split(/\r?\n/).filter(Boolean);
const messages = lines.flatMap((line) => {
try {
const entry = JSON.parse(line) as { type?: string; message?: unknown };
return entry.type === 'message' && entry.message ? [entry.message] : [];
} catch {
return [];
}
});
sendJson(res, 200, { success: true, messages });
} catch (error) {
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
sendJson(res, 404, { success: false, error: 'Transcript not found' });
} else {
sendJson(res, 500, { success: false, error: 'Failed to load transcript' });
}
}
return true;
}
if (url.pathname === '/api/sessions/delete' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ sessionKey: string }>(req);
const sessionKey = body.sessionKey;
if (!sessionKey || !sessionKey.startsWith('agent:')) {
sendJson(res, 400, { success: false, error: `Invalid sessionKey: ${sessionKey}` });
return true;
}
const parts = sessionKey.split(':');
if (parts.length < 3) {
sendJson(res, 400, { success: false, error: `sessionKey has too few parts: ${sessionKey}` });
return true;
}
const agentId = parts[1];
const sessionsDir = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions');
const sessionsJsonPath = join(sessionsDir, 'sessions.json');
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
const sessionsJson = JSON.parse(raw) as Record<string, unknown>;
let uuidFileName: string | undefined;
let resolvedSrcPath: string | undefined;
if (Array.isArray(sessionsJson.sessions)) {
const entry = (sessionsJson.sessions as Array<Record<string, unknown>>)
.find((s) => s.key === sessionKey || s.sessionKey === sessionKey);
if (entry) {
uuidFileName = (entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (!uuidFileName && typeof entry.id === 'string') {
uuidFileName = `${entry.id}.jsonl`;
}
}
}
if (!uuidFileName && sessionsJson[sessionKey] != null) {
const val = sessionsJson[sessionKey];
if (typeof val === 'string') {
uuidFileName = val;
} else if (typeof val === 'object' && val !== null) {
const entry = val as Record<string, unknown>;
const absFile = (entry.sessionFile ?? entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (absFile) {
if (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/)) {
resolvedSrcPath = absFile;
} else {
uuidFileName = absFile;
}
} else {
const uuidVal = (entry.id ?? entry.sessionId) as string | undefined;
if (uuidVal) uuidFileName = uuidVal.endsWith('.jsonl') ? uuidVal : `${uuidVal}.jsonl`;
}
}
}
if (!uuidFileName && !resolvedSrcPath) {
sendJson(res, 404, { success: false, error: `Cannot resolve file for session: ${sessionKey}` });
return true;
}
if (!resolvedSrcPath) {
if (!uuidFileName!.endsWith('.jsonl')) uuidFileName = `${uuidFileName}.jsonl`;
resolvedSrcPath = join(sessionsDir, uuidFileName!);
}
const dstPath = resolvedSrcPath.replace(/\.jsonl$/, '.deleted.jsonl');
try {
await fsP.access(resolvedSrcPath);
await fsP.rename(resolvedSrcPath, dstPath);
} catch {
// Non-fatal; still try to update sessions.json.
}
const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8');
const json2 = JSON.parse(raw2) as Record<string, unknown>;
if (Array.isArray(json2.sessions)) {
json2.sessions = (json2.sessions as Array<Record<string, unknown>>)
.filter((s) => s.key !== sessionKey && s.sessionKey !== sessionKey);
} else if (json2[sessionKey]) {
delete json2[sessionKey];
}
await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8');
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
+112
View File
@@ -0,0 +1,112 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { applyProxySettings } from '../../main/proxy';
import { syncLaunchAtStartupSettingFromStore } from '../../main/launch-at-startup';
import { syncProxyConfigToOpenClaw } from '../../utils/openclaw-proxy';
import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../../utils/store';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
async function handleProxySettingsChange(ctx: HostApiContext): Promise<void> {
const settings = await getAllSettings();
await syncProxyConfigToOpenClaw(settings, { preserveExistingWhenDisabled: false });
await applyProxySettings(settings);
if (ctx.gatewayManager.getStatus().state === 'running') {
await ctx.gatewayManager.restart();
}
}
function patchTouchesProxy(patch: Partial<AppSettings>): boolean {
return Object.keys(patch).some((key) => (
key === 'proxyEnabled' ||
key === 'proxyServer' ||
key === 'proxyHttpServer' ||
key === 'proxyHttpsServer' ||
key === 'proxyAllServer' ||
key === 'proxyBypassRules'
));
}
function patchTouchesLaunchAtStartup(patch: Partial<AppSettings>): boolean {
return Object.prototype.hasOwnProperty.call(patch, 'launchAtStartup');
}
export async function handleSettingsRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/settings' && req.method === 'GET') {
sendJson(res, 200, await getAllSettings());
return true;
}
if (url.pathname === '/api/settings' && req.method === 'PUT') {
try {
const patch = await parseJsonBody<Partial<AppSettings>>(req);
const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>;
for (const [key, value] of entries) {
await setSetting(key, value);
}
if (patchTouchesProxy(patch)) {
await handleProxySettingsChange(ctx);
}
if (patchTouchesLaunchAtStartup(patch)) {
await syncLaunchAtStartupSettingFromStore();
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/settings/') && req.method === 'GET') {
const key = url.pathname.slice('/api/settings/'.length) as keyof AppSettings;
try {
sendJson(res, 200, { value: await getSetting(key) });
} catch (error) {
sendJson(res, 404, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/settings/') && req.method === 'PUT') {
const key = url.pathname.slice('/api/settings/'.length) as keyof AppSettings;
try {
const body = await parseJsonBody<{ value: AppSettings[keyof AppSettings] }>(req);
await setSetting(key, body.value);
if (
key === 'proxyEnabled' ||
key === 'proxyServer' ||
key === 'proxyHttpServer' ||
key === 'proxyHttpsServer' ||
key === 'proxyAllServer' ||
key === 'proxyBypassRules'
) {
await handleProxySettingsChange(ctx);
}
if (key === 'launchAtStartup') {
await syncLaunchAtStartupSettingFromStore();
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/settings/reset' && req.method === 'POST') {
try {
await resetSettings();
await handleProxySettingsChange(ctx);
await syncLaunchAtStartupSettingFromStore();
sendJson(res, 200, { success: true, settings: await getAllSettings() });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
+113
View File
@@ -0,0 +1,113 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { getAllSkillConfigs, updateSkillConfig } from '../../utils/skill-config';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
export async function handleSkillRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/skills/configs' && req.method === 'GET') {
sendJson(res, 200, await getAllSkillConfigs());
return true;
}
if (url.pathname === '/api/skills/config' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{
skillKey: string;
apiKey?: string;
env?: Record<string, string>;
}>(req);
sendJson(res, 200, await updateSkillConfig(body.skillKey, {
apiKey: body.apiKey,
env: body.env,
}));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/capability' && req.method === 'GET') {
try {
sendJson(res, 200, {
success: true,
capability: await ctx.clawHubService.getMarketplaceCapability(),
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/search' && req.method === 'POST') {
try {
const body = await parseJsonBody<Record<string, unknown>>(req);
sendJson(res, 200, {
success: true,
results: await ctx.clawHubService.search(body),
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/install' && req.method === 'POST') {
try {
const body = await parseJsonBody<Record<string, unknown>>(req);
await ctx.clawHubService.install(body);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/uninstall' && req.method === 'POST') {
try {
const body = await parseJsonBody<Record<string, unknown>>(req);
await ctx.clawHubService.uninstall(body);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/list' && req.method === 'GET') {
try {
sendJson(res, 200, { success: true, results: await ctx.clawHubService.listInstalled() });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/open-readme' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ slug?: string; skillKey?: string; baseDir?: string }>(req);
await ctx.clawHubService.openSkillReadme(body.skillKey || body.slug || '', body.slug, body.baseDir);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/open-path' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ slug?: string; skillKey?: string; baseDir?: string }>(req);
await ctx.clawHubService.openSkillPath(body.skillKey || body.slug || '', body.slug, body.baseDir);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
+26
View File
@@ -0,0 +1,26 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { getRecentTokenUsageHistory } from '../../utils/token-usage';
import type { HostApiContext } from '../context';
import { sendJson } from '../route-utils';
export async function handleUsageRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/usage/recent-token-history' && req.method === 'GET') {
const rawLimit = url.searchParams.get('limit');
let limit: number | undefined;
if (rawLimit != null && rawLimit.trim() !== '') {
const parsedLimit = Number(rawLimit);
if (Number.isFinite(parsedLimit)) {
limit = Math.max(Math.floor(parsedLimit), 1);
}
}
sendJson(res, 200, await getRecentTokenUsageHistory(limit));
return true;
}
return false;
}
+135
View File
@@ -0,0 +1,135 @@
import { randomBytes } from 'node:crypto';
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { getPort } from '../utils/config';
import { logger } from '../utils/logger';
import { extensionRegistry } from '../extensions/registry';
import type { HostApiContext } from './context';
import { handleAppRoutes } from './routes/app';
import { handleGatewayRoutes } from './routes/gateway';
import { handleSettingsRoutes } from './routes/settings';
import { handleProviderRoutes } from './routes/providers';
import { handleAgentRoutes } from './routes/agents';
import { handleChannelRoutes } from './routes/channels';
import { handleLogRoutes } from './routes/logs';
import { handleUsageRoutes } from './routes/usage';
import { handleSkillRoutes } from './routes/skills';
import { handleFileRoutes } from './routes/files';
import { handleSessionRoutes } from './routes/sessions';
import { handleCronRoutes } from './routes/cron';
import { handleDiagnosticsRoutes } from './routes/diagnostics';
import { sendJson, setCorsHeaders, requireJsonContentType } from './route-utils';
type RouteHandler = (
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
) => Promise<boolean>;
const coreRouteHandlers: RouteHandler[] = [
handleAppRoutes,
handleGatewayRoutes,
handleSettingsRoutes,
handleProviderRoutes,
handleAgentRoutes,
handleChannelRoutes,
handleSkillRoutes,
handleFileRoutes,
handleSessionRoutes,
handleCronRoutes,
handleDiagnosticsRoutes,
handleLogRoutes,
handleUsageRoutes,
];
function buildRouteHandlers(): RouteHandler[] {
const extensionHandlers = extensionRegistry.getRouteHandlers();
return [...coreRouteHandlers, ...extensionHandlers];
}
/**
* Per-session secret token used to authenticate Host API requests.
* Generated once at server start and shared with the renderer via IPC.
* This prevents cross-origin attackers from reading sensitive data even
* if they can reach 127.0.0.1:13210 (the CORS wildcard alone is not
* sufficient because browsers attach the Origin header but not a secret).
*/
let hostApiToken: string = '';
/** Retrieve the current Host API auth token (for use by IPC proxy). */
export function getHostApiToken(): string {
return hostApiToken;
}
export function startHostApiServer(ctx: HostApiContext, port = getPort('CLAWX_HOST_API')): Server {
// Generate a cryptographically random token for this session.
hostApiToken = randomBytes(32).toString('hex');
const server = createServer(async (req, res) => {
try {
const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`);
// ── CORS headers ─────────────────────────────────────────
// Set origin-aware CORS headers early so every response
// (including error responses) carries them consistently.
const origin = req.headers.origin;
setCorsHeaders(res, origin);
// CORS preflight — respond before auth so browsers can negotiate.
if (req.method === 'OPTIONS') {
res.statusCode = 204;
res.end();
return;
}
// ── Auth gate ──────────────────────────────────────────────
// Every non-preflight request must carry a valid Bearer token.
// Accept via Authorization header (preferred) or ?token= query
// parameter (for EventSource which cannot set custom headers).
const authHeader = req.headers.authorization || '';
const bearerToken = authHeader.startsWith('Bearer ')
? authHeader.slice(7)
: (requestUrl.searchParams.get('token') || '');
if (bearerToken !== hostApiToken) {
sendJson(res, 401, { success: false, error: 'Unauthorized' });
return;
}
// ── Content-Type gate (anti-CSRF) ──────────────────────────
// Mutation requests must use application/json to force a CORS
// preflight, preventing "simple request" CSRF attacks.
if (!requireJsonContentType(req)) {
sendJson(res, 415, { success: false, error: 'Content-Type must be application/json' });
return;
}
const routeHandlers = buildRouteHandlers();
for (const handler of routeHandlers) {
if (await handler(req, res, requestUrl, ctx)) {
return;
}
}
sendJson(res, 404, { success: false, error: `No route for ${req.method} ${requestUrl.pathname}` });
} catch (error) {
logger.error('Host API request failed:', error);
sendJson(res, 500, { success: false, error: String(error) });
}
});
server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EACCES' || error.code === 'EADDRINUSE') {
logger.error(
`Host API server failed to bind port ${port}: ${error.message}. ` +
'On Windows this is often caused by Hyper-V reserving the port range. ' +
`Set CLAWX_PORT_CLAWX_HOST_API env var to override the default port.`,
);
} else {
logger.error('Host API server error:', error);
}
});
server.listen(port, '127.0.0.1', () => {
logger.info(`Host API server listening on http://127.0.0.1:${port}`);
});
return server;
}
@@ -5,33 +5,36 @@ import type {
MarketplaceCapability,
} from '../types';
import type {
MarketplaceSearchParams,
MarketplaceInstallParams,
MarketplaceSkillResult,
ClawHubSearchParams,
ClawHubInstallParams,
ClawHubSkillResult,
} from '../../gateway/clawhub';
class ClawHubMarketplaceExtension implements MarketplaceProviderExtension {
readonly id = 'builtin/clawhub-marketplace';
setup(_ctx: ExtensionContext): void {
// Built-in public ClawHub marketplace is disabled in community builds.
// No setup needed -- search/install delegates to the ClawHubService CLI runner
}
async getCapability(): Promise<MarketplaceCapability> {
return {
mode: 'local-only',
canSearch: false,
canInstall: false,
reason: 'marketplace-disabled',
mode: 'clawhub',
canSearch: true,
canInstall: true,
};
}
async search(_params: MarketplaceSearchParams): Promise<MarketplaceSkillResult[]> {
throw new Error('Marketplace search is disabled');
async search(params: ClawHubSearchParams): Promise<ClawHubSkillResult[]> {
const { ClawHubService } = await import('../../gateway/clawhub');
const svc = new ClawHubService();
return svc.search(params);
}
async install(_params: MarketplaceInstallParams): Promise<void> {
throw new Error('Marketplace install is disabled');
async install(params: ClawHubInstallParams): Promise<void> {
const { ClawHubService } = await import('../../gateway/clawhub');
const svc = new ClawHubService();
return svc.install(params);
}
}
+9 -9
View File
@@ -1,22 +1,22 @@
import { createDiagnosticsApi } from '../../services/diagnostics-api';
import type {
Extension,
ExtensionContext,
HostApiProviderExtension,
HostApiRouteExtension,
RouteHandler,
} from '../types';
class DiagnosticsExtension implements HostApiProviderExtension {
class DiagnosticsExtension implements HostApiRouteExtension {
readonly id = 'builtin/diagnostics';
setup(_ctx: ExtensionContext): void {
// Diagnostics are exposed through host IPC contributions.
// Diagnostics routes are stateless; no setup needed.
}
getHostApiContributions(ctx: ExtensionContext) {
return [{
module: 'diagnostics',
actions: createDiagnosticsApi({ gatewayManager: ctx.gatewayManager }),
}];
getRouteHandler(): RouteHandler {
return async (req, res, url, ctx) => {
const { handleDiagnosticsRoutes } = await import('../../api/routes/diagnostics');
return handleDiagnosticsRoutes(req, res, url, ctx);
};
}
}
+3 -2
View File
@@ -3,14 +3,15 @@ export { registerBuiltinExtension, loadExtensionsFromManifest } from './loader';
export type {
Extension,
ExtensionContext,
HostApiProviderExtension,
HostApiRouteExtension,
MarketplaceProviderExtension,
MarketplaceCapability,
AuthProviderExtension,
AuthStatus,
RouteHandler,
} from './types';
export {
isHostApiProviderExtension,
isHostApiRouteExtension,
isMarketplaceProviderExtension,
isAuthProviderExtension,
} from './types';
+12 -29
View File
@@ -2,24 +2,24 @@ import { logger } from '../utils/logger';
import type {
Extension,
ExtensionContext,
HostApiRouteExtension,
MarketplaceProviderExtension,
RouteHandler,
} from './types';
import {
isHostApiProviderExtension,
isHostApiRouteExtension,
isMarketplaceProviderExtension,
} from './types';
class ExtensionRegistry {
private extensions = new Map<string, Extension>();
private ctx: ExtensionContext | null = null;
private hostApiUnregisters = new Map<string, () => void>();
async initialize(ctx: ExtensionContext): Promise<void> {
this.ctx = ctx;
for (const ext of this.extensions.values()) {
try {
await ext.setup(ctx);
this.registerHostApiContributions(ext, ctx);
logger.info(`[extensions] Extension "${ext.id}" initialized`);
} catch (err) {
logger.error(`[extensions] Extension "${ext.id}" failed to initialize:`, err);
@@ -36,15 +36,9 @@ class ExtensionRegistry {
logger.debug(`[extensions] Registered extension "${extension.id}"`);
if (this.ctx) {
void Promise.resolve(extension.setup(this.ctx))
.then(() => {
if (this.ctx) {
this.registerHostApiContributions(extension, this.ctx);
}
})
.catch((err) => {
logger.error(`[extensions] Late-registered extension "${extension.id}" failed to initialize:`, err);
});
void Promise.resolve(extension.setup(this.ctx)).catch((err) => {
logger.error(`[extensions] Late-registered extension "${extension.id}" failed to initialize:`, err);
});
}
}
@@ -56,6 +50,12 @@ class ExtensionRegistry {
return [...this.extensions.values()];
}
getRouteHandlers(): RouteHandler[] {
return this.getAll()
.filter(isHostApiRouteExtension)
.map((ext: HostApiRouteExtension) => ext.getRouteHandler());
}
getMarketplaceProvider(): MarketplaceProviderExtension | undefined {
return this.getAll().find(isMarketplaceProviderExtension) as MarketplaceProviderExtension | undefined;
}
@@ -63,8 +63,6 @@ class ExtensionRegistry {
async teardownAll(): Promise<void> {
for (const ext of this.extensions.values()) {
try {
this.hostApiUnregisters.get(ext.id)?.();
this.hostApiUnregisters.delete(ext.id);
await ext.teardown?.();
} catch (err) {
logger.warn(`[extensions] Extension "${ext.id}" teardown failed:`, err);
@@ -73,21 +71,6 @@ class ExtensionRegistry {
this.extensions.clear();
this.ctx = null;
}
private registerHostApiContributions(ext: Extension, ctx: ExtensionContext): void {
this.hostApiUnregisters.get(ext.id)?.();
this.hostApiUnregisters.delete(ext.id);
if (!isHostApiProviderExtension(ext)) {
return;
}
const contributions = ext.getHostApiContributions(ctx);
if (contributions.length === 0) {
return;
}
this.hostApiUnregisters.set(ext.id, ctx.hostApi.register(ext.id, contributions));
}
}
export const extensionRegistry = new ExtensionRegistry();
+21 -20
View File
@@ -1,19 +1,25 @@
import type { IncomingMessage, ServerResponse } from 'http';
import type { BrowserWindow } from 'electron';
import type { GatewayManager } from '../gateway/manager';
import type { HostApiContribution, HostApiContributionRegistrar } from '../main/ipc/host-contract';
import type { HostEventBus } from '../api/event-bus';
import type { HostApiContext } from '../api/context';
import type {
MarketplaceSearchParams,
MarketplaceInstallParams,
MarketplaceSkillResult,
ClawHubSearchParams,
ClawHubInstallParams,
ClawHubSkillResult,
} from '../gateway/clawhub';
export type RouteHandler = (
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
) => Promise<boolean>;
export interface ExtensionContext {
gatewayManager: GatewayManager;
eventBus: HostEventBus;
getMainWindow: () => BrowserWindow | null;
hostApi: HostApiContributionRegistrar;
}
export interface Extension {
@@ -22,6 +28,10 @@ export interface Extension {
teardown?(): void | Promise<void>;
}
export interface HostApiRouteExtension extends Extension {
getRouteHandler(): RouteHandler;
}
export interface MarketplaceCapability {
mode: string;
canSearch: boolean;
@@ -31,18 +41,10 @@ export interface MarketplaceCapability {
export interface MarketplaceProviderExtension extends Extension {
getCapability(): Promise<MarketplaceCapability>;
search(params: MarketplaceSearchParams): Promise<MarketplaceSkillResult[]>;
install(params: MarketplaceInstallParams): Promise<void>;
search(params: ClawHubSearchParams): Promise<ClawHubSkillResult[]>;
install(params: ClawHubInstallParams): Promise<void>;
}
export interface HostApiProviderExtension extends Extension {
getHostApiContributions(ctx: ExtensionContext): HostApiContribution[];
}
export type LegacyMarketplaceSearchParams = ClawHubSearchParams;
export type LegacyMarketplaceInstallParams = ClawHubInstallParams;
export type LegacyMarketplaceSkillResult = ClawHubSkillResult;
export interface AuthStatus {
authenticated: boolean;
expired: boolean;
@@ -54,13 +56,12 @@ export interface AuthProviderExtension extends Extension {
onStartup?(mainWindow: BrowserWindow): Promise<void>;
}
export function isMarketplaceProviderExtension(ext: Extension): ext is MarketplaceProviderExtension {
return 'getCapability' in ext && 'search' in ext && 'install' in ext;
export function isHostApiRouteExtension(ext: Extension): ext is HostApiRouteExtension {
return 'getRouteHandler' in ext && typeof (ext as HostApiRouteExtension).getRouteHandler === 'function';
}
export function isHostApiProviderExtension(ext: Extension): ext is HostApiProviderExtension {
return 'getHostApiContributions' in ext
&& typeof (ext as HostApiProviderExtension).getHostApiContributions === 'function';
export function isMarketplaceProviderExtension(ext: Extension): ext is MarketplaceProviderExtension {
return 'getCapability' in ext && 'search' in ext && 'install' in ext;
}
export function isAuthProviderExtension(ext: Extension): ext is AuthProviderExtension {
-141
View File
@@ -1,141 +0,0 @@
import type {
GatewayDiagnosticsSnapshot,
GatewayHealthSummary,
GatewayStatus,
} from './manager';
import type { GatewayRuntimePayload } from '@shared/types/gateway';
export type GatewayCapabilityName = 'openclawHealth' | 'openclawStatus' | 'channels' | 'memory';
export interface GatewayCapabilityProbe {
state: 'unknown' | 'healthy' | 'degraded';
checkedAt?: number;
durationMs?: number;
error?: string;
payload?: GatewayRuntimePayload;
}
export interface GatewayCoreProbe {
ok: boolean;
checkedAt: number;
durationMs?: number;
error?: string;
}
export interface GatewayCapabilitySnapshot {
core: {
process: GatewayStatus['state'];
transport: 'connected' | 'disconnected';
rpcRouter: 'unknown' | 'ready' | 'blocked';
lastProbe?: GatewayCoreProbe;
};
openclawHealth: GatewayCapabilityProbe;
openclawStatus: GatewayCapabilityProbe;
presence: GatewayCapabilityProbe;
channels: GatewayCapabilityProbe;
memory: GatewayCapabilityProbe;
diagnostics: GatewayDiagnosticsSnapshot;
summary?: GatewayHealthSummary;
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function capabilityFromPayload(payload: GatewayRuntimePayload, checkedAt = Date.now()): GatewayCapabilityProbe {
return {
state: 'healthy',
checkedAt,
payload,
};
}
function capabilityFromError(error: unknown, checkedAt = Date.now()): GatewayCapabilityProbe {
return {
state: 'degraded',
checkedAt,
error: formatError(error),
};
}
const UNKNOWN_CAPABILITY: GatewayCapabilityProbe = { state: 'unknown' };
export class GatewayCapabilityMonitor {
private openclawHealth: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
private openclawStatus: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
private presence: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
private channels: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
private memory: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
private lastCoreProbe: GatewayCoreProbe | undefined;
recordOpenClawHealth(payload: GatewayRuntimePayload): void {
this.openclawHealth = capabilityFromPayload(payload);
}
recordOpenClawStatus(payload: GatewayRuntimePayload): void {
this.openclawStatus = capabilityFromPayload(payload);
}
recordPresence(payload: GatewayRuntimePayload): void {
this.presence = capabilityFromPayload(payload);
}
recordCoreProbe(probe: GatewayCoreProbe): void {
this.lastCoreProbe = probe;
}
recordCapabilitySuccess(name: GatewayCapabilityName, payload: GatewayRuntimePayload, durationMs?: number): void {
const probe: GatewayCapabilityProbe = {
state: 'healthy',
checkedAt: Date.now(),
durationMs,
payload,
};
this.setCapability(name, probe);
}
recordCapabilityFailure(name: GatewayCapabilityName, error: unknown, durationMs?: number): void {
const probe = capabilityFromError(error);
probe.durationMs = durationMs;
this.setCapability(name, probe);
}
buildSnapshot(params: {
status: GatewayStatus;
transportConnected: boolean;
diagnostics: GatewayDiagnosticsSnapshot;
summary?: GatewayHealthSummary;
}): GatewayCapabilitySnapshot {
return {
core: {
process: params.status.state,
transport: params.transportConnected ? 'connected' : 'disconnected',
rpcRouter: this.lastCoreProbe?.ok === false
? 'blocked'
: params.status.gatewayReady === true || this.lastCoreProbe?.ok === true
? 'ready'
: 'unknown',
lastProbe: this.lastCoreProbe,
},
openclawHealth: this.openclawHealth,
openclawStatus: this.openclawStatus,
presence: this.presence,
channels: this.channels,
memory: this.memory,
diagnostics: params.diagnostics,
summary: params.summary,
};
}
private setCapability(name: GatewayCapabilityName, probe: GatewayCapabilityProbe): void {
if (name === 'openclawHealth') {
this.openclawHealth = probe;
} else if (name === 'openclawStatus') {
this.openclawStatus = probe;
} else if (name === 'channels') {
this.channels = probe;
} else if (name === 'memory') {
this.memory = probe;
}
}
}
-211
View File
@@ -1,211 +0,0 @@
import type { ChatRuntimeEvent } from '../../shared/chat-runtime-events';
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
}
function readString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value : undefined;
}
function readNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
type ChatRuntimeEventType = ChatRuntimeEvent['type'];
type ChatRuntimeEventFor<T extends ChatRuntimeEventType> = Extract<ChatRuntimeEvent, { type: T }>;
type ChatRuntimeEventBaseFor<T extends ChatRuntimeEventType> = Pick<
ChatRuntimeEventFor<T>,
'type' | 'runId' | 'sessionKey' | 'seq' | 'ts'
>;
function withBase<T extends ChatRuntimeEventType>(
type: T,
payload: Record<string, unknown>,
): ChatRuntimeEventBaseFor<T> | null {
const runId = readString(payload.runId);
if (!runId) return null;
return {
type,
runId,
sessionKey: readString(payload.sessionKey),
seq: readNumber(payload.seq),
ts: readNumber(payload.ts),
} as ChatRuntimeEventBaseFor<T>;
}
export function normalizeGatewayChatRuntimeEvent(payload: unknown): ChatRuntimeEvent | null {
const raw = asRecord(payload);
if (!raw) return null;
const stream = readString(raw.stream);
const data = asRecord(raw.data) ?? raw;
if (stream === 'lifecycle') {
const phase = readString(data.phase);
if (phase === 'start') {
const base = withBase('run.started', raw);
return base
? {
...base,
startedAt: readNumber(data.startedAt),
}
: null;
}
if (phase === 'completed' || phase === 'done' || phase === 'finished') {
const base = withBase('run.ended', raw);
return base
? {
...base,
status: 'completed',
endedAt: readNumber(data.endedAt),
livenessState: readString(data.livenessState),
replayInvalid: typeof data.replayInvalid === 'boolean' ? data.replayInvalid : undefined,
stopReason: readString(data.stopReason),
}
: null;
}
if (phase === 'error' || phase === 'failed') {
const base = withBase('run.ended', raw);
return base
? {
...base,
status: 'error',
endedAt: readNumber(data.endedAt),
error: readString(data.error),
livenessState: readString(data.livenessState),
replayInvalid: typeof data.replayInvalid === 'boolean' ? data.replayInvalid : undefined,
stopReason: readString(data.stopReason),
}
: null;
}
if (phase === 'aborted' || phase === 'cancelled') {
const base = withBase('run.ended', raw);
return base
? {
...base,
status: 'aborted',
endedAt: readNumber(data.endedAt),
error: readString(data.error),
stopReason: readString(data.stopReason),
}
: null;
}
return null;
}
if (stream === 'assistant') {
const base = withBase('assistant.delta', raw);
return base
? {
...base,
text: readString(data.text),
delta: readString(data.delta),
replace: typeof data.replace === 'boolean' ? data.replace : undefined,
phase: readString(data.phase),
mediaUrls: Array.isArray(data.mediaUrls)
? data.mediaUrls.filter((value): value is string => typeof value === 'string' && value.length > 0)
: undefined,
}
: null;
}
if (stream === 'thinking') {
const base = withBase('thinking.delta', raw);
return base
? {
...base,
text: readString(data.text),
delta: readString(data.delta),
}
: null;
}
if (stream === 'tool') {
const phase = readString(data.phase);
const toolCallId = readString(data.toolCallId);
const name = readString(data.name);
if (!toolCallId || !name) return null;
if (phase === 'start') {
const base = withBase('tool.started', raw);
return base ? { ...base, toolCallId, name, args: data.args } : null;
}
if (phase === 'update') {
const base = withBase('tool.updated', raw);
return base ? { ...base, toolCallId, name, partialResult: data.partialResult } : null;
}
if (phase === 'result' || phase === 'end') {
const base = withBase('tool.completed', raw);
return base
? {
...base,
toolCallId,
name,
result: data.result,
meta: data.meta,
isError: typeof data.isError === 'boolean' ? data.isError : undefined,
}
: null;
}
return null;
}
if (stream === 'command_output') {
const base = withBase('command.output', raw);
return base
? {
...base,
itemId: readString(data.itemId),
toolCallId: readString(data.toolCallId),
name: readString(data.name),
title: readString(data.title),
output: readString(data.output),
status: readString(data.status),
phase: readString(data.phase),
exitCode: readNumber(data.exitCode),
durationMs: readNumber(data.durationMs),
cwd: readString(data.cwd),
}
: null;
}
if (stream === 'patch') {
const base = withBase('patch.completed', raw);
return base
? {
...base,
itemId: readString(data.itemId),
toolCallId: readString(data.toolCallId),
name: readString(data.name),
title: readString(data.title),
summary: readString(data.summary),
added: readNumber(data.added),
modified: readNumber(data.modified),
deleted: readNumber(data.deleted),
}
: null;
}
if (stream === 'approval') {
const base = withBase('approval.updated', raw);
return base
? {
...base,
itemId: readString(data.itemId),
toolCallId: readString(data.toolCallId),
title: readString(data.title),
kind: readString(data.kind),
phase: readString(data.phase),
status: readString(data.status),
message: readString(data.message),
}
: null;
}
return null;
}
+306 -135
View File
@@ -1,29 +1,29 @@
/**
* ClawHub Service
* Maintains marketplace-provider compatibility and managed skill uninstall/open helpers.
* Manages interactions with the ClawHub CLI for skills management
*/
import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { shell } from 'electron';
import { getOpenClawConfigDir, ensureDir } from '../utils/paths';
import { removeSkillConfig } from '../utils/skill-config';
import { app, shell } from 'electron';
import { getOpenClawConfigDir, ensureDir, getClawHubCliBinPath, getClawHubCliEntryPath, quoteForCmd } from '../utils/paths';
export interface MarketplaceSearchParams {
export interface ClawHubSearchParams {
query: string;
limit?: number;
}
export interface MarketplaceInstallParams {
export interface ClawHubInstallParams {
slug: string;
version?: string;
force?: boolean;
}
export interface MarketplaceUninstallParams {
export interface ClawHubUninstallParams {
slug: string;
}
export interface MarketplaceSkillResult {
export interface ClawHubSkillResult {
slug: string;
name: string;
description: string;
@@ -33,11 +33,6 @@ export interface MarketplaceSkillResult {
stars?: number;
}
export type ClawHubSearchParams = MarketplaceSearchParams;
export type ClawHubInstallParams = MarketplaceInstallParams;
export type ClawHubUninstallParams = MarketplaceUninstallParams;
export type ClawHubSkillResult = MarketplaceSkillResult;
export interface ClawHubInstalledSkillResult {
slug: string;
version: string;
@@ -47,19 +42,18 @@ export interface ClawHubInstalledSkillResult {
export interface MarketplaceProvider {
getCapability(): Promise<{ mode: string; canSearch: boolean; canInstall: boolean; reason?: string }>;
search(params: MarketplaceSearchParams): Promise<MarketplaceSkillResult[]>;
install(params: MarketplaceInstallParams): Promise<void>;
search(params: ClawHubSearchParams): Promise<ClawHubSkillResult[]>;
install(params: ClawHubInstallParams): Promise<void>;
}
export class ClawHubService {
private workDir: string;
private cliPath: string;
private cliEntryPath: string;
private useNodeRunner: boolean;
private ansiRegex: RegExp;
private marketplaceProvider: MarketplaceProvider | null = null;
constructor() {
this.workDir = getOpenClawConfigDir();
ensureDir(this.workDir);
}
setMarketplaceProvider(provider: MarketplaceProvider): void {
this.marketplaceProvider = provider;
}
@@ -68,131 +62,40 @@ export class ClawHubService {
if (this.marketplaceProvider) {
return this.marketplaceProvider.getCapability();
}
return {
mode: 'local-only',
canSearch: false,
canInstall: false,
reason: 'marketplace-disabled',
};
return { mode: 'clawhub', canSearch: true, canInstall: true };
}
/**
* Search for skills via an extension-provided marketplace.
*/
async search(params: MarketplaceSearchParams): Promise<MarketplaceSkillResult[]> {
if (this.marketplaceProvider) {
return this.marketplaceProvider.search(params);
constructor() {
// Use the user's OpenClaw config directory (~/.openclaw) for skill management
// This avoids installing skills into the project's openclaw submodule
this.workDir = getOpenClawConfigDir();
ensureDir(this.workDir);
const binPath = getClawHubCliBinPath();
const entryPath = getClawHubCliEntryPath();
this.cliEntryPath = entryPath;
if (!app.isPackaged && fs.existsSync(binPath)) {
this.cliPath = binPath;
this.useNodeRunner = false;
} else {
this.cliPath = process.execPath;
this.useNodeRunner = true;
}
throw new Error('Marketplace search is disabled');
const esc = String.fromCharCode(27);
const csi = String.fromCharCode(155);
const pattern = `(?:${esc}|${csi})[[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]`;
this.ansiRegex = new RegExp(pattern, 'g');
}
/**
* Explore marketplace skills via the registered marketplace provider.
*/
async explore(params: { limit?: number } = {}): Promise<MarketplaceSkillResult[]> {
if (this.marketplaceProvider) {
return this.marketplaceProvider.search({ query: '', limit: params.limit });
}
throw new Error('Marketplace search is disabled');
}
/**
* Install a skill through an extension-provided marketplace.
*/
async install(params: MarketplaceInstallParams): Promise<void> {
if (this.marketplaceProvider) {
return this.marketplaceProvider.install(params);
}
throw new Error('Marketplace install is disabled');
}
/**
* Uninstall a managed skill and remove its stored config.
*/
async uninstall(params: ClawHubUninstallParams): Promise<void> {
const fsPromises = fs.promises;
const skillDir = path.join(this.workDir, 'skills', params.slug);
if (fs.existsSync(skillDir)) {
console.log(`Deleting skill directory: ${skillDir}`);
await fsPromises.rm(skillDir, { recursive: true, force: true });
}
const lockFile = path.join(this.workDir, '.clawhub', 'lock.json');
if (fs.existsSync(lockFile)) {
try {
const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf8')) as {
skills?: Record<string, unknown>;
};
if (lockData.skills && lockData.skills[params.slug]) {
console.log(`Removing ${params.slug} from lock.json`);
delete lockData.skills[params.slug];
await fsPromises.writeFile(lockFile, JSON.stringify(lockData, null, 2));
}
} catch (err) {
console.error('Failed to update ClawHub lock file:', err);
}
}
await removeSkillConfig(params.slug);
}
/**
* List installed managed skills from the filesystem.
*/
async listInstalled(): Promise<ClawHubInstalledSkillResult[]> {
const skillsRoot = path.join(this.workDir, 'skills');
if (!fs.existsSync(skillsRoot)) {
return [];
}
try {
const entries = await fs.promises.readdir(skillsRoot, { withFileTypes: true });
const items = await Promise.all(entries
.filter((entry) => entry.isDirectory())
.map(async (entry) => {
const skillDir = path.join(skillsRoot, entry.name);
const manifestPath = path.join(skillDir, 'SKILL.md');
if (!fs.existsSync(manifestPath)) return null;
let version = 'unknown';
const manifestJsonPath = path.join(skillDir, 'manifest.json');
if (fs.existsSync(manifestJsonPath)) {
try {
const manifestJson = JSON.parse(await fs.promises.readFile(manifestJsonPath, 'utf8')) as { version?: string };
version = manifestJson.version?.trim() || version;
} catch {
// Ignore malformed manifest.json
}
}
const originJsonPath = path.join(skillDir, '.clawhub', 'origin.json');
if (fs.existsSync(originJsonPath)) {
try {
const originJson = JSON.parse(await fs.promises.readFile(originJsonPath, 'utf8')) as { installedVersion?: string };
version = originJson.installedVersion?.trim() || version;
} catch {
// Ignore malformed origin.json
}
}
return {
slug: entry.name,
version,
source: 'openclaw-managed',
baseDir: skillDir,
};
}));
return items.filter((item): item is NonNullable<typeof item> => item !== null);
} catch (error) {
console.error('ClawHub list error:', error);
return [];
}
private stripAnsi(line: string): string {
return line.replace(this.ansiRegex, '').trim();
}
private extractFrontmatterName(skillManifestPath: string): string | null {
try {
const raw = fs.readFileSync(skillManifestPath, 'utf8');
// Match the first frontmatter block and read `name: ...`
const frontmatterMatch = raw.match(/^---\s*\n([\s\S]*?)\n---/);
if (!frontmatterMatch) return null;
const body = frontmatterMatch[1];
@@ -238,6 +141,265 @@ export class ClawHubService {
return null;
}
/**
* Run a ClawHub CLI command
*/
private async runCommand(args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
if (this.useNodeRunner && !fs.existsSync(this.cliEntryPath)) {
reject(new Error(`ClawHub CLI entry not found at: ${this.cliEntryPath}`));
return;
}
if (!this.useNodeRunner && !fs.existsSync(this.cliPath)) {
reject(new Error(`ClawHub CLI not found at: ${this.cliPath}`));
return;
}
const commandArgs = this.useNodeRunner ? [this.cliEntryPath, ...args] : args;
const displayCommand = [this.cliPath, ...commandArgs].join(' ');
console.log(`Running ClawHub command: ${displayCommand}`);
const isWin = process.platform === 'win32';
const useShell = isWin && !this.useNodeRunner;
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
const env = {
...baseEnv,
CI: 'true',
FORCE_COLOR: '0',
};
if (this.useNodeRunner) {
env.ELECTRON_RUN_AS_NODE = '1';
}
const spawnCmd = useShell ? quoteForCmd(this.cliPath) : this.cliPath;
const spawnArgs = useShell ? commandArgs.map(a => quoteForCmd(a)) : commandArgs;
const child = spawn(spawnCmd, spawnArgs, {
cwd: this.workDir,
shell: useShell,
env: {
...env,
CLAWHUB_WORKDIR: this.workDir,
},
windowsHide: true,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('error', (error) => {
console.error('ClawHub process error:', error);
reject(error);
});
child.on('close', (code) => {
if (code !== 0 && code !== null) {
console.error(`ClawHub command failed with code ${code}`);
console.error('Stderr:', stderr);
reject(new Error(`Command failed: ${stderr || stdout}`));
} else {
resolve(stdout.trim());
}
});
});
}
/**
* Search for skills. Delegates to the marketplace provider if one is set,
* otherwise falls back to the local ClawHub CLI.
*/
async search(params: ClawHubSearchParams): Promise<ClawHubSkillResult[]> {
if (this.marketplaceProvider) {
return this.marketplaceProvider.search(params);
}
try {
// If query is empty, use 'explore' to show trending skills
if (!params.query || params.query.trim() === '') {
return this.explore({ limit: params.limit });
}
const args = ['search', params.query];
if (params.limit) {
args.push('--limit', String(params.limit));
}
const output = await this.runCommand(args);
if (!output || output.includes('No skills found')) {
return [];
}
const lines = output.split('\n').filter(l => l.trim());
return lines.map(line => {
const cleanLine = this.stripAnsi(line);
// Format could be: slug vversion description (score)
// Or sometimes: slug vversion description
let match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)\s+(.+)$/);
if (match) {
const slug = match[1];
const version = match[2];
let description = match[3];
// Clean up score if present at the end
description = description.replace(/\(\d+\.\d+\)$/, '').trim();
return {
slug,
name: slug,
version,
description,
};
}
// Fallback for new clawhub search format without version:
// slug name/description (score)
match = cleanLine.match(/^(\S+)\s+(.+)$/);
if (match) {
const slug = match[1];
let description = match[2];
// Clean up score if present at the end
description = description.replace(/\(\d+\.\d+\)$/, '').trim();
return {
slug,
name: slug,
version: 'latest', // Fallback version since it's not provided
description,
};
}
return null;
}).filter((s): s is ClawHubSkillResult => s !== null);
} catch (error) {
console.error('ClawHub search error:', error);
throw error;
}
}
/**
* Explore trending skills
*/
async explore(params: { limit?: number } = {}): Promise<ClawHubSkillResult[]> {
try {
const args = ['explore'];
if (params.limit) {
args.push('--limit', String(params.limit));
}
const output = await this.runCommand(args);
if (!output) return [];
const lines = output.split('\n').filter(l => l.trim());
return lines.map(line => {
const cleanLine = this.stripAnsi(line);
// Format: slug vversion time description
// Example: my-skill v1.0.0 2 hours ago A great skill
const match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)\s+(.+? ago|just now|yesterday)\s+(.+)$/i);
if (match) {
return {
slug: match[1],
name: match[1],
version: match[2],
description: match[4],
};
}
return null;
}).filter((s): s is ClawHubSkillResult => s !== null);
} catch (error) {
console.error('ClawHub explore error:', error);
throw error;
}
}
/**
* Install a skill. Delegates to the marketplace provider if one is set,
* otherwise falls back to the local ClawHub CLI.
*/
async install(params: ClawHubInstallParams): Promise<void> {
if (this.marketplaceProvider) {
return this.marketplaceProvider.install(params);
}
const args = ['install', params.slug];
if (params.version) {
args.push('--version', params.version);
}
if (params.force) {
args.push('--force');
}
await this.runCommand(args);
}
/**
* Uninstall a skill
*/
async uninstall(params: ClawHubUninstallParams): Promise<void> {
const fsPromises = fs.promises;
// 1. Delete the skill directory
const skillDir = path.join(this.workDir, 'skills', params.slug);
if (fs.existsSync(skillDir)) {
console.log(`Deleting skill directory: ${skillDir}`);
await fsPromises.rm(skillDir, { recursive: true, force: true });
}
// 2. Remove from lock.json
const lockFile = path.join(this.workDir, '.clawhub', 'lock.json');
if (fs.existsSync(lockFile)) {
try {
const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
if (lockData.skills && lockData.skills[params.slug]) {
console.log(`Removing ${params.slug} from lock.json`);
delete lockData.skills[params.slug];
await fsPromises.writeFile(lockFile, JSON.stringify(lockData, null, 2));
}
} catch (err) {
console.error('Failed to update ClawHub lock file:', err);
}
}
}
/**
* List installed skills
*/
async listInstalled(): Promise<ClawHubInstalledSkillResult[]> {
try {
const output = await this.runCommand(['list']);
if (!output || output.includes('No installed skills')) {
return [];
}
const lines = output.split('\n').filter(l => l.trim());
return lines.map(line => {
const cleanLine = this.stripAnsi(line);
const match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)/);
if (match) {
const slug = match[1];
return {
slug,
version: match[2],
source: 'openclaw-managed',
baseDir: path.join(this.workDir, 'skills', slug),
};
}
return null;
}).filter((s): s is ClawHubInstalledSkillResult => s !== null);
} catch (error) {
console.error('ClawHub list error:', error);
return [];
}
}
private resolveSkillDir(skillKeyOrSlug: string, fallbackSlug?: string, preferredBaseDir?: string): string | null {
const candidates = [skillKeyOrSlug, fallbackSlug]
.filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
@@ -252,9 +414,13 @@ export class ClawHubService {
return directSkillDir || this.resolveSkillDirByManifestName(uniqueCandidates);
}
/**
* Open skill README/manual in default editor
*/
async openSkillReadme(skillKeyOrSlug: string, fallbackSlug?: string, preferredBaseDir?: string): Promise<boolean> {
const skillDir = this.resolveSkillDir(skillKeyOrSlug, fallbackSlug, preferredBaseDir);
// Try to find documentation file
const possibleFiles = ['SKILL.md', 'README.md', 'skill.md', 'readme.md'];
let targetFile = '';
@@ -269,6 +435,7 @@ export class ClawHubService {
}
if (!targetFile) {
// If no md file, just open the directory
if (skillDir) {
targetFile = skillDir;
} else {
@@ -277,6 +444,7 @@ export class ClawHubService {
}
try {
// Open file with default application
await shell.openPath(targetFile);
return true;
} catch (error) {
@@ -285,6 +453,9 @@ export class ClawHubService {
}
}
/**
* Open skill path in file explorer
*/
async openSkillPath(skillKeyOrSlug: string, fallbackSlug?: string, preferredBaseDir?: string): Promise<boolean> {
const skillDir = this.resolveSkillDir(skillKeyOrSlug, fallbackSlug, preferredBaseDir);
if (!skillDir) {
+36 -259
View File
@@ -1,31 +1,13 @@
import { app } from 'electron';
import path from 'path';
import { existsSync, readFileSync, mkdirSync, readdirSync, rmSync, symlinkSync } from 'fs';
import { existsSync, readFileSync, mkdirSync, readdirSync, rmSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
function fsPath(filePath: string): string {
if (process.platform !== 'win32') return filePath;
if (!filePath) return filePath;
if (filePath.startsWith('\\\\?\\')) return filePath;
const windowsPath = filePath.replace(/\//g, '\\');
if (!path.win32.isAbsolute(windowsPath)) return windowsPath;
if (windowsPath.startsWith('\\\\')) {
return `\\\\?\\UNC\\${windowsPath.slice(2)}`;
}
return `\\\\?\\${windowsPath}`;
}
import { linkDirSafe, normalizeFsPath as fsPath } from './fs-link';
import { getAllSettings } from '../utils/store';
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry';
import {
getOpenClawConfigDir,
getOpenClawDir,
getOpenClawEntryPath,
getOpenClawResolvedDir,
getOpenClawSkillsDir,
isOpenClawPresent,
} from '../utils/paths';
import { getOpenClawDir, getOpenClawEntryPath, isOpenClawPresent } from '../utils/paths';
import { getUvMirrorEnv } from '../utils/uv-env';
import { cleanupDanglingWeChatPluginState, listConfiguredChannelsFromConfig, readOpenClawConfig } from '../utils/channel-config';
import { sanitizeOpenClawConfig, batchSyncConfigFields } from '../utils/openclaw-auth';
@@ -33,18 +15,8 @@ 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 { CLAWX_OPENAI_IMAGE_PROVIDER_KEY } from '../utils/openclaw-image-relay-constants';
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe } from '../utils/plugin-install';
import { stripSystemdSupervisorEnv } from './config-sync-env';
import { cleanupAgentsSymlinkedSkills, cleanupStalePluginRuntimeDeps } from './skills-symlink-cleanup';
import {
buildPrelaunchMaintenanceCacheKey,
directoryChildrenSignature,
pathSignature,
runCachedPrelaunchMaintenanceTask,
type PrelaunchMaintenanceRunResult,
type PrelaunchMaintenanceTaskName,
} from './prelaunch-maintenance-cache';
export interface GatewayLaunchContext {
@@ -60,24 +32,14 @@ export interface GatewayLaunchContext {
channelStartupSummary: string;
}
export interface GatewayPrelaunchSyncSummary {
timingsMs: Record<string, number>;
maintenance: Partial<Record<PrelaunchMaintenanceTaskName, PrelaunchMaintenanceRunResult>>;
configuredChannels: string[];
}
// ── Auto-upgrade bundled plugins on startup ──────────────────────
const CHANNEL_PLUGIN_MAP: Record<string, { dirName: string; npmName: string }> = {
dingtalk: { dirName: 'dingtalk', npmName: '@soimy/dingtalk' },
wecom: { dirName: 'wecom', npmName: '@wecom/wecom-openclaw-plugin' },
feishu: { dirName: 'feishu-openclaw-plugin', npmName: '@larksuite/openclaw-lark' },
discord: { dirName: 'discord', npmName: '@openclaw/discord' },
qqbot: { dirName: 'qqbot', npmName: '@openclaw/qqbot' },
whatsapp: { dirName: 'whatsapp', npmName: '@openclaw/whatsapp' },
'openclaw-weixin': { dirName: 'openclaw-weixin', npmName: '@tencent-weixin/openclaw-weixin' },
[CLAWX_OPENAI_IMAGE_PROVIDER_KEY]: { dirName: CLAWX_OPENAI_IMAGE_PROVIDER_KEY, npmName: 'clawx-openai-image-plugin' },
};
/**
@@ -112,30 +74,17 @@ function readPluginVersion(pkgJsonPath: string): string | null {
}
}
function measureSync<T>(timings: Record<string, number>, key: string, fn: () => T): T {
const startedAt = Date.now();
try {
return fn();
} finally {
timings[key] = Date.now() - startedAt;
}
}
async function measureAsync<T>(timings: Record<string, number>, key: string, fn: () => Promise<T>): Promise<T> {
const startedAt = Date.now();
try {
return await fn();
} finally {
timings[key] = Date.now() - startedAt;
}
}
function appVersionForCache(): string {
try {
return app.getVersion();
} catch {
return 'unknown';
}
function buildBundledPluginSources(pluginDirName: string): string[] {
return app.isPackaged
? [
join(process.resourcesPath, 'openclaw-plugins', pluginDirName),
join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', pluginDirName),
join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', pluginDirName),
]
: [
join(app.getAppPath(), 'build', 'openclaw-plugins', pluginDirName),
join(process.cwd(), 'build', 'openclaw-plugins', pluginDirName),
];
}
/**
@@ -143,8 +92,7 @@ function appVersionForCache(): string {
* - Packaged mode: uses bundled plugins from resources/ (includes deps)
* - Dev mode: falls back to node_modules/ with pnpm-aware dep collection
*/
function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean {
let succeeded = true;
function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void {
for (const channelType of configuredChannels) {
const pluginInfo = CHANNEL_PLUGIN_MAP[channelType];
if (!pluginInfo) continue;
@@ -156,7 +104,7 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
const installedVersion = isInstalled ? readPluginVersion(join(targetDir, 'package.json')) : null;
// Try bundled sources first (packaged mode or if bundle-plugins was run)
const bundledSources = buildCandidateSources(dirName);
const bundledSources = buildBundledPluginSources(dirName);
const bundledDir = bundledSources.find((dir) => existsSync(fsPath(join(dir, 'openclaw.plugin.json'))));
if (bundledDir) {
@@ -171,7 +119,6 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
fixupPluginManifest(targetDir);
} catch (err) {
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin:`, err);
succeeded = false;
}
} else if (isInstalled) {
// Same version already installed — still patch manifest ID in case it was
@@ -201,11 +148,9 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
fixupPluginManifest(targetDir);
} catch (err) {
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin from node_modules:`, err);
succeeded = false;
}
}
}
return succeeded;
}
/**
@@ -214,8 +159,7 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
* from scanning residual plugin manifests that were installed by a previous
* configuration but are no longer needed.
*/
function cleanupUnconfiguredChannelPlugins(configuredChannels: string[]): boolean {
let succeeded = true;
function cleanupUnconfiguredChannelPlugins(configuredChannels: string[]): void {
const configuredSet = new Set(configuredChannels);
for (const [channelType, pluginInfo] of Object.entries(CHANNEL_PLUGIN_MAP)) {
@@ -230,92 +174,8 @@ function cleanupUnconfiguredChannelPlugins(configuredChannels: string[]): boolea
rmSync(fsPath(targetDir), { recursive: true, force: true });
} catch (err) {
logger.warn(`[plugin] Failed to remove unconfigured channel plugin ${channelType}:`, err);
succeeded = false;
}
}
return succeeded;
}
function resolveImageGenerationPrimary(config: unknown): string | null {
if (!config || typeof config !== 'object') return null;
const agents = (config as { agents?: unknown }).agents;
if (!agents || typeof agents !== 'object') return null;
const defaults = (agents as { defaults?: unknown }).defaults;
if (!defaults || typeof defaults !== 'object') return null;
const imageGenerationModel = (defaults as { imageGenerationModel?: unknown }).imageGenerationModel;
if (typeof imageGenerationModel === 'string') return imageGenerationModel.trim() || null;
if (imageGenerationModel && typeof imageGenerationModel === 'object') {
const primary = (imageGenerationModel as { primary?: unknown }).primary;
return typeof primary === 'string' && primary.trim() ? primary.trim() : null;
}
return null;
}
function withConfiguredImageGenerationPlugins(configuredChannels: string[], rawConfig: unknown): string[] {
const next = [...configuredChannels];
const primary = resolveImageGenerationPrimary(rawConfig);
const provider = primary?.includes('/') ? primary.slice(0, primary.indexOf('/')).trim() : primary;
if (provider === CLAWX_OPENAI_IMAGE_PROVIDER_KEY && !next.includes(CLAWX_OPENAI_IMAGE_PROVIDER_KEY)) {
next.push(CLAWX_OPENAI_IMAGE_PROVIDER_KEY);
}
return next;
}
function buildPluginSourceSignatures(configuredChannels: string[]): Record<string, unknown> {
const signatures: Record<string, unknown> = {};
for (const channelType of [...configuredChannels].sort()) {
const pluginInfo = CHANNEL_PLUGIN_MAP[channelType];
if (!pluginInfo) continue;
const bundledSources = buildCandidateSources(pluginInfo.dirName);
const bundledDir = bundledSources.find((dir) => existsSync(fsPath(join(dir, 'openclaw.plugin.json'))));
const devPkgPath = join(process.cwd(), 'node_modules', ...pluginInfo.npmName.split('/'));
const sourceDir = bundledDir || (!app.isPackaged ? devPkgPath : '');
signatures[channelType] = sourceDir
? {
sourceDir,
manifest: pathSignature(join(sourceDir, 'openclaw.plugin.json')),
packageJson: pathSignature(join(sourceDir, 'package.json')),
}
: 'missing';
}
return signatures;
}
function buildPluginMaintenanceCacheKey(openclawDir: string, configuredChannels: string[]): string {
return buildPrelaunchMaintenanceCacheKey({
task: 'plugin-maintenance',
appVersion: appVersionForCache(),
openclawDir,
cwd: process.cwd(),
configuredChannels: [...configuredChannels].sort(),
extensionsDir: directoryChildrenSignature(join(homedir(), '.openclaw', 'extensions')),
sourceSignatures: buildPluginSourceSignatures(configuredChannels),
});
}
function buildSkillsSymlinkCleanupCacheKey(openclawDir: string): string {
const workspaceSkillsDir = join(getOpenClawConfigDir(), 'workspace', 'skills');
return buildPrelaunchMaintenanceCacheKey({
task: 'skills-symlink-cleanup',
appVersion: appVersionForCache(),
openclawDir,
skillsDir: getOpenClawSkillsDir(),
skillsDirSignature: directoryChildrenSignature(getOpenClawSkillsDir()),
workspaceSkillsDir,
workspaceSkillsDirSignature: directoryChildrenSignature(workspaceSkillsDir),
});
}
function buildRuntimeDepsCleanupCacheKey(openclawDir: string): string {
const runtimeDepsDir = join(getOpenClawConfigDir(), 'plugin-runtime-deps');
return buildPrelaunchMaintenanceCacheKey({
task: 'runtime-deps-cleanup',
appVersion: appVersionForCache(),
openclawDir,
currentOpenClawDir: getOpenClawResolvedDir(),
runtimeDepsDir,
runtimeDepsDirSignature: directoryChildrenSignature(runtimeDepsDir),
});
}
/**
@@ -374,7 +234,7 @@ function ensureExtensionDepsResolvable(openclawDir: string): void {
if (existsSync(dest)) continue;
try {
mkdirSync(join(topNM, pkg.name), { recursive: true });
symlinkSync(join(scopeDir, sub.name), dest);
linkDirSafe(join(scopeDir, sub.name), dest);
linkedCount++;
} catch { /* skip on error — non-fatal */ }
}
@@ -383,7 +243,7 @@ function ensureExtensionDepsResolvable(openclawDir: string): void {
if (existsSync(dest)) continue;
try {
mkdirSync(topNM, { recursive: true });
symlinkSync(join(extNM, pkg.name), dest);
linkDirSafe(join(extNM, pkg.name), dest);
linkedCount++;
} catch { /* skip on error — non-fatal */ }
}
@@ -404,29 +264,22 @@ function ensureExtensionDepsResolvable(openclawDir: string): void {
export async function syncGatewayConfigBeforeLaunch(
appSettings: Awaited<ReturnType<typeof getAllSettings>>,
openclawDir: string,
): Promise<GatewayPrelaunchSyncSummary> {
const timingsMs: Record<string, number> = {};
const maintenance: GatewayPrelaunchSyncSummary['maintenance'] = {};
let configuredChannels: string[] = [];
): Promise<void> {
// Reset the extension-deps cache so that newly installed extensions
// (e.g. user added a channel while the app was running) get their
// node_modules linked on the next Gateway spawn.
resetExtensionDepsLinked();
await measureAsync(timingsMs, 'proxySyncMs', async () => {
await syncProxyConfigToOpenClaw(appSettings, { preserveExistingWhenDisabled: true });
});
await syncProxyConfigToOpenClaw(appSettings, { preserveExistingWhenDisabled: true });
try {
await measureAsync(timingsMs, 'sanitizeMs', sanitizeOpenClawConfig);
await sanitizeOpenClawConfig();
} catch (err) {
logger.warn('Failed to sanitize openclaw.json:', err);
}
try {
await measureAsync(timingsMs, 'wechatStateCleanupMs', cleanupDanglingWeChatPluginState);
await cleanupDanglingWeChatPluginState();
} catch (err) {
logger.warn('Failed to clean dangling WeChat plugin state before launch:', err);
}
@@ -434,83 +287,31 @@ export async function syncGatewayConfigBeforeLaunch(
// Remove stale copies of built-in extensions (Discord, Telegram) that
// override OpenClaw's working built-in plugins and break channel loading.
try {
measureSync(timingsMs, 'staleBuiltinExtensionCleanupMs', cleanupStaleBuiltInExtensions);
cleanupStaleBuiltInExtensions();
} catch (err) {
logger.warn('Failed to clean stale built-in extensions:', err);
}
// Remove stray symlinks under ~/.openclaw/skills whose realpath resolves
// inside ~/.agents/skills. OpenClaw's hardened skill loader rejects these
// on every launch (reason=symlink-escape) and the underlying skills are
// still discovered via the agents-skills-personal source, so the symlinks
// are pure log noise. Transitional workaround for openclaw/openclaw#59219.
try {
const result = measureSync(timingsMs, 'skillsCleanupMs', () => runCachedPrelaunchMaintenanceTask(
'skills-symlink-cleanup',
() => buildSkillsSymlinkCleanupCacheKey(openclawDir),
() => (cleanupAgentsSymlinkedSkills().failed ?? 0) === 0,
));
maintenance['skills-symlink-cleanup'] = result;
} catch (err) {
logger.warn('Failed to clean .agents/skills-targeted skill symlinks:', err);
}
// Remove stale OpenClaw runtime-deps cache roots that point at an older
// worktree/package. Those symlink trees can make Gateway plugin setup spend
// a long time in synchronous fs.open/copy calls before the RPC router is
// responsive.
try {
const result = measureSync(timingsMs, 'runtimeDepsCleanupMs', () => runCachedPrelaunchMaintenanceTask(
'runtime-deps-cleanup',
() => buildRuntimeDepsCleanupCacheKey(openclawDir),
() => (cleanupStalePluginRuntimeDeps().failed ?? 0) === 0,
));
maintenance['runtime-deps-cleanup'] = result;
} catch (err) {
logger.warn('Failed to clean stale OpenClaw plugin runtime deps:', err);
}
// Auto-upgrade installed plugins before Gateway starts so that
// the plugin manifest ID matches what sanitize wrote to the config.
// Only install/upgrade plugins for channels that are actually configured
// in openclaw.json — do NOT expand the list from plugins.allow.
try {
configuredChannels = await measureAsync(timingsMs, 'configuredChannelsMs', async () => {
const rawCfg = await readOpenClawConfig();
return withConfiguredImageGenerationPlugins(
await listConfiguredChannelsFromConfig(rawCfg),
rawCfg,
);
});
const rawCfg = await readOpenClawConfig();
const configuredChannels = await listConfiguredChannelsFromConfig(rawCfg);
const result = measureSync(timingsMs, 'pluginMaintenanceMs', () => runCachedPrelaunchMaintenanceTask(
'plugin-maintenance',
() => buildPluginMaintenanceCacheKey(openclawDir, configuredChannels),
() => {
const upgradeOk = ensureConfiguredPluginsUpgraded(configuredChannels);
const cleanupOk = cleanupUnconfiguredChannelPlugins(configuredChannels);
return upgradeOk && cleanupOk;
},
));
maintenance['plugin-maintenance'] = result;
ensureConfiguredPluginsUpgraded(configuredChannels);
cleanupUnconfiguredChannelPlugins(configuredChannels);
} catch (err) {
logger.warn('Failed to auto-upgrade plugins:', err);
}
// Batch gateway token, browser config, and session idle into one read+write cycle.
try {
await measureAsync(timingsMs, 'configFieldSyncMs', async () => {
await batchSyncConfigFields(appSettings.gatewayToken);
});
await batchSyncConfigFields(appSettings.gatewayToken);
} catch (err) {
logger.warn('Failed to batch-sync config fields to openclaw.json:', err);
}
return {
timingsMs,
maintenance,
configuredChannels,
};
}
async function loadProviderEnv(): Promise<{ providerEnv: Record<string, string>; loadedProviderKeyCount: number }> {
@@ -582,8 +383,6 @@ async function resolveChannelStartupPolicy(): Promise<{
}
export async function prepareGatewayLaunchContext(port: number): Promise<GatewayLaunchContext> {
const timingsMs: Record<string, number> = {};
const totalStartedAt = Date.now();
const openclawDir = getOpenClawDir();
const entryScript = getOpenClawEntryPath();
@@ -591,10 +390,8 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
throw new Error(`OpenClaw package not found at: ${openclawDir}`);
}
const appSettings = await measureAsync(timingsMs, 'settingsMs', getAllSettings);
const prelaunchSummary = await measureAsync(timingsMs, 'prelaunchSyncMs', async () => (
await syncGatewayConfigBeforeLaunch(appSettings, openclawDir)
));
const appSettings = await getAllSettings();
await syncGatewayConfigBeforeLaunch(appSettings);
if (!existsSync(entryScript)) {
throw new Error(`OpenClaw entry script not found at: ${entryScript}`);
@@ -611,13 +408,9 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
: path.join(process.cwd(), 'resources', 'bin', target);
const binPathExists = existsSync(binPath);
const { providerEnv, loadedProviderKeyCount } = await measureAsync(timingsMs, 'providerEnvMs', loadProviderEnv);
const { skipChannels, channelStartupSummary } = await measureAsync(
timingsMs,
'channelStartupPolicyMs',
resolveChannelStartupPolicy,
);
const uvEnv = await measureAsync(timingsMs, 'uvEnvMs', getUvMirrorEnv);
const { providerEnv, loadedProviderKeyCount } = await loadProviderEnv();
const { skipChannels, channelStartupSummary } = await resolveChannelStartupPolicy();
const uvEnv = await getUvMirrorEnv();
const proxyEnv = buildProxyEnv(appSettings);
const resolvedProxy = resolveProxySettings(appSettings);
const proxySummary = appSettings.proxyEnabled
@@ -638,28 +431,12 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
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
// extension) are resolvable by shared dist/ chunks via symlinks in
// openclaw/node_modules/. NODE_PATH does NOT work for ESM imports.
measureSync(timingsMs, 'extensionDepsMs', () => ensureExtensionDepsResolvable(openclawDir));
timingsMs.totalMs = Date.now() - totalStartedAt;
logger.info('[metric] gateway.prelaunch', {
...prelaunchSummary.timingsMs,
...timingsMs,
maintenance: prelaunchSummary.maintenance,
configuredChannelCount: prelaunchSummary.configuredChannels.length,
});
ensureExtensionDepsResolvable(openclawDir);
return {
appSettings,
+5 -25
View File
@@ -1,11 +1,5 @@
import { GatewayEventType, type JsonRpcNotification } from './protocol';
import { logger } from '../utils/logger';
import { normalizeGatewayChatRuntimeEvent } from './chat-runtime-events';
import type {
GatewayChannelStatusEvent,
GatewayChatMessageEvent,
GatewayRuntimePayload,
} from '@shared/host-events/contract';
type GatewayEventEmitter = {
emit: (event: string, payload: unknown) => boolean;
@@ -23,27 +17,19 @@ export function dispatchProtocolEvent(
emitter.emit('chat:message', { message: payload });
break;
case 'agent': {
const normalized = normalizeGatewayChatRuntimeEvent(payload);
if (normalized) {
emitter.emit('chat:runtime-event', normalized);
}
// Keep "agent" on the canonical notification path to avoid double
// handling in renderer when both notification and chat-message are wired.
emitter.emit('notification', { method: event, params: payload });
break;
}
case 'channel.status':
case 'channel.status_changed':
emitter.emit('channel:status', payload as GatewayChannelStatusEvent);
emitter.emit('channel:status', payload as { channelId: string; status: string });
break;
case 'gateway.ready':
case 'ready':
emitter.emit('gateway:ready', payload);
break;
case 'health':
emitter.emit('gateway:health', payload as GatewayRuntimePayload);
break;
case 'presence':
emitter.emit('gateway:presence', payload as GatewayRuntimePayload);
break;
default:
emitter.emit('notification', { method: event, params: payload });
}
@@ -54,18 +40,12 @@ export function dispatchJsonRpcNotification(
notification: JsonRpcNotification,
): void {
emitter.emit('notification', notification);
if (notification.method === 'agent') {
const normalized = normalizeGatewayChatRuntimeEvent(notification.params);
if (normalized) {
emitter.emit('chat:runtime-event', normalized);
}
}
switch (notification.method) {
case GatewayEventType.CHANNEL_STATUS_CHANGED:
emitter.emit('channel:status', notification.params as GatewayChannelStatusEvent);
emitter.emit('channel:status', notification.params as { channelId: string; status: string });
break;
case GatewayEventType.MESSAGE_RECEIVED:
emitter.emit('chat:message', notification.params as GatewayChatMessageEvent);
emitter.emit('chat:message', notification.params as { message: unknown });
break;
case GatewayEventType.ERROR: {
const errorData = notification.params as { message?: string };
+47
View File
@@ -0,0 +1,47 @@
import { symlinkSync } from 'fs';
import path from 'path';
/**
* Normalize a filesystem path for the current platform. On Windows, convert
* forward slashes to backslashes and apply the `\\?\` extended-length prefix
* for absolute paths so long paths are handled correctly. On POSIX, return
* the path unchanged.
*/
export function normalizeFsPath(filePath: string): string {
if (process.platform !== 'win32') return filePath;
if (!filePath) return filePath;
if (filePath.startsWith('\\\\?\\')) return filePath;
const windowsPath = filePath.replace(/\//g, '\\');
if (!path.win32.isAbsolute(windowsPath)) return windowsPath;
if (windowsPath.startsWith('\\\\')) {
return `\\\\?\\UNC\\${windowsPath.slice(2)}`;
}
return `\\\\?\\${windowsPath}`;
}
/**
* Create a directory link from `src` to `dest`.
*
* On POSIX uses a regular symlink. On Windows prefers a junction (which does
* not require Developer Mode or administrator privileges) and falls back to
* a regular symlink only if the junction attempt fails.
*
* Throws on POSIX when symlink creation fails. On Windows, both attempts
* failing will throw the symlink error — callers guard with try/catch when
* link creation is non-fatal (e.g. optional extension dependency linking).
*/
export function linkDirSafe(src: string, dest: string): void {
const isWin = process.platform === 'win32';
const srcP = normalizeFsPath(src);
const destP = normalizeFsPath(dest);
if (!isWin) {
symlinkSync(srcP, destP, 'dir');
return;
}
try {
symlinkSync(srcP, destP, 'junction');
} catch {
// Junction failed (e.g. cross-volume). Try a symlink as a last resort.
symlinkSync(srcP, destP, 'dir');
}
}
+46 -241
View File
@@ -51,22 +51,6 @@ import {
} from './reload-policy';
import { classifyGatewayStderrMessage, recordGatewayStartupStderrLine } from './startup-stderr';
import { runGatewayStartupSequence } from './startup-orchestrator';
import {
GatewayCapabilityMonitor,
type GatewayCapabilityName,
type GatewayCapabilitySnapshot,
} from './capability-monitor';
import {
isGatewayWsTraceEnabled,
redactGatewayFrameForTrace,
summarizeGatewayFrameForTrace,
} from './ws-trace';
import type {
GatewayChannelStatusEvent,
GatewayChatMessageEvent,
GatewayRuntimePayload,
} from '@shared/host-events/contract';
import type { ChatRuntimeEvent } from '@shared/chat-runtime-events';
export interface GatewayStatus {
state: GatewayLifecycleState;
@@ -95,14 +79,6 @@ export interface GatewayHealthSummary {
lastChannelsStatusFailureAt?: number;
}
export interface GatewayHealthReport {
ok: boolean;
error?: string;
uptime?: number;
version?: string;
capabilities: GatewayCapabilitySnapshot;
}
export interface GatewayDiagnosticsSnapshot {
lastAliveAt?: number;
lastRpcSuccessAt?: number;
@@ -115,27 +91,14 @@ export interface GatewayDiagnosticsSnapshot {
consecutiveRpcFailures: number;
}
function isCoreRpcMethod(method: string): boolean {
return method === 'system-presence';
}
function isTransportRpcFailure(method: string, error: unknown): boolean {
function isTransportRpcFailure(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes('RPC timeout:')
? isCoreRpcMethod(method)
: message.includes('Gateway not connected')
|| message.includes('Gateway not connected')
|| message.includes('Gateway stopped')
|| message.includes('Failed to send RPC request:');
}
function classifyCapabilityMethod(method: string): GatewayCapabilityName | null {
if (method === 'health') return 'openclawHealth';
if (method === 'status') return 'openclawStatus';
if (method === 'channels.status') return 'channels';
if (method.startsWith('doctor.memory.')) return 'memory';
return null;
}
/**
* Gateway Manager Events
*/
@@ -145,11 +108,8 @@ export interface GatewayManagerEvents {
notification: (notification: JsonRpcNotification) => void;
exit: (code: number | null) => void;
error: (error: Error) => void;
'gateway:health': (data: GatewayRuntimePayload) => void;
'gateway:presence': (data: GatewayRuntimePayload) => void;
'channel:status': (data: GatewayChannelStatusEvent) => void;
'chat:message': (data: GatewayChatMessageEvent) => void;
'chat:runtime-event': (data: ChatRuntimeEvent) => void;
'channel:status': (data: { channelId: string; status: string }) => void;
'chat:message': (data: { message: unknown }) => void;
}
/**
@@ -178,7 +138,6 @@ export class GatewayManager extends EventEmitter {
private readonly restartController = new GatewayRestartController();
private readonly restartGovernor = new GatewayRestartGovernor();
private reloadDebounceTimer: NodeJS.Timeout | null = null;
private initialReadyHeartbeatRecoveryTimer: NodeJS.Timeout | null = null;
private reloadPolicy: GatewayReloadPolicy = { ...DEFAULT_GATEWAY_RELOAD_POLICY };
private reloadPolicyLoadedAt = 0;
private reloadPolicyRefreshPromise: Promise<void> | null = null;
@@ -186,18 +145,26 @@ export class GatewayManager extends EventEmitter {
private reconnectAttemptsTotal = 0;
private reconnectSuccessTotal = 0;
private static readonly RELOAD_POLICY_REFRESH_MS = 15_000;
private static readonly HEARTBEAT_INTERVAL_MS = 60_000;
private static readonly HEARTBEAT_TIMEOUT_MS = 30_000;
private static readonly HEARTBEAT_MAX_MISSES = 4;
private static readonly HEARTBEAT_INTERVAL_MS = 30_000;
private static readonly HEARTBEAT_TIMEOUT_MS = 12_000;
private static readonly HEARTBEAT_MAX_MISSES = 3;
// Windows-specific heartbeat parameters — more lenient to reduce log noise
// from false positives caused by Windows Defender scans, system updates,
// and synchronous event-loop blocking in the gateway.
private static readonly HEARTBEAT_INTERVAL_MS_WIN = 60_000;
private static readonly HEARTBEAT_TIMEOUT_MS_WIN = 25_000;
private static readonly HEARTBEAT_MAX_MISSES_WIN = 5;
public static readonly RESTART_COOLDOWN_MS = 5_000;
private static readonly GATEWAY_READY_FALLBACK_PROBE_DELAYS_MS = [1_500, 3_000, 5_000, 8_000, 12_000, 30_000] as const;
private static readonly INITIAL_READY_HEARTBEAT_RECOVERY_GRACE_MS = 5 * 60_000;
// Fallback for the server-side gateway.ready event: if the event doesn't
// arrive within this window after the WS handshake completes, we assume the
// gateway is effectively ready so downstream consumers don't block forever.
// Kept short (5s) because handshake completion already implies a working
// RPC channel — this is only a safety net, not the primary signal.
private static readonly GATEWAY_READY_FALLBACK_MS = 5_000;
private lastRestartAt = 0;
/** Set by scheduleReconnect() before calling start() to signal auto-reconnect. */
private isAutoReconnectStart = false;
private gatewayReadyFallbackTimer: NodeJS.Timeout | null = null;
private gatewayReadyFallbackAttempt = 0;
private readonly capabilityMonitor = new GatewayCapabilityMonitor();
private diagnostics: GatewayDiagnosticsSnapshot = {
consecutiveHeartbeatMisses: 0,
consecutiveRpcFailures: 0,
@@ -234,19 +201,12 @@ export class GatewayManager extends EventEmitter {
// so that async file I/O and key generation don't block module loading.
this.on('gateway:ready', () => {
this.resetGatewayReadyFallback();
this.clearInitialReadyHeartbeatRecoveryTimer();
this.clearGatewayReadyFallback();
if (this.status.state === 'running' && !this.status.gatewayReady) {
logger.info('Gateway subsystems ready (event received)');
this.setStatus({ gatewayReady: true });
}
});
this.on('gateway:health', (payload) => {
this.capabilityMonitor.recordOpenClawHealth(payload);
});
this.on('gateway:presence', (payload) => {
this.capabilityMonitor.recordPresence(payload);
});
}
private async initDeviceIdentity(): Promise<void> {
@@ -284,19 +244,6 @@ export class GatewayManager extends EventEmitter {
return { ...this.diagnostics };
}
getCapabilitySnapshot(summary?: GatewayHealthSummary): GatewayCapabilitySnapshot {
return this.capabilityMonitor.buildSnapshot({
status: this.status,
transportConnected: this.ws?.readyState === WebSocket.OPEN,
diagnostics: this.getDiagnostics(),
summary,
});
}
recordCapabilityFailure(name: GatewayCapabilityName, error: unknown, durationMs?: number): void {
this.capabilityMonitor.recordCapabilityFailure(name, error, durationMs);
}
/**
* Check if Gateway is connected and ready
*/
@@ -344,7 +291,6 @@ export class GatewayManager extends EventEmitter {
}
this.isAutoReconnectStart = false; // consume the flag
this.setStatus({ state: 'starting', reconnectAttempts: this.reconnectAttempts, gatewayReady: false });
this.resetGatewayReadyFallback();
// Check if Python environment is ready (self-healing) asynchronously.
// Fire-and-forget: only needs to run once, not on every retry.
@@ -357,6 +303,7 @@ export class GatewayManager extends EventEmitter {
try {
await runGatewayStartupSequence({
port: this.status.port,
ownedPid: this.process?.pid,
shouldWaitForPortFree: process.platform === 'win32',
hasOwnedProcess: () => this.process?.pid != null && this.ownsProcess,
resetStartupStderrLines: () => {
@@ -440,10 +387,6 @@ export class GatewayManager extends EventEmitter {
error
);
this.setStatus({ state: 'error', error: String(error) });
if (this.shouldReconnect) {
logger.warn('Gateway start failed; scheduling auto-reconnect recovery');
this.scheduleReconnect();
}
throw error;
} finally {
this.startLock = false;
@@ -791,73 +734,25 @@ export class GatewayManager extends EventEmitter {
clearTimeout(this.reloadDebounceTimer);
this.reloadDebounceTimer = null;
}
this.resetGatewayReadyFallback();
this.clearInitialReadyHeartbeatRecoveryTimer();
this.clearGatewayReadyFallback();
}
private clearGatewayReadyFallbackTimer(): void {
private clearGatewayReadyFallback(): void {
if (this.gatewayReadyFallbackTimer) {
clearTimeout(this.gatewayReadyFallbackTimer);
this.gatewayReadyFallbackTimer = null;
}
}
private resetGatewayReadyFallback(): void {
this.clearGatewayReadyFallbackTimer();
this.gatewayReadyFallbackAttempt = 0;
}
private getNextGatewayReadyFallbackDelayMs(): number {
const delays = GatewayManager.GATEWAY_READY_FALLBACK_PROBE_DELAYS_MS;
const index = Math.min(this.gatewayReadyFallbackAttempt, delays.length - 1);
const delayMs = delays[index]!;
this.gatewayReadyFallbackAttempt += 1;
return delayMs;
}
private scheduleGatewayReadyFallback(delayMs?: number): void {
if (this.status.state !== 'running' || this.status.gatewayReady) {
return;
}
this.clearGatewayReadyFallbackTimer();
const effectiveDelayMs = delayMs ?? this.getNextGatewayReadyFallbackDelayMs();
private scheduleGatewayReadyFallback(): void {
this.clearGatewayReadyFallback();
this.gatewayReadyFallbackTimer = setTimeout(() => {
this.gatewayReadyFallbackTimer = null;
void this.probeGatewayReadyFallback();
}, effectiveDelayMs);
}
private async probeGatewayReadyFallback(): Promise<void> {
if (this.status.state !== 'running' || this.status.gatewayReady) {
return;
}
logger.info('Gateway ready fallback triggered; probing RPC router before marking ready');
const startedAt = Date.now();
try {
await this.rpc('system-presence', {}, 5_000);
this.capabilityMonitor.recordCoreProbe({
ok: true,
checkedAt: Date.now(),
durationMs: Date.now() - startedAt,
});
if (this.status.state === 'running' && !this.status.gatewayReady) {
logger.info('Gateway ready fallback RPC router probe succeeded');
this.resetGatewayReadyFallback();
logger.info('Gateway ready fallback triggered (no gateway.ready event within timeout)');
this.setStatus({ gatewayReady: true });
}
} catch (error) {
this.capabilityMonitor.recordCoreProbe({
ok: false,
checkedAt: Date.now(),
durationMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : String(error),
});
logger.warn('Gateway ready fallback RPC router probe failed; waiting for gateway.ready event or heartbeat recovery:', error);
if (this.status.state === 'running' && !this.status.gatewayReady) {
this.scheduleGatewayReadyFallback();
}
}
}, GatewayManager.GATEWAY_READY_FALLBACK_MS);
}
/**
@@ -865,7 +760,6 @@ export class GatewayManager extends EventEmitter {
* Uses OpenClaw protocol format: { type: "req", id: "...", method: "...", params: {...} }
*/
async rpc<T>(method: string, params?: unknown, timeoutMs = 30000): Promise<T> {
const startedAt = Date.now();
return await new Promise<T>((resolve, reject) => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
reject(new Error('Gateway not connected'));
@@ -895,46 +789,15 @@ export class GatewayManager extends EventEmitter {
};
try {
if (isGatewayWsTraceEnabled()) {
logger.debug('[gateway-ws-trace] send', {
summary: summarizeGatewayFrameForTrace(request),
frame: redactGatewayFrameForTrace(request),
});
}
this.ws.send(JSON.stringify(request));
} catch (error) {
rejectPendingGatewayRequest(this.pendingRequests, id, new Error(`Failed to send RPC request: ${error}`));
}
}).then((result) => {
this.recordRpcSuccess();
if (isCoreRpcMethod(method)) {
this.capabilityMonitor.recordCoreProbe({
ok: true,
checkedAt: Date.now(),
durationMs: Date.now() - startedAt,
});
}
const capability = classifyCapabilityMethod(method);
if (capability) {
this.capabilityMonitor.recordCapabilitySuccess(
capability,
result as GatewayRuntimePayload,
Date.now() - startedAt,
);
}
return result;
}).catch((error) => {
const capability = classifyCapabilityMethod(method);
if (capability) {
this.capabilityMonitor.recordCapabilityFailure(capability, error, Date.now() - startedAt);
}
if (isTransportRpcFailure(method, error)) {
this.capabilityMonitor.recordCoreProbe({
ok: false,
checkedAt: Date.now(),
durationMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : String(error),
});
if (isTransportRpcFailure(error)) {
this.recordRpcFailure(method);
}
throw error;
@@ -947,7 +810,7 @@ export class GatewayManager extends EventEmitter {
private startHealthCheck(): void {
this.connectionMonitor.startHealthCheck({
shouldCheck: () => this.status.state === 'running',
checkHealth: () => this.checkTransportHealth(),
checkHealth: () => this.checkHealth(),
onUnhealthy: (errorMessage) => {
this.emit('error', new Error(errorMessage));
},
@@ -961,7 +824,7 @@ export class GatewayManager extends EventEmitter {
* Check Gateway health via WebSocket ping
* OpenClaw Gateway doesn't have an HTTP /health endpoint
*/
private async checkTransportHealth(): Promise<{ ok: boolean; error?: string; uptime?: number }> {
async checkHealth(): Promise<{ ok: boolean; error?: string; uptime?: number }> {
try {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
const uptime = this.status.connectedAt
@@ -975,31 +838,7 @@ export class GatewayManager extends EventEmitter {
}
}
async checkHealth(options?: { probe?: boolean }): Promise<GatewayHealthReport> {
const transport = await this.checkTransportHealth();
if (transport.ok && this.status.state === 'running' && this.status.gatewayReady !== false) {
const timeoutMs = options?.probe ? 8_000 : 3_000;
const [healthResult, statusResult] = await Promise.allSettled([
this.rpc('health', { probe: options?.probe === true }, timeoutMs),
this.rpc('status', {}, timeoutMs),
]);
if (healthResult.status === 'fulfilled') {
this.capabilityMonitor.recordOpenClawHealth(healthResult.value as GatewayRuntimePayload);
}
if (statusResult.status === 'fulfilled') {
this.capabilityMonitor.recordOpenClawStatus(statusResult.value as GatewayRuntimePayload);
}
}
return {
...transport,
capabilities: this.getCapabilitySnapshot(),
};
}
private recordGatewayAlive(): void {
this.clearInitialReadyHeartbeatRecoveryTimer();
this.diagnostics.lastAliveAt = Date.now();
this.diagnostics.consecutiveHeartbeatMisses = 0;
}
@@ -1164,12 +1003,6 @@ export class GatewayManager extends EventEmitter {
private handleMessage(message: unknown): void {
this.connectionMonitor.markAlive('message');
this.recordGatewayAlive();
if (isGatewayWsTraceEnabled()) {
logger.debug('[gateway-ws-trace] recv', {
summary: summarizeGatewayFrameForTrace(message),
frame: redactGatewayFrameForTrace(message),
});
}
if (typeof message !== 'object' || message === null) {
logger.debug('Received non-object Gateway message');
@@ -1223,10 +1056,17 @@ export class GatewayManager extends EventEmitter {
* Start ping interval to keep connection alive
*/
private startPing(): void {
const isWindows = process.platform === 'win32';
this.connectionMonitor.startPing({
intervalMs: GatewayManager.HEARTBEAT_INTERVAL_MS,
timeoutMs: GatewayManager.HEARTBEAT_TIMEOUT_MS,
maxConsecutiveMisses: GatewayManager.HEARTBEAT_MAX_MISSES,
intervalMs: isWindows
? GatewayManager.HEARTBEAT_INTERVAL_MS_WIN
: GatewayManager.HEARTBEAT_INTERVAL_MS,
timeoutMs: isWindows
? GatewayManager.HEARTBEAT_TIMEOUT_MS_WIN
: GatewayManager.HEARTBEAT_TIMEOUT_MS,
maxConsecutiveMisses: isWindows
? GatewayManager.HEARTBEAT_MAX_MISSES_WIN
: GatewayManager.HEARTBEAT_MAX_MISSES,
sendPing: () => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.ping();
@@ -1235,22 +1075,17 @@ export class GatewayManager extends EventEmitter {
onHeartbeatTimeout: ({ consecutiveMisses, timeoutMs }) => {
this.recordHeartbeatTimeout(consecutiveMisses);
const pid = this.process?.pid ?? 'unknown';
const shouldAttemptRecovery = this.shouldReconnect && this.status.state === 'running';
const isWindows = process.platform === 'win32';
const shouldAttemptRecovery = !isWindows && this.shouldReconnect && this.status.state === 'running';
logger.warn(
`Gateway heartbeat: ${consecutiveMisses} consecutive pong misses ` +
`(timeout=${timeoutMs}ms, pid=${pid}, state=${this.status.state}, autoReconnect=${this.shouldReconnect}).`,
);
if (!shouldAttemptRecovery) {
logger.warn('Gateway heartbeat recovery skipped (lifecycle is not in auto-recoverable running state)');
return;
}
const initialReadyRecoveryDelayMs = this.getInitialReadyHeartbeatRecoveryDelayMs();
if (initialReadyRecoveryDelayMs > 0) {
logger.warn(
`Gateway heartbeat recovery deferred while waiting for initial gateway.ready ` +
`(retryAfterMs=${initialReadyRecoveryDelayMs})`,
);
this.scheduleInitialReadyHeartbeatRecovery(initialReadyRecoveryDelayMs);
const reason = isWindows
? 'platform=win32'
: 'lifecycle is not in auto-recoverable running state';
logger.warn(`Gateway heartbeat recovery skipped (${reason})`);
return;
}
logger.warn('Gateway heartbeat recovery: restarting unresponsive gateway process');
@@ -1261,36 +1096,6 @@ export class GatewayManager extends EventEmitter {
});
}
private getInitialReadyHeartbeatRecoveryDelayMs(now = Date.now()): number {
if (this.status.gatewayReady || !this.status.connectedAt) return 0;
const connectedForMs = Math.max(0, now - this.status.connectedAt);
return Math.max(0, GatewayManager.INITIAL_READY_HEARTBEAT_RECOVERY_GRACE_MS - connectedForMs);
}
private scheduleInitialReadyHeartbeatRecovery(delayMs: number): void {
if (this.initialReadyHeartbeatRecoveryTimer) return;
this.initialReadyHeartbeatRecoveryTimer = setTimeout(() => {
this.initialReadyHeartbeatRecoveryTimer = null;
if (
!this.shouldReconnect
|| this.status.state !== 'running'
|| this.status.gatewayReady
) {
return;
}
logger.warn('Gateway heartbeat recovery: initial gateway.ready grace expired, restarting unresponsive gateway process');
void this.restart().catch((error) => {
logger.warn('Gateway heartbeat recovery failed:', error);
});
}, delayMs);
}
private clearInitialReadyHeartbeatRecoveryTimer(): void {
if (!this.initialReadyHeartbeatRecoveryTimer) return;
clearTimeout(this.initialReadyHeartbeatRecoveryTimer);
this.initialReadyHeartbeatRecoveryTimer = null;
}
/**
* Schedule reconnection attempt with exponential backoff
*/
@@ -1,160 +0,0 @@
import { app } from 'electron';
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, join } from 'node:path';
const CACHE_SCHEMA_VERSION = 1;
const CACHE_FILE_NAME = 'gateway-prelaunch-maintenance-cache.json';
export type PrelaunchMaintenanceTaskName =
| 'plugin-maintenance'
| 'runtime-deps-cleanup'
| 'skills-symlink-cleanup';
export interface PrelaunchMaintenanceRunResult {
executed: boolean;
reason: 'cache-hit' | 'cache-miss' | 'cache-unavailable' | 'task-failed';
}
type CacheKeyInput = string | (() => string);
type MaintenanceTask = () => void | boolean;
interface CacheEntry {
key: string;
updatedAt: string;
}
interface CacheFile {
schemaVersion: number;
tasks: Partial<Record<PrelaunchMaintenanceTaskName, CacheEntry>>;
}
function getDefaultCachePath(): string {
return join(app.getPath('userData'), CACHE_FILE_NAME);
}
function emptyCache(): CacheFile {
return {
schemaVersion: CACHE_SCHEMA_VERSION,
tasks: {},
};
}
function readCache(cachePath: string): CacheFile | null {
try {
if (!existsSync(cachePath)) return emptyCache();
const parsed = JSON.parse(readFileSync(cachePath, 'utf-8')) as CacheFile;
if (parsed.schemaVersion !== CACHE_SCHEMA_VERSION || !parsed.tasks) {
return emptyCache();
}
return parsed;
} catch {
return null;
}
}
function writeCache(cachePath: string, cache: CacheFile): boolean {
try {
mkdirSync(dirname(cachePath), { recursive: true });
writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`, 'utf-8');
return true;
} catch {
return false;
}
}
export function stableJson(value: unknown): string {
if (value == null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) {
return `[${value.map((item) => stableJson(item)).join(',')}]`;
}
const entries = Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableJson(entryValue)}`);
return `{${entries.join(',')}}`;
}
export function pathSignature(path: string): string {
try {
const stat = statSync(path);
return `${stat.isDirectory() ? 'dir' : 'file'}:${Math.round(stat.mtimeMs)}:${stat.size}`;
} catch {
return 'missing';
}
}
export function directoryChildrenSignature(path: string, maxEntries = 200): string {
try {
const entries = readdirSync(path, { withFileTypes: true, encoding: 'utf8' })
.sort((left, right) => left.name.localeCompare(right.name))
.slice(0, maxEntries)
.map((entry) => {
const childPath = join(path, entry.name);
return [
entry.name,
entry.isDirectory() ? 'dir' : entry.isSymbolicLink() ? 'symlink' : 'file',
pathSignature(childPath),
].join(':');
});
return stableJson(entries);
} catch {
return 'missing';
}
}
export function buildPrelaunchMaintenanceCacheKey(parts: Record<string, unknown>): string {
return stableJson({
schemaVersion: CACHE_SCHEMA_VERSION,
...parts,
});
}
export function runCachedPrelaunchMaintenanceTask(
taskName: PrelaunchMaintenanceTaskName,
cacheKey: CacheKeyInput,
task: MaintenanceTask,
options: { cachePath?: string } = {},
): PrelaunchMaintenanceRunResult {
const readCacheKey = (): string => (typeof cacheKey === 'function' ? cacheKey() : cacheKey);
const cachePath = options.cachePath ?? getDefaultCachePath();
const cache = readCache(cachePath);
if (!cache) {
task();
return { executed: true, reason: 'cache-unavailable' };
}
let initialCacheKey: string;
try {
initialCacheKey = readCacheKey();
} catch {
task();
return { executed: true, reason: 'cache-unavailable' };
}
if (cache.tasks[taskName]?.key === initialCacheKey) {
return { executed: false, reason: 'cache-hit' };
}
const taskResult = task();
if (taskResult === false) {
return { executed: true, reason: 'task-failed' };
}
let finalCacheKey: string;
try {
finalCacheKey = readCacheKey();
} catch {
return { executed: true, reason: 'cache-unavailable' };
}
cache.tasks[taskName] = {
key: finalCacheKey,
updatedAt: new Date().toISOString(),
};
writeCache(cachePath, cache);
return { executed: true, reason: 'cache-miss' };
}
+4 -26
View File
@@ -32,10 +32,8 @@ const GATEWAY_FETCH_PRELOAD_SOURCE = `'use strict';
delete flat['HTTP-Referer'];
delete flat['x-title'];
delete flat['X-Title'];
delete flat['x-openrouter-title'];
delete flat['X-OpenRouter-Title'];
flat['HTTP-Referer'] = 'https://claw-x.com';
flat['X-OpenRouter-Title'] = 'ClawX';
flat['X-Title'] = 'ClawX';
init.headers = flat;
}
return _f.call(globalThis, input, init);
@@ -119,25 +117,6 @@ export async function launchGatewayProcess(options: {
const lastSpawnSummary = `mode=${mode}, entry="${entryScript}", args="${options.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}"`;
const runtimeEnv = { ...forkEnv };
// Disable OpenClaw's mDNS/Bonjour gateway advertiser unconditionally.
//
// The OpenClaw gateway advertises `_openclaw-gw._tcp.local` on every
// active network interface using a hardcoded `openclaw.local` hostname,
// which causes:
// - cross-machine name collisions when multiple OpenClaw/ClawX peers
// share a LAN (each falls back to "<name> (OpenClaw) (2)")
// - self-collisions on multi-homed hosts (Wi-Fi + Tailscale + utun ...)
// - "ghost" record collisions after an unclean ClawX exit, because
// SIGKILL prevents ciao from emitting the mDNS goodbye record.
//
// ClawX has no UI for LAN gateway discovery today, so the advertiser is
// pure log noise. `OPENCLAW_DISABLE_BONJOUR=1` short-circuits
// `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';
// Only apply the fetch/child_process preload in dev mode.
// In packaged builds Electron's UtilityProcess rejects NODE_OPTIONS
// with --require, logging "Most NODE_OPTIONs are not supported in
@@ -176,11 +155,10 @@ export async function launchGatewayProcess(options: {
reject(error);
};
child.on('error', (error: unknown) => {
const normalizedError = error instanceof Error ? error : new Error(String(error));
child.on('error', (error) => {
logger.error('Gateway process spawn error:', error);
options.onError(normalizedError);
rejectOnce(normalizedError);
options.onError(error);
rejectOnce(error);
});
child.on('exit', (code: number) => {
-101
View File
@@ -1,101 +0,0 @@
type GatewayRpcRunner = (method: string, params?: unknown, timeoutMs?: number) => Promise<unknown>;
type QueuedRpc = {
run: () => Promise<void>;
};
function stableStringify(value: unknown): string {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(',')}]`;
}
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map((key) => (
`${JSON.stringify(key)}:${stableStringify(record[key])}`
)).join(',')}}`;
}
export interface GatewayRpcBackpressureOptions {
maxConcurrentHistory?: number;
}
/**
* Prevents renderer fan-out from forwarding an unbounded number of expensive
* chat.history RPCs to OpenClaw. The Gateway still owns the canonical response;
* this class only coalesces duplicate in-flight history calls and runs distinct
* history requests through a small FIFO queue.
*/
export class GatewayRpcBackpressure {
private readonly maxConcurrentHistory: number;
private readonly inFlightHistory = new Map<string, Promise<unknown>>();
private readonly queue: QueuedRpc[] = [];
private activeHistory = 0;
constructor(options: GatewayRpcBackpressureOptions = {}) {
this.maxConcurrentHistory = Math.max(1, options.maxConcurrentHistory ?? 2);
}
run(
method: string,
params: unknown,
timeoutMs: number | undefined,
runner: GatewayRpcRunner,
): Promise<unknown> {
if (method !== 'chat.history') {
return runner(method, params, timeoutMs);
}
const key = `${method}:${stableStringify(params)}:${timeoutMs ?? 'default'}`;
const existing = this.inFlightHistory.get(key);
if (existing) return existing;
const promise = this.enqueueHistory(() => runner(method, params, timeoutMs))
.finally(() => {
if (this.inFlightHistory.get(key) === promise) {
this.inFlightHistory.delete(key);
}
});
this.inFlightHistory.set(key, promise);
return promise;
}
getDiagnostics(): { activeHistory: number; queuedHistory: number; inFlightHistory: number } {
return {
activeHistory: this.activeHistory,
queuedHistory: this.queue.length,
inFlightHistory: this.inFlightHistory.size,
};
}
private enqueueHistory(work: () => Promise<unknown>): Promise<unknown> {
return new Promise((resolve, reject) => {
const queued: QueuedRpc = {
run: async () => {
this.activeHistory += 1;
try {
resolve(await work());
} catch (error) {
reject(error);
} finally {
this.activeHistory -= 1;
this.drain();
}
},
};
this.queue.push(queued);
this.drain();
});
}
private drain(): void {
while (this.activeHistory < this.maxConcurrentHistory) {
const next = this.queue.shift();
if (!next) return;
void next.run();
}
}
}
-388
View File
@@ -1,388 +0,0 @@
/**
* Pre-launch cleanup for stray skill symlinks under OpenClaw skill roots.
*
* Background: since openclaw commit 253e159700 ("fix: harden workspace skill
* path containment"), the Gateway rejects any candidate under a skills root
* whose realpath escapes that root, logging a noisy
* `Skipping escaped skill path outside its configured root.
* reason=symlink-escape source=openclaw-managed ...`
* warning per offending entry on every start.
*
* Common offenders are one-shot install scripts that drop symlinks into:
* - ~/.openclaw/skills/<name> -> ~/.agents/skills/<name>
* - ~/.openclaw/workspace/skills/<name> -> ~/.openclaw/workspace/.agents/skills/<name>
* - ~/.openclaw/skills/<name> -> ~/workspace/<repo>/skills/<name>
* The hardened loader rejects these because their realpath escapes the
* configured managed root, so they are pure log noise — entries that the
* loader can never accept from this root.
*
* This helper is invoked before each Gateway launch to remove those
* specific symlinks. Scope is intentionally narrow:
* - source dirs: ~/.openclaw/skills and ~/.openclaw/workspace/skills
* - target dirs: anything outside the matching managed skills root
* Symlinks whose realpath stays inside the same managed skills root are left
* untouched.
*
* Removal uses fs.rmSync({ force: true, recursive: true }) rather than
* fs.unlinkSync so that directory symlinks and Windows junctions (the form
* that non-admin Windows installs end up creating) are deleted correctly.
* unlinkSync raises EPERM on those on Windows, and rmSync without recursive
* can reject directory symlinks on some platforms.
*
* This is a transitional workaround. Once openclaw/openclaw#59219 lands and
* the loader stops rejecting managed-source symlinks whose realpath escapes
* the managed root, this helper can be removed entirely.
*/
import {
existsSync,
lstatSync,
readlinkSync,
readdirSync,
realpathSync,
rmSync,
type Dirent,
} from 'node:fs';
import { homedir } from 'node:os';
import path from 'node:path';
import { getOpenClawConfigDir, getOpenClawResolvedDir, getOpenClawSkillsDir } from '../utils/paths';
import { logger } from '../utils/logger';
export interface CleanupOptions {
/** Override for ~/.openclaw/skills (mainly for tests). */
skillsDir?: string;
/** Override for ~/.agents/skills (mainly for tests/log context). */
agentsDir?: string;
/** Override for ~/.openclaw/workspace/skills (mainly for tests). */
workspaceSkillsDir?: string;
/** Override for ~/.openclaw/workspace/.agents/skills (mainly for tests). */
workspaceAgentsDir?: string;
}
export interface CleanupResult {
/** Symlink names that were unlinked from the skills dir. */
removed: string[];
/** Total number of symlink entries that were inspected. */
examined: number;
/** Cleanup operations that could not be completed and should be retried later. */
failed?: number;
}
export interface PluginRuntimeDepsCleanupOptions {
/** Override for ~/.openclaw/plugin-runtime-deps (mainly for tests). */
runtimeDepsDir?: string;
/** Override for the current bundled OpenClaw package dir (mainly for tests). */
currentOpenClawDir?: string;
}
function defaultSkillsDir(): string {
return getOpenClawSkillsDir();
}
function recordCleanupFailure(result: CleanupResult): void {
result.failed = (result.failed ?? 0) + 1;
}
function defaultAgentsDir(): string {
return path.join(homedir(), '.agents', 'skills');
}
function defaultWorkspaceSkillsDir(): string {
return path.join(getOpenClawConfigDir(), 'workspace', 'skills');
}
function defaultWorkspaceAgentsDir(): string {
return path.join(getOpenClawConfigDir(), 'workspace', '.agents', 'skills');
}
function defaultPluginRuntimeDepsDir(): string {
return path.join(getOpenClawConfigDir(), 'plugin-runtime-deps');
}
/**
* Resolve the agents skills directory to its real path. When the directory
* itself does not exist yet (fresh install), fall back to realpath'ing its
* parent and re-appending the basename so a `~/.agents -> /opt/agents`
* indirection is still honored. As a final fallback returns the lexical
* resolved path.
*/
function resolveAgentsRealRoot(agentsDir: string): string {
if (existsSync(agentsDir)) {
try {
return realpathSync(agentsDir);
} catch {
// fall through
}
}
const parent = path.dirname(agentsDir);
const tail = path.basename(agentsDir);
if (parent && parent !== agentsDir && existsSync(parent)) {
try {
return path.join(realpathSync(parent), tail);
} catch {
// fall through
}
}
return path.resolve(agentsDir);
}
/**
* Lower-case path strings on Win32 only so the `path.relative` byte-wise
* comparison aligns with NTFS case-insensitive semantics. No-op elsewhere.
*/
function normalizeForCompare(p: string): string {
return process.platform === 'win32' ? p.toLowerCase() : p;
}
function isInside(parent: string, child: string): boolean {
const rel = path.relative(normalizeForCompare(parent), normalizeForCompare(child));
if (rel === '') return true;
return !rel.startsWith('..') && !path.isAbsolute(rel);
}
function resolveSymlinkTarget(linkPath: string): string | null {
try {
const target = readlinkSync(linkPath);
return path.resolve(path.dirname(linkPath), target);
} catch {
return null;
}
}
function looksLikeOpenClawPackagePath(candidate: string): boolean {
const normalized = candidate.replace(/\\/g, '/');
return /\/node_modules(?:\/\.pnpm\/[^/]+\/node_modules)?\/openclaw(?:\/|$)/.test(normalized);
}
function resolveCurrentOpenClawRoots(currentOpenClawDir: string): string[] {
const roots = new Set<string>([path.resolve(currentOpenClawDir)]);
try {
roots.add(realpathSync(currentOpenClawDir));
} catch {
// fall through
}
return Array.from(roots);
}
export function cleanupAgentsSymlinkedSkills(opts: CleanupOptions = {}): CleanupResult {
const hasMainOverrides = opts.skillsDir !== undefined || opts.agentsDir !== undefined;
const hasWorkspaceOverrides =
opts.workspaceSkillsDir !== undefined || opts.workspaceAgentsDir !== undefined;
const roots = [
{
skillsDir: opts.skillsDir ?? defaultSkillsDir(),
agentsDir: opts.agentsDir ?? defaultAgentsDir(),
},
];
if (!hasMainOverrides || hasWorkspaceOverrides) {
roots.push({
skillsDir: opts.workspaceSkillsDir ?? defaultWorkspaceSkillsDir(),
agentsDir: opts.workspaceAgentsDir ?? defaultWorkspaceAgentsDir(),
});
}
const result: CleanupResult = { removed: [], examined: 0 };
const seenRoots = new Set<string>();
for (const root of roots) {
const rootKey = `${path.resolve(root.skillsDir)}\0${path.resolve(root.agentsDir)}`;
if (seenRoots.has(rootKey)) continue;
seenRoots.add(rootKey);
const rootResult = cleanupSkillsDir(root.skillsDir, root.agentsDir);
result.removed.push(...rootResult.removed);
result.examined += rootResult.examined;
if (rootResult.failed) {
result.failed = (result.failed ?? 0) + rootResult.failed;
}
}
return result;
}
/**
* Remove stale OpenClaw plugin runtime dependency cache roots.
*
* OpenClaw can materialize `~/.openclaw/plugin-runtime-deps/openclaw-*` as a
* symlink tree back into the package's `dist` files. After app upgrades or
* worktree switches those symlinks can point at an old `node_modules/openclaw`
* path. The Gateway may then spend a long time synchronously opening/copying
* old runtime files during plugin setup, which blocks RPC readiness.
*
* Scope is intentionally narrow: only immediate cache roots named `openclaw-*`
* are removed, and only when a symlink inside points at an OpenClaw package
* path outside the current bundled package. The cache is regenerated by
* OpenClaw on demand.
*/
export function cleanupStalePluginRuntimeDeps(
opts: PluginRuntimeDepsCleanupOptions = {},
): CleanupResult {
const runtimeDepsDir = opts.runtimeDepsDir ?? defaultPluginRuntimeDepsDir();
const currentRoots = resolveCurrentOpenClawRoots(opts.currentOpenClawDir ?? getOpenClawResolvedDir());
const result: CleanupResult = { removed: [], examined: 0 };
if (!existsSync(runtimeDepsDir)) {
return result;
}
let entries: Dirent[];
try {
entries = readdirSync(runtimeDepsDir, { withFileTypes: true, encoding: 'utf8' });
} catch (err) {
logger.warn(`[plugin-runtime-deps-cleanup] Failed to list ${runtimeDepsDir}:`, err);
recordCleanupFailure(result);
return result;
}
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith('openclaw-')) {
continue;
}
const cacheRoot = path.join(runtimeDepsDir, entry.name);
const scan = scanRuntimeDepsRootForStaleOpenClawSymlink(cacheRoot, currentRoots);
result.examined += scan.examined;
if (!scan.stale) {
continue;
}
try {
rmSync(cacheRoot, { force: true, recursive: true });
result.removed.push(entry.name);
} catch (err) {
logger.warn(`[plugin-runtime-deps-cleanup] Failed to remove ${cacheRoot}:`, err);
recordCleanupFailure(result);
}
}
if (result.removed.length > 0) {
logger.info(
`[plugin-runtime-deps-cleanup] Removed ${result.removed.length} stale OpenClaw runtime cache root(s): ` +
result.removed.join(', '),
);
}
return result;
}
function scanRuntimeDepsRootForStaleOpenClawSymlink(
cacheRoot: string,
currentOpenClawRoots: string[],
): { stale: boolean; examined: number } {
const stack = [cacheRoot];
let examined = 0;
const maxEntries = 5000;
while (stack.length > 0 && examined < maxEntries) {
const dir = stack.pop()!;
let entries: Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' });
} catch {
continue;
}
for (const entry of entries) {
if (examined >= maxEntries) break;
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
stack.push(entryPath);
continue;
}
let isSymlink = entry.isSymbolicLink();
if (!isSymlink) {
try {
isSymlink = lstatSync(entryPath).isSymbolicLink();
} catch {
continue;
}
}
if (!isSymlink) continue;
examined++;
const target = resolveSymlinkTarget(entryPath);
if (!target || !looksLikeOpenClawPackagePath(target)) {
continue;
}
const pointsAtCurrentOpenClaw = currentOpenClawRoots.some((root) => isInside(root, target));
if (!pointsAtCurrentOpenClaw) {
return { stale: true, examined };
}
}
}
return { stale: false, examined };
}
function cleanupSkillsDir(skillsDir: string, agentsDir: string): CleanupResult {
const result: CleanupResult = { removed: [], examined: 0 };
if (!existsSync(skillsDir)) {
return result;
}
let entries: Dirent[];
try {
entries = readdirSync(skillsDir, { withFileTypes: true, encoding: 'utf8' });
} catch (err) {
logger.warn(`[skills-cleanup] Failed to list ${skillsDir}:`, err);
recordCleanupFailure(result);
return result;
}
const agentsRealRoot = resolveAgentsRealRoot(agentsDir);
const skillsRealRoot = resolveAgentsRealRoot(skillsDir);
for (const entry of entries) {
const entryPath = path.join(skillsDir, entry.name);
let isSymlink = entry.isSymbolicLink();
if (!isSymlink) {
try {
isSymlink = lstatSync(entryPath).isSymbolicLink();
} catch {
continue;
}
}
if (!isSymlink) continue;
result.examined++;
let realTarget: string;
try {
realTarget = realpathSync(entryPath);
} catch {
continue;
}
if (isInside(skillsRealRoot, realTarget)) continue;
try {
// rmSync handles file symlinks, directory symlinks, and Windows
// junctions uniformly. unlinkSync would raise EPERM on directory
// symlinks/junctions on Windows.
rmSync(entryPath, { force: true, recursive: true });
result.removed.push(entry.name);
} catch (err) {
logger.warn(`[skills-cleanup] Failed to remove ${entryPath}:`, err);
recordCleanupFailure(result);
}
}
if (result.removed.length > 0) {
logger.info(
`[skills-cleanup] Removed ${result.removed.length} stray skill symlink(s) ` +
`under ${skillsDir} that escaped managed root ${skillsRealRoot} ` +
`(workaround for openclaw/openclaw#59219): ` +
result.removed.join(', '),
);
} else if (result.examined > 0) {
logger.debug(
`[skills-cleanup] Examined ${result.examined} symlink(s) under ${skillsDir}; ` +
`none escaped managed root (agents context: ${agentsRealRoot})`,
);
}
return result;
}
+4 -20
View File
@@ -1,6 +1,6 @@
import { logger } from '../utils/logger';
import { LifecycleSupersededError } from './lifecycle-controller';
import { connectGatewayWithStartupRetry, getGatewayStartupRecoveryAction } from './startup-recovery';
import { getGatewayStartupRecoveryAction } from './startup-recovery';
export interface ExistingGatewayInfo {
port: number;
@@ -29,22 +29,6 @@ type StartupHooks = {
delay: (ms: number) => Promise<void>;
};
async function connectWithStartupRetry(
hooks: StartupHooks,
port: number,
externalToken?: string,
): Promise<void> {
await connectGatewayWithStartupRetry({
connect: hooks.connect,
port,
externalToken,
delay: hooks.delay,
beforeAttempt: () => hooks.assertLifecycle('start/connect-retry'),
logWarn: (message) => logger.warn(message),
logInfo: (message) => logger.info(message),
});
}
export async function runGatewayStartupSequence(hooks: StartupHooks): Promise<void> {
let configRepairAttempted = false;
let startAttempts = 0;
@@ -61,7 +45,7 @@ export async function runGatewayStartupSequence(hooks: StartupHooks): Promise<vo
hooks.assertLifecycle('start/find-existing');
if (existing) {
logger.debug(`Found existing Gateway on port ${existing.port}`);
await connectWithStartupRetry(hooks, existing.port, existing.externalToken);
await hooks.connect(existing.port, existing.externalToken);
hooks.assertLifecycle('start/connect-existing');
hooks.onConnectedToExistingGateway();
return;
@@ -77,7 +61,7 @@ export async function runGatewayStartupSequence(hooks: StartupHooks): Promise<vo
logger.info('Owned Gateway process still alive (likely in-process restart); waiting for it to become ready');
await hooks.waitForReady(hooks.port);
hooks.assertLifecycle('start/wait-ready-owned');
await connectWithStartupRetry(hooks, hooks.port);
await hooks.connect(hooks.port);
hooks.assertLifecycle('start/connect-owned');
hooks.onConnectedToExistingGateway();
return;
@@ -96,7 +80,7 @@ export async function runGatewayStartupSequence(hooks: StartupHooks): Promise<vo
await hooks.waitForReady(hooks.port);
hooks.assertLifecycle('start/wait-ready');
await connectWithStartupRetry(hooks, hooks.port);
await hooks.connect(hooks.port);
hooks.assertLifecycle('start/connect');
hooks.onConnectedToManagedGateway();
-52
View File
@@ -18,15 +18,10 @@ const TRANSIENT_START_ERROR_PATTERNS: RegExp[] = [
/Gateway process exited before becoming ready/i,
/Timed out waiting for connect\.challenge/i,
/Connect handshake timeout/i,
// OpenClaw can emit connect.challenge before the connect RPC is accepted.
/gateway starting/i,
// Port occupied after orphan kill: transient, worth retrying with backoff
/Port \d+ still occupied after \d+ms/i,
];
/** Backoff between connect() attempts when the Gateway rejects with "still starting". */
export const GATEWAY_CONNECT_STARTUP_RETRY_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000, 8_000] as const;
function normalizeLogLine(value: string): string {
return value.trim();
}
@@ -80,53 +75,6 @@ export function isTransientGatewayStartError(error: unknown): boolean {
return TRANSIENT_START_ERROR_PATTERNS.some((pattern) => pattern.test(errorText));
}
export function isGatewayStillStartingError(error: unknown): boolean {
const errorText = error instanceof Error
? error.message
: String(error ?? '');
return /gateway starting/i.test(errorText);
}
export async function connectGatewayWithStartupRetry(options: {
connect: (port: number, externalToken?: string) => Promise<void>;
port: number;
externalToken?: string;
delay: (ms: number) => Promise<void>;
retryDelaysMs?: readonly number[];
beforeAttempt?: () => void;
logWarn?: (message: string) => void;
logInfo?: (message: string) => void;
}): Promise<void> {
const retryDelaysMs = options.retryDelaysMs ?? GATEWAY_CONNECT_STARTUP_RETRY_DELAYS_MS;
const logWarn = options.logWarn ?? (() => {});
const logInfo = options.logInfo ?? (() => {});
let lastError: unknown;
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) {
options.beforeAttempt?.();
try {
await options.connect(options.port, options.externalToken);
if (attempt > 0) {
logInfo(`Gateway connect succeeded after ${attempt + 1} attempt(s)`);
}
return;
} catch (error) {
lastError = error;
if (!isGatewayStillStartingError(error) || attempt >= retryDelaysMs.length) {
throw error;
}
const delayMs = retryDelaysMs[attempt] ?? retryDelaysMs[retryDelaysMs.length - 1]!;
logWarn(
`Gateway connect rejected while still starting (${String(error)}); `
+ `retrying in ${delayMs}ms (${attempt + 1}/${retryDelaysMs.length})`,
);
await options.delay(delayMs);
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError ?? 'Gateway connect failed'));
}
export type GatewayStartupRecoveryAction = 'repair' | 'retry' | 'fail';
export function getGatewayStartupRecoveryAction(options: {
+3 -29
View File
@@ -7,11 +7,6 @@ import {
signDevicePayload,
} from '../utils/device-identity';
import { logger } from '../utils/logger';
import {
isGatewayWsTraceEnabled,
redactGatewayFrameForTrace,
summarizeGatewayFrameForTrace,
} from './ws-trace';
export const GATEWAY_CHALLENGE_TIMEOUT_MS = 10_000;
export const GATEWAY_CONNECT_HANDSHAKE_TIMEOUT_MS = 20_000;
@@ -107,8 +102,6 @@ export async function waitForGatewayReady(options: {
throw new Error(`Gateway failed to start after ${retries} retries (port ${options.port})`);
}
const GATEWAY_PROTOCOL_VERSION = 4;
export function buildGatewayConnectFrame(options: {
challengeNonce: string;
token: string;
@@ -152,8 +145,8 @@ export function buildGatewayConnectFrame(options: {
id: connectId,
method: 'connect',
params: {
minProtocol: GATEWAY_PROTOCOL_VERSION,
maxProtocol: GATEWAY_PROTOCOL_VERSION,
minProtocol: 3,
maxProtocol: 3,
client: {
id: clientId,
displayName: 'ClawX',
@@ -164,7 +157,7 @@ export function buildGatewayConnectFrame(options: {
auth: {
token: options.token,
},
caps: ['tool-events'],
caps: [],
role,
scopes,
device,
@@ -228,13 +221,6 @@ export async function connectGatewaySocket(options: {
if (settled) return;
settled = true;
cleanupHandshakeRequest();
if (!handshakeComplete) {
try {
ws.terminate();
} catch {
// ignore cleanup errors during failed startup handshakes
}
}
reject(error instanceof Error ? error : new Error(String(error)));
};
@@ -250,12 +236,6 @@ export async function connectGatewaySocket(options: {
});
connectId = connectPayload.connectId;
if (isGatewayWsTraceEnabled()) {
logger.debug('[gateway-ws-trace] send', {
summary: summarizeGatewayFrameForTrace(connectPayload.frame),
frame: redactGatewayFrameForTrace(connectPayload.frame),
});
}
ws.send(JSON.stringify(connectPayload.frame));
const requestTimeout = setTimeout(() => {
@@ -297,12 +277,6 @@ export async function connectGatewaySocket(options: {
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
if (isGatewayWsTraceEnabled()) {
logger.debug('[gateway-ws-trace] recv', {
summary: summarizeGatewayFrameForTrace(message),
frame: redactGatewayFrameForTrace(message),
});
}
if (
!challengeReceived &&
typeof message === 'object' && message !== null &&
-51
View File
@@ -1,51 +0,0 @@
const SECRET_KEYS = new Set([
'token',
'authorization',
'apikey',
'api_key',
'signature',
'cookie',
'set-cookie',
'accesstoken',
'refreshtoken',
]);
export function isGatewayWsTraceEnabled(): boolean {
return process.env.CLAWX_GATEWAY_WS_TRACE === '1';
}
export function redactGatewayFrameForTrace(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => redactGatewayFrameForTrace(item));
}
if (!value || typeof value !== 'object') {
return value;
}
const result: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
const normalizedKey = key.toLowerCase();
result[key] = SECRET_KEYS.has(normalizedKey)
? '[redacted]'
: redactGatewayFrameForTrace(item);
}
return result;
}
export function summarizeGatewayFrameForTrace(value: unknown): string {
if (!value || typeof value !== 'object') return typeof value;
const frame = value as Record<string, unknown>;
if (frame.type === 'req') {
return `req id=${String(frame.id ?? '-')} method=${String(frame.method ?? '-')}`;
}
if (frame.type === 'res') {
return `res id=${String(frame.id ?? '-')} ok=${String(frame.ok ?? !frame.error)}`;
}
if (frame.type === 'event') {
return `event ${String(frame.event ?? '-')}`;
}
if (typeof frame.method === 'string') {
return `jsonrpc method=${frame.method}`;
}
return 'unknown gateway frame';
}
+46 -92
View File
@@ -3,13 +3,12 @@
* Manages window creation, system tray, and IPC handlers
*/
import { app, BrowserWindow, nativeImage, session, shell } from 'electron';
import type { Server } from 'node:http';
import { join } from 'path';
import { GatewayManager } from '../gateway/manager';
import { registerIpcHandlers } from './ipc-handlers';
import { HostApiRegistry } from './ipc/host-invoke';
import { createTray } from './tray';
import { createMenu } from './menu';
import { registerZoomShortcuts } from './zoom-shortcuts';
import { appUpdater, registerUpdateHandlers } from './updater';
import { logger } from '../utils/logger';
@@ -21,15 +20,9 @@ import { extensionRegistry } from '../extensions/registry';
import { loadExtensionsFromManifest } from '../extensions/loader';
import { registerAllBuiltinExtensions } from '../extensions/builtin';
import { loadExternalMainExtensions } from '../extensions/_ext-bridge.generated';
import {
ensureClawXContext,
ensureClawXDefaultIdentity,
repairClawXOnlyBootstrapFiles,
} from '../utils/openclaw-workspace';
import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace';
import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToProfile } from '../utils/openclaw-cli';
import { isQuitting, setQuitting } from './app-state';
import { getMacTrafficLightPosition, syncMacTrafficLightPosition } from './traffic-light-layout';
import { getSetting } from '../utils/store';
import { applyProxySettings } from './proxy';
import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup';
import {
@@ -45,8 +38,11 @@ import {
} from './quit-lifecycle';
import { createSignalQuitHandler } from './signal-quit';
import { acquireProcessInstanceFileLock } from './process-instance-lock';
import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled, trimBundledOpenClawSkillsAndConfigs } from '../utils/skill-config';
import { getSetting } from '../utils/store';
import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '../utils/skill-config';
import { startHostApiServer } from '../api/server';
import { HostEventBus } from '../api/event-bus';
import { deviceOAuthManager } from '../utils/device-oauth';
import { browserOAuthManager } from '../utils/browser-oauth';
import { whatsAppLoginManager } from '../utils/whatsapp-login';
@@ -55,11 +51,6 @@ import { syncAllProviderAuthToRuntime } from '../services/providers/provider-run
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 requestedRemoteDebuggingPort = process.env.CLAWX_REMOTE_DEBUGGING_PORT?.trim();
if (requestedRemoteDebuggingPort) {
app.commandLine.appendSwitch('remote-debugging-port', requestedRemoteDebuggingPort);
}
if (isE2EMode && requestedUserDataDir) {
app.setPath('userData', requestedUserDataDir);
@@ -86,8 +77,7 @@ app.disableHardwareAcceleration();
// on X11 it supplements the StartupWMClass matching.
// Must be called before app.whenReady() / before any window is created.
if (process.platform === 'linux') {
const linuxApp = app as typeof app & { setDesktopName?: (desktopName: string) => void };
linuxApp.setDesktopName?.('clawx.desktop');
app.setDesktopName('clawx.desktop');
}
// Prevent multiple instances of the app from running simultaneously.
@@ -132,16 +122,11 @@ const gotTheLock = gotElectronLock && gotFileLock;
let mainWindow: BrowserWindow | null = null;
let gatewayManager!: GatewayManager;
let clawHubService!: ClawHubService;
const hostApiRegistry = new HostApiRegistry();
let hostEventBus!: HostEventBus;
let hostApiServer: Server | null = null;
const mainWindowFocusState = createMainWindowFocusState();
const quitLifecycleState = createQuitLifecycleState();
function sendMainWindowEvent(channel: string, payload: unknown): void {
const win = mainWindow;
if (!win || win.isDestroyed()) return;
win.webContents.send(channel, payload);
}
/**
* Resolve the icons directory path (works in both dev and packaged mode)
*/
@@ -192,15 +177,11 @@ function createWindow(): BrowserWindow {
webviewTag: true, // Enable <webview> for embedding OpenClaw Control UI
},
titleBarStyle: isMac ? 'hiddenInset' : useCustomTitleBar ? 'hidden' : 'default',
trafficLightPosition: isMac
? getMacTrafficLightPosition(false)
: undefined,
trafficLightPosition: isMac ? { x: 16, y: 16 } : undefined,
frame: isMac || !useCustomTitleBar,
show: false,
});
registerZoomShortcuts(win);
// Handle external links — only allow safe protocols to prevent arbitrary
// command execution via shell.openExternal() (e.g. file://, ms-msdt:, etc.)
win.webContents.setWindowOpenHandler(({ url }) => {
@@ -268,12 +249,6 @@ function createMainWindow(): BrowserWindow {
return;
}
if (process.platform === 'darwin') {
void getSetting('sidebarCollapsed').then((sidebarCollapsed) => {
syncMacTrafficLightPosition(win, sidebarCollapsed);
});
}
const action = consumeMainWindowReady(mainWindowFocusState);
if (action === 'focus') {
focusWindow(win);
@@ -326,7 +301,7 @@ async function initialize(): Promise<void> {
}
// Set application menu
await createMenu();
createMenu();
// Create the main window
const window = createMainWindow();
@@ -360,17 +335,20 @@ async function initialize(): Promise<void> {
);
// Register IPC handlers
registerIpcHandlers(gatewayManager, clawHubService, window, hostApiRegistry);
registerIpcHandlers(gatewayManager, clawHubService, window);
hostApiServer = startHostApiServer({
gatewayManager,
clawHubService,
eventBus: hostEventBus,
mainWindow: window,
});
// Initialize extension system
await extensionRegistry.initialize({
gatewayManager,
eventBus: hostEventBus,
getMainWindow: () => mainWindow,
hostApi: {
register: (extensionId, contributions) => (
hostApiRegistry.registerExtensionContributions(extensionId, contributions)
),
},
});
// Wire marketplace provider to ClawHubService if an extension provides one
@@ -385,14 +363,6 @@ async function initialize(): Promise<void> {
// Note: Auto-check for updates is driven by the renderer (update store init)
// so it respects the user's "Auto-check for updates" setting.
// Seed a stable default IDENTITY.md before the Gateway initializes the
// workspace so ClawX desktop sessions skip OpenClaw's chat-first bootstrap.
if (!isE2EMode) {
void ensureClawXDefaultIdentity().catch((error) => {
logger.warn('Failed to seed default ClawX identity:', error);
});
}
// Repair any bootstrap files that only contain ClawX markers (no OpenClaw
// template content). This fixes a race condition where ensureClawXContext()
// previously created the file before the gateway could seed the full template.
@@ -410,21 +380,6 @@ async function initialize(): Promise<void> {
});
}
// Keep community builds aligned with Clawx-biz by physically trimming
// bundled OpenClaw consumer skills on startup (dev + packaged), keeping only
// `skill-creator`. This also prunes stale openclaw.json entries for trimmed
// bundled skills so we do not keep `enabled: false` config for skills that no
// longer exist.
if (!isE2EMode) {
void trimBundledOpenClawSkillsAndConfigs().then(({ removed, removedConfigs, kept }) => {
if (removed > 0 || removedConfigs > 0) {
logger.info(
`Trimmed bundled OpenClaw skills: removed ${removed}, pruned configs ${removedConfigs}, kept ${kept.join(', ')}`,
);
}
});
}
// Pre-deploy bundled third-party skills from resources/preinstalled-skills.
// This installs full skill directories (not only SKILL.md) in an idempotent,
// non-destructive way and never blocks startup.
@@ -442,7 +397,7 @@ 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 }) => {
sendMainWindowEvent('gateway:status-changed', status);
hostEventBus.emit('gateway:status', status);
if (status.state === 'running' && !isE2EMode) {
void ensureClawXContext().catch((error) => {
logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error);
@@ -451,71 +406,67 @@ async function initialize(): Promise<void> {
});
gatewayManager.on('error', (error) => {
sendMainWindowEvent('gateway:error', { message: error.message });
hostEventBus.emit('gateway:error', { message: error.message });
});
gatewayManager.on('notification', (notification) => {
sendMainWindowEvent('gateway:notification', notification);
});
gatewayManager.on('gateway:health', (data) => {
sendMainWindowEvent('gateway:health-changed', data);
});
gatewayManager.on('gateway:presence', (data) => {
sendMainWindowEvent('gateway:presence-changed', data);
hostEventBus.emit('gateway:notification', notification);
});
gatewayManager.on('chat:message', (data) => {
sendMainWindowEvent('gateway:chat-message', data);
});
gatewayManager.on('chat:runtime-event', (data) => {
sendMainWindowEvent('chat:runtime-event', data);
hostEventBus.emit('gateway:chat-message', data);
});
gatewayManager.on('channel:status', (data) => {
sendMainWindowEvent('gateway:channel-status', data);
hostEventBus.emit('gateway:channel-status', data);
});
gatewayManager.on('exit', (code) => {
sendMainWindowEvent('gateway:exit', { code });
hostEventBus.emit('gateway:exit', { code });
});
deviceOAuthManager.on('oauth:code', (payload) => {
sendMainWindowEvent('oauth:code', payload);
hostEventBus.emit('oauth:code', payload);
});
deviceOAuthManager.on('oauth:start', (payload) => {
hostEventBus.emit('oauth:start', payload);
});
deviceOAuthManager.on('oauth:success', (payload) => {
sendMainWindowEvent('oauth:success', { ...payload, success: true });
hostEventBus.emit('oauth:success', { ...payload, success: true });
});
deviceOAuthManager.on('oauth:error', (error) => {
sendMainWindowEvent('oauth:error', error);
hostEventBus.emit('oauth:error', error);
});
browserOAuthManager.on('oauth:start', (payload) => {
hostEventBus.emit('oauth:start', payload);
});
browserOAuthManager.on('oauth:code', (payload) => {
sendMainWindowEvent('oauth:code', payload);
hostEventBus.emit('oauth:code', payload);
});
browserOAuthManager.on('oauth:success', (payload) => {
sendMainWindowEvent('oauth:success', { ...payload, success: true });
hostEventBus.emit('oauth:success', { ...payload, success: true });
});
browserOAuthManager.on('oauth:error', (error) => {
sendMainWindowEvent('oauth:error', error);
hostEventBus.emit('oauth:error', error);
});
whatsAppLoginManager.on('qr', (data) => {
sendMainWindowEvent('channel:whatsapp-qr', data);
hostEventBus.emit('channel:whatsapp-qr', data);
});
whatsAppLoginManager.on('success', (data) => {
sendMainWindowEvent('channel:whatsapp-success', data);
hostEventBus.emit('channel:whatsapp-success', data);
});
whatsAppLoginManager.on('error', (error) => {
sendMainWindowEvent('channel:whatsapp-error', error);
hostEventBus.emit('channel:whatsapp-error', error);
});
// Start Gateway automatically (this seeds missing bootstrap files with full templates)
@@ -581,6 +532,7 @@ if (gotTheLock) {
gatewayManager = new GatewayManager();
clawHubService = new ClawHubService();
hostEventBus = new HostEventBus();
// Register builtin extensions and load manifest
registerAllBuiltinExtensions();
@@ -644,6 +596,8 @@ if (gotTheLock) {
return;
}
hostEventBus.closeAll();
hostApiServer?.close();
void extensionRegistry.teardownAll();
const stopPromise = gatewayManager.stop().catch((err) => {
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
import { ipcMain } from 'electron';
import { proxyAwareFetch } from '../../utils/proxy-fetch';
import { getPort } from '../../utils/config';
import { getHostApiToken } from '../../api/server';
type HostApiFetchRequest = {
path: string;
method?: string;
headers?: Record<string, string>;
body?: unknown;
};
export function registerHostApiProxyHandlers(): void {
const hostApiPort = getPort('CLAWX_HOST_API');
// Expose the per-session auth token to the renderer so the browser-fallback
// path in host-api.ts can authenticate against the Host API server.
ipcMain.handle('hostapi:token', () => getHostApiToken());
ipcMain.handle('hostapi:fetch', async (_, request: HostApiFetchRequest) => {
try {
const path = typeof request?.path === 'string' ? request.path : '';
if (!path || !path.startsWith('/')) {
throw new Error(`Invalid host API path: ${String(request?.path)}`);
}
const method = (request.method || 'GET').toUpperCase();
const headers: Record<string, string> = { ...(request.headers || {}) };
// Inject the per-session auth token so the Host API server accepts this request.
headers['Authorization'] = `Bearer ${getHostApiToken()}`;
let body: string | undefined;
if (request.body !== undefined && request.body !== null) {
if (typeof request.body === 'string') {
body = request.body;
} else {
body = JSON.stringify(request.body);
}
// Ensure Content-Type is set for requests with a body so the
// server's anti-CSRF Content-Type gate does not reject them.
if (!headers['Content-Type'] && !headers['content-type']) {
headers['Content-Type'] = 'application/json';
}
}
const response = await proxyAwareFetch(`http://127.0.0.1:${hostApiPort}${path}`, {
method,
headers,
body,
});
const data: { status: number; ok: boolean; json?: unknown; text?: string } = {
status: response.status,
ok: response.ok,
};
if (response.status !== 204) {
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
data.json = await response.json().catch(() => undefined);
} else {
data.text = await response.text().catch(() => '');
}
}
return { ok: true, data };
} catch (error) {
return {
ok: false,
error: {
message: error instanceof Error ? error.message : String(error),
},
};
}
});
}
-52
View File
@@ -1,52 +0,0 @@
import type { HostApiContract } from '@shared/host-api/contract';
export type HostRequest = {
id: string;
module: string;
action: string;
payload?: unknown;
};
export type HostErrorCode = 'VALIDATION' | 'UNSUPPORTED' | 'INTERNAL';
export type HostResponse<T = unknown> =
| { id?: string; ok: true; data: T }
| { id?: string; ok: false; error: { code: HostErrorCode; message: string; details?: unknown } };
export type RuntimeHostAction = (payload?: unknown) => Promise<unknown> | unknown;
type MaybePromise<T> = T | Promise<T>;
type HostServiceFunction<TFunction> = TFunction extends (...args: infer Args) => infer Result
? (...args: Args) => MaybePromise<Awaited<Result>>
: never;
type HostServiceModule<TModule> = {
[A in keyof TModule]: HostServiceFunction<TModule[A]>;
};
export type HostServiceRegistry = {
[M in keyof HostApiContract]?: Partial<HostServiceModule<HostApiContract[M]>>;
};
export type CompleteHostServiceRegistry = {
[M in keyof HostApiContract]: HostServiceModule<HostApiContract[M]>;
};
export type HostApiContribution = {
module: string;
actions: Record<string, RuntimeHostAction>;
};
export type HostApiContributionRegistrar = {
register: (extensionId: string, contributions: HostApiContribution[]) => () => void;
};
export function isHostRequest(value: unknown): value is HostRequest {
if (!value || typeof value !== 'object') return false;
const record = value as Record<string, unknown>;
return typeof record.id === 'string'
&& record.id.length > 0
&& typeof record.module === 'string'
&& record.module.length > 0
&& typeof record.action === 'string'
&& record.action.length > 0;
}
-134
View File
@@ -1,134 +0,0 @@
import { ipcMain } from 'electron';
import {
type HostApiContribution,
type HostResponse,
type HostServiceRegistry,
type RuntimeHostAction,
isHostRequest,
} from './host-contract';
type RegisteredHostAction = {
action: RuntimeHostAction;
ownerId: string;
};
function assertValidContributionKey(kind: 'module' | 'action', value: string): void {
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(value)) {
throw new Error(`Invalid host API ${kind}: ${value}`);
}
}
export class HostApiRegistry {
private modules = new Map<string, Map<string, RegisteredHostAction>>();
registerCoreServices(services: HostServiceRegistry): void {
for (const [moduleName, actions] of Object.entries(services)) {
if (!actions || typeof actions !== 'object') continue;
for (const [actionName, action] of Object.entries(actions)) {
if (typeof action !== 'function') continue;
this.registerAction(moduleName, actionName, action as RuntimeHostAction, 'core');
}
}
}
registerExtensionContributions(extensionId: string, contributions: HostApiContribution[]): () => void {
const registered: Array<{ module: string; action: string }> = [];
for (const contribution of contributions) {
assertValidContributionKey('module', contribution.module);
for (const [actionName, action] of Object.entries(contribution.actions)) {
assertValidContributionKey('action', actionName);
this.registerAction(contribution.module, actionName, action, extensionId);
registered.push({ module: contribution.module, action: actionName });
}
}
return () => {
for (const { module, action } of registered) {
const moduleActions = this.modules.get(module);
const registeredAction = moduleActions?.get(action);
if (registeredAction?.ownerId === extensionId) {
moduleActions?.delete(action);
}
if (moduleActions?.size === 0) {
this.modules.delete(module);
}
}
};
}
resolve(moduleName: string, actionName: string): RuntimeHostAction | undefined {
return this.modules.get(moduleName)?.get(actionName)?.action;
}
private registerAction(
moduleName: string,
actionName: string,
action: RuntimeHostAction,
ownerId: string,
): void {
const moduleActions = this.modules.get(moduleName) ?? new Map<string, RegisteredHostAction>();
if (moduleActions.has(actionName)) {
throw new Error(`Host API action already registered: ${moduleName}.${actionName}`);
}
moduleActions.set(actionName, { action, ownerId });
this.modules.set(moduleName, moduleActions);
}
}
function toHostApiRegistry(registryOrServices: HostApiRegistry | HostServiceRegistry): HostApiRegistry {
if (registryOrServices instanceof HostApiRegistry) {
return registryOrServices;
}
const registry = new HostApiRegistry();
registry.registerCoreServices(registryOrServices);
return registry;
}
export function createHostInvokeDispatcher(registryOrServices: HostApiRegistry | HostServiceRegistry) {
const registry = toHostApiRegistry(registryOrServices);
return async function dispatchHostRequest(request: unknown): Promise<HostResponse> {
const requestId = request && typeof request === 'object'
? String((request as Record<string, unknown>).id ?? '')
: undefined;
if (!isHostRequest(request)) {
return {
id: requestId,
ok: false,
error: { code: 'VALIDATION', message: 'Invalid host request format' },
};
}
const action = registry.resolve(request.module, request.action);
if (typeof action !== 'function') {
return {
id: request.id,
ok: false,
error: {
code: 'UNSUPPORTED',
message: `Unsupported host request: ${request.module}.${request.action}`,
},
};
}
try {
const data = await action(request.payload);
return { id: request.id, ok: true, data };
} catch (error) {
return {
id: request.id,
ok: false,
error: {
code: 'INTERNAL',
message: error instanceof Error ? error.message : String(error),
},
};
}
};
}
export function registerHostInvokeHandler(registry: HostApiRegistry): void {
const dispatch = createHostInvokeDispatcher(registry);
ipcMain.handle('host:invoke', async (_event, request: unknown) => dispatch(request));
}
+56 -81
View File
@@ -3,33 +3,12 @@
* Creates the native application menu for macOS/Windows/Linux
*/
import { Menu, app, shell, BrowserWindow } from 'electron';
import { MENU_LABELS } from '@shared/i18n/resources';
import { resolveSupportedLanguage, type LanguageCode } from '@shared/language';
import { getSetting } from '../utils/store';
function applyAppName(label: string): string {
return label.replaceAll('{{appName}}', app.name);
}
async function resolveMenuLanguage(language?: string): Promise<LanguageCode> {
if (language) return resolveSupportedLanguage(language);
try {
return resolveSupportedLanguage(await getSetting('language'));
} catch {
return resolveSupportedLanguage(app.getLocale());
}
}
function getMenuTargetWindow(): BrowserWindow | null {
return BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? null;
}
/**
* Create application menu
*/
export async function createMenu(language?: string): Promise<void> {
export function createMenu(): void {
const isMac = process.platform === 'darwin';
const labels = MENU_LABELS[await resolveMenuLanguage(language)];
const template: Electron.MenuItemConstructorOptions[] = [
// App menu (macOS only)
@@ -38,24 +17,24 @@ export async function createMenu(language?: string): Promise<void> {
{
label: app.name,
submenu: [
{ role: 'about' as const, label: applyAppName(labels.app.about) },
{ role: 'about' as const },
{ type: 'separator' as const },
{
label: labels.app.preferences,
label: 'Preferences...',
accelerator: 'Cmd+,',
click: () => {
const win = getMenuTargetWindow();
const win = BrowserWindow.getFocusedWindow();
win?.webContents.send('navigate', '/settings');
},
},
{ type: 'separator' as const },
{ role: 'services' as const, label: labels.app.services },
{ role: 'services' as const },
{ type: 'separator' as const },
{ role: 'hide' as const, label: applyAppName(labels.app.hide) },
{ role: 'hideOthers' as const, label: labels.app.hideOthers },
{ role: 'unhide' as const, label: labels.app.unhide },
{ role: 'hide' as const },
{ role: 'hideOthers' as const },
{ role: 'unhide' as const },
{ type: 'separator' as const },
{ role: 'quit' as const, label: applyAppName(labels.app.quit) },
{ role: 'quit' as const },
],
},
]
@@ -63,113 +42,110 @@ export async function createMenu(language?: string): Promise<void> {
// File menu
{
label: labels.file.label,
label: 'File',
submenu: [
{
id: 'new-chat',
label: labels.file.newChat,
label: 'New Chat',
accelerator: 'CmdOrCtrl+N',
click: () => {
const win = getMenuTargetWindow();
win?.webContents.send('new-chat');
const win = BrowserWindow.getFocusedWindow();
win?.webContents.send('navigate', '/chat');
},
},
{ type: 'separator' },
isMac
? { role: 'close', label: labels.file.close }
: { role: 'quit', label: applyAppName(labels.app.quit) },
isMac ? { role: 'close' } : { role: 'quit' },
],
},
// Edit menu
{
label: labels.edit.label,
label: 'Edit',
submenu: [
{ role: 'undo', label: labels.edit.undo },
{ role: 'redo', label: labels.edit.redo },
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut', label: labels.edit.cut },
{ role: 'copy', label: labels.edit.copy },
{ role: 'paste', label: labels.edit.paste },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
...(isMac
? [
{ role: 'pasteAndMatchStyle' as const, label: labels.edit.pasteAndMatchStyle },
{ role: 'delete' as const, label: labels.edit.delete },
{ role: 'selectAll' as const, label: labels.edit.selectAll },
{ role: 'pasteAndMatchStyle' as const },
{ role: 'delete' as const },
{ role: 'selectAll' as const },
]
: [
{ role: 'delete' as const, label: labels.edit.delete },
{ role: 'delete' as const },
{ type: 'separator' as const },
{ role: 'selectAll' as const, label: labels.edit.selectAll },
{ role: 'selectAll' as const },
]),
],
},
// View menu
{
label: labels.view.label,
label: 'View',
submenu: [
{ role: 'reload', label: labels.view.reload },
{ role: 'forceReload', label: labels.view.forceReload },
{ role: 'toggleDevTools', label: labels.view.toggleDevTools },
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom', label: labels.view.resetZoom },
{ role: 'zoomIn', label: labels.view.zoomIn },
{ role: 'zoomOut', label: labels.view.zoomOut },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen', label: labels.view.toggleFullscreen },
{ role: 'togglefullscreen' },
],
},
// Navigate menu
{
label: labels.navigate.label,
label: 'Navigate',
submenu: [
{
label: labels.navigate.dashboard,
label: 'Dashboard',
accelerator: 'CmdOrCtrl+1',
click: () => {
const win = getMenuTargetWindow();
const win = BrowserWindow.getFocusedWindow();
win?.webContents.send('navigate', '/');
},
},
{
label: labels.navigate.chat,
label: 'Chat',
accelerator: 'CmdOrCtrl+2',
click: () => {
const win = getMenuTargetWindow();
win?.webContents.send('navigate', '/');
const win = BrowserWindow.getFocusedWindow();
win?.webContents.send('navigate', '/chat');
},
},
{
label: labels.navigate.channels,
label: 'Channels',
accelerator: 'CmdOrCtrl+3',
click: () => {
const win = getMenuTargetWindow();
const win = BrowserWindow.getFocusedWindow();
win?.webContents.send('navigate', '/channels');
},
},
{
label: labels.navigate.skills,
label: 'Skills',
accelerator: 'CmdOrCtrl+4',
click: () => {
const win = getMenuTargetWindow();
const win = BrowserWindow.getFocusedWindow();
win?.webContents.send('navigate', '/skills');
},
},
{
label: labels.navigate.cronTasks,
label: 'Cron Tasks',
accelerator: 'CmdOrCtrl+5',
click: () => {
const win = getMenuTargetWindow();
const win = BrowserWindow.getFocusedWindow();
win?.webContents.send('navigate', '/cron');
},
},
{
label: labels.navigate.settings,
label: 'Settings',
accelerator: isMac ? 'Cmd+,' : 'Ctrl+,',
click: () => {
const win = getMenuTargetWindow();
const win = BrowserWindow.getFocusedWindow();
win?.webContents.send('navigate', '/settings');
},
},
@@ -178,41 +154,40 @@ export async function createMenu(language?: string): Promise<void> {
// Window menu
{
label: labels.window.label,
label: 'Window',
submenu: [
{ role: 'minimize', label: labels.window.minimize },
{ role: 'zoom', label: labels.window.zoom },
{ role: 'minimize' },
{ role: 'zoom' },
...(isMac
? [
{ type: 'separator' as const },
{ role: 'front' as const, label: labels.window.front },
{ role: 'front' as const },
{ type: 'separator' as const },
{ role: 'window' as const, label: labels.window.label },
{ role: 'window' as const },
]
: [{ role: 'close' as const, label: labels.window.close }]),
: [{ role: 'close' as const }]),
],
},
// Help menu
{
role: 'help',
label: labels.help.label,
submenu: [
{
label: labels.help.documentation,
label: 'Documentation',
click: async () => {
await shell.openExternal('https://claw-x.com');
},
},
{
label: labels.help.reportIssue,
label: 'Report Issue',
click: async () => {
await shell.openExternal('https://github.com/ValueCell-ai/ClawX/issues');
},
},
{ type: 'separator' },
{
label: labels.help.openClawDocumentation,
label: 'OpenClaw Documentation',
click: async () => {
await shell.openExternal('https://docs.openclaw.ai');
},
+2 -6
View File
@@ -1,10 +1,6 @@
import { getProviderConfig } from '../utils/provider-registry';
import { getOpenClawProviderKeyForType, isOAuthProviderType } from '../utils/provider-keys';
import type { ProviderConfig } from '../utils/secure-storage';
import {
piAiModelsJsonModelEntry,
type PiAiModelCostRates,
} from '../shared/pi-ai-model-cost';
export interface AgentProviderUpdatePayload {
providerKey: string;
@@ -12,7 +8,7 @@ export interface AgentProviderUpdatePayload {
baseUrl: string;
api: string;
apiKey: string | undefined;
models: Array<{ id: string; name: string; cost: PiAiModelCostRates }>;
models: Array<{ id: string; name: string }>;
};
}
@@ -46,7 +42,7 @@ export function buildNonOAuthAgentProviderUpdate(
baseUrl,
api,
apiKey: meta?.apiKeyEnv,
models: modelId ? [piAiModelsJsonModelEntry(modelId)] : [],
models: modelId ? [{ id: modelId, name: modelId }] : [],
},
};
}
+1 -8
View File
@@ -4,14 +4,7 @@ import { buildElectronProxyConfig } from '../utils/proxy';
import { logger } from '../utils/logger';
export async function applyProxySettings(
partialSettings?: Pick<AppSettings,
| 'proxyEnabled'
| 'proxyServer'
| 'proxyHttpServer'
| 'proxyHttpsServer'
| 'proxyAllServer'
| 'proxyBypassRules'
>,
partialSettings?: Pick<AppSettings, 'proxyEnabled' | 'proxyServer' | 'proxyBypassRules'>
): Promise<void> {
const settings = partialSettings ?? await getAllSettings();
const config = buildElectronProxyConfig(settings);
-40
View File
@@ -1,40 +0,0 @@
import { release } from 'node:os';
import type { BrowserWindow } from 'electron';
const MAC_SIDEBAR_CHROME_HEIGHT = 28;
const MAC_TRAFFIC_LIGHT_GAP = 8;
const MAC_TRAFFIC_LIGHT_FRAME_HEIGHT = 16;
const MAC_TRAFFIC_LIGHT_FRAME_HEIGHT_TAHOE = 14;
function getMacTrafficLightFrameHeight(darwinMajor: number): number {
return darwinMajor >= 25
? MAC_TRAFFIC_LIGHT_FRAME_HEIGHT_TAHOE
: MAC_TRAFFIC_LIGHT_FRAME_HEIGHT;
}
function getMacTrafficLightChromeOffset(buttonFrameHeight: number): number {
return Math.floor((MAC_SIDEBAR_CHROME_HEIGHT - buttonFrameHeight) / 2);
}
export function getMacTrafficLightPosition(sidebarCollapsed: boolean): { x: number; y: number } {
const darwinMajor = Number.parseInt(release().split('.')[0] ?? '0', 10);
const buttonFrameHeight = getMacTrafficLightFrameHeight(darwinMajor);
const offset = getMacTrafficLightChromeOffset(buttonFrameHeight);
if (sidebarCollapsed) {
return { x: MAC_TRAFFIC_LIGHT_GAP, y: Math.max(MAC_TRAFFIC_LIGHT_GAP, offset) };
}
return { x: offset + 1, y: offset };
}
export function syncMacTrafficLightPosition(
win: BrowserWindow,
sidebarCollapsed: boolean,
): void {
if (process.platform !== 'darwin' || win.isDestroyed()) {
return;
}
win.setWindowButtonPosition(getMacTrafficLightPosition(sidebarCollapsed));
}
+9 -10
View File
@@ -60,7 +60,7 @@ export class AppUpdater extends EventEmitter {
});
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.logger = {
info: (msg: string) => logger.info('[Updater]', msg),
@@ -131,6 +131,10 @@ export class AppUpdater extends EventEmitter {
autoUpdater.on('update-downloaded', (event: UpdateDownloadedEvent) => {
this.updateStatus({ status: 'downloaded', info: event });
this.emit('update-downloaded', event);
if (autoUpdater.autoDownload) {
this.startAutoInstallCountdown();
}
});
autoUpdater.on('error', (error: Error) => {
@@ -230,7 +234,7 @@ export class AppUpdater extends EventEmitter {
* Start a countdown that auto-installs the downloaded update.
* Sends `update:auto-install-countdown` events to the renderer each second.
*/
startAutoInstallCountdown(): void {
private startAutoInstallCountdown(): void {
this.clearAutoInstallTimer();
this.autoInstallCountdown = AppUpdater.AUTO_INSTALL_DELAY_SECONDS;
this.sendToRenderer('update:auto-install-countdown', { seconds: this.autoInstallCountdown });
@@ -266,15 +270,10 @@ export class AppUpdater extends EventEmitter {
}
/**
* Set auto-download preference.
*
* ClawX uses a prompt-first update flow: finding an update shows a UI prompt,
* and downloads/installations only start after the user chooses an action.
* Keep this legacy IPC method as a no-op-compatible setter so stale renderer
* settings cannot re-enable electron-updater's implicit auto-download path.
* Set auto-download preference
*/
setAutoDownload(_enable: boolean): void {
autoUpdater.autoDownload = false;
setAutoDownload(enable: boolean): void {
autoUpdater.autoDownload = enable;
}
/**
-47
View File
@@ -1,47 +0,0 @@
import type { BrowserWindow } from 'electron';
export type ZoomShortcutAction = 'in' | 'out' | 'reset';
type ZoomShortcutInput = Pick<Electron.Input, 'key' | 'code' | 'control' | 'meta' | 'alt'>;
export function getZoomShortcutAction(input: ZoomShortcutInput): ZoomShortcutAction | null {
if ((!input.control && !input.meta) || input.alt) {
return null;
}
const key = input.key.toLowerCase();
if (key === '+' || key === '=' || input.code === 'Equal' || input.code === 'NumpadAdd') {
return 'in';
}
if (key === '-' || input.code === 'Minus' || input.code === 'NumpadSubtract') {
return 'out';
}
if (key === '0' || input.code === 'Digit0' || input.code === 'Numpad0') {
return 'reset';
}
return null;
}
export function registerZoomShortcuts(win: BrowserWindow): void {
win.webContents.on('before-input-event', (event, input) => {
const action = getZoomShortcutAction(input);
if (!action) {
return;
}
event.preventDefault();
if (action === 'reset') {
win.webContents.setZoomLevel(0);
return;
}
const delta = action === 'in' ? 1 : -1;
win.webContents.setZoomLevel(win.webContents.getZoomLevel() + delta);
});
}
+127 -34
View File
@@ -2,20 +2,7 @@
* Preload Script
* Exposes safe APIs to the renderer process via contextBridge
*/
import { contextBridge, ipcRenderer, webUtils } from 'electron';
import type { HostRequest } from '@shared/host-api/types';
import { HOST_EVENT_CHANNELS } from '@shared/host-events/contract';
const validStaticEventChannels: Set<string> = new Set(
Object.values(HOST_EVENT_CHANNELS).flatMap((moduleChannels) => Object.values(moduleChannels)),
);
const DYNAMIC_CHANNEL_EVENT_RE = /^channel:[a-z0-9_-]+-(?:qr|success|error)$/i;
function isValidEventChannel(channel: string): boolean {
return validStaticEventChannels.has(channel)
|| DYNAMIC_CHANNEL_EVENT_RE.test(channel)
|| channel.startsWith('ext:');
}
import { contextBridge, ipcRenderer } from 'electron';
/**
* IPC renderer methods exposed to the renderer process
@@ -29,26 +16,40 @@ const electronAPI = {
const validChannels = [
// Gateway
'gateway:status',
'gateway:isConnected',
'gateway:start',
'gateway:stop',
'gateway:restart',
'gateway:rpc',
'gateway:httpProxy',
'hostapi:fetch',
'hostapi:token',
'gateway:health',
'gateway:getControlUiUrl',
// OpenClaw
'openclaw:status',
'openclaw:isReady',
// Shell
'shell:openExternal',
'shell:showItemInFolder',
'shell:openPath',
// Dialog
'dialog:open',
'dialog:save',
'dialog:message',
// App
'app:version',
'app:name',
'app:getPath',
'app:platform',
'app:quit',
'app:relaunch',
'app:request',
// Window controls
'window:minimize',
'window:maximize',
'window:close',
'window:isMaximized',
'window:syncTrafficLightPosition',
// Settings
'settings:get',
'settings:set',
@@ -82,14 +83,58 @@ const electronAPI = {
'provider:setDefault',
'provider:getDefault',
'provider:validateKey',
// File preview (sandboxed read/write/list/tree)
'file:readText',
'file:readBinary',
'file:writeText',
'file:stat',
'file:listDir',
'file:listTree',
'provider:requestOAuth',
'provider:cancelOAuth',
// Cron
'cron:list',
'cron:create',
'cron:update',
'cron:delete',
'cron:toggle',
'cron:trigger',
// Channel Config
'channel:saveConfig',
'channel:getConfig',
'channel:getFormValues',
'channel:deleteConfig',
'channel:listConfigured',
'channel:setEnabled',
'channel:validate',
'channel:validateCredentials',
// WhatsApp
'channel:requestWhatsAppQr',
'channel:cancelWhatsAppQr',
// ClawHub
'clawhub:search',
'clawhub:install',
'clawhub:uninstall',
'clawhub:list',
'clawhub:openSkillReadme',
// UV
'uv:check',
'uv:install-all',
// Skill config (direct file access)
'skill:updateConfig',
'skill:getConfig',
'skill:getAllConfigs',
// Logs
'log:getRecent',
'log:readFile',
'log:getFilePath',
'log:getDir',
'log:listFiles',
// File staging & media
'file:stage',
'file:stageBuffer',
'media:getThumbnails',
'media:saveImage',
// Chat send with media (reads staged files in main process)
'chat:sendWithMedia',
// Session management
'session:delete',
// OpenClaw extras
'openclaw:getDir',
'openclaw:getConfigDir',
'openclaw:getSkillsDir',
'openclaw:getCliCommand',
];
@@ -105,7 +150,37 @@ const electronAPI = {
* Listen for events from main process
*/
on: (channel: string, callback: (...args: unknown[]) => void) => {
if (isValidEventChannel(channel)) {
const validChannels = [
'gateway:status-changed',
'gateway:message',
'gateway:notification',
'gateway:channel-status',
'gateway:chat-message',
'channel:whatsapp-qr',
'channel:whatsapp-success',
'channel:whatsapp-error',
'channel:wechat-qr',
'channel:wechat-success',
'channel:wechat-error',
'gateway:exit',
'gateway:error',
'navigate',
'update:status-changed',
'update:checking',
'update:available',
'update:not-available',
'update:progress',
'update:downloaded',
'update:error',
'update:auto-install-countdown',
'cron:updated',
'oauth:code',
'oauth:success',
'oauth:error',
'openclaw:cli-installed',
];
if (validChannels.includes(channel) || channel.startsWith('ext:')) {
const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => {
callback(...args);
};
@@ -124,7 +199,35 @@ const electronAPI = {
* Listen for a single event from main process
*/
once: (channel: string, callback: (...args: unknown[]) => void) => {
if (isValidEventChannel(channel)) {
const validChannels = [
'gateway:status-changed',
'gateway:message',
'gateway:notification',
'gateway:channel-status',
'gateway:chat-message',
'channel:whatsapp-qr',
'channel:whatsapp-success',
'channel:whatsapp-error',
'channel:wechat-qr',
'channel:wechat-success',
'channel:wechat-error',
'gateway:exit',
'gateway:error',
'navigate',
'update:status-changed',
'update:checking',
'update:available',
'update:not-available',
'update:progress',
'update:downloaded',
'update:error',
'update:auto-install-countdown',
'oauth:code',
'oauth:success',
'oauth:error',
];
if (validChannels.includes(channel) || channel.startsWith('ext:')) {
ipcRenderer.once(channel, (_event, ...args) => callback(...args));
return;
}
@@ -152,11 +255,6 @@ const electronAPI = {
return ipcRenderer.invoke('shell:openExternal', url);
},
/**
* Resolve the on-disk path for a native drag/drop or <input type="file"> File.
*/
getPathForFile: (file: File) => webUtils.getPathForFile(file),
/**
* Get current platform
*/
@@ -168,13 +266,8 @@ const electronAPI = {
isDev: process.env.NODE_ENV === 'development' || !!process.env.VITE_DEV_SERVER_URL,
};
const clawxAPI = {
hostInvoke: (request: HostRequest) => ipcRenderer.invoke('host:invoke', request),
};
// Expose the API to the renderer process
contextBridge.exposeInMainWorld('electron', electronAPI);
contextBridge.exposeInMainWorld('clawx', clawxAPI);
// Type declarations for the renderer process
export type ElectronAPI = typeof electronAPI;
-129
View File
@@ -1,129 +0,0 @@
import type { GatewayManager } from '../gateway/manager';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import {
assignChannelToAgent,
clearChannelBinding,
createAgent,
deleteAgentConfig,
listAgentsSnapshot,
removeAgentWorkspaceDirectory,
resolveAccountIdForAgent,
updateAgentModel,
updateAgentName,
} from '../utils/agent-config';
import { deleteChannelAccountConfig } from '../utils/channel-config';
import { ensureClawXContext } from '../utils/openclaw-workspace';
import { isRecord } from './payload-utils';
import { syncAgentModelOverrideToRuntime, syncAllProviderAuthToRuntime } from './providers/provider-runtime-sync';
type AgentsApiContext = {
gatewayManager: GatewayManager;
};
function requireString(payload: unknown, key: string): string {
if (!isRecord(payload) || typeof payload[key] !== 'string' || !payload[key].trim()) {
throw new Error(`${key} is required`);
}
return payload[key].trim();
}
function scheduleGatewayReload(ctx: AgentsApiContext, reason: string): void {
if (ctx.gatewayManager.getStatus().state !== 'stopped') {
ctx.gatewayManager.debouncedReload();
return;
}
void reason;
}
async function restartGatewayForAgentDeletion(ctx: AgentsApiContext): Promise<void> {
try {
await ctx.gatewayManager.restart();
console.log('[agents] Gateway restart completed after agent deletion');
} catch (err) {
console.warn('[agents] Gateway restart after agent deletion failed:', err);
}
}
export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegistry['agents'] {
return {
list: async () => ({ success: true, ...(await listAgentsSnapshot()) }),
create: async (payload) => {
const name = requireString(payload, 'name');
const inheritWorkspace = isRecord(payload) ? payload.inheritWorkspace === true : undefined;
const snapshot = await createAgent(name, { inheritWorkspace });
syncAllProviderAuthToRuntime().catch((err) => {
console.warn('[agents] Failed to sync provider auth after agent creation:', err);
});
scheduleGatewayReload(ctx, 'create-agent');
void ensureClawXContext({ waitForAllConfiguredWorkspaces: true }).catch((err) => {
console.warn('[agents] Failed to ensure ClawX context after agent creation:', err);
});
return { success: true, ...snapshot };
},
update: async (payload) => {
const agentId = requireString(payload, 'id');
const name = requireString(payload, 'name');
const snapshot = await updateAgentName(agentId, name);
scheduleGatewayReload(ctx, 'update-agent');
return { success: true, ...snapshot };
},
updateModel: async (payload) => {
const agentId = requireString(payload, 'id');
const modelRef = isRecord(payload) && typeof payload.modelRef === 'string' ? payload.modelRef : null;
const snapshot = await updateAgentModel(agentId, modelRef);
try {
await syncAllProviderAuthToRuntime();
await syncAgentModelOverrideToRuntime(agentId);
} catch (syncError) {
console.warn('[agents] Failed to sync runtime after updating agent model:', syncError);
}
// Agent model changes must be picked up by the running Gateway before
// the next send; otherwise the UI can show the new selection while the
// active runtime still answers with the previous model.
scheduleGatewayReload(ctx, 'update-agent-model');
return { success: true, ...snapshot };
},
delete: async (payload) => {
const agentId = requireString(payload, 'id');
const { snapshot, removedEntry } = await deleteAgentConfig(agentId);
await restartGatewayForAgentDeletion(ctx);
await removeAgentWorkspaceDirectory(removedEntry).catch((err) => {
console.warn('[agents] Failed to remove workspace after agent deletion:', err);
});
return { success: true, ...snapshot };
},
assignChannel: async (payload) => {
const agentId = requireString(payload, 'id');
const channelType = requireString(payload, 'channelType');
const snapshot = await assignChannelToAgent(agentId, channelType);
scheduleGatewayReload(ctx, 'assign-channel');
return { success: true, ...snapshot };
},
removeChannel: async (payload) => {
const agentId = requireString(payload, 'id');
const channelType = requireString(payload, 'channelType');
const ownerId = agentId.trim().toLowerCase();
const snapshotBefore = await listAgentsSnapshot();
const ownedAccountIds = Object.entries(snapshotBefore.channelAccountOwners)
.filter(([channelAccountKey, owner]) => {
if (owner !== ownerId) return false;
return channelAccountKey.startsWith(`${channelType}:`);
})
.map(([channelAccountKey]) => channelAccountKey.slice(channelAccountKey.indexOf(':') + 1));
if (ownedAccountIds.length === 0) {
const legacyAccountId = resolveAccountIdForAgent(agentId);
if (snapshotBefore.channelAccountOwners[`${channelType}:${legacyAccountId}`] === ownerId) {
ownedAccountIds.push(legacyAccountId);
}
}
for (const accountId of ownedAccountIds) {
await deleteChannelAccountConfig(channelType, accountId);
await clearChannelBinding(channelType, accountId);
}
const snapshot = await listAgentsSnapshot();
scheduleGatewayReload(ctx, 'remove-agent-channel');
return { success: true, ...snapshot };
},
};
}
-16
View File
@@ -1,16 +0,0 @@
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../utils/openclaw-doctor';
import { isRecord } from './payload-utils';
type OpenClawDoctorPayload = {
mode?: unknown;
};
export function createAppApi(): CompleteHostServiceRegistry['app'] {
return {
openClawDoctor: async (payload) => {
const body = isRecord(payload) ? payload as OpenClawDoctorPayload : {};
return body.mode === 'fix' ? runOpenClawDoctorFix() : runOpenClawDoctor();
},
};
}
-112
View File
@@ -1,112 +0,0 @@
import type { GatewayManager } from '../gateway/manager';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { logger } from '../utils/logger';
import { isRecord } from './payload-utils';
const VISION_MIME_TYPES = new Set([
'image/png',
'image/jpeg',
'image/bmp',
'image/webp',
]);
type ChatSendWithMediaPayload = {
sessionKey?: unknown;
message?: unknown;
deliver?: unknown;
idempotencyKey?: unknown;
media?: unknown;
};
type MediaPayload = {
filePath?: unknown;
mimeType?: unknown;
fileName?: unknown;
};
function normalizeMedia(media: unknown): Array<{ filePath: string; mimeType: string; fileName: string }> {
if (!Array.isArray(media)) return [];
return media.flatMap((entry): Array<{ filePath: string; mimeType: string; fileName: string }> => {
if (!isRecord(entry)) return [];
const item = entry as MediaPayload;
if (typeof item.filePath !== 'string' || !item.filePath) return [];
return [{
filePath: item.filePath,
mimeType: typeof item.mimeType === 'string' && item.mimeType ? item.mimeType : 'application/octet-stream',
fileName: typeof item.fileName === 'string' && item.fileName ? item.fileName : item.filePath.split(/[\\/]/).pop() || 'file',
}];
});
}
export function createChatApi({ gatewayManager }: { 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' };
}
try {
let message = typeof body.message === 'string' ? body.message : '';
const imageAttachments: Array<Record<string, unknown>> = [];
const fileReferences: string[] = [];
const media = normalizeMedia(body.media);
if (media.length > 0) {
const fsP = await import('node:fs/promises');
for (const item of media) {
const exists = await fsP.access(item.filePath).then(() => true, () => false);
logger.info(
`[chat:sendWithMedia] Processing file: ${item.fileName} (${item.mimeType}), path: ${item.filePath}, exists: ${exists}, isVision: ${VISION_MIME_TYPES.has(item.mimeType)}`,
);
fileReferences.push(
`[media attached: ${item.filePath} (${item.mimeType}) | ${item.filePath}]`,
);
if (VISION_MIME_TYPES.has(item.mimeType)) {
const fileBuffer = await fsP.readFile(item.filePath);
const base64Data = fileBuffer.toString('base64');
logger.info(`[chat:sendWithMedia] Read ${fileBuffer.length} bytes, base64 length: ${base64Data.length}`);
imageAttachments.push({
content: base64Data,
mimeType: item.mimeType,
fileName: item.fileName,
});
}
}
}
if (fileReferences.length > 0) {
const refs = fileReferences.join('\n');
message = message ? `${message}\n\n${refs}` : refs;
}
const rpcParams: Record<string, unknown> = {
sessionKey,
message,
deliver: body.deliver ?? false,
idempotencyKey,
};
if (imageAttachments.length > 0) {
rpcParams.attachments = imageAttachments;
}
logger.info(
`[chat:sendWithMedia] Sending: message="${message.substring(0, 100)}", attachments=${imageAttachments.length}, fileRefs=${fileReferences.length}`,
);
const result = await gatewayManager.rpc('chat.send', rpcParams, 120000);
logger.info(`[chat:sendWithMedia] RPC result: ${JSON.stringify(result)}`);
const response = isRecord(result) && typeof result.runId === 'string'
? { runId: result.runId }
: undefined;
return { success: true, ...(response ? { result: response } : {}) };
} catch (error) {
logger.error(`[chat:sendWithMedia] Error: ${String(error)}`);
return { success: false, error: String(error) };
}
},
};
}
-592
View File
@@ -1,592 +0,0 @@
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 { GatewayManager } from '../gateway/manager';
import { getOpenClawConfigDir } from '../utils/paths';
import { resolveAgentIdFromChannel } from '../utils/agent-config';
import { toOpenClawChannelType, toUiChannelType } from '../utils/channel-alias';
import { resolveAccountIdFromSessionHistory } from '../utils/session-util';
import { isRecord } from './payload-utils';
interface GatewayCronJob {
id: string;
name: string;
description?: string;
enabled: boolean;
createdAtMs: number;
updatedAtMs: number;
schedule: { kind: string; expr?: string; everyMs?: number; at?: string; tz?: string };
payload: { kind: string; message?: string; text?: string };
delivery?: { mode: string; channel?: string; to?: string; accountId?: string };
sessionTarget?: string;
state: {
nextRunAtMs?: number;
runningAtMs?: number;
lastRunAtMs?: number;
lastStatus?: string;
lastError?: string;
lastDurationMs?: number;
};
}
interface CronRunLogEntry {
jobId?: string;
action?: string;
status?: string;
error?: string;
summary?: string;
sessionId?: string;
sessionKey?: string;
ts?: number;
runAtMs?: number;
durationMs?: number;
model?: string;
provider?: string;
}
interface CronSessionKeyParts {
agentId: string;
jobId: string;
runSessionId?: string;
}
interface CronSessionFallbackMessage {
id: string;
role: 'assistant' | 'system';
content: string;
timestamp: number;
isError?: boolean;
}
type JsonRecord = Record<string, unknown>;
function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
if (!sessionKey.startsWith('agent:')) return null;
const parts = sessionKey.split(':');
if (parts.length < 4 || parts[2] !== 'cron') return null;
const agentId = parts[1] || 'main';
const jobId = parts[3];
if (!jobId) return null;
if (parts.length === 4) return { agentId, jobId };
if (parts.length === 6 && parts[4] === 'run' && parts[5]) {
return { agentId, jobId, runSessionId: parts[5] };
}
return null;
}
function normalizeTimestampMs(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value < 1e12 ? value * 1000 : value;
}
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function formatDuration(durationMs: number | undefined): string | null {
if (!durationMs || !Number.isFinite(durationMs)) return null;
if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
if (durationMs < 10_000) return `${(durationMs / 1000).toFixed(1)}s`;
return `${Math.round(durationMs / 1000)}s`;
}
function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSessionFallbackMessage | null {
const timestamp = normalizeTimestampMs(entry.ts) ?? normalizeTimestampMs(entry.runAtMs);
if (!timestamp) return null;
const status = typeof entry.status === 'string' ? entry.status.toLowerCase() : '';
const summary = typeof entry.summary === 'string' ? entry.summary.trim() : '';
const error = typeof entry.error === 'string' ? entry.error.trim() : '';
let content = summary || error;
if (!content) {
content = status === 'error' ? 'Scheduled task failed.' : 'Scheduled task completed.';
}
if (status === 'error' && !content.toLowerCase().startsWith('run failed:')) {
content = `Run failed: ${content}`;
}
const meta: string[] = [];
const duration = formatDuration(entry.durationMs);
if (duration) meta.push(`Duration: ${duration}`);
if (entry.provider && entry.model) meta.push(`Model: ${entry.provider}/${entry.model}`);
else if (entry.model) meta.push(`Model: ${entry.model}`);
if (meta.length > 0) content = `${content}\n\n${meta.join(' | ')}`;
return {
id: `cron-run-${entry.sessionId ?? entry.ts ?? index}`,
role: status === 'error' ? 'system' : 'assistant',
content,
timestamp,
...(status === 'error' ? { isError: true } : {}),
};
}
async function readCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
const logPath = join(getOpenClawConfigDir(), 'cron', 'runs', `${jobId}.jsonl`);
const raw = await readFile(logPath, 'utf8').catch(() => '');
if (!raw.trim()) return [];
const entries: CronRunLogEntry[] = [];
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const entry = JSON.parse(trimmed) as CronRunLogEntry;
if (!entry || entry.jobId !== jobId) continue;
if (entry.action && entry.action !== 'finished') continue;
entries.push(entry);
} catch {
// Ignore malformed log lines.
}
}
return entries;
}
async function readSessionStoreEntry(
agentId: string,
sessionKey: string,
): Promise<Record<string, unknown> | undefined> {
const storePath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json');
const raw = await readFile(storePath, 'utf8').catch(() => '');
if (!raw.trim()) return undefined;
try {
const store = JSON.parse(raw) as Record<string, unknown>;
const directEntry = store[sessionKey];
if (directEntry && typeof directEntry === 'object') return directEntry as Record<string, unknown>;
const sessions = (store as { sessions?: unknown }).sessions;
if (Array.isArray(sessions)) {
const arrayEntry = sessions.find((entry) => {
if (!entry || typeof entry !== 'object') return false;
const record = entry as Record<string, unknown>;
return record.key === sessionKey || record.sessionKey === sessionKey;
});
if (arrayEntry && typeof arrayEntry === 'object') return arrayEntry as Record<string, unknown>;
}
} catch {
return undefined;
}
return undefined;
}
function buildCronSessionFallbackMessages(params: {
sessionKey: string;
job?: Pick<GatewayCronJob, 'name' | 'payload' | 'state'>;
runs: CronRunLogEntry[];
sessionEntry?: { label?: string; updatedAt?: number };
limit?: number;
}): CronSessionFallbackMessage[] {
const parsed = parseCronSessionKey(params.sessionKey);
if (!parsed) return [];
const matchingRuns = params.runs
.filter((entry) => {
if (!parsed.runSessionId) return true;
return entry.sessionId === parsed.runSessionId || entry.sessionKey === `${params.sessionKey}`;
})
.sort((a, b) => {
const left = normalizeTimestampMs(a.ts) ?? normalizeTimestampMs(a.runAtMs) ?? 0;
const right = normalizeTimestampMs(b.ts) ?? normalizeTimestampMs(b.runAtMs) ?? 0;
return left - right;
});
const messages: CronSessionFallbackMessage[] = [];
const prompt = params.job?.payload?.message || params.job?.payload?.text || '';
const taskName = params.job?.name?.trim()
|| params.sessionEntry?.label?.replace(/^Cron:\s*/, '').trim()
|| '';
const firstRelevantTimestamp = matchingRuns.length > 0
? (normalizeTimestampMs(matchingRuns[0]?.runAtMs) ?? normalizeTimestampMs(matchingRuns[0]?.ts))
: (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'),
timestamp: Math.max(0, (firstRelevantTimestamp ?? Date.now()) - 1),
});
}
matchingRuns.forEach((entry, index) => {
const message = buildCronRunMessage(entry, index);
if (message) messages.push(message);
});
if (matchingRuns.length === 0) {
const runningAt = normalizeTimestampMs(params.job?.state?.runningAtMs);
if (runningAt) {
messages.push({
id: `cron-running-${parsed.jobId}`,
role: 'system',
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(),
});
}
}
const limit = typeof params.limit === 'number' && Number.isFinite(params.limit)
? Math.max(1, Math.floor(params.limit))
: messages.length;
return messages.slice(-limit);
}
function getUnsupportedCronDeliveryError(_channel: string | undefined): string | null {
return null;
}
function normalizeCronDelivery(
rawDelivery: unknown,
fallbackMode: CronJobDelivery['mode'] = 'none',
): CronJobDelivery {
if (!rawDelivery || typeof rawDelivery !== 'object') return { mode: fallbackMode };
const delivery = rawDelivery as JsonRecord;
const mode = delivery.mode === 'announce' ? 'announce' : fallbackMode;
const channel = typeof delivery.channel === 'string' && delivery.channel.trim()
? toOpenClawChannelType(delivery.channel.trim())
: undefined;
const to = typeof delivery.to === 'string' && delivery.to.trim() ? delivery.to.trim() : undefined;
const accountId = typeof delivery.accountId === 'string' && delivery.accountId.trim()
? delivery.accountId.trim()
: undefined;
if (mode === 'announce' && !channel) return { mode: 'none' };
return {
mode,
...(channel ? { channel } : {}),
...(to ? { to } : {}),
...(accountId ? { accountId } : {}),
};
}
function normalizeCronSchedule(schedule: GatewayCronJob['schedule']): CronJob['schedule'] {
if (schedule.kind === 'at' && typeof schedule.at === 'string') {
return { kind: 'at', at: schedule.at };
}
if (schedule.kind === 'every' && typeof schedule.everyMs === 'number') {
return {
kind: 'every',
everyMs: schedule.everyMs,
...(typeof (schedule as CronSchedule & { anchorMs?: unknown }).anchorMs === 'number'
? { anchorMs: (schedule as CronSchedule & { anchorMs: number }).anchorMs }
: {}),
};
}
if (schedule.kind === 'cron' && typeof schedule.expr === 'string') {
return { kind: 'cron', expr: schedule.expr, ...(schedule.tz ? { tz: schedule.tz } : {}) };
}
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 {};
const delivery = rawDelivery as JsonRecord;
const patch: Record<string, unknown> = {};
if ('mode' in delivery) {
patch.mode = typeof delivery.mode === 'string' && delivery.mode.trim() ? delivery.mode.trim() : 'none';
}
if ('channel' in delivery) {
patch.channel = typeof delivery.channel === 'string' && delivery.channel.trim()
? toOpenClawChannelType(delivery.channel.trim())
: '';
}
if ('to' in delivery) patch.to = typeof delivery.to === 'string' ? delivery.to : '';
if ('accountId' in delivery) patch.accountId = typeof delivery.accountId === 'string' ? delivery.accountId : '';
return patch;
}
function buildCronUpdatePatch(input: Record<string, unknown>): Record<string, unknown> {
const patch = { ...input };
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;
}
if ('delivery' in patch) patch.delivery = normalizeCronDeliveryPatch(patch.delivery);
if ('agentId' in patch) {
patch.agentId = typeof patch.agentId === 'string' && patch.agentId.trim() ? patch.agentId.trim() : 'main';
}
return patch;
}
function transformCronJob(job: GatewayCronJob): CronJob {
const message = job.payload?.message || job.payload?.text || '';
const gatewayDelivery = normalizeCronDelivery(job.delivery);
const channelType = gatewayDelivery.channel ? toUiChannelType(gatewayDelivery.channel) : undefined;
const delivery = channelType ? { ...gatewayDelivery, channel: channelType } : gatewayDelivery;
const target = channelType
? {
channelType,
channelId: delivery.accountId || gatewayDelivery.channel || channelType,
channelName: channelType,
recipient: delivery.to,
}
: undefined;
const lastRun = job.state?.lastRunAtMs
? {
time: new Date(job.state.lastRunAtMs).toISOString(),
success: job.state.lastStatus === 'ok',
error: job.state.lastError,
duration: job.state.lastDurationMs,
}
: undefined;
const nextRun = job.state?.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : undefined;
const agentId = (job as unknown as { agentId?: string }).agentId || 'main';
return {
id: job.id,
name: job.name,
message,
schedule: normalizeCronSchedule(job.schedule),
delivery,
target,
enabled: job.enabled,
createdAt: new Date(job.createdAtMs).toISOString(),
updatedAt: new Date(job.updatedAtMs).toISOString(),
lastRun,
nextRun,
agentId,
};
}
async function listCronJobs(gatewayManager: GatewayManager): Promise<CronJob[]> {
let jobs: GatewayCronJob[] = [];
let usedFallback = false;
try {
const result = await gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000);
const data = result as { jobs?: GatewayCronJob[] };
jobs = data?.jobs ?? (Array.isArray(result) ? result as GatewayCronJob[] : []);
} catch {
try {
const cronJsonPath = join(getOpenClawConfigDir(), 'cron', 'cron.json');
const raw = await readFile(cronJsonPath, 'utf-8');
const parsed = JSON.parse(raw);
jobs = (Array.isArray(parsed) ? parsed : (parsed?.jobs ?? [])) as GatewayCronJob[];
usedFallback = true;
} catch {
// No fallback data available.
}
}
if (!usedFallback && jobs.length > 0) {
repairCronJobsInBackground(gatewayManager, jobs);
}
return jobs.map((job) => ({ ...transformCronJob(job), ...(usedFallback ? { _fromFallback: true } : {}) }));
}
function repairCronJobsInBackground(gatewayManager: GatewayManager, jobs: GatewayCronJob[]): void {
const jobsToRepairDelivery = jobs.filter((job) => {
const isIsolatedAgent = (job.sessionTarget === 'isolated' || !job.sessionTarget)
&& job.payload?.kind === 'agentTurn';
return isIsolatedAgent && job.delivery?.mode === 'announce' && !job.delivery?.channel;
});
if (jobsToRepairDelivery.length > 0) {
void (async () => {
for (const job of jobsToRepairDelivery) {
try {
await gatewayManager.rpc('cron.update', {
id: job.id,
patch: { delivery: { mode: 'none' } },
});
} catch {
// ignore per-job repair failure
}
}
})();
for (const job of jobsToRepairDelivery) {
job.delivery = { mode: 'none' };
if (job.state?.lastError?.includes('Channel is required')) {
job.state.lastError = undefined;
job.state.lastStatus = 'ok';
}
}
}
const jobsToRepairAgent = jobs.filter((job) => {
const jobAgentId = (job as unknown as { agentId?: string }).agentId;
return (
(job.sessionTarget === 'isolated' || !job.sessionTarget)
&& job.payload?.kind === 'agentTurn'
&& job.delivery?.mode === 'announce'
&& job.delivery?.channel
&& jobAgentId === undefined
);
});
if (jobsToRepairAgent.length > 0) {
void (async () => {
for (const job of jobsToRepairAgent) {
try {
const channel = toOpenClawChannelType(job.delivery!.channel!);
const accountId = job.delivery!.accountId;
const toAddress = job.delivery!.to;
let correctAgentId = await resolveAgentIdFromChannel(channel, accountId);
let resolvedAccountId: string | null = null;
if (!correctAgentId && !accountId && toAddress) {
resolvedAccountId = await resolveAccountIdFromSessionHistory(toAddress, channel);
if (resolvedAccountId) {
correctAgentId = await resolveAgentIdFromChannel(channel, resolvedAccountId);
}
}
if (correctAgentId) {
const patch: Record<string, unknown> = { agentId: correctAgentId };
if (resolvedAccountId && !accountId) patch.delivery = { accountId: resolvedAccountId };
await gatewayManager.rpc('cron.update', { id: job.id, patch });
(job as unknown as { agentId: string }).agentId = correctAgentId;
if (resolvedAccountId && !accountId && job.delivery) job.delivery.accountId = resolvedAccountId;
}
} catch {
// ignore per-job repair failure
}
}
})();
}
}
function getId(payload: unknown): string {
const body = isRecord(payload) ? payload : {};
const id = body.id;
if (typeof id !== 'string' || !id.trim()) throw new Error('id is required');
return id.trim();
}
export function createCronApi({ gatewayManager }: { gatewayManager: GatewayManager }): CompleteHostServiceRegistry['cron'] {
return {
list: async () => listCronJobs(gatewayManager),
create: async (payload) => {
const input = payload;
const agentId = typeof input.agentId === 'string' && input.agentId.trim() ? input.agentId.trim() : 'main';
const delivery = normalizeCronDelivery(input.delivery);
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(delivery.channel);
if (delivery.mode === 'announce' && unsupportedDeliveryError) {
throw new Error(unsupportedDeliveryError);
}
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);
},
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);
}
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);
},
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 },
});
},
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() : '';
const parsedSession = parseCronSessionKey(sessionKey);
if (!parsedSession) return { success: false, error: `Invalid cron sessionKey: ${sessionKey}` };
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 [jobsResult, runs, sessionEntry] = await Promise.all([
gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000)
.catch(() => ({ jobs: [] as GatewayCronJob[] })),
readCronRunLog(parsedSession.jobId),
readSessionStoreEntry(parsedSession.agentId, sessionKey),
]);
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
const job = jobs.find((item) => item.id === parsedSession.jobId);
return {
messages: buildCronSessionFallbackMessages({
sessionKey,
job,
runs,
sessionEntry: sessionEntry ? {
label: typeof sessionEntry.label === 'string' ? sessionEntry.label : undefined,
updatedAt: normalizeTimestampMs(sessionEntry.updatedAt),
} : undefined,
limit,
}),
};
},
deliveryTargets: async () => ({ success: true, targets: [] }),
};
}
-9
View File
@@ -1,9 +0,0 @@
import { dialog, type MessageBoxOptions, type OpenDialogOptions } from 'electron';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
export function createDialogApi(): CompleteHostServiceRegistry['dialog'] {
return {
open: (payload) => dialog.showOpenDialog(payload as OpenDialogOptions),
message: (payload) => dialog.showMessageBox(payload as MessageBoxOptions),
};
}
-485
View File
@@ -1,485 +0,0 @@
import { app, nativeImage } from 'electron';
import crypto from 'node:crypto';
import { homedir } from 'node:os';
import { basename, extname, join, relative, resolve, sep } from 'node:path';
import type {
FilePreviewTreeNode,
FilePreviewTreeOptions,
FileReadBinaryOptions,
} from '@shared/host-api/contract';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { expandPath } from '../utils/paths';
import { isRecord } from './payload-utils';
const EXT_MIME_MAP: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
'.mkv': 'video/x-matroska',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
'.pdf': 'application/pdf',
'.zip': 'application/zip',
'.gz': 'application/gzip',
'.tar': 'application/x-tar',
'.7z': 'application/x-7z-compressed',
'.rar': 'application/vnd.rar',
'.json': 'application/json',
'.xml': 'application/xml',
'.csv': 'text/csv',
'.txt': 'text/plain',
'.md': 'text/markdown',
'.html': 'text/html',
'.htm': 'text/html',
'.css': 'text/css',
'.js': 'text/javascript',
'.ts': 'text/typescript',
'.py': 'text/x-python',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.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([
'node_modules',
'.venv',
'__pycache__',
'.git',
'dist',
'build',
'.next',
'.turbo',
'.cache',
]);
type StagePathsPayload = {
filePaths?: unknown;
};
type StageBufferPayload = {
base64?: unknown;
fileName?: unknown;
mimeType?: unknown;
};
type PathPayload = {
path?: unknown;
content?: unknown;
opts?: unknown;
};
type ResolvedSandboxedPath = {
realPath: string;
readOnly: boolean;
};
function getMimeType(ext: string): string {
return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream';
}
function mimeToExt(mimeType: string): string {
for (const [ext, mime] of Object.entries(EXT_MIME_MAP)) {
if (mime === mimeType) return ext;
}
return '';
}
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
try {
const img = nativeImage.createFromPath(filePath);
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')}`;
}
const { readFile } = await import('node:fs/promises');
const buf = await readFile(filePath);
return `data:${mimeType};base64,${buf.toString('base64')}`;
} catch {
return null;
}
}
function requirePath(payload: unknown): string {
const path = isRecord(payload) ? payload.path : payload;
if (typeof path !== 'string' || !path.trim()) {
throw new Error('Invalid file path');
}
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);
}
function getFilePreviewWriteRoots(): string[] {
const roots: string[] = [];
roots.push(resolve(join(homedir(), '.openclaw')));
try {
roots.push(resolve(app.getPath('userData')));
} catch {
// ignore
}
roots.push(resolve(OUTBOUND_DIR));
return roots;
}
async function resolveSandboxedPath(
input: string,
mode: 'read' | 'write' = 'read',
): Promise<ResolvedSandboxedPath> {
if (!input.trim()) {
throw new Error('outsideSandbox');
}
const expanded = expandPath(input);
const fsP = await import('node:fs/promises');
let real: string;
try {
real = await fsP.realpath(expanded);
} catch {
real = resolve(expanded);
}
const writeRoots = getFilePreviewWriteRoots();
if (writeRoots.some((root) => isPathInside(real, root))) {
return { realPath: real, readOnly: false };
}
if (mode === 'write') {
throw new Error('readOnlyRoot');
}
return { realPath: real, readOnly: true };
}
function looksLikeBinary(buf: Buffer): boolean {
const limit = Math.min(buf.length, 8192);
for (let i = 0; i < limit; i += 1) {
if (buf[i] === 0) return true;
}
return false;
}
function shouldSkipDirEntry(name: string, includeHidden: boolean): boolean {
if (FILE_PREVIEW_DIR_BLACKLIST.has(name)) return true;
if (!includeHidden && name.startsWith('.')) return true;
return false;
}
function shouldSkipFileEntry(name: string, includeHidden: boolean): boolean {
if (!includeHidden && name.startsWith('.')) return true;
return false;
}
function getTreeOptions(opts: unknown): FilePreviewTreeOptions {
return isRecord(opts) ? opts as FilePreviewTreeOptions : {};
}
function getBinaryOptions(opts: unknown): FileReadBinaryOptions {
return isRecord(opts) ? opts as FileReadBinaryOptions : {};
}
export function createFilesApi(): CompleteHostServiceRegistry['files'] {
return {
stagePaths: async (payload) => {
const body = isRecord(payload) ? payload as StagePathsPayload : {};
const filePaths = Array.isArray(body.filePaths)
? 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();
const fileName = basename(filePath);
const sourceStat = await fsP.stat(filePath);
if (sourceStat.isDirectory()) {
results.push({
id,
fileName,
mimeType: DIRECTORY_MIME_TYPE,
fileSize: 0,
stagedPath: filePath,
preview: null,
});
continue;
}
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)
: null;
results.push({ id, fileName, mimeType, fileSize: s.size, stagedPath, preview });
}
return results;
},
stageBuffer: async (payload) => {
const body = isRecord(payload) ? payload as StageBufferPayload : {};
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)
: null;
return {
id,
fileName: body.fileName,
mimeType,
fileSize: buffer.length,
stagedPath,
preview,
};
},
readText: async (payload) => {
try {
const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read');
const fsP = await import('node:fs/promises');
const stat = await fsP.stat(real);
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 fsP.readFile(real);
if (looksLikeBinary(buf)) return { ok: false, error: 'binary', size: stat.size };
return {
ok: true,
content: buf.toString('utf8'),
mimeType: getMimeType(extname(real)),
size: stat.size,
readOnly,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
if (message.includes('ENOENT')) return { ok: false, error: 'notFound' };
return { ok: false, error: message };
}
},
readBinary: async (payload) => {
try {
const body = isRecord(payload) ? payload as PathPayload : {};
const opts = getBinaryOptions(body.opts);
const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read');
const fsP = await import('node:fs/promises');
const stat = await fsP.stat(real);
if (!stat.isFile()) return { ok: false, error: 'notFound' };
const maxBytes = typeof opts.maxBytes === 'number' ? opts.maxBytes : undefined;
const cap = Math.max(1, Math.min(maxBytes ?? FILE_PREVIEW_MAX_BINARY_BYTES, FILE_PREVIEW_MAX_BINARY_BYTES));
if (stat.size > cap) return { ok: false, error: 'tooLarge', size: stat.size };
const buf = await fsP.readFile(real);
const view = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
return {
ok: true,
data: view,
mimeType: getMimeType(extname(real)),
size: stat.size,
readOnly,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
if (message.includes('ENOENT')) return { ok: false, error: 'notFound' };
return { ok: false, error: message };
}
},
writeText: async (payload) => {
try {
const body = isRecord(payload) ? payload as PathPayload : {};
if (typeof body.content !== 'string') return { ok: false, error: 'invalidContent' };
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 fsP = await import('node:fs/promises');
let stat;
try {
stat = await fsP.stat(real);
} catch {
return { ok: false, error: 'notFound' };
}
if (!stat.isFile()) return { ok: false, error: 'notFound' };
await fsP.writeFile(real, body.content, 'utf8');
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
if (message === 'readOnlyRoot') return { ok: false, error: 'readOnlyRoot' };
return { ok: false, error: message };
}
},
stat: async (payload) => {
try {
const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read');
const fsP = await import('node:fs/promises');
const stat = await fsP.stat(real);
return {
ok: true,
size: stat.size,
mtime: stat.mtimeMs,
isFile: stat.isFile(),
isDir: stat.isDirectory(),
readOnly,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
if (message.includes('ENOENT')) return { ok: false, error: 'notFound' };
return { ok: false, error: message };
}
},
listDir: async (payload) => {
try {
const { realPath: real } = await resolveSandboxedPath(requirePath(payload), 'read');
const fsP = await import('node:fs/promises');
const dirents = await fsP.readdir(real, { withFileTypes: true });
const entries = await Promise.all(dirents.map(async (entry) => {
const abs = join(real, entry.name);
let size = 0;
try {
if (entry.isFile()) size = (await fsP.stat(abs)).size;
} catch {
// non-fatal
}
return {
name: entry.name,
path: abs,
isDir: entry.isDirectory(),
size,
};
}));
return { ok: true, entries };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
if (message.includes('ENOENT')) return { ok: false, error: 'notFound' };
return { ok: false, error: message };
}
},
listTree: async (payload) => {
try {
const body = isRecord(payload) ? payload as PathPayload : {};
const opts = getTreeOptions(body.opts);
const { realPath: real } = await resolveSandboxedPath(requirePath(payload), 'read');
const fsP = await import('node:fs/promises');
const stat = await fsP.stat(real);
if (!stat.isDirectory()) return { ok: false, error: 'notDirectory' };
const maxDepth = Math.max(1, Math.min(opts.maxDepth ?? FILE_PREVIEW_TREE_MAX_DEPTH, 12));
const maxNodes = Math.max(1, Math.min(opts.maxNodes ?? FILE_PREVIEW_TREE_MAX_NODES, 50000));
const includeHidden = !!opts.includeHidden;
let nodeCount = 0;
let truncated = false;
const walk = async (absDir: string, depth: number): Promise<FilePreviewTreeNode[] | undefined> => {
if (depth > maxDepth || truncated) return undefined;
let dirents;
try {
dirents = await fsP.readdir(absDir, { withFileTypes: true });
} catch {
return [];
}
const children: FilePreviewTreeNode[] = [];
for (const entry of dirents) {
if (truncated) break;
const isDir = entry.isDirectory();
const isFile = entry.isFile();
if (!isDir && !isFile) continue;
if (isDir && shouldSkipDirEntry(entry.name, includeHidden)) continue;
if (isFile && shouldSkipFileEntry(entry.name, includeHidden)) continue;
if (nodeCount >= maxNodes) {
truncated = true;
break;
}
nodeCount += 1;
const abs = join(absDir, entry.name);
const node: FilePreviewTreeNode = {
name: entry.name,
relPath: relative(real, abs).split(sep).join('/'),
absPath: abs,
isDir,
};
if (isFile) {
try {
const fstat = await fsP.stat(abs);
node.size = fstat.size;
node.mtime = fstat.mtimeMs;
} catch {
// non-fatal
}
} else {
try {
node.mtime = (await fsP.stat(abs)).mtimeMs;
} catch {
// non-fatal
}
node.children = await walk(abs, depth + 1) ?? [];
}
children.push(node);
}
children.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
return children;
};
const root: FilePreviewTreeNode = {
name: basename(real) || real,
relPath: '',
absPath: real,
isDir: true,
mtime: stat.mtimeMs,
children: (await walk(real, 1)) ?? [],
};
return { ok: true, root, truncated };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
if (message.includes('ENOENT')) return { ok: false, error: 'notFound' };
return { ok: false, error: message };
}
},
};
}
-79
View File
@@ -1,79 +0,0 @@
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 { isRecord } from './payload-utils';
type HealthPayload = {
probe?: unknown;
};
type ControlUiPayload = {
view?: unknown;
};
type RpcPayload = {
method?: unknown;
params?: unknown;
timeoutMs?: unknown;
};
function parseTimeoutMs(timeoutMs: unknown): number | undefined {
if (timeoutMs === undefined) return undefined;
if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new Error('Invalid gateway RPC timeout');
}
return timeoutMs;
}
export function createGatewayApi(
gatewayManager: GatewayManager,
gatewayRpcBackpressure: GatewayRpcBackpressure,
): CompleteHostServiceRegistry['gateway'] {
return {
status: () => gatewayManager.getStatus(),
start: async () => {
await gatewayManager.start();
return { success: true };
},
stop: async () => {
await gatewayManager.stop();
return { success: true };
},
restart: async () => {
await gatewayManager.restart();
return { success: true };
},
health: async (payload) => {
const body = isRecord(payload) ? payload as HealthPayload : {};
return gatewayManager.checkHealth({ probe: body.probe === true });
},
controlUi: async (payload) => {
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 };
},
rpc: async (payload) => {
const body = isRecord(payload) ? payload as RpcPayload : {};
const method = typeof body.method === 'string' ? body.method.trim() : '';
if (!method) {
throw new Error('Invalid gateway RPC method');
}
const timeoutMs = parseTimeoutMs(body.timeoutMs);
return gatewayRpcBackpressure.run(
method,
body.params,
timeoutMs,
(rpcMethod, rpcParams, rpcTimeoutMs) => gatewayManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
);
},
};
}
-87
View File
@@ -1,87 +0,0 @@
import { readFile } from 'node:fs/promises';
import { extname, relative, resolve, sep } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { logger } from '../utils/logger';
import { isRecord } from './payload-utils';
type RecentPayload = {
tailLines?: unknown;
};
type ReadFilePayload = RecentPayload & {
path?: unknown;
};
type MemoryPayload = {
count?: unknown;
};
function safePositiveInteger(value: unknown, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
return Math.max(1, Math.floor(value));
}
function isPathInside(parentDir: string, childPath: string): boolean {
const relativePath = relative(parentDir, childPath);
return relativePath.length > 0
&& !relativePath.startsWith('..')
&& !relativePath.includes(`..${sep}`);
}
async function validateLogFilePath(path: unknown): Promise<string> {
if (typeof path !== 'string' || path.length === 0) {
throw new Error('Invalid log file path');
}
const resolvedPath = resolve(path);
const files = await logger.listLogFiles();
if (files.some((file) => resolve(file.path) === resolvedPath)) {
return resolvedPath;
}
const logDir = logger.getLogDir();
if (!logDir) {
throw new Error('Invalid log file path');
}
const resolvedLogDir = resolve(logDir);
if (!isPathInside(resolvedLogDir, resolvedPath) || extname(resolvedPath) !== '.log') {
throw new Error('Invalid log file path');
}
return resolvedPath;
}
async function readLogFileTail(path: string, tailLines: number): Promise<string> {
const content = await readFile(path, 'utf8');
const lines = content.split('\n');
const hasTrailingNewline = lines.at(-1) === '';
if (hasTrailingNewline) {
lines.pop();
}
if (lines.length <= tailLines) return content;
const tail = lines.slice(-tailLines).join('\n');
return hasTrailingNewline ? `${tail}\n` : tail;
}
export function createLogsApi(): CompleteHostServiceRegistry['logs'] {
return {
recent: async (payload) => {
const body = isRecord(payload) ? payload as RecentPayload : {};
return { content: await logger.readLogFile(safePositiveInteger(body.tailLines, 100)) };
},
memory: (payload) => {
const body = isRecord(payload) ? payload as MemoryPayload : {};
return logger.getRecentLogs(
body.count === undefined ? undefined : safePositiveInteger(body.count, 100),
);
},
dir: () => ({ dir: logger.getLogDir() }),
filePath: () => ({ path: logger.getLogFilePath() }),
listFiles: async () => ({ files: await logger.listLogFiles() }),
readFile: async (payload) => {
const body = isRecord(payload) ? payload as ReadFilePayload : {};
const path = await validateLogFilePath(body.path);
return { content: await readLogFileTail(path, safePositiveInteger(body.tailLines, 200)) };
},
};
}
-221
View File
@@ -1,221 +0,0 @@
import { dialog, nativeImage } from 'electron';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import {
CLAWX_OPENAI_IMAGE_DEFAULT_MODEL,
CLAWX_OPENAI_IMAGE_PROVIDER_KEY,
} from '../utils/openclaw-image-relay-constants';
import {
applyOpenAiImageRelaySettings,
getImageGenerationSettingsSnapshot,
listImageGenerationProvidersFromRuntime,
runImageGenerationTest,
setImageGenerationConfig,
type ImageGenerationModelConfig,
} from '../utils/openclaw-image-generation';
import { isRecord } from './payload-utils';
type ThumbnailEntry = {
filePath?: unknown;
gatewayUrl?: unknown;
mimeType?: unknown;
};
type SaveImagePayload = {
base64?: unknown;
mimeType?: unknown;
filePath?: unknown;
defaultFileName?: unknown;
};
type ImageGenerationSettingsPayload = {
timeoutMs?: unknown;
openAiRelayEnabled?: unknown;
openAiRelayBaseUrl?: unknown;
openAiRelayModel?: unknown;
openAiRelayApiKey?: unknown;
};
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
try {
const { readFile } = await import('node:fs/promises');
if (mimeType === 'image/svg+xml') {
const buf = await readFile(filePath);
return `data:${mimeType};base64,${buf.toString('base64')}`;
}
const img = nativeImage.createFromPath(filePath);
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')}`;
}
const buf = await readFile(filePath);
return `data:${mimeType};base64,${buf.toString('base64')}`;
} catch {
return null;
}
}
async function resolveOutgoingMediaUrl(
gatewayUrl: string,
): Promise<{ path: string; mimeType: string } | null> {
try {
const match = gatewayUrl.match(/\/api\/chat\/media\/outgoing\/[^/]+\/([^/]+)\//);
if (!match) return null;
const attachmentId = decodeURIComponent(match[1]);
if (!/^[A-Za-z0-9._-]+$/.test(attachmentId)) return null;
const recordPath = join(homedir(), '.openclaw', 'media', 'outgoing', 'records', `${attachmentId}.json`);
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(recordPath, 'utf8');
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',
};
} catch {
return null;
}
}
function normalizeThumbnailEntries(payload: unknown): ThumbnailEntry[] {
const value = isRecord(payload) ? payload.paths : payload;
return Array.isArray(value) ? value as ThumbnailEntry[] : [];
}
export function createMediaApi(): CompleteHostServiceRegistry['media'] {
return {
thumbnails: async (payload) => {
const entries = normalizeThumbnailEntries(payload);
const fsP = await import('node:fs/promises');
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 (typeof entry.filePath === 'string' && entry.filePath) {
try {
const stat = await fsP.stat(entry.filePath);
const preview = mimeType.startsWith('image/')
? await generateImagePreview(entry.filePath, mimeType)
: null;
results[entry.filePath] = { preview, fileSize: stat.size };
} catch {
results[entry.filePath] = { preview: null, fileSize: 0 };
}
continue;
}
if (typeof entry.gatewayUrl === 'string' && entry.gatewayUrl) {
const resolved = await resolveOutgoingMediaUrl(entry.gatewayUrl);
if (!resolved) {
results[entry.gatewayUrl] = { preview: null, fileSize: 0 };
continue;
}
try {
const stat = await fsP.stat(resolved.path);
const preview = resolved.mimeType.startsWith('image/')
? await generateImagePreview(resolved.path, resolved.mimeType)
: null;
results[entry.gatewayUrl] = { preview, fileSize: stat.size };
} catch {
results[entry.gatewayUrl] = { preview: null, fileSize: 0 };
}
}
}
return results;
},
saveImage: async (payload) => {
const body = isRecord(payload) ? payload as SaveImagePayload : {};
const defaultFileName = typeof body.defaultFileName === 'string' && body.defaultFileName
? body.defaultFileName
: 'image.png';
const mimeType = typeof body.mimeType === 'string' ? body.mimeType : undefined;
const ext = defaultFileName.includes('.')
? defaultFileName.split('.').pop()!
: (mimeType?.split('/')[1] || 'png');
const result = await dialog.showSaveDialog({
defaultPath: join(homedir(), 'Downloads', defaultFileName),
filters: [
{ name: 'Images', extensions: [ext, 'png', 'jpg', 'jpeg', 'webp', 'gif'] },
{ name: 'All Files', extensions: ['*'] },
],
});
if (result.canceled || !result.filePath) return { success: false };
const fsP = await import('node:fs/promises');
if (typeof body.filePath === 'string' && body.filePath) {
try {
await fsP.access(body.filePath);
await fsP.copyFile(body.filePath, result.filePath);
} catch {
return { success: false, error: 'Source file not found' };
}
} else if (typeof body.base64 === 'string' && body.base64) {
await fsP.writeFile(result.filePath, Buffer.from(body.base64, 'base64'));
} else {
return { success: false, error: 'No image data provided' };
}
return { success: true, savedPath: result.filePath };
},
imageGenerationSettings: async () => ({
success: true,
...(await getImageGenerationSettingsSnapshot()),
}),
saveImageGenerationSettings: async (payload) => {
const body = isRecord(payload) ? payload as ImageGenerationSettingsPayload : {};
const current = await getImageGenerationSettingsSnapshot();
const normalizeRelayModel = (value: unknown): string => {
const raw = typeof value === 'string' && value.trim()
? value.trim()
: (current.openAiRelay.model || CLAWX_OPENAI_IMAGE_DEFAULT_MODEL);
const slash = raw.indexOf('/');
return (slash > 0 ? raw.slice(slash + 1) : raw).trim() || CLAWX_OPENAI_IMAGE_DEFAULT_MODEL;
};
const relayModel = normalizeRelayModel(body.openAiRelayModel);
let nextPrimary = current.config.primary;
if (body.openAiRelayEnabled === true) {
nextPrimary = `${CLAWX_OPENAI_IMAGE_PROVIDER_KEY}/${relayModel}`;
} else if (body.openAiRelayEnabled === false) {
nextPrimary = null;
}
const next: ImageGenerationModelConfig = {
primary: nextPrimary,
fallbacks: [],
timeoutMs: body.timeoutMs !== undefined
? (typeof body.timeoutMs === 'number' && body.timeoutMs > 0 ? Math.floor(body.timeoutMs) : null)
: current.config.timeoutMs,
};
if (typeof body.openAiRelayEnabled === 'boolean') {
await applyOpenAiImageRelaySettings({
enabled: body.openAiRelayEnabled,
baseUrl: typeof body.openAiRelayBaseUrl === 'string' ? body.openAiRelayBaseUrl : null,
apiKey: typeof body.openAiRelayApiKey === 'string' ? body.openAiRelayApiKey : undefined,
model: relayModel,
});
}
const config = await setImageGenerationConfig(next);
return {
success: true,
...(await getImageGenerationSettingsSnapshot()),
config,
};
},
imageGenerationProviders: async () => ({
success: true,
providers: await listImageGenerationProvidersFromRuntime(),
}),
testImageGeneration: async (payload) => runImageGenerationTest(isRecord(payload) ? payload : {}),
};
}
-25
View File
@@ -1,25 +0,0 @@
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { getOpenClawCliCommand } from '../utils/openclaw-cli';
import { ensureDir, getOpenClawSkillsDir, getOpenClawStatus } from '../utils/paths';
import { existsSync } from 'node:fs';
export function createOpenClawApi(): CompleteHostServiceRegistry['openclaw'] {
return {
status: () => getOpenClawStatus(),
getSkillsDir: () => {
const dir = getOpenClawSkillsDir();
ensureDir(dir);
return dir;
},
getCliCommand: () => {
const status = getOpenClawStatus();
if (!status.packageExists) {
return { success: false, error: `OpenClaw package not found at: ${status.dir}` };
}
if (!existsSync(status.entryPath)) {
return { success: false, error: `OpenClaw entry script not found at: ${status.entryPath}` };
}
return { success: true, command: getOpenClawCliCommand() };
},
};
}
-5
View File
@@ -1,5 +0,0 @@
export type UnknownRecord = Record<string, unknown>;
export function isRecord(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
-501
View File
@@ -1,501 +0,0 @@
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 { ProviderConfig } from '../utils/secure-storage';
import { browserOAuthManager, type BrowserOAuthProviderType } from '../utils/browser-oauth';
import { deviceOAuthManager, type OAuthProviderType } from '../utils/device-oauth';
import { removeProviderFromOpenClaw, saveProviderKeyToOpenClaw } from '../utils/openclaw-auth';
import { getProviderConfig } from '../utils/provider-registry';
import { logger } from '../utils/logger';
import { getProviderService } from './providers/provider-service';
import { providerAccountToConfig } from './providers/provider-store';
import {
getOpenClawProviderKey,
syncDefaultProviderToRuntime,
syncDeletedProviderApiKeyToRuntime,
syncDeletedProviderToRuntime,
syncProviderApiKeyToRuntime,
syncSavedProviderToRuntime,
syncUpdatedProviderToRuntime,
} from './providers/provider-runtime-sync';
import { validateApiKeyWithProvider } from './providers/provider-validation';
import type { ProviderAccount } from '../shared/providers/types';
import { isRecord } from './payload-utils';
type ProvidersApiContext = {
gatewayManager: GatewayManager;
mainWindow: BrowserWindow;
};
type ProviderPayload<Action extends keyof HostApiContract['providers']> =
Parameters<HostApiContract['providers'][Action]>[0];
type ValidationOptions = {
baseUrl?: string;
apiProtocol?: string;
};
function hasObjectChanges<T extends Record<string, unknown>>(
existing: T,
patch: Partial<T> | undefined,
): boolean {
if (!patch) return false;
const keys = Object.keys(patch) as Array<keyof T>;
if (keys.length === 0) return false;
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;
const value = payload[key];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function requireString(payload: unknown, key: string, action: string): string {
const value = payloadString(payload, key);
if (!value) {
throw new Error(`Invalid providers.${action} payload`);
}
return value;
}
function getPayloadRecord(payload: unknown, action: string): Record<string, unknown> {
if (!isRecord(payload)) {
throw new Error(`Invalid providers.${action} payload`);
}
return payload;
}
function getProviderId(payload: unknown, action: string): string {
if (Array.isArray(payload)) {
const [providerId] = payload;
if (typeof providerId === 'string' && providerId.trim()) return providerId.trim();
}
return requireString(payload, 'providerId', action);
}
function getAccountId(payload: unknown, action: string): string {
return requireString(payload, 'accountId', action);
}
function getApiKeyPayload(payload: unknown, action: string): { providerId: string; apiKey: string } {
if (Array.isArray(payload)) {
const [providerId, apiKey] = payload;
if (typeof providerId === 'string' && providerId.trim() && typeof apiKey === 'string') {
return { providerId: providerId.trim(), apiKey };
}
}
const record = getPayloadRecord(payload, action);
const providerId = typeof record.providerId === 'string' ? record.providerId.trim() : '';
if (!providerId || typeof record.apiKey !== 'string') {
throw new Error(`Invalid providers.${action} payload`);
}
return { providerId, apiKey: record.apiKey };
}
function getProviderUpdatePayload(payload: unknown): {
providerId: string;
updates: Partial<ProviderConfig>;
apiKey?: string;
} {
if (Array.isArray(payload)) {
const [providerId, updates, apiKey] = payload;
if (typeof providerId === 'string' && providerId.trim() && isRecord(updates)) {
return { providerId: providerId.trim(), updates: updates as Partial<ProviderConfig>, apiKey: typeof apiKey === 'string' ? apiKey : undefined };
}
}
const record = getPayloadRecord(payload, 'updateWithKey');
const providerId = typeof record.providerId === 'string' ? record.providerId.trim() : '';
if (!providerId || !isRecord(record.updates)) {
throw new Error('Invalid providers.updateWithKey payload');
}
return {
providerId,
updates: record.updates as Partial<ProviderConfig>,
apiKey: typeof record.apiKey === 'string' ? record.apiKey : undefined,
};
}
function getSavePayload(payload: unknown): { config: ProviderConfig; apiKey?: string } {
if (Array.isArray(payload)) {
const [config, apiKey] = payload;
if (isRecord(config)) {
return { config: config as unknown as ProviderConfig, apiKey: typeof apiKey === 'string' ? apiKey : undefined };
}
}
const record = getPayloadRecord(payload, 'save');
if (!isRecord(record.config)) {
throw new Error('Invalid providers.save payload');
}
return {
config: record.config as unknown as ProviderConfig,
apiKey: typeof record.apiKey === 'string' ? record.apiKey : undefined,
};
}
async function validateKey(payload: ProviderPayload<'validateKey'>): Promise<{ valid: boolean; error?: string }> {
try {
const body = getPayloadRecord(payload, 'validateKey');
const accountId = typeof body.accountId === 'string' && body.accountId.trim()
? body.accountId.trim()
: undefined;
const vendorId = typeof body.vendorId === 'string' && body.vendorId.trim()
? body.vendorId.trim()
: undefined;
const providerId = typeof body.providerId === 'string' && body.providerId.trim()
? body.providerId.trim()
: undefined;
const apiKey = typeof body.apiKey === 'string' ? body.apiKey : undefined;
if (!apiKey) {
return { valid: false, error: 'Invalid providers.validateKey payload' };
}
const providerService = getProviderService();
const lookupId = accountId || vendorId || providerId || '';
const account = lookupId ? await providerService.getAccount(lookupId) : null;
const legacyProvider = !account && providerId ? await providerService._getProviderInternal(providerId) : null;
const providerType = account?.vendorId || legacyProvider?.type || vendorId || providerId || lookupId;
if (!providerType) {
return { valid: false, error: 'Invalid providers.validateKey payload' };
}
const options = isRecord(body.options) ? body.options as ValidationOptions : undefined;
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
const resolvedBaseUrl = options?.baseUrl || account?.baseUrl || legacyProvider?.baseUrl || registryBaseUrl;
const resolvedProtocol = options?.apiProtocol || account?.apiProtocol || legacyProvider?.apiProtocol;
return await validateApiKeyWithProvider(providerType, apiKey, {
baseUrl: resolvedBaseUrl,
apiProtocol: resolvedProtocol,
});
} catch (error) {
return { valid: false, error: String(error) };
}
}
async function saveProvider(payload: ProviderPayload<'save'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const { config, apiKey } = getSavePayload(payload);
try {
await providerService._saveProviderInternal(config);
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await providerService._setProviderApiKeyInternal(config.id, trimmedKey);
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
}
}
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function deleteProvider(payload: ProviderPayload<'delete'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const providerId = getProviderId(payload, 'delete');
try {
const existing = await providerService._getProviderInternal(providerId);
await providerService._deleteProviderInternal(providerId);
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function setProviderApiKey(payload: ProviderPayload<'setApiKey'>) {
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);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function updateProviderWithKey(payload: ProviderPayload<'updateWithKey'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const { providerId, updates, apiKey } = getProviderUpdatePayload(payload);
const existing = await providerService._getProviderInternal(providerId);
if (!existing) {
return { success: false, error: 'Provider not found' };
}
const previousKey = await providerService._getProviderApiKeyInternal(providerId);
const previousOck = getOpenClawProviderKey(existing.type, providerId);
try {
const nextConfig: ProviderConfig = {
...existing,
...updates,
updatedAt: new Date().toISOString(),
};
const ock = getOpenClawProviderKey(nextConfig.type, providerId);
await providerService._saveProviderInternal(nextConfig);
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await providerService._setProviderApiKeyInternal(providerId, trimmedKey);
await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey);
} else {
await providerService._deleteProviderApiKeyInternal(providerId);
await removeProviderFromOpenClaw(ock);
}
}
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
return { success: true };
} catch (error) {
try {
await providerService._saveProviderInternal(existing);
if (previousKey) {
await providerService._setProviderApiKeyInternal(providerId, previousKey);
await saveProviderKeyToOpenClaw(previousOck, previousKey);
} else {
await providerService._deleteProviderApiKeyInternal(providerId);
await removeProviderFromOpenClaw(previousOck);
}
} catch (rollbackError) {
logger.warn('Failed to rollback provider updateWithKey:', rollbackError);
}
return { success: false, error: String(error) };
}
}
async function deleteProviderApiKey(payload: ProviderPayload<'deleteApiKey'>) {
const providerService = getProviderService();
const providerId = getProviderId(payload, 'deleteApiKey');
try {
await providerService._deleteProviderApiKeyInternal(providerId);
const provider = await providerService._getProviderInternal(providerId);
await syncDeletedProviderApiKeyToRuntime(provider, providerId);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function setDefaultProvider(payload: ProviderPayload<'setDefault'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const providerId = getProviderId(payload, 'setDefault');
try {
await providerService._setDefaultProviderInternal(providerId);
await syncDefaultProviderToRuntime(providerId, gatewayManager);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function createAccount(payload: ProviderPayload<'createAccount'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const body = getPayloadRecord(payload, 'createAccount');
if (!isRecord(body.account)) {
throw new Error('Invalid providers.createAccount payload');
}
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);
return { success: true, account };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function updateAccount(payload: ProviderPayload<'updateAccount'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const body = getPayloadRecord(payload, 'updateAccount');
const accountId = typeof body.accountId === 'string' ? body.accountId.trim() : '';
const updates = isRecord(body.updates) ? body.updates as Partial<ProviderAccount> : undefined;
const apiKey = typeof body.apiKey === 'string' ? body.apiKey : undefined;
if (!accountId || !updates) {
throw new Error('Invalid providers.updateAccount payload');
}
try {
const existing = await providerService.getAccount(accountId);
if (!existing) {
return { success: false, error: 'Provider account not found' };
}
const hasPatchChanges = hasObjectChanges(existing as unknown as Record<string, unknown>, updates as Record<string, unknown>);
if (!hasPatchChanges && apiKey === undefined) {
return { success: true, noChange: true, account: existing };
}
const account = await providerService.updateAccount(accountId, updates, apiKey);
await syncUpdatedProviderToRuntime(providerAccountToConfig(account), apiKey, gatewayManager);
return { success: true, account };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function deleteAccount(
payload: ProviderPayload<'deleteAccount'> & { apiKeyOnly?: boolean },
gatewayManager?: GatewayManager,
) {
const providerService = getProviderService();
const body = getPayloadRecord(payload, 'deleteAccount');
const accountId = typeof body.accountId === 'string' ? body.accountId.trim() : '';
const apiKeyOnly = body.apiKeyOnly === true;
if (!accountId) {
throw new Error('Invalid providers.deleteAccount payload');
}
try {
const existing = await providerService.getAccount(accountId);
const runtimeProviderKey = existing?.authMode === 'oauth_browser' && existing.vendorId === 'openai'
? 'openai'
: undefined;
if (apiKeyOnly) {
await providerService._deleteProviderApiKeyInternal(accountId);
await syncDeletedProviderApiKeyToRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
runtimeProviderKey,
);
return { success: true };
}
const currentDefaultAccountId = await providerService.getDefaultAccountId();
const replacementDefault = currentDefaultAccountId === accountId
? selectReplacementDefaultAccount(await providerService.listAccounts(), accountId)
: undefined;
await providerService.deleteAccount(accountId);
if (replacementDefault) {
await providerService.setDefaultAccount(replacementDefault.id);
await syncDefaultProviderToRuntime(replacementDefault.id);
}
await syncDeletedProviderToRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
gatewayManager,
runtimeProviderKey,
);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function setDefaultAccount(payload: ProviderPayload<'setDefaultAccount'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const accountId = getAccountId(payload, 'setDefaultAccount');
try {
const currentDefault = await providerService.getDefaultAccountId();
if (currentDefault === accountId) {
return { success: true, noChange: true };
}
await providerService.setDefaultAccount(accountId);
await syncDefaultProviderToRuntime(accountId, gatewayManager);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function requestOAuth(payload: ProviderPayload<'requestOAuth'>) {
const body = getPayloadRecord(payload, 'requestOAuth');
const provider = typeof body.provider === 'string' ? body.provider : undefined;
if (!provider) {
return { success: false, error: 'Invalid providers.requestOAuth payload' };
}
const region = body.region === 'global' || body.region === 'cn' ? body.region : undefined;
const options = {
accountId: typeof body.accountId === 'string' ? body.accountId : undefined,
label: typeof body.label === 'string' ? body.label : undefined,
};
try {
if (provider === 'openai') {
await browserOAuthManager.startFlow(provider as BrowserOAuthProviderType, options);
} else {
await deviceOAuthManager.startFlow(provider as OAuthProviderType, region, options);
}
return { success: true };
} catch (error) {
logger.error('providers.requestOAuth failed', error);
return { success: false, error: String(error) };
}
}
async function cancelOAuth() {
try {
await deviceOAuthManager.stopFlow();
await browserOAuthManager.stopFlow();
return { success: true };
} catch (error) {
logger.error('providers.cancelOAuth failed', error);
return { success: false, error: String(error) };
}
}
async function submitOAuth(payload: ProviderPayload<'submitOAuth'>) {
const body = getPayloadRecord(payload, 'submitOAuth');
const code = typeof body.code === 'string' ? body.code : '';
try {
const accepted = browserOAuthManager.submitManualCode(code);
if (!accepted) {
return { success: false, error: 'No active manual OAuth input pending' };
}
return { success: true };
} catch (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);
return {
list: async () => providerService._listProvidersWithKeyInfoInternal(),
get: async (payload) => providerService._getProviderInternal(getProviderId(payload, 'get')),
getDefault: async () => providerService._getDefaultProviderInternal(),
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),
accounts: async () => providerService.listAccounts(),
vendors: async () => providerService.listVendors(),
accountKeyInfo: async () => providerService.listAccountsKeyInfo(),
getDefaultAccount: async () => ({ accountId: await providerService.getDefaultAccountId() ?? null }),
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),
requestOAuth,
cancelOAuth,
submitOAuth,
};
}
@@ -5,32 +5,23 @@ import type { ProviderConfig } from '../../utils/secure-storage';
import { getAllProviders, getApiKey, getDefaultProvider, getProvider } from '../../utils/secure-storage';
import { getProviderConfig, getProviderDefaultModel } from '../../utils/provider-registry';
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,
type PiAiModelCostRates,
} from '../../shared/pi-ai-model-cost';
import { logger } from '../../utils/logger';
import { listAgentsSnapshot } from '../../utils/agent-config';
/** OpenClaw Codex OAuth hooks only apply to the canonical `openai` provider id. */
const OPENAI_OAUTH_RUNTIME_PROVIDER = 'openai';
const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.5`;
const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli';
const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`;
const OPENAI_OAUTH_RUNTIME_PROVIDER = 'openai-codex';
const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.4`;
/**
* Provider types that are not in the built-in provider registry (no `providerConfig.api`).
@@ -103,8 +94,13 @@ export function getOpenClawProviderKey(type: string, providerId: string): string
async function resolveRuntimeProviderKey(config: ProviderConfig): Promise<string> {
const account = await getProviderAccount(config.id);
if (account?.authMode === 'oauth_browser' && config.type === 'openai') {
return OPENAI_OAUTH_RUNTIME_PROVIDER;
if (account?.authMode === 'oauth_browser') {
if (config.type === 'google') {
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
}
if (config.type === 'openai') {
return OPENAI_OAUTH_RUNTIME_PROVIDER;
}
}
return getOpenClawProviderKey(config.type, config.id);
}
@@ -120,6 +116,9 @@ async function getBrowserOAuthRuntimeProvider(config: ProviderConfig): Promise<s
return null;
}
if (config.type === 'google') {
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
}
if (config.type === 'openai') {
return OPENAI_OAUTH_RUNTIME_PROVIDER;
}
@@ -214,8 +213,8 @@ 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({
id: account.id,
@@ -267,12 +266,6 @@ async function syncProviderSecretToRuntime(
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await saveProviderKeyToOpenClaw(runtimeProviderKey, trimmedKey);
} else {
// An explicit empty string means the caller wants to clear the key.
// Mirror that intent into OpenClaw auth-profiles so the gateway no
// longer authenticates with the stale value (matches the explicit
// delete branch in the legacy /api/providers/:id PUT handler).
await removeProviderKeyFromOpenClaw(runtimeProviderKey);
}
return;
}
@@ -343,7 +336,7 @@ async function syncCustomProviderAgentModel(
await updateAgentModelProvider(runtimeProviderKey, {
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl, config.apiProtocol || 'openai-completions'),
api: config.apiProtocol || 'openai-completions',
models: modelId ? [piAiModelsJsonModelEntry(modelId)] : [],
models: modelId ? [{ id: modelId, name: modelId }] : [],
apiKey: resolvedKey,
});
}
@@ -379,26 +372,6 @@ 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 {
@@ -432,7 +405,7 @@ async function buildAgentModelProviderEntry(
): Promise<{
baseUrl?: string;
api?: string;
models?: Array<{ id: string; name: string; cost: PiAiModelCostRates }>;
models?: Array<{ id: string; name: string }>;
apiKey?: string;
authHeader?: boolean;
} | null> {
@@ -461,7 +434,7 @@ async function buildAgentModelProviderEntry(
return {
baseUrl,
api,
models: [piAiModelsJsonModelEntry(modelId)],
models: [{ id: modelId, name: modelId }],
apiKey,
authHeader,
};
@@ -540,8 +513,7 @@ export async function syncUpdatedProviderToRuntime(
const fallbackModels = await getProviderFallbackModelRefs(config);
const defaultProviderId = await getDefaultProvider();
const isDefaultProvider = defaultProviderId === config.id;
if (isDefaultProvider) {
if (defaultProviderId === config.id) {
const modelOverride = config.model ? `${ock}/${config.model}` : undefined;
if (!isUnregisteredProviderType(config.type)) {
if (shouldUseExplicitDefaultOverride(config, ock)) {
@@ -617,48 +589,6 @@ export async function syncDefaultProviderToRuntime(
return;
}
// Self-heal: opportunistically remove any pre-existing models.providers
// entries with an invalid `api` field so a switch to a healthy provider
// can rescue the user from a previously broken config (e.g. the historical
// openrouter `api: 'openrouter'` bug). Covers both OAuth and non-OAuth
// branches below.
try {
const removed = await pruneInvalidApiProviderEntries();
if (removed.length > 0) {
logger.warn(
`[provider-runtime] Pruned invalid models.providers entries before switch: ${removed.join(', ')}`,
);
}
} catch (err) {
logger.warn('[provider-runtime] Failed to prune invalid provider entries before switch:', err);
}
// Self-heal: pin the embedded agent runtime for legacy OpenAI provider entries
// (`openai`, `openai-codex`) that would otherwise be auto-routed to the
// unbundled `codex` harness. Running this before every default-provider switch
// repairs on-disk config written by earlier ClawX builds.
try {
const pinned = await ensureOpenClawProviderAgentRuntimePins();
if (pinned.length > 0) {
logger.warn(
`[provider-runtime] Pinned embedded agent runtime for models.providers entries before switch: ${pinned.join(', ')}`,
);
}
} catch (err) {
logger.warn('[provider-runtime] Failed to pin embedded agent runtime for provider entries before switch:', err);
}
try {
const healed = await ensureAnthropicMessagesModelMaxTokens();
if (healed.length > 0) {
logger.warn(
`[provider-runtime] Ensured anthropic-messages maxTokens for models.providers entries before switch: ${healed.join(', ')}`,
);
}
} catch (err) {
logger.warn('[provider-runtime] Failed to ensure anthropic-messages maxTokens before switch:', err);
}
const ock = await resolveRuntimeProviderKey(provider);
const providerKey = await getApiKey(providerId);
const fallbackModels = await getProviderFallbackModelRefs(provider);
@@ -705,26 +635,19 @@ export async function syncDefaultProviderToRuntime(
expires: secret.expiresAt,
email: secret.email,
projectId: secret.subject,
accountId: secret.subject,
});
}
const defaultModelRef = OPENAI_OAUTH_DEFAULT_MODEL_REF;
const defaultModelRef = browserOAuthRuntimeProvider === GOOGLE_OAUTH_RUNTIME_PROVIDER
? GOOGLE_OAUTH_DEFAULT_MODEL_REF
: OPENAI_OAUTH_DEFAULT_MODEL_REF;
const modelOverride = provider.model
? (provider.model.startsWith(`${browserOAuthRuntimeProvider}/`)
? provider.model.replace(/^openai-codex\//, `${browserOAuthRuntimeProvider}/`)
? provider.model
: `${browserOAuthRuntimeProvider}/${provider.model}`)
: defaultModelRef;
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}/`)),
);
await setOpenClawDefaultModel(browserOAuthRuntimeProvider, modelOverride, fallbackModels);
logger.info(`Configured openclaw.json for browser OAuth provider "${provider.id}"`);
try {
await syncAgentModelsToRuntime();
@@ -766,7 +689,7 @@ export async function syncDefaultProviderToRuntime(
api,
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
models: defaultModelId ? [piAiModelsJsonModelEntry(defaultModelId)] : [],
models: defaultModelId ? [{ id: defaultModelId, name: defaultModelId }] : [],
});
} catch (err) {
logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err);
@@ -782,7 +705,7 @@ export async function syncDefaultProviderToRuntime(
await updateAgentModelProvider(ock, {
baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'),
api: provider.apiProtocol || 'openai-completions',
models: modelId ? [piAiModelsJsonModelEntry(modelId)] : [],
models: modelId ? [{ id: modelId, name: modelId }] : [],
apiKey: providerKey,
});
}
+43 -281
View File
@@ -28,17 +28,8 @@ import {
setDefaultProvider,
storeApiKey,
} from '../../utils/secure-storage';
import {
getActiveOpenClawProviders,
getOpenClawProvidersConfig,
getProviderApiKeyFromOpenClaw,
} from '../../utils/openclaw-auth';
import {
filterActiveProviderKeysForUi,
getAliasSourceTypes,
OPENAI_CODEX_RUNTIME_PROVIDER_KEY,
resolveOpenClawProviderKey,
} from '../../utils/provider-keys';
import { getActiveOpenClawProviders, getOpenClawProvidersConfig } from '../../utils/openclaw-auth';
import { getAliasSourceTypes, getOpenClawProviderKeyForType } from '../../utils/provider-keys';
import type { ProviderWithKeyInfo } from '../../shared/providers/types';
import { logger } from '../../utils/logger';
@@ -62,40 +53,6 @@ function logLegacyProviderApiUsage(method: string, replacement: string): void {
);
}
function inferProviderVendorIdFromOpenClawEntry(
key: string,
entry: Record<string, unknown>,
): ProviderType | 'custom' {
if (key === 'minimax-portal') {
const baseUrl = typeof entry.baseUrl === 'string' ? entry.baseUrl.toLowerCase() : '';
if (baseUrl.includes('api.minimaxi.com')) {
return 'minimax-portal-cn';
}
}
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;
@@ -121,7 +78,7 @@ export class ProviderService {
// Index store accounts by their openclaw runtime key for fast lookup.
const storeByKey = new Map<string, ProviderAccount[]>();
for (const account of allStoreAccounts) {
const ock = resolveOpenClawProviderKey(account);
const ock = getOpenClawProviderKeyForType(account.vendorId, account.id);
const group = storeByKey.get(ock) ?? [];
group.push(account);
storeByKey.set(ock, group);
@@ -130,31 +87,8 @@ export class ProviderService {
const result: ProviderAccount[] = [];
const processedKeys = new Set<string>();
let hasConfiguredOpenAiApiKey = false;
if (activeProviders.has('openai')) {
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;
}
}
}
}
const activeKeysForUi = filterActiveProviderKeysForUi(activeProviders, {
hasConfiguredOpenAiApiKey,
});
// For each active provider in openclaw.json, produce exactly ONE account.
for (const key of activeKeysForUi) {
for (const key of activeProviders) {
if (processedKeys.has(key)) continue;
processedKeys.add(key);
@@ -167,9 +101,10 @@ 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.
let kept = candidates[0];
const kept = candidates[0];
for (const account of storeGroup) {
if (account.id !== kept.id) {
logger.info(
@@ -178,34 +113,6 @@ 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];
@@ -225,30 +132,6 @@ export class ProviderService {
}
}
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) {
continue;
}
const apiKey = await getApiKey(account.id);
const openClawKey = await getProviderApiKeyFromOpenClaw('openai');
if (!apiKey && !openClawKey) {
logger.info(
`[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);
}
}
}
}
return result;
}
@@ -274,8 +157,9 @@ export class ProviderService {
for (const [key, entry] of Object.entries(providers)) {
if (existingIds.has(key)) continue;
const vendorId = inferProviderVendorIdFromOpenClawEntry(key, entry);
const definition = getProviderDefinition(vendorId === 'custom' ? key : vendorId);
const definition = getProviderDefinition(key);
const isBuiltin = (BUILTIN_PROVIDER_TYPES as readonly string[]).includes(key);
const vendorId = isBuiltin ? key : 'custom';
// Skip if an account with this vendorId already exists (e.g. user already
// created "openrouter-uuid" via UI — no need to import bare "openrouter").
@@ -289,15 +173,6 @@ 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;
@@ -318,9 +193,6 @@ export class ProviderService {
? (entry.headers as Record<string, string>)
: undefined),
model,
metadata: customModels && customModels.length > 0
? { customModels }
: undefined,
enabled: true,
isDefault: false,
createdAt: now,
@@ -391,22 +263,21 @@ export class ProviderService {
return deleteProvider(accountId);
}
// ── Internal silent variants ─────────────────────────────────────
// These mirror the legacy public API but never emit deprecation
// warnings, so internal callers (HTTP routes, IPC handlers, the new
// /api/provider-accounts surface) can reuse the same logic without
// contributing to the migration noise. Public legacy methods below
// delegate here after logging exactly once per process.
/** Internal: list providers in the legacy ProviderConfig shape. */
async _listProvidersFromAccountsInternal(): Promise<ProviderConfig[]> {
/**
* @deprecated Use listAccounts() and map account data in callers.
*/
async listLegacyProviders(): Promise<ProviderConfig[]> {
logLegacyProviderApiUsage('listLegacyProviders', 'listAccounts');
const accounts = await this.listAccounts();
return accounts.map(providerAccountToConfig);
}
/** Internal: list providers with hasKey/keyMasked metadata. */
async _listProvidersWithKeyInfoInternal(): Promise<ProviderWithKeyInfo[]> {
const providers = await this._listProvidersFromAccountsInternal();
/**
* @deprecated Use listAccounts() + secret-store based key summary.
*/
async listLegacyProvidersWithKeyInfo(): Promise<ProviderWithKeyInfo[]> {
logLegacyProviderApiUsage('listLegacyProvidersWithKeyInfo', 'listAccounts');
const providers = await this.listLegacyProviders();
const results: ProviderWithKeyInfo[] = [];
for (const provider of providers) {
const apiKey = await getApiKey(provider.id);
@@ -419,15 +290,21 @@ export class ProviderService {
return results;
}
/** Internal: resolve a single provider in the legacy ProviderConfig shape. */
async _getProviderInternal(providerId: string): Promise<ProviderConfig | null> {
/**
* @deprecated Use getAccount(accountId).
*/
async getLegacyProvider(providerId: string): Promise<ProviderConfig | null> {
logLegacyProviderApiUsage('getLegacyProvider', 'getAccount');
await ensureProviderStoreMigrated();
const account = await getProviderAccount(providerId);
return account ? providerAccountToConfig(account) : null;
}
/** Internal: upsert a legacy provider config (creates or updates the account). */
async _saveProviderInternal(config: ProviderConfig): Promise<void> {
/**
* @deprecated Use createAccount()/updateAccount().
*/
async saveLegacyProvider(config: ProviderConfig): Promise<void> {
logLegacyProviderApiUsage('saveLegacyProvider', 'createAccount/updateAccount');
await ensureProviderStoreMigrated();
const account = providerConfigToAccount(config);
const existing = await getProviderAccount(config.id);
@@ -438,129 +315,14 @@ export class ProviderService {
await this.createAccount(account);
}
/** Internal: delete a provider account by id. */
async _deleteProviderInternal(providerId: string): Promise<boolean> {
await ensureProviderStoreMigrated();
await this.deleteAccount(providerId);
return true;
}
/** Internal: set default account without warning. */
async _setDefaultProviderInternal(providerId: string): Promise<void> {
await this.setDefaultAccount(providerId);
}
/** Internal: read default account id without warning. */
async _getDefaultProviderInternal(): Promise<string | undefined> {
return this.getDefaultAccountId();
}
/** Internal: store an account's api key without warning. */
async _setProviderApiKeyInternal(providerId: string, apiKey: string): Promise<boolean> {
return storeApiKey(providerId, apiKey);
}
/** Internal: read an account's api key without warning. */
async _getProviderApiKeyInternal(providerId: string): Promise<string | null> {
return getApiKey(providerId);
}
/** Internal: delete an account's api key without warning. */
async _deleteProviderApiKeyInternal(providerId: string): Promise<boolean> {
return deleteApiKey(providerId);
}
/** Internal: check if an account has a stored api key. */
async _hasProviderApiKeyInternal(providerId: string): Promise<boolean> {
return hasApiKey(providerId);
}
// ── New clean account-based public API ───────────────────────────
// These never log deprecation warnings — they operate purely in
// the account namespace and are the preferred surface for the
// /api/provider-accounts/* HTTP routes and modern renderer code.
/** Return per-account API key status for the new account API surface. */
async listAccountsKeyInfo(): Promise<Array<{ accountId: string; hasKey: boolean; keyMasked: string | null }>> {
const accounts = await this.listAccounts();
const results: Array<{ accountId: string; hasKey: boolean; keyMasked: string | null }> = [];
for (const account of accounts) {
const runtimeProviderKey = resolveOpenClawProviderKey(account);
const apiKey = (await getProviderApiKeyFromOpenClaw(runtimeProviderKey))
?? (await getApiKey(account.id))
?? (runtimeProviderKey !== account.id ? await getApiKey(runtimeProviderKey) : null);
results.push({
accountId: account.id,
hasKey: !!apiKey,
keyMasked: maskApiKey(apiKey),
});
}
return results;
}
/** Read an account's API key (clean alternative to getLegacyProviderApiKey). */
async getAccountApiKey(accountId: string): Promise<string | null> {
return this._getProviderApiKeyInternal(accountId);
}
/** Check whether an account has an API key stored. */
async hasAccountApiKey(accountId: string): Promise<boolean> {
const account = await this.getAccount(accountId);
const runtimeProviderKey = account
? resolveOpenClawProviderKey(account)
: accountId;
if (await getProviderApiKeyFromOpenClaw(runtimeProviderKey)) {
return true;
}
if (runtimeProviderKey !== accountId && (await hasApiKey(runtimeProviderKey))) {
return true;
}
return this._hasProviderApiKeyInternal(accountId);
}
// ── Legacy public API (logs deprecation warning once per method) ─
// These exist solely for backward compatibility with external clients
// (older Gateway code, third-party tooling, in-flight tests). Internal
// ClawX callers should use the internal/clean methods above.
/**
* @deprecated Use listAccounts() and map account data in callers.
*/
async listLegacyProviders(): Promise<ProviderConfig[]> {
logLegacyProviderApiUsage('listLegacyProviders', 'listAccounts');
return this._listProvidersFromAccountsInternal();
}
/**
* @deprecated Use listAccountsKeyInfo() + the account snapshot API.
*/
async listLegacyProvidersWithKeyInfo(): Promise<ProviderWithKeyInfo[]> {
logLegacyProviderApiUsage('listLegacyProvidersWithKeyInfo', 'listAccountsKeyInfo');
return this._listProvidersWithKeyInfoInternal();
}
/**
* @deprecated Use getAccount(accountId).
*/
async getLegacyProvider(providerId: string): Promise<ProviderConfig | null> {
logLegacyProviderApiUsage('getLegacyProvider', 'getAccount');
return this._getProviderInternal(providerId);
}
/**
* @deprecated Use createAccount()/updateAccount().
*/
async saveLegacyProvider(config: ProviderConfig): Promise<void> {
logLegacyProviderApiUsage('saveLegacyProvider', 'createAccount/updateAccount');
return this._saveProviderInternal(config);
}
/**
* @deprecated Use deleteAccount(accountId).
*/
async deleteLegacyProvider(providerId: string): Promise<boolean> {
logLegacyProviderApiUsage('deleteLegacyProvider', 'deleteAccount');
return this._deleteProviderInternal(providerId);
await ensureProviderStoreMigrated();
await this.deleteAccount(providerId);
return true;
}
/**
@@ -568,7 +330,7 @@ export class ProviderService {
*/
async setDefaultLegacyProvider(providerId: string): Promise<void> {
logLegacyProviderApiUsage('setDefaultLegacyProvider', 'setDefaultAccount');
return this._setDefaultProviderInternal(providerId);
await this.setDefaultAccount(providerId);
}
/**
@@ -576,7 +338,7 @@ export class ProviderService {
*/
async getDefaultLegacyProvider(): Promise<string | undefined> {
logLegacyProviderApiUsage('getDefaultLegacyProvider', 'getDefaultAccountId');
return this._getDefaultProviderInternal();
return this.getDefaultAccountId();
}
/**
@@ -584,15 +346,15 @@ export class ProviderService {
*/
async setLegacyProviderApiKey(providerId: string, apiKey: string): Promise<boolean> {
logLegacyProviderApiUsage('setLegacyProviderApiKey', 'setProviderSecret(accountId, api_key)');
return this._setProviderApiKeyInternal(providerId, apiKey);
return storeApiKey(providerId, apiKey);
}
/**
* @deprecated Use getAccountApiKey(accountId).
* @deprecated Use secret-store APIs by accountId.
*/
async getLegacyProviderApiKey(providerId: string): Promise<string | null> {
logLegacyProviderApiUsage('getLegacyProviderApiKey', 'getAccountApiKey');
return this._getProviderApiKeyInternal(providerId);
logLegacyProviderApiUsage('getLegacyProviderApiKey', 'getProviderSecret(accountId)');
return getApiKey(providerId);
}
/**
@@ -600,15 +362,15 @@ export class ProviderService {
*/
async deleteLegacyProviderApiKey(providerId: string): Promise<boolean> {
logLegacyProviderApiUsage('deleteLegacyProviderApiKey', 'deleteProviderSecret(accountId)');
return this._deleteProviderApiKeyInternal(providerId);
return deleteApiKey(providerId);
}
/**
* @deprecated Use hasAccountApiKey(accountId).
* @deprecated Use secret-store APIs by accountId.
*/
async hasLegacyProviderApiKey(providerId: string): Promise<boolean> {
logLegacyProviderApiUsage('hasLegacyProviderApiKey', 'hasAccountApiKey');
return this._hasProviderApiKeyInternal(providerId);
logLegacyProviderApiUsage('hasLegacyProviderApiKey', 'getProviderSecret(accountId)');
return hasApiKey(providerId);
}
async setDefaultAccount(accountId: string): Promise<void> {
-467
View File
@@ -1,467 +0,0 @@
import { openSync, closeSync, fstatSync, readSync } from 'node:fs';
import { join } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { RawMessage } from '@shared/chat/types';
import { getOpenClawConfigDir } from '../utils/paths';
import { logger } from '../utils/logger';
import {
removeSessionEntry,
resolveSessionTranscriptPath,
sweepSessionArtefacts,
} from '../utils/session-files';
import { isRecord } from './payload-utils';
const SAFE_SESSION_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
const RECENT_TRANSCRIPT_INITIAL_READ_BYTES = 256 * 1024;
const RECENT_TRANSCRIPT_MAX_READ_BYTES = 8 * 1024 * 1024;
const RECENT_TRANSCRIPT_MAX_SCAN_LINES = 5_000;
type SessionSummary = {
sessionKey: string;
firstUserText: string | null;
lastTimestamp: number | null;
};
type TranscriptMessage = RawMessage;
type ParsedTranscriptLine = {
type?: string;
message?: TranscriptMessage;
};
type SessionPayload = {
id?: unknown;
sessionKey?: unknown;
label?: unknown;
title?: unknown;
agentId?: unknown;
sessionId?: unknown;
limit?: unknown;
sessionKeys?: unknown;
};
function extractMessageText(content: unknown): string {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
return (content as Array<{ type?: unknown; text?: unknown }>)
.filter((block) => block?.type === 'text' && typeof block.text === 'string' && block.text.trim())
.map((block) => String(block.text))
.join('\n')
.trim();
}
function cleanSummaryUserText(text: string): string {
return text
.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, '')
.replace(/^Sender\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '')
.replace(/^Sender\s*:\s*\{[\s\S]*?\}\s*/i, '')
.replace(/^Sender\s*:[^\n]*(?:\n\s*)*/i, '')
.replace(/^```json\n[\s\S]*?```\s*/i, '')
.replace(/^\{[\s\S]*?\}\s*/i, '')
.replace(/\s*\[media attached:[^\]]*\]/g, '')
.replace(/\s*\[message_id:\s*[^\]]+\]/g, '')
.replace(/^Conversation info\s*\([^)]*\):\s*```[a-z]*\n[\s\S]*?```\s*/i, '')
.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();
}
function isInternalSummaryText(text: string): boolean {
if (!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)
&& /Do not relay it to the user unless explicitly requested/i.test(text)
) {
return true;
}
if (
/^\s*Current time\s*:/i.test(text)
&& /^\s*Current time\s*:[^\n]*\/\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+UTC\s*$/i.test(text)
) {
return true;
}
return false;
}
function normalizeTimestamp(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value)) return 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;
}
return entry.message;
} catch {
return null;
}
}
function parseRecentMessagesFromTailChunk(chunk: string, readStart: number, limit: number): TranscriptMessage[] {
const lines = chunk.split(/\r?\n/);
if (readStart > 0) lines.shift();
const collected: TranscriptMessage[] = [];
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);
if (collected.length >= limit) break;
}
}
return collected.reverse();
}
function readRecentTranscriptMessages(transcriptPath: string, limit: number): TranscriptMessage[] {
const boundedLimit = Math.max(1, Math.min(Math.floor(limit), 1000));
let fd: number | null = null;
try {
fd = openSync(transcriptPath, 'r');
const size = fstatSync(fd).size;
if (size === 0) return [];
let readBytes = Math.min(size, Math.max(RECENT_TRANSCRIPT_INITIAL_READ_BYTES, boundedLimit * 2048));
while (readBytes <= size) {
const readStart = Math.max(0, size - readBytes);
const readLen = size - readStart;
const buffer = Buffer.allocUnsafe(readLen);
readSync(fd, buffer, 0, readLen, readStart);
const messages = parseRecentMessagesFromTailChunk(buffer.toString('utf8'), readStart, boundedLimit);
if (
messages.length >= boundedLimit
|| readStart === 0
|| readBytes >= RECENT_TRANSCRIPT_MAX_READ_BYTES
) {
return messages;
}
readBytes = Math.min(size, readBytes * 2);
}
return [];
} finally {
if (fd !== null) closeSync(fd);
}
}
async function readAllTranscriptMessages(transcriptPath: string): Promise<TranscriptMessage[]> {
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(transcriptPath, 'utf8');
return raw.split(/\r?\n/).filter(Boolean).flatMap((line) => {
const message = parseMessageLine(line);
return message ? [message] : [];
});
}
function summarizeTranscriptMessages(sessionKey: string, messages: TranscriptMessage[]): SessionSummary {
let firstUserText: string | null = null;
let lastTimestamp: number | null = null;
for (const message of messages) {
const normalizedTs = normalizeTimestamp(message.timestamp);
if (normalizedTs != null) {
lastTimestamp = normalizedTs;
}
if (firstUserText == null && message.role === 'user') {
const text = cleanSummaryUserText(extractMessageText(message.content));
if (text && !isInternalSummaryText(text)) {
firstUserText = text;
}
}
}
return { sessionKey, firstUserText, lastTimestamp };
}
function parseSessionKey(sessionKey: string): { agentId: string; suffix: string } | null {
if (!sessionKey.startsWith('agent:')) return null;
const parts = sessionKey.split(':');
if (parts.length < 3) return null;
const agentId = parts[1] || '';
const suffix = parts.slice(2).join(':');
if (!SAFE_SESSION_SEGMENT.test(agentId) || !suffix) return null;
return { agentId, suffix };
}
function getSessionKey(payload: unknown): string {
const body = isRecord(payload) ? payload as SessionPayload : {};
const value = body.sessionKey ?? body.id ?? payload;
if (typeof value !== 'string' || !value.startsWith('agent:')) {
throw new Error(`Invalid sessionKey: ${String(value)}`);
}
return value;
}
function getLimit(payload: unknown, fallback = 200): number {
const value = isRecord(payload) ? (payload as SessionPayload).limit : undefined;
const limitRaw = typeof value === 'number' ? value : fallback;
return Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 1000) : fallback;
}
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 raw = await fsP.readFile(sessionsJsonPath, 'utf8');
return JSON.parse(raw) as Record<string, unknown>;
}
function resolveSessionTranscriptPathByKey(
sessionKey: string,
sessionsDir: string,
sessionsJson: Record<string, unknown>,
): string | null {
let resolvedSrcPath: string | undefined;
let fileName: string | undefined;
if (Array.isArray(sessionsJson.sessions)) {
const entry = (sessionsJson.sessions as Array<Record<string, unknown>>)
.find((session) => session.key === sessionKey || session.sessionKey === sessionKey);
if (entry) {
fileName = (entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (!fileName && typeof entry.id === 'string') {
fileName = `${entry.id}.jsonl`;
}
const absFile = (entry.sessionFile ?? entry.absolutePath) as string | undefined;
if (absFile && (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/))) {
resolvedSrcPath = absFile;
}
}
}
if (!fileName && !resolvedSrcPath && sessionsJson[sessionKey] != null) {
const value = sessionsJson[sessionKey];
if (typeof value === 'string') {
fileName = value;
} else if (typeof value === 'object' && value !== null) {
const entry = value as Record<string, unknown>;
const absFile = (entry.sessionFile ?? entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (absFile) {
if (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/)) {
resolvedSrcPath = absFile;
} else {
fileName = absFile;
}
} else {
const id = (entry.id ?? entry.sessionId) as string | undefined;
if (id) fileName = id.endsWith('.jsonl') ? id : `${id}.jsonl`;
}
}
}
if (!resolvedSrcPath && fileName) {
resolvedSrcPath = join(sessionsDir, fileName.endsWith('.jsonl') ? fileName : `${fileName}.jsonl`);
}
return resolvedSrcPath ?? null;
}
async function loadSessionSummary(sessionKey: string): Promise<SessionSummary> {
const parsed = parseSessionKey(sessionKey);
if (!parsed) {
return { sessionKey, firstUserText: null, lastTimestamp: null };
}
try {
const sessionsDir = join(getOpenClawConfigDir(), 'agents', parsed.agentId, 'sessions');
const sessionsJson = await readSessionsJson(parsed.agentId);
const transcriptPath = resolveSessionTranscriptPathByKey(sessionKey, sessionsDir, sessionsJson);
if (!transcriptPath) {
return { sessionKey, firstUserText: null, lastTimestamp: null };
}
const messages = await readAllTranscriptMessages(transcriptPath);
return summarizeTranscriptMessages(sessionKey, messages);
} catch {
return { sessionKey, firstUserText: null, lastTimestamp: null };
}
}
async function loadSessionTranscriptByKey(sessionKey: string, limit: number): Promise<RawMessage[] | null> {
const parsed = parseSessionKey(sessionKey);
if (!parsed) return null;
try {
const sessionsDir = join(getOpenClawConfigDir(), 'agents', parsed.agentId, 'sessions');
const sessionsJson = await readSessionsJson(parsed.agentId);
const transcriptPath = resolveSessionTranscriptPathByKey(sessionKey, sessionsDir, sessionsJson);
if (!transcriptPath) return null;
return readRecentTranscriptMessages(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}` };
}
const parts = sessionKey.split(':');
if (parts.length < 3) {
return { success: false, error: `sessionKey has too few parts: ${sessionKey}` };
}
const agentId = parts[1];
if (!SAFE_SESSION_SEGMENT.test(agentId)) {
return { success: false, error: `Invalid agentId: ${agentId}` };
}
const sessionsDir = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions');
const sessionsJsonPath = join(sessionsDir, 'sessions.json');
logger.info(`[session:delete] key=${sessionKey} agentId=${agentId}`);
logger.info(`[session:delete] sessionsJson=${sessionsJsonPath}`);
const fsP = await import('node:fs/promises');
let sessionsJson: Record<string, unknown>;
try {
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
sessionsJson = JSON.parse(raw) as Record<string, unknown>;
} catch (error) {
logger.warn(`[session:delete] Could not read sessions.json: ${String(error)}`);
return { success: false, error: `Could not read sessions.json: ${String(error)}` };
}
const resolution = resolveSessionTranscriptPath(sessionsJson, sessionsDir, sessionKey);
if (!resolution.ok) {
if (resolution.failure.kind === 'not-found') {
logger.warn(`[session:delete] Cannot resolve file for "${sessionKey}". Raw value: ${JSON.stringify(sessionsJson[sessionKey])}`);
return { success: false, error: `Cannot resolve file for session: ${sessionKey}` };
}
logger.warn(`[session:delete] Refusing to delete out-of-scope path for "${sessionKey}": ${resolution.failure.resolvedPath}`);
return {
success: false,
error: `Resolved session path is outside the agent sessions dir: ${resolution.failure.resolvedPath}`,
};
}
const { resolvedSrcPath, sessionsDirAbs, baseId } = resolution;
logger.info(`[session:delete] file: ${resolvedSrcPath}`);
const sweep = await sweepSessionArtefacts(sessionsDirAbs, baseId);
for (const removedPath of sweep.removed) {
logger.info(`[session:delete] Unlinked ${removedPath}`);
}
for (const { path: failedPath, error } of sweep.errors) {
logger.warn(`[session:delete] Failed to unlink ${failedPath}: ${String(error)}`);
}
logger.info(`[session:delete] Hard-deleted ${sweep.removed.length} file(s) for ${baseId}`);
try {
const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8');
const json2 = JSON.parse(raw2) as Record<string, unknown>;
removeSessionEntry(json2, sessionKey);
await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8');
logger.info(`[session:delete] Removed "${sessionKey}" from sessions.json`);
} catch (error) {
logger.warn(`[session:delete] Could not update sessions.json: ${String(error)}`);
}
return { success: true };
}
async function renameSession(sessionKey: string, label: string): Promise<{ success: boolean; error?: string }> {
if (!sessionKey || !sessionKey.startsWith('agent:')) {
return { success: false, error: `Invalid sessionKey: ${sessionKey}` };
}
if (!label || typeof label !== 'string' || !label.trim()) {
return { success: false, error: 'Label cannot be empty' };
}
const parts = sessionKey.split(':');
if (parts.length < 3) {
return { success: false, error: `Malformed sessionKey: ${sessionKey}` };
}
const agentId = parts[1];
if (!SAFE_SESSION_SEGMENT.test(agentId)) {
return { success: false, error: `Invalid agentId in sessionKey: ${agentId}` };
}
const sessionsJsonPath = join(getOpenClawConfigDir(), '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>;
const trimmedLabel = label.trim();
let found = false;
if (json[sessionKey] && typeof json[sessionKey] === 'object') {
(json[sessionKey] as Record<string, unknown>).label = trimmedLabel;
found = true;
}
if (Array.isArray(json.sessions)) {
for (const entry of json.sessions as Array<Record<string, unknown>>) {
if (entry.key === sessionKey || entry.sessionKey === sessionKey) {
entry.label = trimmedLabel;
found = true;
}
}
}
if (!found) {
return { success: false, error: `Session not found in sessions.json: ${sessionKey}` };
}
await fsP.writeFile(sessionsJsonPath, JSON.stringify(json, null, 2), 'utf8');
logger.info(`[session:rename] key=${sessionKey} label=${trimmedLabel}`);
return { success: true };
}
export function createSessionsApi(): CompleteHostServiceRegistry['sessions'] {
return {
delete: async (payload) => deleteSession(getSessionKey(payload)),
rename: async (payload) => {
const body = isRecord(payload) ? payload as SessionPayload : {};
const sessionKey = getSessionKey(payload);
const label = body.label ?? body.title;
if (typeof label !== 'string') {
throw new Error('Label cannot be empty');
}
return renameSession(sessionKey, label);
},
summaries: async (payload) => {
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: [] };
return {
success: true,
summaries: await Promise.all(sessionKeys.map((sessionKey) => loadSessionSummary(sessionKey))),
};
},
history: async (payload) => {
const body = isRecord(payload) ? payload as SessionPayload : {};
const limit = getLimit(payload);
if (typeof body.sessionKey === 'string' && body.sessionKey.trim()) {
const messages = await loadSessionTranscriptByKey(body.sessionKey.trim(), limit);
if (!messages) return { success: false, error: 'Transcript not found' };
return { success: true, messages };
}
const agentId = typeof body.agentId === 'string' ? body.agentId.trim() : '';
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
if (!agentId || !sessionId) {
return { success: false, error: 'agentId and sessionId are required' };
}
if (!SAFE_SESSION_SEGMENT.test(agentId) || !SAFE_SESSION_SEGMENT.test(sessionId)) {
return { success: false, error: 'Invalid transcript identifier' };
}
try {
const transcriptPath = join(getOpenClawConfigDir(), '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') {
return { success: false, error: 'Transcript not found' };
}
return { success: false, error: 'Failed to load transcript' };
}
},
};
}
-133
View File
@@ -1,133 +0,0 @@
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { GatewayManager } from '../gateway/manager';
import { syncLaunchAtStartupSettingFromStore } from '../main/launch-at-startup';
import { createMenu } from '../main/menu';
import { applyProxySettings } from '../main/proxy';
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
import {
type AppSettings,
getAllSettings,
getSetting,
resetSettings,
setSetting,
} from '../utils/store';
import { isRecord } from './payload-utils';
type KeyPayload = {
key?: unknown;
};
type SetPayload = KeyPayload & {
value?: unknown;
};
type SetManyPayload = {
patch?: unknown;
};
const PROXY_SETTING_KEYS = new Set<keyof AppSettings>([
'proxyEnabled',
'proxyServer',
'proxyHttpServer',
'proxyHttpsServer',
'proxyAllServer',
'proxyBypassRules',
]);
async function validateSettingKey(key: unknown): Promise<boolean> {
if (typeof key !== 'string' || key.length === 0) return false;
const settings = await getAllSettings();
return Object.prototype.hasOwnProperty.call(settings, key);
}
async function requireSettingKey(payload: unknown): Promise<keyof AppSettings> {
const key = (payload as KeyPayload | undefined)?.key;
if (!await validateSettingKey(key)) {
throw new Error('Invalid settings key');
}
return key as keyof AppSettings;
}
async function requireSettingsPatch(payload: unknown): Promise<Partial<AppSettings>> {
const patch = (payload as SetManyPayload | undefined)?.patch;
if (!isRecord(patch)) {
throw new Error('Invalid settings patch');
}
const entries = Object.entries(patch);
for (const [key] of entries) {
if (!await validateSettingKey(key)) {
throw new Error('Invalid settings key');
}
}
return Object.fromEntries(entries) as Partial<AppSettings>;
}
function patchTouchesProxy(patch: Partial<AppSettings>): boolean {
return Object.keys(patch).some((key) => PROXY_SETTING_KEYS.has(key as keyof AppSettings));
}
function patchTouchesLaunchAtStartup(patch: Partial<AppSettings>): boolean {
return Object.prototype.hasOwnProperty.call(patch, 'launchAtStartup');
}
function patchTouchesLanguage(patch: Partial<AppSettings>): boolean {
return Object.prototype.hasOwnProperty.call(patch, 'language');
}
async function handleProxySettingsChange(gatewayManager: GatewayManager): Promise<void> {
const settings = await getAllSettings();
await syncProxyConfigToOpenClaw(settings, { preserveExistingWhenDisabled: false });
await applyProxySettings(settings);
if (gatewayManager.getStatus().state === 'running') {
await gatewayManager.restart();
}
}
async function runSettingsSideEffects(
gatewayManager: GatewayManager,
patch: Partial<AppSettings>,
): Promise<void> {
if (patchTouchesProxy(patch)) {
await handleProxySettingsChange(gatewayManager);
}
if (patchTouchesLaunchAtStartup(patch)) {
await syncLaunchAtStartupSettingFromStore();
}
if (patchTouchesLanguage(patch)) {
await createMenu(typeof patch.language === 'string' ? patch.language : undefined);
}
}
export function createSettingsApi(gatewayManager: GatewayManager): CompleteHostServiceRegistry['settings'] {
return {
getAll: () => getAllSettings(),
get: async (payload) => {
const key = await requireSettingKey(payload);
return getSetting(key as never);
},
set: async (payload) => {
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>);
return { success: true };
},
setMany: async (payload) => {
const patch = await requireSettingsPatch(payload);
const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>;
for (const [key, value] of entries) {
await setSetting(key, value as never);
}
await runSettingsSideEffects(gatewayManager, patch);
return { success: true };
},
reset: async () => {
await resetSettings();
await handleProxySettingsChange(gatewayManager);
await syncLaunchAtStartupSettingFromStore();
const settings = await getAllSettings();
await createMenu(settings.language);
return { success: true, settings };
},
};
}
-38
View File
@@ -1,38 +0,0 @@
import { shell } from 'electron';
import { homedir } from 'node:os';
import { join, sep } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
function expandShellPath(input: string): string {
if (input === '~') return homedir();
if (input.startsWith(`~${sep}`) || input.startsWith('~/') || input.startsWith('~\\')) {
return join(homedir(), input.slice(2));
}
return input;
}
function requirePath(path: unknown): string {
if (typeof path !== 'string' || !path.trim()) {
throw new Error('path is required');
}
return path;
}
function requireUrl(url: unknown): string {
if (typeof url !== 'string' || !url.trim()) {
throw new Error('url is required');
}
return url;
}
export function createShellApi(): CompleteHostServiceRegistry['shell'] {
return {
openExternal: async (payload) => {
await shell.openExternal(requireUrl(payload.url));
},
showItemInFolder: (payload) => {
shell.showItemInFolder(expandShellPath(requirePath(payload.path)));
},
openPath: (payload) => shell.openPath(expandShellPath(requirePath(payload.path))),
};
}
-192
View File
@@ -1,192 +0,0 @@
import type { GatewayManager } from '../gateway/manager';
import type { ClawHubService, ClawHubInstallParams, ClawHubSearchParams, ClawHubUninstallParams } from '../gateway/clawhub';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { getAllSkillConfigs, getSkillConfig, updateSkillConfig, updateSkillConfigs } from '../utils/skill-config';
import {
collectQuickAccessSkills,
filterEnabledQuickAccessSkills,
type QuickAccessRuntimeSkillStatus,
} from '../utils/skill-quick-access';
import { listLocalSkills } from './skills/local-skill-service';
import { isRecord } from './payload-utils';
type SkillConfigPayload = {
skillKey?: unknown;
enabled?: unknown;
apiKey?: unknown;
env?: unknown;
};
type SkillConfigsPayload = {
updates?: unknown;
};
type NormalizedSkillConfigUpdate = {
skillKey: string;
enabled?: boolean;
apiKey?: string;
env?: Record<string, string>;
};
type QuickAccessPayload = {
workspace?: unknown;
};
type SkillOpenPayload = {
slug?: unknown;
skillKey?: unknown;
baseDir?: unknown;
};
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function getSkillKey(payload: unknown): string {
const body = isRecord(payload) ? payload as SkillConfigPayload : {};
if (typeof body.skillKey !== 'string' || !body.skillKey.trim()) {
throw new Error('skillKey is required');
}
return body.skillKey.trim();
}
function getEnv(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) return undefined;
return Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
);
}
function getConfigUpdate(payload: unknown): NormalizedSkillConfigUpdate {
const body = isRecord(payload) ? payload as SkillConfigPayload : {};
return {
skillKey: getSkillKey(payload),
enabled: typeof body.enabled === 'boolean' ? body.enabled : undefined,
apiKey: typeof body.apiKey === 'string' ? body.apiKey : undefined,
env: getEnv(body.env),
};
}
function getConfigUpdates(payload: unknown): NormalizedSkillConfigUpdate[] {
const body = isRecord(payload) ? payload as SkillConfigsPayload : {};
if (!Array.isArray(body.updates)) return [];
return body.updates.flatMap((entry) => {
if (!isRecord(entry)) return [];
const skillKey = typeof entry.skillKey === 'string' ? entry.skillKey.trim() : '';
if (!skillKey) return [];
return [{
skillKey,
enabled: typeof entry.enabled === 'boolean' ? entry.enabled : undefined,
apiKey: typeof entry.apiKey === 'string' ? entry.apiKey : undefined,
env: getEnv(entry.env),
}];
});
}
export function createSkillsApi({
clawHubService,
gatewayManager,
}: {
clawHubService: ClawHubService;
gatewayManager: GatewayManager;
}): CompleteHostServiceRegistry['skills'] {
return {
local: async () => ({ success: true, skills: await listLocalSkills() }),
configs: async () => getAllSkillConfigs(),
allConfigs: async () => getAllSkillConfigs(),
getConfig: async (payload) => {
const config = await getSkillConfig(getSkillKey(payload));
return config ? { ...config } : undefined;
},
updateConfig: async (payload) => {
const { skillKey, ...updates } = getConfigUpdate(payload);
return updateSkillConfig(skillKey, updates);
},
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([
collectQuickAccessSkills({
workspace: typeof body.workspace === 'string' ? body.workspace : undefined,
}),
getAllSkillConfigs(),
]);
let runtimeSkills: QuickAccessRuntimeSkillStatus[] | undefined;
if (gatewayManager.getStatus().state === 'running') {
try {
const runtimeStatus = await gatewayManager.rpc<{ skills?: QuickAccessRuntimeSkillStatus[] }>('skills.status');
runtimeSkills = runtimeStatus.skills || [];
} catch {
runtimeSkills = undefined;
}
}
return {
success: true,
skills: filterEnabledQuickAccessSkills(scannedSkills, runtimeSkills, configs),
};
},
clawhubCapability: async () => {
try {
return { success: true, capability: await clawHubService.getMarketplaceCapability() };
} catch (error) {
return { success: false, error: errorMessage(error) };
}
},
clawhubList: async () => {
try {
return { success: true, results: await clawHubService.listInstalled() };
} catch (error) {
return { success: false, error: errorMessage(error) };
}
},
clawhubSearch: async (payload) => {
try {
return { success: true, results: await clawHubService.search((isRecord(payload) ? payload : {}) as ClawHubSearchParams) };
} catch (error) {
return { success: false, error: errorMessage(error) };
}
},
clawhubInstall: async (payload) => {
try {
await clawHubService.install((isRecord(payload) ? payload : {}) as ClawHubInstallParams);
return { success: true };
} catch (error) {
return { success: false, error: errorMessage(error) };
}
},
clawhubUninstall: async (payload) => {
try {
await clawHubService.uninstall((isRecord(payload) ? payload : {}) as ClawHubUninstallParams);
return { success: true };
} catch (error) {
return { success: false, error: errorMessage(error) };
}
},
clawhubOpenSkillReadme: async (payload) => {
try {
const body = isRecord(payload) ? payload as SkillOpenPayload : {};
const skillKey = typeof body.skillKey === 'string' ? body.skillKey : '';
const slug = typeof body.slug === 'string' ? body.slug : undefined;
const baseDir = typeof body.baseDir === 'string' ? body.baseDir : undefined;
await clawHubService.openSkillReadme(skillKey || slug || '', slug, baseDir);
return { success: true };
} catch (error) {
return { success: false, error: errorMessage(error) };
}
},
clawhubOpenSkillPath: async (payload) => {
try {
const body = isRecord(payload) ? payload as SkillOpenPayload : {};
const skillKey = typeof body.skillKey === 'string' ? body.skillKey : '';
const slug = typeof body.slug === 'string' ? body.slug : undefined;
const baseDir = typeof body.baseDir === 'string' ? body.baseDir : undefined;
await clawHubService.openSkillPath(skillKey || slug || '', slug, baseDir);
return { success: true };
} catch (error) {
return { success: false, error: errorMessage(error) };
}
},
};
}
@@ -1,430 +0,0 @@
import { access, lstat, readdir, readFile, realpath, stat } from 'node:fs/promises';
import { constants } from 'node:fs';
import { basename, join, relative, resolve } from 'node:path';
import { homedir } from 'node:os';
import YAML from 'yaml';
import { listAgentsSnapshot } from '../../utils/agent-config';
import { expandPath, getOpenClawResolvedDir, getOpenClawSkillsDir } from '../../utils/paths';
import { getAllSkillConfigs } from '../../utils/skill-config';
import type { SkillConfigUpdates } from '../../utils/skill-config';
export interface LocalSkillMarketplaceMeta {
provider: string;
slug?: string;
installedVersion?: string;
manifestPath?: string;
originPath?: string;
}
export interface LocalSkillRecord {
id: string;
slug?: string;
name: string;
description: string;
enabled: boolean;
icon?: string;
version?: string;
author?: string;
config?: Record<string, unknown>;
isCore?: boolean;
isBundled?: boolean;
source?: string;
baseDir?: string;
filePath?: string;
marketplace?: LocalSkillMarketplaceMeta;
}
type SourceDescriptor = {
root: string;
source: string;
priority: number;
allowedSkillSlugs?: Set<string>;
};
type ParsedSkillManifest = {
id: string;
slug?: string;
name: string;
description: string;
icon?: string;
version?: string;
author?: string;
isCore?: boolean;
};
type ScannedSkillRecord = LocalSkillRecord & {
priority: number;
};
type OriginMeta = {
provider: string;
slug?: string;
installedVersion?: string;
source?: string;
};
type ManifestMeta = {
slug?: string;
version?: string;
author?: string;
};
type PreinstalledMeta = {
slug?: string;
version?: string;
};
const MAX_SKILL_FILE_BYTES = 256_000;
const BUNDLED_OPENCLAW_SKILL_ALLOWLIST = new Set(['skill-creator']);
async function pathExists(targetPath: string): Promise<boolean> {
try {
await access(targetPath, constants.F_OK);
return true;
} catch {
return false;
}
}
function isInsideRoot(rootPath: string, candidatePath: string): boolean {
const rel = relative(rootPath, candidatePath);
return rel === '' || (!rel.startsWith('..') && rel !== '..');
}
function normalizeKey(value?: string | null): string {
return (value || '').trim().toLowerCase();
}
function dedupePaths(paths: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const entry of paths) {
const normalized = resolve(entry);
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
result.push(normalized);
}
return result;
}
function parseFrontmatter(content: string): Record<string, unknown> {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) return {};
try {
const parsed = YAML.parse(match[1]);
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : {};
} catch {
return {};
}
}
function parseBodyDescription(content: string): string {
const lines = content.split(/\r?\n/);
let inFrontmatter = false;
let frontmatterClosed = false;
for (let index = 0; index < lines.length; index += 1) {
const rawLine = lines[index] ?? '';
const trimmed = rawLine.trim();
if (index === 0 && trimmed === '---') {
inFrontmatter = true;
continue;
}
if (inFrontmatter) {
if (trimmed === '---') {
inFrontmatter = false;
frontmatterClosed = true;
}
continue;
}
if (!trimmed) continue;
if (frontmatterClosed && trimmed === '---') continue;
if (/^#{1,6}\s+/.test(trimmed)) continue;
return trimmed.replace(/^[-*]\s+/, '');
}
for (const rawLine of lines) {
const trimmed = rawLine.trim();
if (/^#{1,6}\s+/.test(trimmed)) {
return trimmed.replace(/^#{1,6}\s+/, '');
}
}
return 'No description available.';
}
function toStringValue(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function toBooleanValue(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined;
}
async function parseSkillManifest(manifestPath: string, fallbackId: string): Promise<ParsedSkillManifest> {
const fileStat = await stat(manifestPath);
if (fileStat.size > MAX_SKILL_FILE_BYTES) {
return {
id: fallbackId,
name: fallbackId,
description: 'Description unavailable (SKILL.md exceeds size limit).',
};
}
const content = await readFile(manifestPath, 'utf-8');
const frontmatter = parseFrontmatter(content);
const metadata = frontmatter.metadata && typeof frontmatter.metadata === 'object'
? frontmatter.metadata as Record<string, unknown>
: {};
const openclawMeta = metadata.openclaw && typeof metadata.openclaw === 'object'
? metadata.openclaw as Record<string, unknown>
: {};
return {
id: toStringValue(openclawMeta.skillKey) || fallbackId,
slug: undefined,
name: toStringValue(frontmatter.name) || fallbackId,
description: toStringValue(frontmatter.description) || parseBodyDescription(content),
icon: toStringValue(openclawMeta.emoji),
version: toStringValue(frontmatter.version),
author: toStringValue(frontmatter.author),
isCore: toBooleanValue(openclawMeta.always) || false,
};
}
async function safeReadJson<T>(filePath: string): Promise<T | null> {
if (!(await pathExists(filePath))) return null;
try {
const raw = await readFile(filePath, 'utf-8');
return JSON.parse(raw) as T;
} catch {
return null;
}
}
async function readOriginMeta(skillDir: string): Promise<OriginMeta | null> {
const parsed = await safeReadJson<Record<string, unknown>>(join(skillDir, '.clawhub', 'origin.json'));
if (!parsed) return null;
return {
provider: 'clawhub',
slug: toStringValue(parsed.slug),
installedVersion: toStringValue(parsed.installedVersion) || toStringValue(parsed.version),
source: toStringValue(parsed.source),
};
}
async function readManifestMeta(skillDir: string): Promise<ManifestMeta | null> {
const parsed = await safeReadJson<Record<string, unknown>>(join(skillDir, 'manifest.json'));
if (!parsed) return null;
return {
slug: toStringValue(parsed.slug) || toStringValue(parsed.name),
version: toStringValue(parsed.version),
author: toStringValue(parsed.author),
};
}
async function readPreinstalledMeta(skillDir: string): Promise<PreinstalledMeta | null> {
const parsed = await safeReadJson<Record<string, unknown>>(join(skillDir, '.clawx-preinstalled.json'));
if (!parsed) return null;
return {
slug: toStringValue(parsed.slug),
version: toStringValue(parsed.version),
};
}
async function resolveSafeRoot(root: string): Promise<string | null> {
if (!(await pathExists(root))) return null;
try {
const rootStat = await stat(root);
if (!rootStat.isDirectory()) return null;
return await realpath(root);
} catch {
return null;
}
}
async function inspectSkillDir(
descriptor: SourceDescriptor,
rootRealPath: string,
skillDir: string,
configs: Record<string, SkillConfigUpdates>,
): Promise<ScannedSkillRecord | null> {
const manifestPath = join(skillDir, 'SKILL.md');
if (!(await pathExists(manifestPath))) return null;
try {
const skillDirRealPath = await realpath(skillDir);
if (!isInsideRoot(rootRealPath, skillDirRealPath)) {
return null;
}
const fallbackId = basename(skillDirRealPath);
const parsedManifest = await parseSkillManifest(manifestPath, fallbackId);
const [originMeta, manifestMeta, preinstalledMeta] = await Promise.all([
readOriginMeta(skillDirRealPath),
readManifestMeta(skillDirRealPath),
readPreinstalledMeta(skillDirRealPath),
]);
const skillKey = parsedManifest.id || manifestMeta?.slug || originMeta?.slug || fallbackId;
const rawConfig = configs[skillKey] || {};
const config: Record<string, unknown> = { ...rawConfig };
const version = manifestMeta?.version || parsedManifest.version || originMeta?.installedVersion;
const source = descriptor.source;
const isBundled = source === 'openclaw-bundled' || Boolean(preinstalledMeta);
const marketplace = originMeta || manifestMeta
? {
provider: originMeta?.provider || (manifestMeta ? 'manifest' : source),
slug: originMeta?.slug || manifestMeta?.slug || preinstalledMeta?.slug || fallbackId,
installedVersion: version,
manifestPath: manifestMeta ? join(skillDirRealPath, 'manifest.json') : undefined,
originPath: originMeta ? join(skillDirRealPath, '.clawhub', 'origin.json') : undefined,
}
: undefined;
return {
id: skillKey,
slug: originMeta?.slug || manifestMeta?.slug || preinstalledMeta?.slug || fallbackId,
name: parsedManifest.name,
description: parsedManifest.description,
enabled: rawConfig.enabled !== false,
icon: parsedManifest.icon || (isBundled ? '🧩' : '📦'),
version,
author: manifestMeta?.author || parsedManifest.author,
config,
isCore: parsedManifest.isCore,
isBundled,
source,
baseDir: skillDirRealPath,
filePath: manifestPath,
marketplace,
priority: descriptor.priority,
};
} catch {
return null;
}
}
async function scanRoot(
descriptor: SourceDescriptor,
configs: Record<string, SkillConfigUpdates>,
): Promise<ScannedSkillRecord[]> {
const rootRealPath = await resolveSafeRoot(descriptor.root);
if (!rootRealPath) return [];
const skillDirs = new Set<string>();
const rootManifest = join(descriptor.root, 'SKILL.md');
if (await pathExists(rootManifest)) {
skillDirs.add(descriptor.root);
}
try {
const entries = await readdir(descriptor.root, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
if (entry.name === 'node_modules') continue;
const entryPath = join(descriptor.root, entry.name);
if (entry.isDirectory()) {
if (!descriptor.allowedSkillSlugs || descriptor.allowedSkillSlugs.has(entry.name)) {
skillDirs.add(entryPath);
}
continue;
}
if (entry.isSymbolicLink()) {
try {
const symlinkStat = await lstat(entryPath);
if (symlinkStat.isSymbolicLink()) {
const resolved = await stat(entryPath);
if (resolved.isDirectory()) {
skillDirs.add(entryPath);
}
}
} catch {
// Ignore broken symlinks.
}
}
}
} catch {
return [];
}
const items = await Promise.all([...skillDirs].map((skillDir) => inspectSkillDir(descriptor, rootRealPath, skillDir, configs)));
return items.filter((item): item is ScannedSkillRecord => item != null);
}
async function buildDescriptors(): Promise<SourceDescriptor[]> {
const agentsSnapshot = await listAgentsSnapshot();
const workspaces = dedupePaths(
agentsSnapshot.agents
.map((agent) => expandPath(agent.workspace || ''))
.filter(Boolean),
);
return [
...workspaces.map((workspace) => ({
root: join(workspace, 'skills'),
source: 'openclaw-workspace',
priority: 0,
})),
...workspaces.map((workspace) => ({
root: join(workspace, '.agents', 'skills'),
source: 'agents-skills-project',
priority: 1,
})),
{
root: join(homedir(), '.agents', 'skills'),
source: 'agents-skills-personal',
priority: 2,
},
{
root: getOpenClawSkillsDir(),
source: 'openclaw-managed',
priority: 3,
},
{
root: join(getOpenClawResolvedDir(), 'skills'),
source: 'openclaw-bundled',
priority: 4,
allowedSkillSlugs: BUNDLED_OPENCLAW_SKILL_ALLOWLIST,
},
];
}
function mergeScannedSkills(skills: ScannedSkillRecord[]): LocalSkillRecord[] {
const byKey = new Map<string, ScannedSkillRecord>();
for (const skill of skills) {
const key = normalizeKey(skill.id || skill.slug || skill.name || skill.baseDir);
if (!key) continue;
const existing = byKey.get(key);
if (!existing || skill.priority < existing.priority) {
byKey.set(key, skill);
}
}
return [...byKey.values()]
.sort((left, right) => {
if (left.enabled !== right.enabled) {
return left.enabled ? -1 : 1;
}
if (left.isCore !== right.isCore) {
return left.isCore ? -1 : 1;
}
if (left.priority !== right.priority) {
return left.priority - right.priority;
}
return left.name.localeCompare(right.name);
})
.map(({ priority: _priority, ...skill }) => skill);
}
export async function listLocalSkills(): Promise<LocalSkillRecord[]> {
const [descriptors, configs] = await Promise.all([
buildDescriptors(),
getAllSkillConfigs(),
]);
const discovered = await Promise.all(descriptors.map((descriptor) => scanRoot(descriptor, configs)));
return mergeScannedSkills(discovered.flat());
}
-75
View File
@@ -1,75 +0,0 @@
import type {
UpdateInfoSnapshot,
UpdateProgressSnapshot,
UpdateStatusSnapshot,
} from '@shared/host-api/contract';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { AppUpdater, UpdateStatus } from '../main/updater';
function normalizeInfo(info: UpdateStatus['info']): UpdateInfoSnapshot | undefined {
if (!info) return undefined;
return {
version: info.version,
releaseDate: info.releaseDate,
releaseNotes: typeof info.releaseNotes === 'string' || info.releaseNotes == null ? info.releaseNotes : String(info.releaseNotes),
};
}
function normalizeProgress(progress: UpdateStatus['progress']): UpdateProgressSnapshot | undefined {
if (!progress) return undefined;
return {
total: progress.total,
delta: progress.delta,
transferred: progress.transferred,
percent: progress.percent,
bytesPerSecond: progress.bytesPerSecond,
};
}
function normalizeStatus(status: UpdateStatus): UpdateStatusSnapshot {
return {
status: status.status,
info: normalizeInfo(status.info),
progress: normalizeProgress(status.progress),
error: status.error,
};
}
export function createUpdatesApi(updater: AppUpdater): CompleteHostServiceRegistry['updates'] {
return {
status: () => normalizeStatus(updater.getStatus()),
version: () => updater.getCurrentVersion(),
check: async () => {
try {
await updater.checkForUpdates();
return { success: true, status: normalizeStatus(updater.getStatus()) };
} catch (error) {
return { success: false, error: String(error), status: normalizeStatus(updater.getStatus()) };
}
},
download: async () => {
try {
await updater.downloadUpdate();
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
},
install: () => {
updater.quitAndInstall();
return { success: true };
},
setChannel: (payload) => {
updater.setChannel(payload.channel);
return { success: true };
},
setAutoDownload: (payload) => {
updater.setAutoDownload(payload.enable);
return { success: true };
},
cancelAutoInstall: () => {
updater.cancelAutoInstall();
return { success: true };
},
};
}
-27
View File
@@ -1,27 +0,0 @@
import { getRecentTokenUsageHistory } from '../utils/token-usage';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { isRecord } from './payload-utils';
type RecentTokenHistoryPayload = {
limit?: unknown;
};
function getSafeLimit(payload: unknown): number | undefined {
const value = isRecord(payload) ? (payload as RecentTokenHistoryPayload).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 createUsageApi(): CompleteHostServiceRegistry['usage'] {
return {
recentTokenHistory: async (payload) => getRecentTokenUsageHistory(getSafeLimit(payload)),
};
}
-20
View File
@@ -1,20 +0,0 @@
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-setup';
export function createUvApi(): CompleteHostServiceRegistry['uv'] {
return {
installAll: async () => {
try {
const isInstalled = await checkUvInstalled();
if (!isInstalled) {
await installUv();
}
await setupManagedPython();
return { success: true };
} catch (error) {
console.error('Failed to setup uv/python:', error);
return { success: false, error: String(error) };
}
},
};
}
-25
View File
@@ -1,25 +0,0 @@
import type { BrowserWindow } from 'electron';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { syncMacTrafficLightPosition } from '../main/traffic-light-layout';
export function createWindowApi(mainWindow: BrowserWindow): CompleteHostServiceRegistry['window'] {
return {
syncTrafficLightPosition: (payload) => {
syncMacTrafficLightPosition(mainWindow, payload.sidebarCollapsed);
},
minimize: () => {
mainWindow.minimize();
},
maximize: () => {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
},
close: () => {
mainWindow.close();
},
isMaximized: () => mainWindow.isMaximized(),
};
}
-43
View File
@@ -1,43 +0,0 @@
/**
* Per-million-token rates expected by `@mariozechner/pi-ai` `calculateCost`.
* Custom / synced catalog rows often omit pricing; zeros keep accounting stable
* and avoid `Cannot read properties of undefined (reading 'input')` when usage
* chunks arrive during openai-completions streaming.
*/
export const PI_AI_MODEL_ZERO_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
} as const;
export type PiAiModelCostRates = {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
};
export function normalizePiAiModelCost(existing: unknown): PiAiModelCostRates {
if (!existing || typeof existing !== 'object') {
return { ...PI_AI_MODEL_ZERO_COST };
}
const record = existing as Record<string, unknown>;
const num = (value: unknown) =>
typeof value === 'number' && Number.isFinite(value) ? value : 0;
return {
input: num(record.input),
output: num(record.output),
cacheRead: num(record.cacheRead),
cacheWrite: num(record.cacheWrite),
};
}
/** Entry shape suitable for OpenClaw agent `models.json` provider.models[]. */
export function piAiModelsJsonModelEntry(
id: string,
name: string = id,
): { id: string; name: string; cost: PiAiModelCostRates } {
return { id, name, cost: normalizePiAiModelCost(undefined) };
}
@@ -1,19 +0,0 @@
export type ModelInputModality = 'text' | 'image';
/**
* 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'];
}
+20 -58
View File
@@ -16,8 +16,6 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
category: 'official',
envVar: 'ANTHROPIC_API_KEY',
defaultModelId: 'claude-opus-4-6',
showModelId: true,
modelIdPlaceholder: 'claude-opus-4-6',
supportedAuthModes: ['api_key'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
@@ -31,11 +29,12 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
requiresApiKey: true,
category: 'official',
envVar: 'OPENAI_API_KEY',
defaultModelId: 'gpt-5.5',
defaultModelId: 'gpt-5.4',
isOAuth: true,
supportsApiKey: true,
showModelId: true,
modelIdPlaceholder: 'gpt-5.5',
showModelIdInDevModeOnly: true,
modelIdPlaceholder: 'gpt-5.4',
supportedAuthModes: ['api_key', 'oauth_browser'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
@@ -54,10 +53,13 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
requiresApiKey: true,
category: 'official',
envVar: 'GEMINI_API_KEY',
defaultModelId: 'gemini-3.1-pro-preview',
defaultModelId: 'gemini-3-pro-preview',
isOAuth: true,
supportsApiKey: true,
showModelId: true,
modelIdPlaceholder: 'gemini-3.1-pro-preview',
supportedAuthModes: ['api_key'],
showModelIdInDevModeOnly: true,
modelIdPlaceholder: 'gemini-3-pro-preview',
supportedAuthModes: ['api_key', 'oauth_browser'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
},
@@ -82,7 +84,7 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
apiKeyEnv: 'OPENROUTER_API_KEY',
headers: {
'HTTP-Referer': 'https://claw-x.com',
'X-OpenRouter-Title': 'ClawX',
'X-Title': 'ClawX',
},
},
},
@@ -120,8 +122,6 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
requiresApiKey: true,
defaultBaseUrl: 'https://api.moonshot.cn/v1',
defaultModelId: 'kimi-k2.6',
showModelId: true,
modelIdPlaceholder: 'kimi-k2.6',
category: 'official',
envVar: 'MOONSHOT_API_KEY',
supportedAuthModes: ['api_key'],
@@ -153,8 +153,6 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
requiresApiKey: true,
defaultBaseUrl: 'https://api.moonshot.ai/v1',
defaultModelId: 'kimi-k2.6',
showModelId: true,
modelIdPlaceholder: 'kimi-k2.6',
category: 'official',
envVar: 'MOONSHOT_GLOBAL_API_KEY',
supportedAuthModes: ['api_key'],
@@ -186,6 +184,7 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
requiresApiKey: true,
defaultBaseUrl: 'https://api.siliconflow.cn/v1',
showModelId: true,
showModelIdInDevModeOnly: true,
modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3',
defaultModelId: 'deepseek-ai/DeepSeek-V3',
category: 'compatible',
@@ -208,6 +207,7 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
requiresApiKey: true,
defaultBaseUrl: 'https://api.deepseek.com/v1',
showModelId: true,
showModelIdInDevModeOnly: true,
modelIdPlaceholder: 'deepseek-v4-pro',
defaultModelId: 'deepseek-v4-pro',
apiKeyUrl: 'https://platform.deepseek.com/api_keys',
@@ -231,9 +231,10 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
requiresApiKey: false,
isOAuth: true,
supportsApiKey: true,
defaultModelId: 'MiniMax-M3',
defaultModelId: 'MiniMax-M2.7',
showModelId: true,
modelIdPlaceholder: 'MiniMax-M3',
showModelIdInDevModeOnly: true,
modelIdPlaceholder: 'MiniMax-M2.7',
apiKeyUrl: 'https://platform.minimax.io',
category: 'official',
envVar: 'MINIMAX_API_KEY',
@@ -244,26 +245,6 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
baseUrl: 'https://api.minimax.io/anthropic',
api: 'anthropic-messages',
apiKeyEnv: 'MINIMAX_API_KEY',
models: [
{
id: 'MiniMax-M3',
name: 'MiniMax M3',
reasoning: false,
input: ['text', 'image'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 524288,
maxTokens: 131072,
},
{
id: 'MiniMax-M2.7',
name: 'MiniMax M2.7',
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 204800,
maxTokens: 131072,
},
],
},
},
{
@@ -275,9 +256,10 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
requiresApiKey: false,
isOAuth: true,
supportsApiKey: true,
defaultModelId: 'MiniMax-M3',
defaultModelId: 'MiniMax-M2.7',
showModelId: true,
modelIdPlaceholder: 'MiniMax-M3',
showModelIdInDevModeOnly: true,
modelIdPlaceholder: 'MiniMax-M2.7',
apiKeyUrl: 'https://platform.minimaxi.com/',
category: 'official',
envVar: 'MINIMAX_CN_API_KEY',
@@ -288,26 +270,6 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
baseUrl: 'https://api.minimaxi.com/anthropic',
api: 'anthropic-messages',
apiKeyEnv: 'MINIMAX_CN_API_KEY',
models: [
{
id: 'MiniMax-M3',
name: 'MiniMax M3',
reasoning: false,
input: ['text', 'image'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 524288,
maxTokens: 131072,
},
{
id: 'MiniMax-M2.7',
name: 'MiniMax M2.7',
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 204800,
maxTokens: 131072,
},
],
},
},
{
@@ -319,10 +281,10 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
requiresApiKey: true,
defaultBaseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
showBaseUrl: true,
defaultModelId: 'qwen3.6-plus',
defaultModelId: 'qwen3.5-plus',
showModelId: true,
showModelIdInDevModeOnly: true,
modelIdPlaceholder: 'qwen3.6-plus',
modelIdPlaceholder: 'qwen3.5-plus',
category: 'official',
envVar: 'MODELSTUDIO_API_KEY',
supportedAuthModes: ['api_key'],
+5 -60
View File
@@ -5,7 +5,6 @@ export const PROVIDER_TYPES = [
'openrouter',
'ark',
'moonshot',
'moonshot-global',
'siliconflow',
'deepseek',
'minimax-portal',
@@ -22,7 +21,6 @@ export const BUILTIN_PROVIDER_TYPES = [
'openrouter',
'ark',
'moonshot',
'moonshot-global',
'siliconflow',
'deepseek',
'minimax-portal',
@@ -36,63 +34,10 @@ export type BuiltinProviderType = (typeof BUILTIN_PROVIDER_TYPES)[number];
export const OLLAMA_PLACEHOLDER_API_KEY = 'ollama-local';
/**
* Authoritative set of `models.providers.*.api` values accepted by the
* OpenClaw Gateway config schema. Keep in sync with OpenClaw's
* `assertValidGatewayStartupConfigSnapshot`.
*
* Writing any other value into `~/.openclaw/openclaw.json` triggers
* `Invalid config` rejection on next reload/restart and tears down all
* channels. Use `assertValidApiProtocol` at every write site.
*/
export const OPENCLAW_API_PROTOCOLS = [
'openai-completions',
'openai-responses',
'openai-chatgpt-responses',
'anthropic-messages',
'google-generative-ai',
'github-copilot',
'bedrock-converse-stream',
'ollama',
'azure-openai-responses',
] as const;
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(
`Invalid OpenClaw api protocol${providerKey ? ` for provider "${providerKey}"` : ''}: ` +
`${JSON.stringify(api)}. Expected one of: ${OPENCLAW_API_PROTOCOLS.join(', ')}.`,
);
this.name = 'InvalidApiProtocolError';
}
}
export function assertValidApiProtocol(
api: unknown,
providerKey?: string,
): asserts api is OpenClawApiProtocol {
if (typeof api !== 'string' || !(OPENCLAW_API_PROTOCOLS as readonly string[]).includes(api)) {
throw new InvalidApiProtocolError(api, providerKey);
}
}
export type ProviderProtocol = OpenClawApiProtocol;
export type ProviderProtocol =
| 'openai-completions'
| 'openai-responses'
| 'anthropic-messages';
export type ProviderAuthMode =
| 'api_key'
@@ -154,7 +99,7 @@ export interface ProviderModelEntry extends Record<string, unknown> {
export interface ProviderBackendConfig {
baseUrl: string;
api: OpenClawApiProtocol;
api: ProviderProtocol;
apiKeyEnv: string;
models?: ProviderModelEntry[];
headers?: Record<string, string>;
+4 -15
View File
@@ -7,7 +7,6 @@ import { withConfigLock } from './config-mutex';
import { expandPath, getOpenClawConfigDir } from './paths';
import * as logger from './logger';
import { toUiChannelType } from './channel-alias';
import { ensureClawXIdentityFile } from './openclaw-workspace';
const MAIN_AGENT_ID = 'main';
const MAIN_AGENT_NAME = 'Main Agent';
@@ -422,13 +421,11 @@ async function provisionAgentFilesystem(
// When inheritWorkspace is true, copy the main agent's workspace bootstrap
// files (SOUL.md, AGENTS.md, etc.) so the new agent inherits the same
// personality / instructions. Otherwise OpenClaw will seed the missing files
// on first use, but ClawX still pre-seeds IDENTITY.md so desktop workspaces
// skip the chat-first bootstrap flow.
// personality / instructions. When false (default), leave the workspace
// empty and let OpenClaw Gateway seed the default bootstrap files on startup.
if (options?.inheritWorkspace && targetWorkspace !== sourceWorkspace) {
await copyBootstrapFiles(sourceWorkspace, targetWorkspace);
}
await ensureClawXIdentityFile(targetWorkspace, { createDir: true });
if (targetAgentDir !== sourceAgentDir) {
await copyRuntimeFiles(sourceAgentDir, targetAgentDir);
}
@@ -543,16 +540,8 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedCha
}
export async function listAgentsSnapshot(): Promise<AgentsSnapshot> {
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
const { pruneStaleRuntimeAgentModelRefs } = await import('./openclaw-auth');
const modified = await pruneStaleRuntimeAgentModelRefs(config as unknown as Record<string, unknown>);
if (modified) {
await writeOpenClawConfig(config);
logger.info('Pruned stale runtime agent model refs from openclaw.json');
}
return buildSnapshotFromConfig(config);
});
const config = await readOpenClawConfig() as AgentConfigDocument;
return buildSnapshotFromConfig(config);
}
export async function listAgentsSnapshotFromConfig(config: OpenClawConfig, configuredChannels?: string[]): Promise<AgentsSnapshot> {
+82 -80
View File
@@ -1,27 +1,21 @@
import { EventEmitter } from 'events';
import { BrowserWindow, shell } from 'electron';
import { logger } from './logger';
import { loginGeminiCliOAuth, type GeminiCliOAuthCredentials } from './gemini-cli-oauth';
import { loginOpenAICodexOAuth, type OpenAICodexOAuthCredentials } from './openai-codex-oauth';
import { getProviderService } from '../services/providers/provider-service';
import { getSecretStore } from '../services/secrets/secret-store';
import {
ensureOpenClawProviderAgentRuntimePins,
OPENAI_CODEX_OAUTH_PROVIDER_CONFIG,
saveOAuthTokenToOpenClaw,
setOpenClawDefaultModelWithOverride,
} from './openclaw-auth';
import { saveOAuthTokenToOpenClaw } 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
// PATH and ships with explicit "use at your own risk" warnings about Google
// 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 BrowserOAuthProviderType = 'google' | 'openai';
const OPENAI_RUNTIME_PROVIDER_ID = 'openai';
const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.5';
const GOOGLE_RUNTIME_PROVIDER_ID = 'google-gemini-cli';
const GOOGLE_OAUTH_DEFAULT_MODEL = 'gemini-3-pro-preview';
const OPENAI_RUNTIME_PROVIDER_ID = 'openai-codex';
const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.4';
class BrowserOAuthManager extends EventEmitter {
private activeProvider: BrowserOAuthProviderType | null = null;
private activeAccountId: string | null = null;
private activeLabel: string | null = null;
private active = false;
@@ -42,44 +36,72 @@ class BrowserOAuthManager extends EventEmitter {
}
this.active = true;
this.activeProvider = provider;
this.activeAccountId = options?.accountId || provider;
this.activeLabel = options?.label || null;
this.emit('oauth:start', { provider, accountId: this.activeAccountId });
// OpenAI flow may switch to manual callback mode; keep start API non-blocking.
void this.executeFlow(provider);
if (provider === 'openai') {
// OpenAI flow may switch to manual callback mode; keep start API non-blocking.
void this.executeFlow(provider);
return true;
}
await this.executeFlow(provider);
return true;
}
private async executeFlow(provider: BrowserOAuthProviderType): Promise<void> {
try {
const token = await loginOpenAICodexOAuth({
openUrl: async (url) => {
await shell.openExternal(url);
},
onProgress: (message) => logger.info(`[BrowserOAuth] ${message}`),
onManualCodeRequired: ({ authorizationUrl, reason }) => {
const message = reason === 'port_in_use'
? 'OpenAI OAuth callback port 1455 is in use. Complete sign-in, then paste the final callback URL or code.'
: 'OpenAI OAuth callback timed out. Paste the final callback URL or code to continue.';
const payload = {
provider,
mode: 'manual' as const,
authorizationUrl,
message,
};
this.emit('oauth:code', payload);
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:code', payload);
}
},
onManualCodeInput: async () => {
return await new Promise<string>((resolve, reject) => {
this.pendingManualCodeResolve = resolve;
this.pendingManualCodeReject = reject;
});
},
});
const token = provider === 'google'
? await loginGeminiCliOAuth({
isRemote: false,
openUrl: async (url) => {
await shell.openExternal(url);
},
log: (message) => logger.info(`[BrowserOAuth] ${message}`),
note: async (message, title) => {
logger.info(`[BrowserOAuth] ${title || 'OAuth note'}: ${message}`);
},
prompt: async () => {
throw new Error('Manual browser OAuth fallback is not implemented in ClawX yet.');
},
progress: {
update: (message) => logger.info(`[BrowserOAuth] ${message}`),
stop: (message) => {
if (message) {
logger.info(`[BrowserOAuth] ${message}`);
}
},
},
})
: await loginOpenAICodexOAuth({
openUrl: async (url) => {
await shell.openExternal(url);
},
onProgress: (message) => logger.info(`[BrowserOAuth] ${message}`),
onManualCodeRequired: ({ authorizationUrl, reason }) => {
const message = reason === 'port_in_use'
? 'OpenAI OAuth callback port 1455 is in use. Complete sign-in, then paste the final callback URL or code.'
: 'OpenAI OAuth callback timed out. Paste the final callback URL or code to continue.';
const payload = {
provider,
mode: 'manual' as const,
authorizationUrl,
message,
};
this.emit('oauth:code', payload);
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:code', payload);
}
},
onManualCodeInput: async () => {
return await new Promise<string>((resolve, reject) => {
this.pendingManualCodeResolve = resolve;
this.pendingManualCodeReject = reject;
});
},
});
await this.onSuccess(provider, token);
} catch (error) {
@@ -89,6 +111,7 @@ class BrowserOAuthManager extends EventEmitter {
logger.error(`[BrowserOAuth] Flow error for ${provider}:`, error);
this.emitError(error instanceof Error ? error.message : String(error));
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
this.pendingManualCodeResolve = null;
@@ -98,6 +121,7 @@ class BrowserOAuthManager extends EventEmitter {
async stopFlow(): Promise<void> {
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
if (this.pendingManualCodeReject) {
@@ -121,11 +145,12 @@ class BrowserOAuthManager extends EventEmitter {
private async onSuccess(
providerType: BrowserOAuthProviderType,
token: OpenAICodexOAuthCredentials,
token: GeminiCliOAuthCredentials | OpenAICodexOAuthCredentials,
) {
const accountId = this.activeAccountId || providerType;
const accountLabel = this.activeLabel;
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
this.pendingManualCodeResolve = null;
@@ -134,18 +159,24 @@ class BrowserOAuthManager extends EventEmitter {
const providerService = getProviderService();
const existing = await providerService.getAccount(accountId);
const runtimeProviderId = OPENAI_RUNTIME_PROVIDER_ID;
const defaultModel = OPENAI_OAUTH_DEFAULT_MODEL;
const accountLabelDefault = 'OpenAI Codex';
const oauthTokenEmail = typeof token.email === 'string' ? token.email : undefined;
const oauthTokenSubject = typeof token.accountId === 'string' ? token.accountId : undefined;
const isGoogle = providerType === 'google';
const runtimeProviderId = isGoogle ? GOOGLE_RUNTIME_PROVIDER_ID : OPENAI_RUNTIME_PROVIDER_ID;
const defaultModel = isGoogle ? GOOGLE_OAUTH_DEFAULT_MODEL : OPENAI_OAUTH_DEFAULT_MODEL;
const accountLabelDefault = isGoogle ? 'Google Gemini' : 'OpenAI Codex';
const oauthTokenEmail = 'email' in token && typeof token.email === 'string' ? token.email : undefined;
const oauthTokenSubject = 'projectId' in token && typeof token.projectId === 'string'
? token.projectId
: ('accountId' in token && typeof token.accountId === 'string' ? token.accountId : undefined);
const normalizedExistingModel = (() => {
const value = existing?.model?.trim();
if (!value) return undefined;
if (value.startsWith('openai/') || value.startsWith('openai-codex/')) {
return value.split('/').pop();
if (isGoogle) {
return value.includes('/') ? value.split('/').pop() : value;
}
// OpenAI OAuth uses openai-codex/* runtime; existing openai/* refs are incompatible.
if (value.startsWith('openai/')) return undefined;
if (value.startsWith('openai-codex/')) return value.split('/').pop();
return value.includes('/') ? value.split('/').pop() : value;
})();
@@ -186,37 +217,8 @@ class BrowserOAuthManager extends EventEmitter {
expires: token.expires,
email: oauthTokenEmail,
projectId: oauthTokenSubject,
accountId: oauthTokenSubject,
});
const modelId = normalizedExistingModel || defaultModel;
const modelRef = `${runtimeProviderId}/${modelId}`;
const fallbackModelRefs = (nextAccount.fallbackModels ?? [])
.map((fallback) => fallback.trim())
.filter(Boolean)
.map((fallback) => (
fallback.replace(/^openai-codex\//, `${runtimeProviderId}/`).startsWith(`${runtimeProviderId}/`)
? fallback.replace(/^openai-codex\//, `${runtimeProviderId}/`)
: `${runtimeProviderId}/${fallback}`
));
try {
await setOpenClawDefaultModelWithOverride(
runtimeProviderId,
modelRef,
{
baseUrl: OPENAI_CODEX_OAUTH_PROVIDER_CONFIG.baseUrl,
api: OPENAI_CODEX_OAUTH_PROVIDER_CONFIG.api,
},
fallbackModelRefs,
);
await ensureOpenClawProviderAgentRuntimePins();
logger.info(`[BrowserOAuth] Registered ${runtimeProviderId} in openclaw.json (default model: ${modelRef})`);
} catch (err) {
logger.warn('[BrowserOAuth] Failed to register OpenAI OAuth provider in openclaw.json:', err);
throw err;
}
this.emit('oauth:success', { provider: providerType, accountId: nextAccount.id });
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:success', {
+36 -131
View File
@@ -32,28 +32,6 @@ const DEFAULT_ACCOUNT_ID = 'default';
// schema validation errors. ClawX falls back to DEFAULT_ACCOUNT_ID
// when `defaultAccount` is absent.
const CHANNELS_OMIT_DEFAULT_ACCOUNT_KEY = new Set(['dingtalk']);
// Channels whose schema accepts a top-level default account and account map,
// but whose account payload contains nested strict-schema objects that ClawX
// can accidentally make invalid by adding UI convenience fields. Keep this
// sanitization narrowly scoped to known nested maps so local config remains
// OpenClaw-compatible after a save.
const DISCORD_GUILD_CHANNEL_KEYS_TO_KEEP = new Set([
'autoArchiveDuration',
'autoThread',
'autoThreadName',
'enabled',
'ignoreOtherMentions',
'includeThreadStarter',
'requireMention',
'roles',
'skills',
'systemPrompt',
'tools',
'toolsBySender',
'users',
]);
const DISCORD_CHANNEL_ALLOW_FLAG_KEYS = new Set(['allow']);
const CHANNEL_TOP_LEVEL_KEYS_TO_KEEP = new Set(['accounts', 'defaultAccount', 'enabled']);
const WECHAT_STATE_DIR = join(OPENCLAW_DIR, WECHAT_PLUGIN_ID);
const WECHAT_ACCOUNT_INDEX_FILE = join(WECHAT_STATE_DIR, 'accounts.json');
@@ -62,8 +40,8 @@ const LEGACY_WECHAT_CREDENTIALS_DIR = join(OPENCLAW_DIR, 'credentials', WECHAT_P
const LEGACY_WECHAT_SYNC_DIR = join(OPENCLAW_DIR, 'agents', 'default', 'sessions', '.openclaw-weixin-sync');
// Channels that are managed as plugins (config goes under plugins.entries, not channels)
const PLUGIN_CHANNELS: string[] = ['discord', 'qqbot', 'whatsapp'];
const LEGACY_BUILTIN_CHANNEL_PLUGIN_IDS = new Set<string>();
const PLUGIN_CHANNELS: string[] = [];
const LEGACY_BUILTIN_CHANNEL_PLUGIN_IDS = new Set(['whatsapp']);
const BUILTIN_CHANNEL_IDS = new Set([
'discord',
'telegram',
@@ -100,57 +78,6 @@ const CHANNEL_UNIQUE_CREDENTIAL_KEY: Record<string, string> = {
// ── Helpers ──────────────────────────────────────────────────────
function sanitizeDiscordGuildChannelConfig(channelConfig: unknown): void {
if (!channelConfig || typeof channelConfig !== 'object' || Array.isArray(channelConfig)) {
return;
}
const record = channelConfig as Record<string, unknown>;
// Backward compatibility for the older ClawX-generated shape:
// channels: { "123": { allow: true, requireMention: true } }
// OpenClaw's current DiscordGuildChannelConfig does not include `allow`;
// represent deny/allow using `enabled` instead.
if (record.allow === false && record.enabled === undefined) {
record.enabled = false;
}
for (const key of Object.keys(record)) {
if (DISCORD_CHANNEL_ALLOW_FLAG_KEYS.has(key)) {
delete record[key];
continue;
}
if (!DISCORD_GUILD_CHANNEL_KEYS_TO_KEEP.has(key)) {
delete record[key];
}
}
}
function sanitizeDiscordGuilds(config: unknown): void {
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return;
}
const record = config as Record<string, unknown>;
const guilds = record.guilds;
if (!guilds || typeof guilds !== 'object' || Array.isArray(guilds)) {
return;
}
for (const guildConfig of Object.values(guilds as Record<string, unknown>)) {
if (!guildConfig || typeof guildConfig !== 'object' || Array.isArray(guildConfig)) {
continue;
}
const channels = (guildConfig as Record<string, unknown>).channels;
if (!channels || typeof channels !== 'object' || Array.isArray(channels)) {
continue;
}
for (const channelConfig of Object.values(channels as Record<string, unknown>)) {
sanitizeDiscordGuildChannelConfig(channelConfig);
}
}
}
/**
* Strip `defaultAccount` from channel sections whose plugin schema
* declares additionalProperties:false without listing `defaultAccount`.
@@ -165,17 +92,6 @@ function sanitizeChannelSectionsBeforeWrite(config: OpenClawConfig): void {
delete section.defaultAccount;
}
}
const discordSection = config.channels.discord;
if (discordSection) {
sanitizeDiscordGuilds(discordSection);
const accounts = getChannelAccountsMap(discordSection);
if (accounts) {
for (const accountConfig of Object.values(accounts)) {
sanitizeDiscordGuilds(accountConfig);
}
}
}
}
async function fileExists(p: string): Promise<boolean> {
@@ -505,10 +421,6 @@ async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType:
ensurePluginRegistration(currentConfig, channelType);
}
if (channelType === 'discord' || channelType === 'qqbot' || channelType === 'whatsapp') {
ensurePluginRegistration(currentConfig, channelType);
}
if (channelType === 'feishu') {
const feishuPluginId = await resolveFeishuPluginId();
if (!currentConfig.plugins) {
@@ -517,6 +429,8 @@ async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType:
enabled: true,
entries: {
[feishuPluginId]: { enabled: true },
// Disable the built-in feishu plugin when using openclaw-lark
...(feishuPluginId !== 'feishu' ? { feishu: { enabled: false } } : {}),
}
};
} else {
@@ -537,10 +451,15 @@ async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType:
if (!currentConfig.plugins.entries) {
currentConfig.plugins.entries = {};
}
// Remove conflicting feishu plugin entries; keep only the resolved
// external plugin id. A disabled plugins.entries.feishu record
// blocks openclaw-lark in OpenClaw's gateway startup planner.
delete currentConfig.plugins.entries['feishu'];
// Remove conflicting feishu plugin entries; keep only the resolved plugin id.
// When the resolved plugin id is NOT 'feishu', explicitly disable the
// built-in feishu plugin (OpenClaw ships one in dist/extensions/feishu/)
// to prevent it from conflicting with the official openclaw-lark plugin.
if (feishuPluginId !== 'feishu') {
currentConfig.plugins.entries['feishu'] = { enabled: false };
} else {
delete currentConfig.plugins.entries['feishu'];
}
for (const candidateId of FEISHU_PLUGIN_ID_CANDIDATES) {
if (candidateId !== feishuPluginId) {
delete currentConfig.plugins.entries[candidateId];
@@ -659,11 +578,11 @@ function transformChannelConfig(
if (channelId && typeof channelId === 'string' && channelId.trim()) {
guildConfig.channels = {
[channelId.trim()]: { requireMention: true }
[channelId.trim()]: { allow: true, requireMention: true }
};
} else {
guildConfig.channels = {
'*': { requireMention: true }
'*': { allow: true, requireMention: true }
};
}
@@ -704,16 +623,6 @@ function transformChannelConfig(
transformedConfig.allowFrom = allowFrom;
}
if (channelType === 'whatsapp') {
// The WhatsApp plugin stores QR/session state on disk and does not
// require static credentials, but the runtime still needs an enabled
// plugin config entry for the channel account to appear in status.
transformedConfig = {
...transformedConfig,
enabled: transformedConfig.enabled ?? true,
};
}
if (channelType === 'dingtalk') {
// The per-account schema uses additionalProperties:false and does
// NOT include these legacy/obsolete fields. Strip them before
@@ -754,7 +663,7 @@ function migrateLegacyChannelConfigToAccounts(
const legacyPayload = getLegacyChannelPayload(channelSection);
const legacyKeys = Object.keys(legacyPayload);
const existingAccounts = getChannelAccountsMap(channelSection);
const hasAccounts = existingAccounts ? Object.keys(existingAccounts).length > 0 : false;
const hasAccounts = Boolean(existingAccounts) && Object.keys(existingAccounts).length > 0;
if (legacyKeys.length === 0) {
if (hasAccounts && typeof channelSection.defaultAccount !== 'string') {
@@ -841,8 +750,22 @@ export async function saveChannelConfig(
await ensurePluginAllowlist(currentConfig, resolvedChannelType);
syncBuiltinChannelsWithPluginAllowlist(currentConfig, [resolvedChannelType]);
// Plugin-based channels are mirrored into plugins.entries.<id> below,
// but ClawX still keeps channels.<id> as the local account-list source.
// Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
ensurePluginRegistration(currentConfig, resolvedChannelType);
currentConfig.plugins!.entries![resolvedChannelType] = {
...currentConfig.plugins!.entries![resolvedChannelType],
enabled: config.enabled ?? true,
};
await writeOpenClawConfig(currentConfig);
logger.info('Plugin channel config saved', {
channelType: resolvedChannelType,
configFile: CONFIG_FILE,
path: `plugins.entries.${resolvedChannelType}`,
});
console.log(`Saved plugin channel config for ${resolvedChannelType}`);
return;
}
if (!currentConfig.channels) {
currentConfig.channels = {};
@@ -889,21 +812,6 @@ export async function saveChannelConfig(
// read channels.<type>.enabled still work.
channelSection.enabled = transformedConfig.enabled ?? channelSection.enabled ?? true;
// Plugin-backed channel packages read their activation/config from
// plugins.entries.<id>. Mirror the enabled flag and account map there
// while preserving channels.<id> for ClawX's account list UI.
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
ensurePluginRegistration(currentConfig, resolvedChannelType);
const pluginEntry = currentConfig.plugins!.entries![resolvedChannelType];
const pluginAccounts = ensureChannelAccountsMap(pluginEntry);
pluginEntry.defaultAccount = channelSection.defaultAccount;
pluginEntry.enabled = channelSection.enabled;
pluginAccounts[resolvedAccountId] = {
...pluginAccounts[resolvedAccountId],
...accounts[resolvedAccountId],
};
}
// Most OpenClaw channel plugins/built-ins also read the default
// account's credentials from the top level of `channels.<type>`
// (e.g. channels.feishu.appId). Mirror them there so the
@@ -1360,14 +1268,11 @@ export async function setChannelEnabled(channelType: string, enabled: boolean):
if (enabled) {
ensurePluginRegistration(currentConfig, resolvedChannelType);
} else {
const plugins = currentConfig.plugins ?? (currentConfig.plugins = {});
const entries = plugins.entries ?? (plugins.entries = {});
entries[resolvedChannelType] ??= {};
if (!currentConfig.plugins) currentConfig.plugins = {};
if (!currentConfig.plugins.entries) currentConfig.plugins.entries = {};
if (!currentConfig.plugins.entries[resolvedChannelType]) currentConfig.plugins.entries[resolvedChannelType] = {};
}
const entries = currentConfig.plugins?.entries;
const pluginEntry = entries?.[resolvedChannelType];
if (!pluginEntry) throw new Error(`Plugin entry not initialized: ${resolvedChannelType}`);
pluginEntry.enabled = enabled;
currentConfig.plugins.entries[resolvedChannelType].enabled = enabled;
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
await writeOpenClawConfig(currentConfig);
console.log(`Set plugin channel ${resolvedChannelType} enabled: ${enabled}`);
-296
View File
@@ -1,296 +0,0 @@
import { app, utilityProcess } from 'electron';
import { existsSync } from 'fs';
import { readFile } from 'fs/promises';
import { join } from 'path';
import { PORTS } from './config';
import { prependPathEntry } from './env-path';
import { logger } from './logger';
import { getOpenClawConfigDir, getOpenClawDir, getOpenClawEntryPath } from './paths';
import { getSetting } from './store';
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';
export type PendingDevicePairingRequest = {
requestId?: string;
clientId?: string;
clientMode?: string;
role?: string;
roles?: string[];
scopes?: string[];
platform?: string;
};
export type DevicePairingList = {
pending?: PendingDevicePairingRequest[];
paired?: unknown[];
};
export type GatewayPairingRpcClient = {
isConnected: () => boolean;
getStatus?: () => { port?: number };
rpc: <T>(method: string, params?: unknown, timeoutMs?: number) => Promise<T>;
};
const DEFAULT_POLL_INTERVAL_MS = 800;
const DEFAULT_WATCH_TIMEOUT_MS = 90_000;
const LIST_RPC_TIMEOUT_MS = 10_000;
const APPROVE_RPC_TIMEOUT_MS = 15_000;
const CLI_APPROVE_TIMEOUT_MS = 20_000;
let activeWatcher: { cancel: () => void } | null = null;
export function isControlUiBrowserPairingRequest(request: PendingDevicePairingRequest): boolean {
const clientId = typeof request.clientId === 'string' ? request.clientId.trim() : '';
return clientId === CONTROL_UI_BROWSER_CLIENT_ID;
}
function parseDevicePairingList(value: unknown): DevicePairingList {
const record = typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
return {
pending: Array.isArray(record.pending)
? (record.pending as PendingDevicePairingRequest[])
: [],
paired: Array.isArray(record.paired) ? record.paired : [],
};
}
function resolveGatewayPort(gateway: GatewayPairingRpcClient): number {
return gateway.getStatus?.()?.port ?? PORTS.OPENCLAW_GATEWAY;
}
function resolveWatchTimeoutMs(explicit?: number): number {
return typeof explicit === 'number' ? explicit : DEFAULT_WATCH_TIMEOUT_MS;
}
/** Read ~/.openclaw/devices/pending.json (same store the Gateway uses on loopback). */
export async function readLocalPendingPairingRequests(): Promise<PendingDevicePairingRequest[]> {
const pendingPath = join(getOpenClawConfigDir(), 'devices', 'pending.json');
try {
const raw = await readFile(pendingPath, 'utf8');
const parsed = JSON.parse(raw) as Record<string, PendingDevicePairingRequest>;
if (!parsed || typeof parsed !== 'object') return [];
return Object.values(parsed).filter((entry) => entry && typeof entry === 'object');
} catch {
return [];
}
}
async function listPendingPairingRequests(gateway: GatewayPairingRpcClient): Promise<PendingDevicePairingRequest[]> {
const merged = new Map<string, PendingDevicePairingRequest>();
for (const request of await readLocalPendingPairingRequests()) {
const requestId = typeof request.requestId === 'string' ? request.requestId.trim() : '';
if (requestId) merged.set(requestId, request);
}
if (gateway.isConnected()) {
try {
const list = parseDevicePairingList(
await gateway.rpc<unknown>('device.pair.list', {}, LIST_RPC_TIMEOUT_MS),
);
for (const request of list.pending ?? []) {
const requestId = typeof request.requestId === 'string' ? request.requestId.trim() : '';
if (requestId) merged.set(requestId, request);
}
} catch (error) {
logger.debug(`[control-ui] device.pair.list RPC failed, using local pending file: ${String(error)}`);
}
}
return [...merged.values()];
}
function getBundledBinPath(): string {
const target = `${process.platform}-${process.arch}`;
return app.isPackaged
? join(process.resourcesPath, 'bin')
: join(process.cwd(), 'resources', 'bin', target);
}
/**
* Run `openclaw devices approve` in-process (not shown to the user).
* OpenClaw falls back to local pending.json on loopback when RPC is unavailable.
*/
async function approveViaOpenClawCli(requestId: string, _port: number): Promise<boolean> {
const entryScript = getOpenClawEntryPath();
const openclawDir = getOpenClawDir();
if (!existsSync(entryScript)) {
logger.warn('[control-ui] Cannot run devices approve: OpenClaw entry missing');
return false;
}
const token = await getSetting('gatewayToken');
const args = ['devices', 'approve', requestId, '--token', token, '--timeout', String(CLI_APPROVE_TIMEOUT_MS)];
const binPath = getBundledBinPath();
const binPathExists = existsSync(binPath);
const baseEnv = (binPathExists
? prependPathEntry(process.env as Record<string, string | undefined>, binPath).env
: process.env) as Record<string, string | undefined>;
const uvEnv = await getUvMirrorEnv();
return await new Promise<boolean>((resolve) => {
const child = utilityProcess.fork(entryScript, args, {
cwd: openclawDir,
stdio: 'pipe',
env: {
...baseEnv,
...uvEnv,
OPENCLAW_NO_RESPAWN: '1',
OPENCLAW_EMBEDDED_IN: 'ClawX',
} as NodeJS.ProcessEnv,
});
let settled = false;
const finish = (ok: boolean) => {
if (settled) return;
settled = true;
resolve(ok);
};
const timeout = setTimeout(() => {
logger.warn(`[control-ui] devices approve timed out for ${requestId}`);
try {
child.kill();
} catch {
// ignore
}
finish(false);
}, CLI_APPROVE_TIMEOUT_MS + 5_000);
child.on('error', (error) => {
clearTimeout(timeout);
logger.warn(`[control-ui] devices approve spawn failed: ${String(error)}`);
finish(false);
});
child.on('exit', (code: number) => {
clearTimeout(timeout);
finish(code === 0);
});
});
}
async function approvePairingRequest(
gateway: GatewayPairingRpcClient,
requestId: string,
port: number,
): Promise<boolean> {
if (gateway.isConnected()) {
try {
await gateway.rpc('device.pair.approve', { requestId }, APPROVE_RPC_TIMEOUT_MS);
return true;
} catch (error) {
logger.debug(
`[control-ui] device.pair.approve RPC failed for ${requestId}, trying CLI fallback: ${String(error)}`,
);
}
}
return approveViaOpenClawCli(requestId, port);
}
function sleep(ms: number, signal: { cancelled: boolean }): Promise<void> {
return new Promise((resolve) => {
if (signal.cancelled) {
resolve();
return;
}
const timer = setTimeout(() => {
clearTimeout(timer);
resolve();
}, ms);
});
}
/**
* Approve pending Control UI browser pairing requests.
* Uses Gateway RPC when available; falls back to local pending.json + embedded CLI on Windows packaged builds.
*/
export async function approvePendingControlUiPairingRequests(
gateway: GatewayPairingRpcClient,
options?: { approvedRequestIds?: Set<string> },
): Promise<string[]> {
const port = resolveGatewayPort(gateway);
const approvedRequestIds = options?.approvedRequestIds ?? new Set<string>();
const pending = await listPendingPairingRequests(gateway);
const approved: string[] = [];
for (const request of pending) {
if (!isControlUiBrowserPairingRequest(request)) continue;
const requestId = typeof request.requestId === 'string' ? request.requestId.trim() : '';
if (!requestId || approvedRequestIds.has(requestId)) continue;
try {
const ok = await approvePairingRequest(gateway, requestId, port);
if (!ok) continue;
approvedRequestIds.add(requestId);
approved.push(requestId);
logger.info(
`[control-ui] Auto-approved browser device pairing (requestId=${requestId}, mode=${request.clientMode ?? 'unknown'})`,
);
} catch (error) {
logger.warn(
`[control-ui] Failed to auto-approve pairing request ${requestId}: ${String(error)}`,
);
}
}
return approved;
}
async function watchControlUiPairingApprovals(
gateway: GatewayPairingRpcClient,
signal: { cancelled: boolean },
timeoutMs: number,
pollIntervalMs: number,
): Promise<void> {
const approvedRequestIds = new Set<string>();
const deadline = Date.now() + timeoutMs;
while (!signal.cancelled && Date.now() < deadline) {
try {
await approvePendingControlUiPairingRequests(gateway, { approvedRequestIds });
} catch (error) {
logger.debug(`[control-ui] Pairing poll error: ${String(error)}`);
}
await sleep(pollIntervalMs, signal);
}
}
/**
* Poll for Control UI browser pairing requests and approve them locally.
* Safe to call repeatedly; only one watcher runs at a time.
*/
export function scheduleControlUiDeviceAutoApproval(
gateway: GatewayPairingRpcClient,
options?: {
timeoutMs?: number;
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;
}
});
}
+4 -4
View File
@@ -60,16 +60,16 @@ async function fileExists(p: string): Promise<boolean> {
/** Generate a new Ed25519 identity (async key generation). */
async function generateIdentity(): Promise<DeviceIdentity> {
const { publicKey, privateKey } = await new Promise<{ publicKey: crypto.KeyObject; privateKey: crypto.KeyObject }>(
const { publicKey, privateKey } = await new Promise<crypto.KeyPairKeyObjectResult>(
(resolve, reject) => {
crypto.generateKeyPair('ed25519', {}, (err, publicKey, privateKey) => {
crypto.generateKeyPair('ed25519', (err, publicKey, privateKey) => {
if (err) reject(err);
else resolve({ publicKey, privateKey });
});
},
);
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' });
const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const publicKeyPem = (publicKey.export({ type: 'spki', format: 'pem' }) as Buffer).toString();
const privateKeyPem = (privateKey.export({ type: 'pkcs8', format: 'pem' }) as Buffer).toString();
return {
deviceId: fingerprintPublicKey(publicKeyPem),
publicKeyPem,
+6 -2
View File
@@ -9,18 +9,22 @@ type BuildGatewayHealthSummaryOptions = {
diagnostics: GatewayDiagnosticsSnapshot;
lastChannelsStatusOkAt?: number;
lastChannelsStatusFailureAt?: number;
platform?: string;
now?: number;
};
const CHANNEL_STATUS_FAILURE_WINDOW_MS = 2 * 60_000;
const HEARTBEAT_MISS_THRESHOLD = 4;
const HEARTBEAT_MISS_THRESHOLD_DEFAULT = 3;
const HEARTBEAT_MISS_THRESHOLD_WIN = 5;
export function buildGatewayHealthSummary(
options: BuildGatewayHealthSummaryOptions,
): GatewayHealthSummary {
const now = options.now ?? Date.now();
const reasons = new Set<string>();
const heartbeatThreshold = HEARTBEAT_MISS_THRESHOLD;
const heartbeatThreshold = options.platform === 'win32'
? HEARTBEAT_MISS_THRESHOLD_WIN
: HEARTBEAT_MISS_THRESHOLD_DEFAULT;
const channelStatusFailureIsRecent =
typeof options.lastChannelsStatusFailureAt === 'number'
+739
View File
@@ -0,0 +1,739 @@
import { execFile, execFileSync } from 'node:child_process';
import { createHash, randomBytes } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { delimiter, dirname, join } from 'node:path';
import { getClawXConfigDir } from './paths';
import { proxyAwareFetch } from './proxy-fetch';
const CLIENT_ID_KEYS = ['OPENCLAW_GEMINI_OAUTH_CLIENT_ID', 'GEMINI_CLI_OAUTH_CLIENT_ID'];
const CLIENT_SECRET_KEYS = [
'OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET',
'GEMINI_CLI_OAUTH_CLIENT_SECRET',
];
const REDIRECT_URI = 'http://127.0.0.1:8085/oauth2callback';
const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
const USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json';
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
const SCOPES = [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
];
const TIER_FREE = 'free-tier';
const TIER_LEGACY = 'legacy-tier';
const TIER_STANDARD = 'standard-tier';
const LOCAL_GEMINI_DIR = join(getClawXConfigDir(), 'gemini-cli');
export type GeminiCliOAuthCredentials = {
access: string;
refresh: string;
expires: number;
email?: string;
projectId?: string;
};
export type GeminiCliOAuthContext = {
isRemote: boolean;
openUrl: (url: string) => Promise<void>;
log: (msg: string) => void;
note: (message: string, title?: string) => Promise<void>;
prompt: (message: string) => Promise<string>;
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
};
export class DetailedError extends Error {
detail: string;
constructor(message: string, detail: string) {
super(message);
this.name = 'DetailedError';
this.detail = detail;
}
}
let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null;
function resolveEnv(keys: string[]): string | undefined {
for (const key of keys) {
const value = process.env[key]?.trim();
if (value) {
return value;
}
}
return undefined;
}
function findInPath(name: string): string | null {
const exts = process.platform === 'win32' ? ['.cmd', '.bat', '.exe', ''] : [''];
for (const dir of (process.env.PATH ?? '').split(delimiter)) {
if (!dir) continue;
for (const ext of exts) {
const p = join(dir, name + ext);
if (existsSync(p)) {
return p;
}
}
}
return null;
}
function findFile(dir: string, name: string, depth: number): string | null {
if (depth <= 0) {
return null;
}
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const next = join(dir, entry.name);
if (entry.isFile() && entry.name === name) {
return next;
}
if (entry.isDirectory() && !entry.name.startsWith('.')) {
const found = findFile(next, name, depth - 1);
if (found) {
return found;
}
}
}
} catch {
return null;
}
return null;
}
export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null {
if (cachedGeminiCliCredentials) {
return cachedGeminiCliCredentials;
}
try {
const geminiPath = findInPath('gemini');
if (!geminiPath) {
return null;
}
const resolvedPath = realpathSync(geminiPath);
const geminiCliDir = dirname(dirname(resolvedPath));
const searchPaths = [
join(
geminiCliDir,
'node_modules',
'@google',
'gemini-cli-core',
'dist',
'src',
'code_assist',
'oauth2.js',
),
join(
geminiCliDir,
'node_modules',
'@google',
'gemini-cli-core',
'dist',
'code_assist',
'oauth2.js',
),
];
let content: string | null = null;
for (const p of searchPaths) {
if (existsSync(p)) {
content = readFileSync(p, 'utf8');
break;
}
}
if (!content) {
const found = findFile(geminiCliDir, 'oauth2.js', 10);
if (found) {
content = readFileSync(found, 'utf8');
}
}
if (!content) {
return null;
}
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
if (idMatch && secretMatch) {
cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] };
return cachedGeminiCliCredentials;
}
} catch {
return null;
}
return null;
}
function extractFromLocalInstall(): { clientId: string; clientSecret: string } | null {
const coreDir = join(LOCAL_GEMINI_DIR, 'node_modules', '@google', 'gemini-cli-core');
if (!existsSync(coreDir)) {
return null;
}
const searchPaths = [
join(coreDir, 'dist', 'src', 'code_assist', 'oauth2.js'),
join(coreDir, 'dist', 'code_assist', 'oauth2.js'),
];
let content: string | null = null;
for (const p of searchPaths) {
if (existsSync(p)) {
content = readFileSync(p, 'utf8');
break;
}
}
if (!content) {
const found = findFile(coreDir, 'oauth2.js', 10);
if (found) {
content = readFileSync(found, 'utf8');
}
}
if (!content) {
return null;
}
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
if (idMatch && secretMatch) {
return { clientId: idMatch[1], clientSecret: secretMatch[1] };
}
return null;
}
async function installViaNpm(onProgress?: (msg: string) => void): Promise<boolean> {
const npmBin = findInPath('npm');
if (!npmBin) {
return false;
}
onProgress?.('Installing Gemini OAuth helper...');
return await new Promise((resolve) => {
const useShell = process.platform === 'win32';
const child = execFile(
npmBin,
['install', '--prefix', LOCAL_GEMINI_DIR, '@google/gemini-cli'],
{ timeout: 120_000, shell: useShell, env: { ...process.env, NODE_ENV: '' } },
(err) => {
if (err) {
onProgress?.(`Gemini helper install failed, falling back to direct download...`);
resolve(false);
} else {
cachedGeminiCliCredentials = null;
onProgress?.('Gemini OAuth helper installed');
resolve(true);
}
},
);
child.stderr?.on('data', () => {
// Suppress npm noise.
});
});
}
async function installViaDirectDownload(onProgress?: (msg: string) => void): Promise<boolean> {
try {
onProgress?.('Downloading Gemini OAuth helper...');
const metaRes = await proxyAwareFetch('https://registry.npmjs.org/@google/gemini-cli-core/latest');
if (!metaRes.ok) {
onProgress?.(`Failed to fetch Gemini package metadata: ${metaRes.status}`);
return false;
}
const meta = (await metaRes.json()) as { dist?: { tarball?: string } };
const tarballUrl = meta.dist?.tarball;
if (!tarballUrl) {
onProgress?.('Gemini package tarball URL missing');
return false;
}
const tarRes = await proxyAwareFetch(tarballUrl);
if (!tarRes.ok) {
onProgress?.(`Failed to download Gemini package: ${tarRes.status}`);
return false;
}
const buffer = Buffer.from(await tarRes.arrayBuffer());
const targetDir = join(LOCAL_GEMINI_DIR, 'node_modules', '@google', 'gemini-cli-core');
mkdirSync(targetDir, { recursive: true });
const tmpFile = join(LOCAL_GEMINI_DIR, '_tmp_gemini-cli-core.tgz');
writeFileSync(tmpFile, buffer);
try {
execFileSync('tar', ['xzf', tmpFile, '-C', targetDir, '--strip-components=1'], {
timeout: 30_000,
});
} finally {
try {
unlinkSync(tmpFile);
} catch {
// ignore
}
}
cachedGeminiCliCredentials = null;
onProgress?.('Gemini OAuth helper ready');
return true;
} catch (err) {
onProgress?.(`Direct Gemini helper download failed: ${err instanceof Error ? err.message : String(err)}`);
return false;
}
}
async function ensureOAuthClientConfig(
onProgress?: (msg: string) => void,
): Promise<{ clientId: string; clientSecret?: string }> {
const envClientId = resolveEnv(CLIENT_ID_KEYS);
const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS);
if (envClientId) {
return { clientId: envClientId, clientSecret: envClientSecret };
}
const extracted = extractGeminiCliCredentials();
if (extracted) {
return extracted;
}
const localExtracted = extractFromLocalInstall();
if (localExtracted) {
return localExtracted;
}
mkdirSync(LOCAL_GEMINI_DIR, { recursive: true });
const installed = await installViaNpm(onProgress) || await installViaDirectDownload(onProgress);
if (installed) {
const installedExtracted = extractFromLocalInstall();
if (installedExtracted) {
return installedExtracted;
}
}
throw new Error(
'Unable to prepare Gemini OAuth credentials automatically. Set GEMINI_CLI_OAUTH_CLIENT_ID or try again later.',
);
}
function generatePkce(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString('hex');
const challenge = createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
function buildAuthUrl(clientId: string, challenge: string, verifier: string): string {
const params = new URLSearchParams({
client_id: clientId,
response_type: 'code',
redirect_uri: REDIRECT_URI,
scope: SCOPES.join(' '),
code_challenge: challenge,
code_challenge_method: 'S256',
state: verifier,
access_type: 'offline',
prompt: 'consent',
});
return `${AUTH_URL}?${params.toString()}`;
}
async function waitForLocalCallback(params: {
expectedState: string;
timeoutMs: number;
onProgress?: (message: string) => void;
}): Promise<{ code: string; state: string }> {
const port = 8085;
const hostname = '127.0.0.1';
const expectedPath = '/oauth2callback';
return new Promise((resolve, reject) => {
let timeout: NodeJS.Timeout | null = null;
const server = createServer((req, res) => {
try {
const requestUrl = new URL(req.url ?? '/', `http://${hostname}:${port}`);
if (requestUrl.pathname !== expectedPath) {
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain');
res.end('Not found');
return;
}
const error = requestUrl.searchParams.get('error');
const code = requestUrl.searchParams.get('code')?.trim();
const state = requestUrl.searchParams.get('state')?.trim();
if (error) {
res.statusCode = 400;
res.setHeader('Content-Type', 'text/plain');
res.end(`Authentication failed: ${error}`);
finish(new Error(`OAuth error: ${error}`));
return;
}
if (!code || !state) {
res.statusCode = 400;
res.setHeader('Content-Type', 'text/plain');
res.end('Missing code or state');
finish(new Error('Missing OAuth code or state'));
return;
}
if (state !== params.expectedState) {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(
"<!doctype html><html><head><meta charset='utf-8'/></head><body><h2>Session expired</h2><p>This authorization link is from a previous attempt. Please go back to ClawX and try again.</p></body></html>",
);
return;
}
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(
"<!doctype html><html><head><meta charset='utf-8'/></head><body><h2>Gemini CLI OAuth complete</h2><p>You can close this window and return to ClawX.</p></body></html>",
);
finish(undefined, { code, state });
} catch (err) {
finish(err instanceof Error ? err : new Error('OAuth callback failed'));
}
});
const finish = (err?: Error, result?: { code: string; state: string }) => {
if (timeout) {
clearTimeout(timeout);
}
try {
server.close();
} catch {
// ignore
}
if (err) {
reject(err);
} else if (result) {
resolve(result);
}
};
server.once('error', (err) => {
finish(err instanceof Error ? err : new Error('OAuth callback server error'));
});
server.listen(port, hostname, () => {
params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}...`);
});
timeout = setTimeout(() => {
finish(new DetailedError(
'OAuth login timed out. The browser did not redirect back. Check if localhost:8085 is blocked.',
`Waited ${params.timeoutMs / 1000}s for callback on ${hostname}:${port}`,
));
}, params.timeoutMs);
});
}
async function getUserEmail(accessToken: string): Promise<string | undefined> {
try {
const response = await proxyAwareFetch(USERINFO_URL, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (response.ok) {
const data = (await response.json()) as { email?: string };
return data.email;
}
} catch {
// ignore
}
return undefined;
}
function getDefaultTier(
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>,
): { id?: string } | undefined {
if (!allowedTiers?.length) {
return { id: TIER_LEGACY };
}
return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY };
}
function isVpcScAffected(payload: unknown): boolean {
if (!payload || typeof payload !== 'object') {
return false;
}
const error = (payload as { error?: unknown }).error;
if (!error || typeof error !== 'object') {
return false;
}
const details = (error as { details?: unknown[] }).details;
if (!Array.isArray(details)) {
return false;
}
return details.some(
(item) =>
typeof item === 'object'
&& item
&& (item as { reason?: string }).reason === 'SECURITY_POLICY_VIOLATED',
);
}
async function pollOperation(
operationName: string,
headers: Record<string, string>,
): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> {
for (let attempt = 0; attempt < 24; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 5000));
const response = await proxyAwareFetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { headers });
if (!response.ok) {
continue;
}
const data = (await response.json()) as {
done?: boolean;
response?: { cloudaicompanionProject?: { id?: string } };
};
if (data.done) {
return data;
}
}
throw new Error('Operation polling timeout');
}
async function discoverProject(accessToken: string): Promise<string> {
const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID;
const headers = {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'User-Agent': 'google-api-nodejs-client/9.15.1',
'X-Goog-Api-Client': 'gl-node/clawx',
};
const loadBody = {
cloudaicompanionProject: envProject,
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
duetProject: envProject,
},
};
let data: {
currentTier?: { id?: string };
cloudaicompanionProject?: string | { id?: string };
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
} = {};
const response = await proxyAwareFetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
method: 'POST',
headers,
body: JSON.stringify(loadBody),
});
if (!response.ok) {
const errorPayload = await response.json().catch(() => null);
if (isVpcScAffected(errorPayload)) {
data = { currentTier: { id: TIER_STANDARD } };
} else {
throw new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`);
}
} else {
data = (await response.json()) as typeof data;
}
if (data.currentTier) {
const project = data.cloudaicompanionProject;
if (typeof project === 'string' && project) {
return project;
}
if (typeof project === 'object' && project?.id) {
return project.id;
}
if (envProject) {
return envProject;
}
}
const hasExistingTierButNoProject = !!data.currentTier;
const tier = hasExistingTierButNoProject ? { id: TIER_FREE } : getDefaultTier(data.allowedTiers);
const tierId = tier?.id || TIER_FREE;
if (tierId !== TIER_FREE && !envProject) {
throw new DetailedError(
'Your Google account requires a Cloud project. Please create one and set GOOGLE_CLOUD_PROJECT.',
`tierId=${tierId}, currentTier=${JSON.stringify(data.currentTier ?? null)}, allowedTiers=${JSON.stringify(data.allowedTiers)}`,
);
}
const onboardBody: Record<string, unknown> = {
tierId,
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
},
};
if (tierId !== TIER_FREE && envProject) {
onboardBody.cloudaicompanionProject = envProject;
(onboardBody.metadata as Record<string, unknown>).duetProject = envProject;
}
const onboardResponse = await proxyAwareFetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
method: 'POST',
headers,
body: JSON.stringify(onboardBody),
});
if (!onboardResponse.ok) {
const respText = await onboardResponse.text().catch(() => '');
throw new DetailedError(
'Google project provisioning failed. Please try again later.',
`onboardUser ${onboardResponse.status} ${onboardResponse.statusText}: ${respText}`,
);
}
let lro = (await onboardResponse.json()) as {
done?: boolean;
name?: string;
response?: { cloudaicompanionProject?: { id?: string } };
};
if (!lro.done && lro.name) {
lro = await pollOperation(lro.name, headers);
}
const projectId = lro.response?.cloudaicompanionProject?.id;
if (projectId) {
return projectId;
}
if (envProject) {
return envProject;
}
throw new DetailedError(
'Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.',
`tierId=${tierId}, onboardResponse=${JSON.stringify(lro)}, currentTier=${JSON.stringify(data.currentTier ?? null)}`,
);
}
async function exchangeCodeForTokens(
code: string,
verifier: string,
clientConfig: { clientId: string; clientSecret?: string },
): Promise<GeminiCliOAuthCredentials> {
const { clientId, clientSecret } = clientConfig;
const body = new URLSearchParams({
client_id: clientId,
code,
grant_type: 'authorization_code',
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
});
if (clientSecret) {
body.set('client_secret', clientSecret);
}
const response = await proxyAwareFetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${errorText}`);
}
const data = (await response.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
if (!data.refresh_token) {
throw new Error('No refresh token received. Please try again.');
}
const email = await getUserEmail(data.access_token);
const projectId = await discoverProject(data.access_token);
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
return {
refresh: data.refresh_token,
access: data.access_token,
expires: expiresAt,
projectId,
email,
};
}
export async function loginGeminiCliOAuth(
ctx: GeminiCliOAuthContext,
): Promise<GeminiCliOAuthCredentials> {
if (ctx.isRemote) {
throw new Error('Remote/manual Gemini OAuth is not implemented in ClawX yet.');
}
await ctx.note(
[
'Browser will open for Google authentication.',
'Sign in with your Google account for Gemini CLI access.',
'The callback will be captured automatically on 127.0.0.1:8085.',
].join('\n'),
'Gemini CLI OAuth',
);
ctx.progress.update('Preparing Google OAuth...');
const clientConfig = await ensureOAuthClientConfig((msg) => ctx.progress.update(msg));
const { verifier, challenge } = generatePkce();
const authUrl = buildAuthUrl(clientConfig.clientId, challenge, verifier);
ctx.progress.update('Complete sign-in in browser...');
try {
await ctx.openUrl(authUrl);
} catch {
ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`);
}
try {
const { code } = await waitForLocalCallback({
expectedState: verifier,
timeoutMs: 5 * 60 * 1000,
onProgress: (msg) => ctx.progress.update(msg),
});
ctx.progress.update('Exchanging authorization code for tokens...');
return await exchangeCodeForTokens(code, verifier, clientConfig);
} catch (err) {
if (
err instanceof Error
&& (err.message.includes('EADDRINUSE')
|| err.message.includes('port')
|| err.message.includes('listen'))
) {
throw new Error(
'Port 8085 is in use by another process. Close the other application using port 8085 and try again.',
{ cause: err },
);
}
throw err;
}
}
// Best-effort check to help with diagnostics if the user claims gemini is installed but PATH is stale.
export function detectGeminiCliVersion(): string | null {
try {
const geminiPath = findInPath('gemini');
if (!geminiPath) {
return null;
}
return execFileSync(geminiPath, ['--version'], { encoding: 'utf8' }).trim();
} catch {
return null;
}
}
+11 -22
View File
@@ -28,7 +28,6 @@ export interface OpenAICodexOAuthCredentials {
refresh: string;
expires: number;
accountId: string;
email?: string;
}
interface OpenAICodexAuthorizationFlow {
@@ -116,27 +115,16 @@ function decodeJwtPayload(token: string): Record<string, unknown> | null {
function getAccountIdFromAccessToken(accessToken: string): string | null {
const payload = decodeJwtPayload(accessToken);
const authClaims = payload?.[JWT_CLAIM_PATH];
if (authClaims && typeof authClaims === 'object') {
const claims = authClaims as Record<string, unknown>;
for (const key of ['chatgpt_account_id', 'account_id', 'user_id', 'sub']) {
const value = claims[key];
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
}
if (!authClaims || typeof authClaims !== 'object') {
return null;
}
if (typeof payload?.sub === 'string' && payload.sub.trim()) {
return payload.sub.trim();
const accountId = (authClaims as Record<string, unknown>).chatgpt_account_id;
if (typeof accountId !== 'string' || !accountId.trim()) {
return null;
}
return null;
}
function getEmailFromAccessToken(accessToken: string): string | undefined {
const payload = decodeJwtPayload(accessToken);
const email = payload?.email;
return typeof email === 'string' && email.trim() ? email.trim() : undefined;
return accountId;
}
async function createAuthorizationFlow(): Promise<OpenAICodexAuthorizationFlow> {
@@ -193,9 +181,8 @@ function startLocalOAuthServer(state: string): Promise<OpenAICodexLocalServer |
});
return new Promise((resolve) => {
// Bind dual-stack loopback so both `localhost` and `127.0.0.1` redirects work.
server
.listen(1455, () => {
.listen(1455, 'localhost', () => {
resolve({
close: () => server.close(),
waitForCode: async () => {
@@ -301,14 +288,16 @@ export async function loginOpenAICodexOAuth(options: {
}
const token = await exchangeAuthorizationCode(code, verifier);
const accountId = getAccountIdFromAccessToken(token.access) ?? 'default';
const accountId = getAccountIdFromAccessToken(token.access);
if (!accountId) {
throw new Error('Failed to extract OpenAI accountId from token');
}
return {
access: token.access,
refresh: token.refresh,
expires: token.expires,
accountId,
email: getEmailFromAccessToken(token.access),
};
} finally {
server?.close();
-297
View File
@@ -1,297 +0,0 @@
/**
* 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;
}
File diff suppressed because it is too large Load Diff

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