mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 08:53:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6988b2b5bf | ||
|
|
7014a50886 | ||
|
|
f847982632 | ||
|
|
4485491913 | ||
|
|
897983432b | ||
|
|
4cfb552b1d | ||
|
|
995a7f070d | ||
|
|
01adc828b5 | ||
|
|
e7923d0120 | ||
|
|
abc0c6e7d5 | ||
|
|
740116ae9d | ||
|
|
5e880221b2 | ||
|
|
02c8e7534b | ||
|
|
1d4081fde7 | ||
|
|
554ae2b01f | ||
|
|
c0a3903377 | ||
|
|
c0c8701cc3 | ||
|
|
272432783a | ||
|
|
a48dacfe64 | ||
|
|
38391dd093 | ||
|
|
882da7b904 | ||
|
|
5c07ad77fc | ||
|
|
706789cf4d | ||
|
|
945c3b39b4 | ||
|
|
2769c69a84 | ||
|
|
1f90a40972 | ||
|
|
1b266227e4 | ||
|
|
e80f666441 | ||
|
|
050ee10850 | ||
|
|
53a51642ce | ||
|
|
baa551b30c | ||
|
|
ce7e890509 | ||
|
|
f37d2ac112 | ||
|
|
5b59c8a8e2 | ||
|
|
14646a69fc | ||
|
|
a853f01583 | ||
|
|
4c786915c8 | ||
|
|
a575977e3c | ||
|
|
da8ed3bb32 | ||
|
|
d11e266cbb | ||
|
|
672888d9c1 | ||
|
|
95e090ecb5 | ||
|
|
34dcb48e27 | ||
|
|
3a697c4daa | ||
|
|
ce0e5fd8af | ||
|
|
ed40a3b7f4 | ||
|
|
31e80f256b | ||
|
|
880995af19 | ||
|
|
45d7ff61c3 | ||
|
|
807f6b8adf | ||
|
|
9502d9b1c5 | ||
|
|
99681777a0 | ||
|
|
80e89ddc5c | ||
|
|
d3960a3d0f | ||
|
|
36c0fcb5c7 | ||
|
|
17e6ab9149 | ||
|
|
d9ae0f3263 | ||
|
|
19b5b2d540 | ||
|
|
1bae8229af | ||
|
|
65c2b73e23 | ||
|
|
b86f47171b | ||
|
|
905ce02b0b | ||
|
|
3d664c017a | ||
|
|
e28eba01e1 | ||
|
|
8b45960662 | ||
|
|
9bb684af68 | ||
|
|
d32d84f1a9 | ||
|
|
2c5c82bb74 | ||
|
|
3d804a9f5e | ||
|
|
c03d92e9a2 | ||
|
|
b41a8eedd9 | ||
|
|
8c44419eaa | ||
|
|
e1599ee7ef | ||
|
|
c5f5e02f4a | ||
|
|
3ce4b5d17a | ||
|
|
e7d4cf73d5 | ||
|
|
01efd87642 | ||
|
|
0901d9912a | ||
|
|
c5058908af | ||
|
|
b6adf58f0f | ||
|
|
0720ee0d7f | ||
|
|
2f17e74944 | ||
|
|
ee4ff651a7 | ||
|
|
8eae7df3a1 | ||
|
|
d14ae0a8f5 | ||
|
|
be800f6cfc | ||
|
|
5ddb7d281d | ||
|
|
fd58e721bd | ||
|
|
52748d78b5 | ||
|
|
76df84e68c | ||
|
|
89028756e1 | ||
|
|
ee189a1bde | ||
|
|
e8a0390b93 | ||
|
|
9703f361f9 | ||
|
|
30b03add1c | ||
|
|
3371e4fe74 | ||
|
|
5049709c5d | ||
|
|
828cae0186 | ||
|
|
402129354a | ||
|
|
c49c7f18bd | ||
|
|
f18c91fd6a | ||
|
|
d0a38519d2 | ||
|
|
2b966cc573 | ||
|
|
c1719b3d52 | ||
|
|
296a755396 | ||
|
|
e52916a7ef | ||
|
|
bc47b455b5 | ||
|
|
9532400053 | ||
|
|
382d737fa7 | ||
|
|
f17ffe32c7 | ||
|
|
e40f4b2163 | ||
|
|
c09b45832b | ||
|
|
08e5e4fabb | ||
|
|
d4d12cf1fd | ||
|
|
c0b1288557 | ||
|
|
29ef9591cf | ||
|
|
19406757f1 | ||
|
|
62108bdc23 | ||
|
|
0bc4b7cbc2 | ||
|
|
91cb69d01c | ||
|
|
4b5644418e | ||
|
|
730d5466dd | ||
|
|
7d0621dcc2 | ||
|
|
a8f61d5a61 | ||
|
|
a3462e2de6 | ||
|
|
bdafbca8d4 | ||
|
|
1b4da25d0d | ||
|
|
9d17202ea3 | ||
|
|
9d07f611a3 | ||
|
|
3353de91b8 | ||
|
|
cfd387980d | ||
|
|
bf62639573 | ||
|
|
95a4c0234a | ||
|
|
ed9b733798 | ||
|
|
a75b96f739 | ||
|
|
8cda9235b3 | ||
|
|
1b45d891c3 | ||
|
|
b4ef2bd51d | ||
|
|
6859656847 | ||
|
|
163099add8 | ||
|
|
4d948347ea | ||
|
|
e303841373 | ||
|
|
bffa85e0e8 | ||
|
|
dbf88a79be | ||
|
|
98703a0ab8 | ||
|
|
864d6a499e | ||
|
|
afad6c58b0 | ||
|
|
270bc1e402 | ||
|
|
d63810f54b | ||
|
|
d4f77a442c | ||
|
|
538d285c71 | ||
|
|
96bd37d1b1 | ||
|
|
386d4c5454 | ||
|
|
0fb1a1a78d | ||
|
|
f70d5b0c28 | ||
|
|
5d548da2e6 | ||
|
|
7d8dd344ad | ||
|
|
3a1666ca59 | ||
|
|
5bc72948d9 | ||
|
|
87616b4250 | ||
|
|
2a50aea448 | ||
|
|
3420ee2447 | ||
|
|
45d5e9e576 | ||
|
|
f36e143f3f | ||
|
|
8a2db04556 | ||
|
|
aaa2864281 | ||
|
|
0b2236f91f | ||
|
|
efe091b301 | ||
|
|
0f3505661d | ||
|
|
0be07eab2a | ||
|
|
7929a43601 | ||
|
|
d38a6b012d | ||
|
|
c591ceb73d | ||
|
|
e46243b5b6 | ||
|
|
a14552a474 | ||
|
|
9ff0eb81fd | ||
|
|
45932a95c3 | ||
|
|
7b16b6af14 | ||
|
|
e1ae68ce7e | ||
|
|
4f50630291 | ||
|
|
402aeeb98b | ||
|
|
d11f45cc73 | ||
|
|
8f9fc056a4 | ||
|
|
4bbeb0d181 | ||
|
|
1ca0017f85 | ||
|
|
96ac13fd90 | ||
|
|
264ef460a5 | ||
|
|
6584cadd26 | ||
|
|
6383e10d63 | ||
|
|
02d88e4963 | ||
|
|
0038f26465 | ||
|
|
516e5fecd1 | ||
|
|
9a039ab9fb | ||
|
|
ade1b2ac8b | ||
|
|
b84b645aa1 | ||
|
|
7baafea262 | ||
|
|
6990793e69 | ||
|
|
d317dfd153 | ||
|
|
e8c11887d0 | ||
|
|
2804b6da73 | ||
|
|
f0738af2eb | ||
|
|
265b12281c | ||
|
|
1166074a52 | ||
|
|
2dfcc4e600 | ||
|
|
25476488c6 | ||
|
|
9bdc868056 | ||
|
|
eaac3664bd | ||
|
|
6453702bf9 | ||
|
|
5d1d704031 | ||
|
|
3da8c71602 | ||
|
|
0adbac1688 | ||
|
|
1fae91667d | ||
|
|
4e7733353c | ||
|
|
0102ba5870 | ||
|
|
f48452b52b | ||
|
|
6eb764191e | ||
|
|
b6e62aa7e6 | ||
|
|
1b5ffaefb7 | ||
|
|
d55305839f | ||
|
|
d572176b06 | ||
|
|
1b02d63399 | ||
|
|
cf27bb0d64 | ||
|
|
f32521b484 | ||
|
|
a0096ef36c | ||
|
|
34105d60c6 | ||
|
|
f821949829 | ||
|
|
1808990f5b | ||
|
|
b87e676826 | ||
|
|
15f3ef1f72 | ||
|
|
98b6367f44 | ||
|
|
baea49328d | ||
|
|
c969e899c8 | ||
|
|
1c418ab22a | ||
|
|
4483718405 | ||
|
|
b9b9eb9c02 | ||
|
|
c112899118 | ||
|
|
1759b98443 | ||
|
|
7360a0adcf | ||
|
|
5005d405d9 | ||
|
|
26ce009a41 |
@@ -10,6 +10,8 @@ on:
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: '1'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
@@ -37,6 +39,8 @@ jobs:
|
||||
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: '1'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
name: Package Windows (Manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Git ref to build (branch, tag, or SHA). Leave empty for current default ref.'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
package-windows:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Download uv binaries for Windows
|
||||
run: pnpm run uv:download:win
|
||||
|
||||
- name: Build Windows package (no publish)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: pnpm run build:vite && pnpm exec zx scripts/bundle-openclaw.mjs && pnpm exec electron-builder --win --publish never
|
||||
|
||||
- name: Upload Windows Installer (x64)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-installer-x64
|
||||
path: release/*-win-x64.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload Windows Installer (arm64)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-installer-arm64
|
||||
path: release/*-win-arm64.exe
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload Windows Blockmap Files
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-blockmap
|
||||
path: release/*.blockmap
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload Windows Update Manifests
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-update-manifests
|
||||
path: |
|
||||
release/*.yml
|
||||
!release/builder-debug.yml
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
@@ -37,9 +37,9 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '24'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -73,40 +73,35 @@ jobs:
|
||||
run: pnpm run uv:download:linux
|
||||
|
||||
# macOS specific steps
|
||||
# --publish never: prevent electron-builder from auto-publishing to GitHub.
|
||||
# All artifacts are collected and published atomically in the publish job.
|
||||
- name: Build macOS
|
||||
if: matrix.platform == 'mac'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Code signing
|
||||
CSC_LINK: ${{ secrets.MAC_CERTS }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
|
||||
# Notarization
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
# Increase file descriptor limit to handle large number of files during code signing
|
||||
ulimit -n 65536
|
||||
echo "File descriptor limit: $(ulimit -n)"
|
||||
|
||||
pnpm run package:mac
|
||||
pnpm run build:vite && pnpm exec zx scripts/bundle-openclaw.mjs && pnpm exec electron-builder --mac --publish never
|
||||
|
||||
# Windows specific steps
|
||||
- name: Build Windows
|
||||
if: matrix.platform == 'win'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# For code signing (optional)
|
||||
# CSC_LINK: ${{ secrets.WIN_CERTS }}
|
||||
# CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTS_PASSWORD }}
|
||||
run: pnpm run package:win
|
||||
run: pnpm run build:vite && pnpm exec zx scripts/bundle-openclaw.mjs && pnpm exec electron-builder --win --publish never
|
||||
|
||||
# Linux specific steps
|
||||
- name: Build Linux
|
||||
if: matrix.platform == 'linux'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: pnpm run package:linux
|
||||
run: pnpm run build:vite && pnpm exec zx scripts/bundle-openclaw.mjs && pnpm exec electron-builder --linux --publish never
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -115,6 +110,7 @@ jobs:
|
||||
path: |
|
||||
release/*.dmg
|
||||
release/*.zip
|
||||
release/*.blockmap
|
||||
release/*.exe
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
@@ -152,7 +148,7 @@ jobs:
|
||||
echo "Removing builder-debug.yml files to avoid duplicate asset upload conflicts..."
|
||||
find release-artifacts/ -name "builder-debug.yml" -delete -print || true
|
||||
|
||||
- name: Create GitHub Release
|
||||
- name: Create GitHub Release (as pre-release)
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
@@ -165,8 +161,8 @@ jobs:
|
||||
release-artifacts/**/*.rpm
|
||||
release-artifacts/**/*.yml
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') }}
|
||||
make_latest: ${{ !(contains(github.ref, 'alpha') || contains(github.ref, 'beta')) }}
|
||||
prerelease: true
|
||||
make_latest: false
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
## 🚀 ClawX ${{ github.ref_name }}
|
||||
@@ -200,7 +196,8 @@ jobs:
|
||||
|
||||
- **macOS**: On first launch, you may see "cannot verify developer". Go to System Preferences → Security & Privacy to allow the app to run
|
||||
- **Windows**: SmartScreen may block the app. Click "More info" → "Run anyway" to proceed
|
||||
- **Linux**: AppImage requires executable permission: `chmod +x ClawX-*.AppImage`
|
||||
- **Linux AppImage**: First run `chmod +x ClawX-*.AppImage` to add execute permission. On Ubuntu 22.04 you may also need `sudo apt install libfuse2`; on Ubuntu 24.04 use `sudo apt install libfuse2t64`
|
||||
- **Linux .deb (Ubuntu 24.04)**: If installation fails due to missing dependencies, use `sudo apt install libgtk-3-0t64 libnotify4t64 libxss1t64` before installing
|
||||
|
||||
---
|
||||
|
||||
@@ -399,3 +396,39 @@ jobs:
|
||||
|
||||
echo ""
|
||||
echo "All files uploaded and verified successfully!"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Job: Finalize Release
|
||||
# Promotes the GitHub Release from pre-release to latest AFTER
|
||||
# both GitHub Release assets and OSS uploads are fully complete.
|
||||
# This ensures /releases/latest API never returns an incomplete
|
||||
# release — the website and electron-updater only see it when
|
||||
# all platform artifacts are ready.
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
finalize:
|
||||
needs: [publish, upload-oss]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
steps:
|
||||
- name: Promote release from pre-release to latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
IS_PRERELEASE_CHANNEL=false
|
||||
|
||||
if [[ "$TAG" == *"alpha"* ]] || [[ "$TAG" == *"beta"* ]]; then
|
||||
IS_PRERELEASE_CHANNEL=true
|
||||
fi
|
||||
|
||||
if [ "$IS_PRERELEASE_CHANNEL" = "true" ]; then
|
||||
echo "Tag $TAG is an alpha/beta release — keeping as pre-release."
|
||||
else
|
||||
echo "Promoting $TAG from pre-release to latest release..."
|
||||
gh release edit "$TAG" \
|
||||
--prerelease=false \
|
||||
--latest \
|
||||
--repo "${{ github.repository }}"
|
||||
echo "Release $TAG is now the latest release."
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package-import-method=copy
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
@@ -0,0 +1,41 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
### Overview
|
||||
|
||||
ClawX is a cross-platform **Electron desktop app** (React 19 + Vite + TypeScript) providing a GUI for the OpenClaw AI agent runtime. It uses pnpm as its package manager (pinned version in `package.json`'s `packageManager` field).
|
||||
|
||||
### Quick reference
|
||||
|
||||
Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Install deps + download uv | `pnpm run init` |
|
||||
| Dev server (Vite + Electron) | `pnpm dev` |
|
||||
| Lint (ESLint, auto-fix) | `pnpm run lint` |
|
||||
| Type check | `pnpm run typecheck` |
|
||||
| Unit tests (Vitest) | `pnpm test` |
|
||||
| E2E tests (Playwright) | `pnpm run test:e2e` |
|
||||
| Build frontend only | `pnpm run build:vite` |
|
||||
|
||||
### Non-obvious caveats
|
||||
|
||||
- **pnpm version**: The exact pnpm version is pinned via `packageManager` in `package.json`. Use `corepack enable && corepack prepare` to activate the correct version before installing.
|
||||
- **Electron on headless Linux**: The dbus errors (`Failed to connect to the bus`) are expected and harmless in a headless/cloud environment. The app still runs fine with `$DISPLAY` set (e.g., `:1` via Xvfb/VNC).
|
||||
- **`pnpm run lint` race condition**: If `pnpm run uv:download` was recently run, ESLint may fail with `ENOENT: no such file or directory, scandir '/workspace/temp_uv_extract'` because the temp directory was created and removed during download. Simply re-run lint after the download script finishes.
|
||||
- **Build scripts warning**: `pnpm install` may warn about ignored build scripts for `@discordjs/opus` and `koffi`. These are optional messaging-channel dependencies and the warnings are safe to ignore.
|
||||
- **`pnpm run init`**: This is a convenience script that runs `pnpm install` followed by `pnpm run uv:download`. Either run `pnpm run init` or run the two steps separately.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- Do not call Gateway HTTP endpoints directly from renderer (`fetch('http://127.0.0.1:18789/...')` etc.). Use Main-process proxy channels (`hostapi:fetch`, `gateway:httpProxy`) to avoid CORS/env drift.
|
||||
- Transport policy is Main-owned and fixed as `WS -> HTTP -> IPC fallback`; renderer should not implement protocol switching UI/business logic.
|
||||
- **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.
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
|
||||
<p align="center">
|
||||
<img src="src/assets/logo.svg" width="128" height="128" alt="ClawX Logo" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ClawX</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>OpenClaw AIエージェントのためのデスクトップインターフェース</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#機能">機能</a> •
|
||||
<a href="#なぜclawxなのか">なぜClawXなのか</a> •
|
||||
<a href="#はじめに">はじめに</a> •
|
||||
<a href="#アーキテクチャ">アーキテクチャ</a> •
|
||||
<a href="#開発">開発</a> •
|
||||
<a href="#コントリビューション">コントリビューション</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/platform-MacOS%20%7C%20Windows%20%7C%20Linux-blue" alt="Platform" />
|
||||
<img src="https://img.shields.io/badge/electron-40+-47848F?logo=electron" alt="Electron" />
|
||||
<img src="https://img.shields.io/badge/react-19-61DAFB?logo=react" alt="React" />
|
||||
<a href="https://discord.com/invite/84Kex3GGAh" target="_blank">
|
||||
<img src="https://img.shields.io/discord/1399603591471435907?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb" alt="chat on Discord" />
|
||||
</a>
|
||||
<img src="https://img.shields.io/github/downloads/ValueCell-ai/ClawX/total?color=%23027DEB" alt="Downloads" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | 日本語
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
**ClawX**は、強力なAIエージェントと日常のユーザーとの間のギャップを埋めます。[OpenClaw](https://github.com/OpenClaw)をベースに構築されており、コマンドラインによるAIオーケストレーションを、アクセスしやすく美しいデスクトップ体験に変換します。ターミナルは不要です。
|
||||
|
||||
ワークフローの自動化、AI搭載チャネルの管理、インテリジェントなタスクのスケジューリングなど、ClawXはAIエージェントを効果的に活用するために必要なインターフェースを提供します。
|
||||
|
||||
ClawXはベストプラクティスのモデルプロバイダーが事前設定されており、Windowsおよび多言語設定をネイティブにサポートしています。もちろん、**設定 → 詳細設定 → 開発者モード**から高度な設定を微調整することもできます。
|
||||
|
||||
---
|
||||
## スクリーンショット
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/jp/chat.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/jp/cron.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/jp/skills.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/jp/channels.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/jp/models.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/jp/settings.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## なぜClawXなのか
|
||||
|
||||
AIエージェントの構築にコマンドラインの習得は不要であるべきです。ClawXはシンプルな哲学のもとに設計されました:**強力な技術には、あなたの時間を尊重するインターフェースがふさわしい。**
|
||||
|
||||
| 課題 | ClawXのソリューション |
|
||||
|------|----------------------|
|
||||
| 複雑なCLIセットアップ | ワンクリックインストールとガイド付きセットアップウィザード |
|
||||
| 設定ファイル | リアルタイムバリデーション付きのビジュアル設定 |
|
||||
| プロセス管理 | ゲートウェイライフサイクルの自動管理 |
|
||||
| 複数のAIプロバイダー | 統合プロバイダー設定パネル |
|
||||
| スキル/プラグインのインストール | 組み込みのスキルマーケットプレイスと管理機能 |
|
||||
|
||||
### OpenClaw内蔵
|
||||
|
||||
ClawXは公式の**OpenClaw**コアを直接ベースに構築されています。別途インストールを必要とせず、アプリケーション内にランタイムを組み込むことで、シームレスな「バッテリー同梱」体験を提供します。
|
||||
|
||||
私たちはアップストリームのOpenClawプロジェクトとの厳密な整合性を維持することにコミットしており、公式リリースが提供する最新の機能、安定性の改善、エコシステムの互換性に常にアクセスできることを保証します。
|
||||
|
||||
---
|
||||
|
||||
## 機能
|
||||
|
||||
### 🎯 ゼロ設定バリア
|
||||
インストールから最初のAIインタラクションまで、すべてのセットアップを直感的なグラフィカルインターフェースで完了できます。ターミナルコマンド不要、YAMLファイル不要、環境変数の探索も不要です。
|
||||
|
||||
### 💬 インテリジェントチャットインターフェース
|
||||
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングに加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
|
||||
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
|
||||
|
||||
### 📡 マルチチャネル管理
|
||||
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。
|
||||
|
||||
### ⏰ Cronベースの自動化
|
||||
AIタスクを自動的に実行するようスケジュール設定できます。トリガーを定義し、間隔を設定することで、手動介入なしにAIエージェントを24時間稼働させることができます。
|
||||
|
||||
### 🧩 拡張可能なスキルシステム
|
||||
事前構築されたスキルでAIエージェントを拡張できます。統合スキルパネルからスキルの閲覧、インストール、管理が可能です。パッケージマネージャーは不要です。
|
||||
ClawX はドキュメント処理スキル(`pdf`、`xlsx`、`docx`、`pptx`)もフル内容で同梱し、起動時に管理スキルディレクトリ(既定 `~/.openclaw/skills`)へ自動配備し、初回インストール時に既定で有効化します。追加の同梱スキル(`find-skills`、`self-improving-agent`、`tavily-search`、`brave-web-search`、`bocha-skill`)も既定で有効化されますが、必要な API キーが未設定の場合は OpenClaw が実行時に設定エラーを表示します。
|
||||
Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、workspace、追加スキルディレクトリ)から検出されたスキルを表示でき、各スキルの実際のパスを確認して実フォルダを直接開けます。
|
||||
|
||||
主な検索スキルで必要な環境変数:
|
||||
- `BRAVE_SEARCH_API_KEY`: `brave-web-search` 用
|
||||
- `TAVILY_API_KEY`: `tavily-search` 用(上流ランタイムで OAuth 対応の場合あり)
|
||||
- `BOCHA_API_KEY`: `bocha-skill` 用
|
||||
|
||||
### 🔐 セキュアなプロバイダー統合
|
||||
複数のAIプロバイダー(OpenAI、Anthropicなど)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuth(Codex サブスクリプション)の両方に対応しています。
|
||||
|
||||
### 🌙 アダプティブテーマ
|
||||
ライトモード、ダークモード、またはシステム同期テーマ。ClawXはあなたの好みに自動的に適応します。
|
||||
|
||||
### 🚀 自動起動設定
|
||||
**設定 → 通用** から **システム起動時に自動起動** を有効化すると、ログイン後に ClawX が自動的に起動します。
|
||||
|
||||
---
|
||||
|
||||
## はじめに
|
||||
|
||||
### システム要件
|
||||
|
||||
- **オペレーティングシステム**: macOS 11以上、Windows 10以上、またはLinux(Ubuntu 20.04以上)
|
||||
- **メモリ**: 最低4GB RAM(8GB推奨)
|
||||
- **ストレージ**: 1GBの空きディスク容量
|
||||
|
||||
### インストール
|
||||
|
||||
#### ビルド済みリリース(推奨)
|
||||
|
||||
[Releases](https://github.com/ValueCell-ai/ClawX/releases)ページから、お使いのプラットフォーム向けの最新リリースをダウンロードしてください。
|
||||
|
||||
#### ソースからビルド
|
||||
|
||||
```bash
|
||||
# リポジトリをクローン
|
||||
git clone https://github.com/ValueCell-ai/ClawX.git
|
||||
cd ClawX
|
||||
|
||||
# プロジェクトの初期化
|
||||
pnpm run init
|
||||
|
||||
# 開発モードで起動
|
||||
pnpm dev
|
||||
```
|
||||
### 初回起動
|
||||
|
||||
ClawXを初めて起動すると、**セットアップウィザード**が以下の手順をガイドします:
|
||||
|
||||
1. **言語と地域** – 使用する言語・地域の設定
|
||||
2. **AIプロバイダー** – APIキーまたは OAuth(ブラウザ/デバイスログイン対応プロバイダー)で追加
|
||||
3. **スキルバンドル** – 一般的なユースケース向けの事前設定スキルを選択
|
||||
4. **検証** – メインインターフェースに入る前に設定をテスト
|
||||
|
||||
### プロキシ設定
|
||||
|
||||
ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネルがローカルプロキシクライアントを介してインターネットにアクセスする必要がある環境向けに、組み込みのプロキシ設定が含まれています。
|
||||
|
||||
**設定 → ゲートウェイ → プロキシ**を開いて以下を設定します:
|
||||
|
||||
- **プロキシサーバー**: すべてのリクエストのデフォルトプロキシ
|
||||
- **バイパスルール**: 直接接続すべきホスト(セミコロン、カンマ、または改行で区切る)
|
||||
- **開発者モード**では、オプションで以下をオーバーライドできます:
|
||||
- **HTTP プロキシ**
|
||||
- **HTTPS プロキシ**
|
||||
- **ALL_PROXY / SOCKS**
|
||||
|
||||
推奨されるローカル設定例:
|
||||
|
||||
```text
|
||||
プロキシサーバー: http://127.0.0.1:7890
|
||||
```
|
||||
注意事項:
|
||||
|
||||
- `host:port`のみの値はHTTPとして扱われます。
|
||||
- 高度なプロキシフィールドが空の場合、ClawXは`プロキシサーバー`にフォールバックします。
|
||||
- プロキシ設定を保存すると、Electronのネットワーク設定が即座に再適用され、ゲートウェイが自動的に再起動されます。
|
||||
- ClawXはTelegramが有効な場合、プロキシをOpenClawのTelegramチャネル設定にも同期します。
|
||||
- **設定 → 詳細 → 開発者** では **OpenClaw Doctor** を実行でき、`openclaw doctor --json` の診断出力をアプリ内で確認できます。
|
||||
|
||||
---
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を採用しています。Renderer は単一クライアント抽象を呼び出し、プロトコル選択とライフサイクルは Main が管理します:
|
||||
|
||||
```┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ClawX デスクトップアプリ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Electron メインプロセス │ │
|
||||
│ │ • ウィンドウ&アプリケーションライフサイクル管理 │ │
|
||||
│ │ • ゲートウェイプロセスの監視 │ │
|
||||
│ │ • システム統合(トレイ、通知、キーチェーン) │ │
|
||||
│ │ • 自動アップデートオーケストレーション │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ IPC(権威ある制御プレーン) │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ React レンダラープロセス │ │
|
||||
│ │ • モダンなコンポーネントベースUI(React 19) │ │
|
||||
│ │ • Zustandによるステート管理 │ │
|
||||
│ │ • 統一 host-api/api-client 呼び出し │ │
|
||||
│ │ • リッチなMarkdownレンダリング │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
│ Main管理のトランスポート戦略
|
||||
│(WS優先、HTTP次点、IPCフォールバック)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Host API と Main プロキシ層 │
|
||||
│ │
|
||||
│ • hostapi:fetch(Mainプロキシ、CORS回避) │
|
||||
│ • gateway:httpProxy(RendererはGateway HTTPに直アクセスしない) │
|
||||
│ • 統一エラーマッピングとリトライ/バックオフ │
|
||||
└──────────────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
│ WS / HTTP / IPC フォールバック
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OpenClaw ゲートウェイ │
|
||||
│ │
|
||||
│ • AIエージェントランタイムとオーケストレーション │
|
||||
│ • メッセージチャネル管理 │
|
||||
│ • スキル/プラグイン実行環境 │
|
||||
│ • プロバイダー抽象化レイヤー │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
### 設計原則
|
||||
|
||||
- **プロセス分離**: AIランタイムは別プロセスで動作し、重い計算処理中でもUIの応答性を確保します
|
||||
- **フロントエンド呼び出しの単一入口**: Renderer は host-api/api-client を通じて呼び出し、下位プロトコルに依存しません
|
||||
- **Mainによるトランスポート制御**: WS/HTTP の選択と IPC フォールバックを Main で一元管理します
|
||||
- **グレースフルリカバリ**: 再接続・タイムアウト・バックオフで一時的障害を自動処理します
|
||||
- **セキュアストレージ**: APIキーや機密データは、OSのネイティブセキュアストレージ機構を活用します
|
||||
- **CORSセーフ設計**: ローカルHTTPはMainプロキシ経由とし、Renderer側CORS問題を回避します
|
||||
|
||||
---
|
||||
|
||||
## ユースケース
|
||||
|
||||
### 🤖 パーソナルAIアシスタント
|
||||
質問への回答、メールの下書き、ドキュメントの要約、日常タスクのサポートなど、汎用的なAIエージェントを設定できます。すべてクリーンなデスクトップインターフェースから操作できます。
|
||||
|
||||
### 📊 自動モニタリング
|
||||
ニュースフィード、価格追跡、特定イベントの監視などを行うスケジュールエージェントを設定できます。結果はお好みの通知チャネルに配信されます。
|
||||
|
||||
### 💻 開発者の生産性向上
|
||||
AI を開発ワークフローに統合できます。エージェントを使用して、コードレビュー、ドキュメント生成、反復的なコーディングタスクの自動化が可能です。
|
||||
|
||||
### 🔄 ワークフロー自動化
|
||||
複数のスキルを連鎖させて、高度な自動化パイプラインを作成できます。データの処理、コンテンツの変換、アクションのトリガーを、すべてビジュアルにオーケストレーションできます。
|
||||
|
||||
---
|
||||
|
||||
## 開発
|
||||
|
||||
### 前提条件
|
||||
|
||||
- **Node.js**: 22以上(LTS推奨)
|
||||
- **パッケージマネージャー**: pnpm 9以上(推奨)またはnpm
|
||||
|
||||
### プロジェクト構成
|
||||
|
||||
```ClawX/
|
||||
├── electron/ # Electron メインプロセス
|
||||
│ ├── api/ # メイン側 API ルーターとハンドラー
|
||||
│ │ └── routes/ # RPC/HTTP プロキシのルートモジュール
|
||||
│ ├── services/ # Provider/Secrets/ランタイムサービス
|
||||
│ │ ├── providers/ # provider/account モデル同期ロジック
|
||||
│ │ └── secrets/ # OS キーチェーンと秘密情報管理
|
||||
│ ├── shared/ # 共通 Provider スキーマ/定数
|
||||
│ │ └── providers/
|
||||
│ ├── main/ # アプリ入口、ウィンドウ、IPC 登録
|
||||
│ ├── gateway/ # OpenClaw ゲートウェイプロセスマネージャー
|
||||
│ ├── preload/ # セキュア IPC ブリッジ
|
||||
│ └── utils/ # ユーティリティ(ストレージ、認証、パス)
|
||||
├── src/ # React レンダラープロセス
|
||||
│ ├── lib/ # フロントエンド統一 API とエラーモデル
|
||||
│ ├── stores/ # Zustand ストア(settings/chat/gateway)
|
||||
│ ├── components/ # 再利用可能な UI コンポーネント
|
||||
│ ├── pages/ # Setup/Dashboard/Chat/Channels/Skills/Cron/Settings
|
||||
│ ├── i18n/ # ローカライズリソース
|
||||
│ └── types/ # TypeScript 型定義
|
||||
├── tests/
|
||||
│ └── unit/ # Vitest ユニット/統合寄りテスト
|
||||
├── resources/ # 静的アセット(アイコン、画像)
|
||||
└── scripts/ # ビルド/ユーティリティスクリプト
|
||||
```
|
||||
### 利用可能なコマンド
|
||||
|
||||
```bash
|
||||
# 開発
|
||||
pnpm run init # 依存関係のインストール + uvのダウンロード
|
||||
pnpm dev # ホットリロードで起動
|
||||
|
||||
# コード品質
|
||||
pnpm lint # ESLintを実行
|
||||
pnpm typecheck # TypeScriptの型チェック
|
||||
|
||||
# テスト
|
||||
pnpm test # ユニットテストを実行
|
||||
|
||||
# ビルド&パッケージ
|
||||
pnpm run build:vite # フロントエンドのみビルド
|
||||
pnpm build # フルプロダクションビルド(パッケージアセット含む)
|
||||
pnpm package # 現在のプラットフォーム向けにパッケージ化
|
||||
pnpm package:mac # macOS向けにパッケージ化
|
||||
pnpm package:win # Windows向けにパッケージ化
|
||||
pnpm package:linux # Linux向けにパッケージ化
|
||||
```
|
||||
### 技術スタック
|
||||
|
||||
| レイヤー | 技術 |
|
||||
|---------|------|
|
||||
| ランタイム | Electron 40以上 |
|
||||
| UIフレームワーク | React 19 + TypeScript |
|
||||
| スタイリング | Tailwind CSS + shadcn/ui |
|
||||
| ステート管理 | Zustand |
|
||||
| ビルド | Vite + electron-builder |
|
||||
| テスト | Vitest + Playwright |
|
||||
| アニメーション | Framer Motion |
|
||||
| アイコン | Lucide React |
|
||||
|
||||
---
|
||||
|
||||
## コントリビューション
|
||||
|
||||
コミュニティからのコントリビューションを歓迎します!バグ修正、新機能、ドキュメントの改善、翻訳など、あらゆる貢献がClawXをより良くするのに役立ちます。
|
||||
|
||||
### コントリビューション方法
|
||||
|
||||
1. リポジトリを**フォーク**する
|
||||
2. フィーチャーブランチを**作成**する(`git checkout -b feature/amazing-feature`)
|
||||
3. 明確なメッセージで変更を**コミット**する
|
||||
4. ブランチに**プッシュ**する
|
||||
5. **プルリクエスト**を作成する
|
||||
|
||||
### ガイドライン
|
||||
|
||||
- 既存のコードスタイルに従う(ESLint + Prettier)
|
||||
- 新機能にはテストを書く
|
||||
- 必要に応じてドキュメントを更新する
|
||||
- コミットはアトミックかつ説明的に保つ
|
||||
|
||||
---
|
||||
|
||||
## 謝辞
|
||||
|
||||
ClawXは優れたオープンソースプロジェクトの上に構築されています:
|
||||
|
||||
- [OpenClaw](https://github.com/OpenClaw) – AIエージェントランタイム
|
||||
- [Electron](https://www.electronjs.org/) – クロスプラットフォームデスクトップフレームワーク
|
||||
- [React](https://react.dev/) – UIコンポーネントライブラリ
|
||||
- [shadcn/ui](https://ui.shadcn.com/) – 美しくデザインされたコンポーネント
|
||||
- [Zustand](https://github.com/pmndrs/zustand) – 軽量ステート管理
|
||||
|
||||
---
|
||||
|
||||
## コミュニティ
|
||||
|
||||
コミュニティに参加して、他のユーザーとつながり、サポートを受け、体験を共有しましょう。
|
||||
|
||||
| 企業微信 | Feishuグループ | Discord |
|
||||
| :---: | :---: | :---: |
|
||||
| <img src="src/assets/community/wecom-qr.png" width="150" alt="WeChat QRコード" /> | <img src="src/assets/community/feishu-qr.png" width="150" alt="Feishu QRコード" /> | <img src="src/assets/community/20260212-185822.png" width="150" alt="Discord QRコード" /> |
|
||||
|
||||
### ClawX パートナープログラム 🚀
|
||||
|
||||
ClawX パートナープログラムを開始します。特に、カスタム AI エージェントや自動化ニーズを持つより多くの顧客に ClawX を紹介してくださるパートナーを募集しています。
|
||||
|
||||
パートナーの皆さまには、見込みユーザーや案件との接点づくりを担っていただき、ClawX チームは技術サポート、カスタマイズ、統合を全面的に提供します。
|
||||
|
||||
AI ツールや自動化に関心のある顧客とお仕事をされている方は、ぜひご一緒できればうれしいです。
|
||||
|
||||
詳細は DM いただくか、[public@valuecell.ai](mailto:public@valuecell.ai) までメールでご連絡ください。
|
||||
|
||||
---
|
||||
|
||||
## スター履歴
|
||||
|
||||
<p align="center">
|
||||
<img src="https://api.star-history.com/svg?repos=ValueCell-ai/ClawX&type=Date" alt="スター履歴チャート" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## ライセンス
|
||||
|
||||
ClawXは[MITライセンス](LICENSE)の下でリリースされています。本ソフトウェアの使用、変更、配布は自由に行えます。
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<sub>ValueCell Teamが❤️を込めて開発</sub>
|
||||
</p>
|
||||
@@ -19,17 +19,18 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-blue" alt="Platform" />
|
||||
<img src="https://img.shields.io/badge/platform-MacOS%20%7C%20Windows%20%7C%20Linux-blue" alt="Platform" />
|
||||
<img src="https://img.shields.io/badge/electron-40+-47848F?logo=electron" alt="Electron" />
|
||||
<img src="https://img.shields.io/badge/react-19-61DAFB?logo=react" alt="React" />
|
||||
<a href="https://discord.com/invite/84Kex3GGAh" target="_blank">
|
||||
<img src="https://img.shields.io/discord/1399603591471435907?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb" alt="chat on Discord" />
|
||||
</a>
|
||||
<img src="https://img.shields.io/github/downloads/ValueCell-ai/ClawX/total?color=%23027DEB" alt="Downloads" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
English | <a href="README.zh-CN.md">简体中文</a>
|
||||
English | <a href="README.zh-CN.md">简体中文</a> | <a href="README.ja-JP.md">日本語</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -46,27 +47,27 @@ ClawX comes pre-configured with best-practice model providers and natively suppo
|
||||
## Screenshot
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/chat.png" style="width: 100%; height: auto;">
|
||||
<img src="resources/screenshot/en/chat.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/cron_task.png" style="width: 100%; height: auto;">
|
||||
<img src="resources/screenshot/en/cron.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/skills.png" style="width: 100%; height: auto;">
|
||||
<img src="resources/screenshot/en/skills.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/channels.png" style="width: 100%; height: auto;">
|
||||
<img src="resources/screenshot/en/channels.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/dashboard.png" style="width: 100%; height: auto;">
|
||||
<img src="resources/screenshot/en/models.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/settings.png" style="width: 100%; height: auto;">
|
||||
<img src="resources/screenshot/en/settings.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -97,7 +98,8 @@ We are committed to maintaining strict alignment with the upstream OpenClaw proj
|
||||
Complete the entire setup—from installation to your first AI interaction—through an intuitive graphical interface. No terminal commands, no YAML files, no environment variable hunting.
|
||||
|
||||
### 💬 Intelligent Chat Interface
|
||||
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, and rich content rendering with Markdown.
|
||||
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, rich content rendering with Markdown, and direct `@agent` routing in the main composer for multi-agent setups.
|
||||
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.
|
||||
|
||||
### 📡 Multi-Channel Management
|
||||
Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks.
|
||||
@@ -107,13 +109,24 @@ Schedule AI tasks to run automatically. Define triggers, set intervals, and let
|
||||
|
||||
### 🧩 Extensible Skill System
|
||||
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`, `brave-web-search`, `bocha-skill`) 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.
|
||||
|
||||
Environment variables for bundled search skills:
|
||||
- `BRAVE_SEARCH_API_KEY` for `brave-web-search`
|
||||
- `TAVILY_API_KEY` for `tavily-search` (OAuth may also be supported by upstream skill runtime)
|
||||
- `BOCHA_API_KEY` for `bocha-skill`
|
||||
- `find-skills` and `self-improving-agent` do not require API keys
|
||||
|
||||
### 🔐 Secure Provider Integration
|
||||
Connect to multiple AI providers (OpenAI, Anthropic, and more) with credentials stored securely in your system's native keychain.
|
||||
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.
|
||||
|
||||
### 🌙 Adaptive Theming
|
||||
Light mode, dark mode, or system-synchronized themes. ClawX adapts to your preferences automatically.
|
||||
|
||||
### 🚀 Startup Launch Control
|
||||
In **Settings → General**, you can enable **Launch at system startup** so ClawX starts automatically after login.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
@@ -143,24 +156,51 @@ pnpm run init
|
||||
# Start in development mode
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### First Launch
|
||||
|
||||
When you launch ClawX for the first time, the **Setup Wizard** will guide you through:
|
||||
|
||||
1. **Language & Region** – Configure your preferred locale
|
||||
2. **AI Provider** – Enter your API keys for supported providers
|
||||
2. **AI Provider** – Add providers with API keys or OAuth (for providers that support browser/device login)
|
||||
3. **Skill Bundles** – Select pre-configured skills for common use cases
|
||||
4. **Verification** – Test your configuration before entering the main interface
|
||||
|
||||
> Note for Moonshot (Kimi): ClawX keeps Kimi web search enabled by default.
|
||||
> When Moonshot is configured, ClawX also syncs Kimi web search to the China endpoint (`https://api.moonshot.cn/v1`) in OpenClaw config.
|
||||
|
||||
### Proxy Settings
|
||||
|
||||
ClawX includes built-in proxy settings for environments where Electron, the OpenClaw Gateway, or channels such as Telegram need to reach the internet through a local proxy client.
|
||||
|
||||
Open **Settings → Gateway → Proxy** and configure:
|
||||
|
||||
- **Proxy Server**: the default proxy for all requests
|
||||
- **Bypass Rules**: hosts that should connect directly, separated by semicolons, commas, or new lines
|
||||
- In **Developer Mode**, you can optionally override:
|
||||
- **HTTP Proxy**
|
||||
- **HTTPS Proxy**
|
||||
- **ALL_PROXY / SOCKS**
|
||||
|
||||
Recommended local examples:
|
||||
|
||||
```text
|
||||
Proxy Server: http://127.0.0.1:7890
|
||||
```
|
||||
Notes:
|
||||
|
||||
- A bare `host:port` value is treated as HTTP.
|
||||
- If advanced proxy fields are left empty, ClawX falls back to `Proxy Server`.
|
||||
- Saving proxy settings reapplies Electron networking immediately and restarts the Gateway automatically.
|
||||
- ClawX also syncs the proxy to OpenClaw's Telegram channel config when Telegram is enabled.
|
||||
- In **Settings → Advanced → Developer**, you can run **OpenClaw Doctor** to execute `openclaw doctor --json` and inspect the diagnostic output without leaving the app.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
ClawX employs a **dual-process architecture** that separates UI concerns from AI runtime operations:
|
||||
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 │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
@@ -171,18 +211,29 @@ ClawX employs a **dual-process architecture** that separates UI concerns from AI
|
||||
│ │ • Auto-update orchestration │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ IPC │
|
||||
│ │ IPC (authoritative control plane) │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ React Renderer Process │ │
|
||||
│ │ • Modern component-based UI (React 19) │ │
|
||||
│ │ • State management with Zustand │ │
|
||||
│ │ • Real-time WebSocket communication │ │
|
||||
│ │ • Unified host-api/api-client calls │ │
|
||||
│ │ • Rich Markdown rendering │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
│ WebSocket (JSON-RPC)
|
||||
│ Main-owned transport strategy
|
||||
│ (WS first, HTTP then IPC fallback)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Host API & Main Process Proxies │
|
||||
│ │
|
||||
│ • hostapi:fetch (Main proxy, avoids CORS in dev/prod) │
|
||||
│ • gateway:httpProxy (Renderer never calls Gateway HTTP direct) │
|
||||
│ • Unified error mapping & retry/backoff │
|
||||
└──────────────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
│ WS / HTTP / IPC fallback
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OpenClaw Gateway │
|
||||
@@ -193,13 +244,14 @@ ClawX employs a **dual-process architecture** that separates UI concerns from AI
|
||||
│ • Provider abstraction layer │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Process Isolation**: The AI runtime operates in a separate process, ensuring UI responsiveness even during heavy computation
|
||||
- **Graceful Recovery**: Built-in reconnection logic with exponential backoff handles transient failures automatically
|
||||
- **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 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
|
||||
- **Hot Reload**: Development mode supports instant UI updates without restarting the gateway
|
||||
- **CORS-Safe by Design**: Local HTTP access is proxied by Main, preventing renderer-side CORS issues
|
||||
|
||||
---
|
||||
|
||||
@@ -228,60 +280,53 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro
|
||||
|
||||
### Project Structure
|
||||
|
||||
```ClawX/
|
||||
├── electron/ # Electron Main Process
|
||||
│ ├── 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
|
||||
│ │ └── providers/
|
||||
│ ├── main/ # App entry, windows, IPC registration
|
||||
│ ├── gateway/ # OpenClaw Gateway process manager
|
||||
│ ├── preload/ # Secure IPC bridge
|
||||
│ └── utils/ # Utilities (storage, auth, paths)
|
||||
├── src/ # React Renderer Process
|
||||
│ ├── lib/ # Unified frontend API + error model
|
||||
│ ├── stores/ # Zustand stores (settings/chat/gateway)
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ ├── pages/ # Setup/Dashboard/Chat/Channels/Skills/Cron/Settings
|
||||
│ ├── i18n/ # Localization resources
|
||||
│ └── types/ # TypeScript type definitions
|
||||
├── tests/
|
||||
│ └── unit/ # Vitest unit/integration-like tests
|
||||
├── resources/ # Static assets (icons/images)
|
||||
└── scripts/ # Build and utility scripts
|
||||
```
|
||||
ClawX/
|
||||
├── electron/ # Electron Main Process
|
||||
│ ├── main/ # Application entry, window management
|
||||
│ ├── gateway/ # OpenClaw Gateway process manager
|
||||
│ ├── preload/ # Secure IPC bridge scripts
|
||||
│ └── utils/ # Utilities (storage, auth, paths)
|
||||
├── src/ # React Renderer Process
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ │ ├── ui/ # Base components (shadcn/ui)
|
||||
│ │ ├── layout/ # Layout components (sidebar, header)
|
||||
│ │ └── common/ # Shared components
|
||||
│ ├── pages/ # Application pages
|
||||
│ │ ├── Setup/ # Initial setup wizard
|
||||
│ │ ├── Dashboard/ # Home dashboard
|
||||
│ │ ├── Chat/ # AI chat interface
|
||||
│ │ ├── Channels/ # Channel management
|
||||
│ │ ├── Skills/ # Skill browser & manager
|
||||
│ │ ├── Cron/ # Scheduled tasks
|
||||
│ │ └── Settings/ # Configuration panels
|
||||
│ ├── stores/ # Zustand state stores
|
||||
│ ├── lib/ # Frontend utilities
|
||||
│ └── types/ # TypeScript type definitions
|
||||
├── resources/ # Static assets (icons, images)
|
||||
├── scripts/ # Build & utility scripts
|
||||
└── tests/ # Test suites
|
||||
```
|
||||
|
||||
### Available Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm run init # Install dependencies + download uv
|
||||
pnpm dev # Start with hot reload
|
||||
pnpm dev:electron # Launch Electron directly
|
||||
|
||||
# Quality
|
||||
pnpm lint # Run ESLint
|
||||
pnpm lint:fix # Auto-fix issues
|
||||
pnpm typecheck # TypeScript validation
|
||||
|
||||
# Testing
|
||||
pnpm test # Run unit tests
|
||||
pnpm test:watch # Watch mode
|
||||
pnpm test:coverage # Generate coverage report
|
||||
pnpm test:e2e # Run Playwright E2E tests
|
||||
|
||||
# Build & Package
|
||||
pnpm build # Full production build
|
||||
pnpm run build:vite # Build frontend only
|
||||
pnpm build # Full production build (with packaging assets)
|
||||
pnpm package # Package for current platform
|
||||
pnpm package:mac # Package for macOS
|
||||
pnpm package:win # Package for Windows
|
||||
pnpm package:linux # Package for Linux
|
||||
```
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
@@ -338,6 +383,24 @@ Join our community to connect with other users, get support, and share your expe
|
||||
| :---: | :---: | :---: |
|
||||
| <img src="src/assets/community/wecom-qr.png" width="150" alt="WeChat QR Code" /> | <img src="src/assets/community/feishu-qr.png" width="150" alt="Feishu QR Code" /> | <img src="src/assets/community/20260212-185822.png" width="150" alt="Discord QR Code" /> |
|
||||
|
||||
### ClawX Partner Program 🚀
|
||||
|
||||
We're launching the ClawX Partner Program and looking for partners who can help introduce ClawX to more clients, especially those with custom AI agent or automation needs.
|
||||
|
||||
Partners help connect us with potential users and projects, while the ClawX team provides full technical support, customization, and integration.
|
||||
|
||||
If you work with clients interested in AI tools or automation, we'd love to collaborate.
|
||||
|
||||
DM us or email [public@valuecell.ai](mailto:public@valuecell.ai) to learn more.
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
<p align="center">
|
||||
<img src="https://api.star-history.com/svg?repos=ValueCell-ai/ClawX&type=Date" alt="Star History Chart" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
+117
-55
@@ -19,17 +19,18 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-blue" alt="Platform" />
|
||||
<img src="https://img.shields.io/badge/platform-MacOS%20%7C%20Windows%20%7C%20Linux-blue" alt="Platform" />
|
||||
<img src="https://img.shields.io/badge/electron-40+-47848F?logo=electron" alt="Electron" />
|
||||
<img src="https://img.shields.io/badge/react-19-61DAFB?logo=react" alt="React" />
|
||||
<a href="https://discord.com/invite/84Kex3GGAh" target="_blank">
|
||||
<img src="https://img.shields.io/discord/1399603591471435907?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb" alt="chat on Discord" />
|
||||
</a>
|
||||
<img src="https://img.shields.io/github/downloads/ValueCell-ai/ClawX/total?color=%23027DEB" alt="Downloads" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | 简体中文
|
||||
<a href="README.md">English</a> | 简体中文 | <a href="README.ja-JP.md">日本語</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -47,27 +48,27 @@ ClawX 预置了最佳实践的模型供应商配置,原生支持 Windows 平
|
||||
## 截图预览
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/zh/聊天.png" style="width: 100%; height: auto;">
|
||||
<img src="resources/screenshot/zh/chat.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/zh/定时任务.png" style="width: 100%; height: auto;">
|
||||
<img src="resources/screenshot/zh/cron.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/zh/技能.png" style="width: 100%; height: auto;">
|
||||
<img src="resources/screenshot/zh/skills.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/zh/频道.png" style="width: 100%; height: auto;">
|
||||
<img src="resources/screenshot/zh/channels.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/zh/仪表盘.png" style="width: 100%; height: auto;">
|
||||
<img src="resources/screenshot/zh/models.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/zh/设置.png" style="width: 100%; height: auto;">
|
||||
<img src="resources/screenshot/zh/settings.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -98,7 +99,8 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
|
||||
从安装到第一次 AI 对话,全程通过直观的图形界面完成。无需终端命令,无需 YAML 文件,无需到处寻找环境变量。
|
||||
|
||||
### 💬 智能聊天界面
|
||||
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录以及 Markdown 富文本渲染。
|
||||
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、Markdown 富文本渲染,以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。
|
||||
当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
|
||||
|
||||
### 📡 多频道管理
|
||||
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。
|
||||
@@ -108,13 +110,23 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
|
||||
|
||||
### 🧩 可扩展技能系统
|
||||
通过预构建的技能扩展 AI 智能体的能力。在集成的技能面板中浏览、安装和管理技能——无需包管理器。
|
||||
ClawX 还会内置预装完整的文档处理技能(`pdf`、`xlsx`、`docx`、`pptx`),在启动时自动部署到托管技能目录(默认 `~/.openclaw/skills`),并在首次安装时默认启用。额外预装技能(`find-skills`、`self-improving-agent`、`tavily-search`、`brave-web-search`、`bocha-skill`)也会默认启用;若缺少必需的 API Key,OpenClaw 会在运行时给出配置错误提示。
|
||||
Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、workspace、额外技能目录),并显示每个技能的实际路径,便于直接打开真实安装位置。
|
||||
|
||||
重点搜索技能所需环境变量:
|
||||
- `BRAVE_SEARCH_API_KEY`:用于 `brave-web-search`
|
||||
- `TAVILY_API_KEY`:用于 `tavily-search`(上游运行时也可能支持 OAuth)
|
||||
- `BOCHA_API_KEY`:用于 `bocha-skill`
|
||||
|
||||
### 🔐 安全的供应商集成
|
||||
连接多个 AI 供应商(OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。
|
||||
连接多个 AI 供应商(OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuth(Codex 订阅)登录。
|
||||
|
||||
### 🌙 自适应主题
|
||||
支持浅色模式、深色模式或跟随系统主题。ClawX 自动适应你的偏好设置。
|
||||
|
||||
### 🚀 开机启动控制
|
||||
在 **设置 → 通用** 中,你可以开启 **开机自动启动**,让 ClawX 在系统登录后自动启动。
|
||||
|
||||
---
|
||||
|
||||
## 快速上手
|
||||
@@ -144,24 +156,51 @@ pnpm run init
|
||||
# 以开发模式启动
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 首次启动
|
||||
|
||||
首次启动 ClawX 时,**设置向导** 将引导你完成以下步骤:
|
||||
|
||||
1. **语言与区域** – 配置你的首选语言和地区
|
||||
2. **AI 供应商** – 输入所支持供应商的 API 密钥
|
||||
2. **AI 供应商** – 通过 API 密钥或 OAuth(支持浏览器/设备登录的供应商)添加账号
|
||||
3. **技能包** – 选择适用于常见场景的预配置技能
|
||||
4. **验证** – 在进入主界面前测试你的配置
|
||||
|
||||
> Moonshot(Kimi)说明:ClawX 默认保持开启 Kimi 的 web search。
|
||||
> 当配置 Moonshot 后,ClawX 也会将 OpenClaw 配置中的 Kimi web search 同步到中国区端点(`https://api.moonshot.cn/v1`)。
|
||||
|
||||
### 代理设置
|
||||
|
||||
ClawX 内置了代理设置,适用于需要通过本地代理客户端访问外网的场景,包括 Electron 本身、OpenClaw Gateway,以及 Telegram 这类频道的联网请求。
|
||||
|
||||
打开 **设置 → 网关 → 代理**,配置以下内容:
|
||||
|
||||
- **代理服务器**:所有请求默认使用的代理
|
||||
- **绕过规则**:需要直连的主机,使用分号、逗号或换行分隔
|
||||
- 在 **开发者模式** 下,还可以单独覆盖:
|
||||
- **HTTP 代理**
|
||||
- **HTTPS 代理**
|
||||
- **ALL_PROXY / SOCKS**
|
||||
|
||||
本地代理的常见填写示例:
|
||||
|
||||
```text
|
||||
代理服务器: http://127.0.0.1:7890
|
||||
```
|
||||
说明:
|
||||
|
||||
- 只填写 `host:port` 时,会按 HTTP 代理处理。
|
||||
- 高级代理项留空时,会自动回退到“代理服务器”。
|
||||
- 保存代理设置后,Electron 网络层会立即重新应用代理,并自动重启 Gateway。
|
||||
- 如果启用了 Telegram,ClawX 还会把代理同步到 OpenClaw 的 Telegram 频道配置中。
|
||||
- 在 **设置 → 高级 → 开发者** 中,可以直接运行 **OpenClaw Doctor**,执行 `openclaw doctor --json` 并在应用内查看诊断输出。
|
||||
|
||||
---
|
||||
|
||||
## 系统架构
|
||||
|
||||
ClawX 采用 **双进程架构**,将 UI 层与 AI 运行时操作分离:
|
||||
ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用统一客户端抽象,协议选择与进程生命周期由 Electron 主进程统一管理:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
```┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ClawX 桌面应用 │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
@@ -172,18 +211,29 @@ ClawX 采用 **双进程架构**,将 UI 层与 AI 运行时操作分离:
|
||||
│ │ • 自动更新编排 │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ IPC │
|
||||
│ │ IPC(权威控制面) │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ React 渲染进程 │ │
|
||||
│ │ • 现代组件化 UI(React 19) │ │
|
||||
│ │ • Zustand 状态管理 │ │
|
||||
│ │ • WebSocket 实时通信 │ │
|
||||
│ │ • 统一 host-api/api-client 调用 │ │
|
||||
│ │ • Markdown 富文本渲染 │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
│ WebSocket (JSON-RPC)
|
||||
│ 主进程统一传输策略
|
||||
│(WS 优先,HTTP 次之,IPC 回退)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Host API 与主进程代理层 │
|
||||
│ │
|
||||
│ • hostapi:fetch(主进程代理,规避开发/生产 CORS) │
|
||||
│ • gateway:httpProxy(渲染进程不直连 Gateway HTTP) │
|
||||
│ • 统一错误映射与重试/退避策略 │
|
||||
└──────────────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
│ WS / HTTP / IPC 回退
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OpenClaw 网关 │
|
||||
@@ -194,13 +244,14 @@ ClawX 采用 **双进程架构**,将 UI 层与 AI 运行时操作分离:
|
||||
│ • 供应商抽象层 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 设计原则
|
||||
|
||||
- **进程隔离**:AI 运行时在独立进程中运行,确保即使在高负载计算期间 UI 也能保持响应
|
||||
- **优雅恢复**:内置带指数退避的重连逻辑,自动处理瞬时故障
|
||||
- **前端调用单一入口**:渲染层统一走 host-api/api-client,不感知底层协议细节
|
||||
- **主进程掌控传输策略**:WS/HTTP 选择与 IPC 回退在主进程集中处理,提升稳定性
|
||||
- **优雅恢复**:内置重连、超时、退避逻辑,自动处理瞬时故障
|
||||
- **安全存储**:API 密钥和敏感数据利用操作系统原生的安全存储机制
|
||||
- **热重载**:开发模式支持即时 UI 更新,无需重启网关
|
||||
- **CORS 安全**:本地 HTTP 请求由主进程代理,避免渲染进程跨域问题
|
||||
|
||||
---
|
||||
|
||||
@@ -229,60 +280,53 @@ ClawX 采用 **双进程架构**,将 UI 层与 AI 运行时操作分离:
|
||||
|
||||
### 项目结构
|
||||
|
||||
```ClawX/
|
||||
├── electron/ # Electron 主进程
|
||||
│ ├── api/ # 主进程 API 路由与处理器
|
||||
│ │ └── routes/ # RPC/HTTP 代理路由模块
|
||||
│ ├── services/ # Provider、Secrets 与运行时服务
|
||||
│ │ ├── providers/ # Provider/account 模型同步逻辑
|
||||
│ │ └── secrets/ # 系统钥匙串与密钥存储
|
||||
│ ├── shared/ # 共享 Provider schema/常量
|
||||
│ │ └── providers/
|
||||
│ ├── main/ # 应用入口、窗口、IPC 注册
|
||||
│ ├── gateway/ # OpenClaw 网关进程管理
|
||||
│ ├── preload/ # 安全 IPC 桥接
|
||||
│ └── utils/ # 工具模块(存储、认证、路径)
|
||||
├── src/ # React 渲染进程
|
||||
│ ├── lib/ # 前端统一 API 与错误模型
|
||||
│ ├── stores/ # Zustand 状态仓库(settings/chat/gateway)
|
||||
│ ├── components/ # 可复用 UI 组件
|
||||
│ ├── pages/ # Setup/Dashboard/Chat/Channels/Skills/Cron/Settings
|
||||
│ ├── i18n/ # 国际化资源
|
||||
│ └── types/ # TypeScript 类型定义
|
||||
├── tests/
|
||||
│ └── unit/ # Vitest 单元/集成型测试
|
||||
├── resources/ # 静态资源(图标、图片)
|
||||
└── scripts/ # 构建与工具脚本
|
||||
```
|
||||
ClawX/
|
||||
├── electron/ # Electron 主进程
|
||||
│ ├── main/ # 应用入口、窗口管理
|
||||
│ ├── gateway/ # OpenClaw 网关进程管理
|
||||
│ ├── preload/ # 安全 IPC 桥接脚本
|
||||
│ └── utils/ # 工具模块(存储、认证、路径)
|
||||
├── src/ # React 渲染进程
|
||||
│ ├── components/ # 可复用 UI 组件
|
||||
│ │ ├── ui/ # 基础组件(shadcn/ui)
|
||||
│ │ ├── layout/ # 布局组件(侧边栏、顶栏)
|
||||
│ │ └── common/ # 公共组件
|
||||
│ ├── pages/ # 应用页面
|
||||
│ │ ├── Setup/ # 初始设置向导
|
||||
│ │ ├── Dashboard/ # 首页仪表盘
|
||||
│ │ ├── Chat/ # AI 聊天界面
|
||||
│ │ ├── Channels/ # 频道管理
|
||||
│ │ ├── Skills/ # 技能浏览与管理
|
||||
│ │ ├── Cron/ # 定时任务
|
||||
│ │ └── Settings/ # 配置面板
|
||||
│ ├── stores/ # Zustand 状态仓库
|
||||
│ ├── lib/ # 前端工具库
|
||||
│ └── types/ # TypeScript 类型定义
|
||||
├── resources/ # 静态资源(图标、图片)
|
||||
├── scripts/ # 构建与工具脚本
|
||||
└── tests/ # 测试套件
|
||||
```
|
||||
|
||||
### 常用命令
|
||||
|
||||
```bash
|
||||
# 开发
|
||||
pnpm run init # 安装依赖并下载 uv
|
||||
pnpm dev # 以热重载模式启动
|
||||
pnpm dev:electron # 直接启动 Electron
|
||||
|
||||
# 代码质量
|
||||
pnpm lint # 运行 ESLint 检查
|
||||
pnpm lint:fix # 自动修复问题
|
||||
pnpm typecheck # TypeScript 类型检查
|
||||
|
||||
# 测试
|
||||
pnpm test # 运行单元测试
|
||||
pnpm test:watch # 监听模式
|
||||
pnpm test:coverage # 生成覆盖率报告
|
||||
pnpm test:e2e # 运行 Playwright E2E 测试
|
||||
|
||||
# 构建与打包
|
||||
pnpm build # 完整生产构建
|
||||
pnpm run build:vite # 仅构建前端
|
||||
pnpm build # 完整生产构建(含打包资源)
|
||||
pnpm package # 为当前平台打包
|
||||
pnpm package:mac # 为 macOS 打包
|
||||
pnpm package:win # 为 Windows 打包
|
||||
pnpm package:linux # 为 Linux 打包
|
||||
```
|
||||
|
||||
### 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
@@ -339,6 +383,24 @@ ClawX 构建于以下优秀的开源项目之上:
|
||||
| :---: | :---: | :---: |
|
||||
| <img src="src/assets/community/wecom-qr.png" width="150" alt="企业微信二维码" /> | <img src="src/assets/community/feishu-qr.png" width="150" alt="飞书二维码" /> | <img src="src/assets/community/20260212-185822.png" width="150" alt="Discord 二维码" /> |
|
||||
|
||||
### ClawX 合作伙伴计划 🚀
|
||||
|
||||
我们正在启动 ClawX 合作伙伴计划,寻找能够帮助我们将 ClawX 介绍给更多客户的合作伙伴,尤其是那些有定制化 AI 智能体或自动化需求的客户。
|
||||
|
||||
合作伙伴负责帮助我们连接潜在用户和项目,ClawX 团队则提供完整的技术支持、定制开发与集成服务。
|
||||
|
||||
如果你服务的客户对 AI 工具或自动化方案感兴趣,欢迎与我们合作。
|
||||
|
||||
欢迎私信我们,或发送邮件至 [public@valuecell.ai](mailto:public@valuecell.ai) 了解更多。
|
||||
|
||||
---
|
||||
|
||||
## Stars 历史
|
||||
|
||||
<p align="center">
|
||||
<img src="https://api.star-history.com/svg?repos=ValueCell-ai/ClawX&type=Date" alt="Stars 历史图表" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
+37
-14
@@ -1,7 +1,7 @@
|
||||
appId: app.clawx.desktop
|
||||
productName: ClawX
|
||||
copyright: Copyright © 2026 ClawX
|
||||
compression: normal
|
||||
compression: maximum
|
||||
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
|
||||
|
||||
directories:
|
||||
@@ -21,10 +21,17 @@ extraResources:
|
||||
- "!icons/*.md"
|
||||
- "!icons/*.svg"
|
||||
- "!bin/**"
|
||||
- "!screenshot/**"
|
||||
# OpenClaw package (node_modules copied separately by afterPack hook
|
||||
# because electron-builder respects .gitignore which excludes node_modules/)
|
||||
- from: build/openclaw/
|
||||
to: openclaw/
|
||||
# Pre-bundled third-party skills (full directories, not only SKILL.md)
|
||||
- from: build/preinstalled-skills/
|
||||
to: resources/preinstalled-skills/
|
||||
# NOTE: OpenClaw plugin mirrors (dingtalk, etc.) are bundled by the
|
||||
# afterPack hook (after-pack.cjs) directly from node_modules, so they
|
||||
# don't need an extraResources entry here.
|
||||
|
||||
afterPack: ./scripts/after-pack.cjs
|
||||
|
||||
@@ -32,6 +39,13 @@ asar: true
|
||||
asarUnpack:
|
||||
- "**/*.node"
|
||||
|
||||
# Disable native module rebuilding.
|
||||
# The Electron renderer/main process has no native (.node) dependencies.
|
||||
# All native modules (opus, sharp, koffi …) belong to openclaw, which is
|
||||
# bundled separately (bundle-openclaw.mjs → extraResources) and runs in its
|
||||
# own process — @electron/rebuild must NOT touch them.
|
||||
npmRebuild: false
|
||||
|
||||
# Auto-update configuration
|
||||
# Primary: Alibaba Cloud OSS (fast for Chinese users, used for auto-update)
|
||||
# Fallback: GitHub Releases (backup, used when OSS is unavailable)
|
||||
@@ -42,13 +56,14 @@ publish:
|
||||
- provider: github
|
||||
owner: ValueCell-ai
|
||||
repo: ClawX
|
||||
releaseType: release
|
||||
|
||||
# macOS Configuration
|
||||
mac:
|
||||
extraResources:
|
||||
- from: resources/bin/darwin-${arch}
|
||||
to: bin
|
||||
- from: resources/cli/posix/
|
||||
to: cli/
|
||||
category: public.app-category.productivity
|
||||
icon: resources/icons/icon.icns
|
||||
target:
|
||||
@@ -88,12 +103,14 @@ dmg:
|
||||
|
||||
# Windows Configuration
|
||||
win:
|
||||
forceCodeSigning: false
|
||||
# Skip update signature verification: we ship via OSS + GitHub without a
|
||||
# code-signing certificate, so verifying would always fail on the updater.
|
||||
verifyUpdateCodeSignature: false
|
||||
signAndEditExecutable: true
|
||||
extraResources:
|
||||
- from: resources/bin/win32-${arch}
|
||||
to: bin
|
||||
- from: resources/cli/win32/
|
||||
to: cli/
|
||||
icon: resources/icons/icon.ico
|
||||
target:
|
||||
- target: nsis
|
||||
@@ -104,6 +121,7 @@ win:
|
||||
nsis:
|
||||
oneClick: false
|
||||
perMachine: false
|
||||
warningsAsErrors: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
deleteAppDataOnUninstall: false
|
||||
differentialPackage: true
|
||||
@@ -118,6 +136,11 @@ nsis:
|
||||
|
||||
# Linux Configuration
|
||||
linux:
|
||||
extraResources:
|
||||
- from: resources/bin/linux-${arch}
|
||||
to: bin
|
||||
- from: resources/cli/posix/
|
||||
to: cli/
|
||||
icon: resources/icons
|
||||
target:
|
||||
- target: AppImage
|
||||
@@ -132,32 +155,32 @@ linux:
|
||||
arch:
|
||||
- x64
|
||||
category: Utility
|
||||
maintainer: ClawX Team <team@clawx.app>
|
||||
maintainer: ClawX Team <public@valuecell.ai>
|
||||
vendor: ClawX
|
||||
synopsis: AI Assistant powered by OpenClaw
|
||||
description: |
|
||||
ClawX is a graphical AI assistant application that integrates with
|
||||
OpenClaw Gateway to provide intelligent automation and assistance
|
||||
across multiple messaging platforms.
|
||||
description: ClawX is a graphical AI assistant application that integrates with OpenClaw Gateway to provide intelligent automation and assistance across multiple messaging platforms.
|
||||
desktop:
|
||||
entry:
|
||||
Name: ClawX
|
||||
Comment: AI Assistant powered by OpenClaw
|
||||
Categories: Utility;Network;
|
||||
Keywords: ai;assistant;automation;chat;
|
||||
StartupWMClass: clawx
|
||||
|
||||
appImage:
|
||||
license: LICENSE
|
||||
|
||||
deb:
|
||||
depends:
|
||||
- libgtk-3-0
|
||||
- libnotify4
|
||||
# Use OR syntax to support both Ubuntu 22.04 and Ubuntu 24.04 (t64 transition).
|
||||
# Ubuntu 24.04 renamed many libraries with a t64 suffix (64-bit time_t ABI transition).
|
||||
- libgtk-3-0 | libgtk-3-0t64
|
||||
- libnotify4 | libnotify4t64
|
||||
- libnss3
|
||||
- libxss1
|
||||
- libxtst6
|
||||
- libxss1 | libxss1t64
|
||||
- libxtst6 | libxtst6t64
|
||||
- xdg-utils
|
||||
- libatspi2.0-0
|
||||
- libatspi2.0-0 | libatspi2.0-0t64
|
||||
- libuuid1
|
||||
afterInstall: scripts/linux/after-install.sh
|
||||
afterRemove: scripts/linux/after-remove.sh
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function setCorsHeaders(res: ServerResponse): void {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
}
|
||||
|
||||
export function sendJson(res: ServerResponse, statusCode: number, payload: unknown): void {
|
||||
setCorsHeaders(res);
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function sendNoContent(res: ServerResponse): void {
|
||||
setCorsHeaders(res);
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
}
|
||||
|
||||
export function sendText(res: ServerResponse, statusCode: number, text: string): void {
|
||||
setCorsHeaders(res);
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.end(text);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import {
|
||||
assignChannelToAgent,
|
||||
clearChannelBinding,
|
||||
createAgent,
|
||||
deleteAgentConfig,
|
||||
listAgentsSnapshot,
|
||||
resolveAccountIdForAgent,
|
||||
updateAgentName,
|
||||
} from '../../utils/agent-config';
|
||||
import { deleteChannelAccountConfig } from '../../utils/channel-config';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
function scheduleGatewayReload(ctx: HostApiContext, reason: string): void {
|
||||
if (ctx.gatewayManager.getStatus().state !== 'stopped') {
|
||||
ctx.gatewayManager.debouncedReload();
|
||||
return;
|
||||
}
|
||||
void reason;
|
||||
}
|
||||
|
||||
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 }>(req);
|
||||
const snapshot = await createAgent(body.name);
|
||||
scheduleGatewayReload(ctx, 'create-agent');
|
||||
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 === 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 = await deleteAgentConfig(agentId);
|
||||
scheduleGatewayReload(ctx, 'delete-agent');
|
||||
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 accountId = resolveAccountIdForAgent(agentId);
|
||||
await deleteChannelAccountConfig(channelType, accountId);
|
||||
const snapshot = await clearChannelBinding(channelType, accountId);
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody } from '../route-utils';
|
||||
import { setCorsHeaders, sendJson, sendNoContent } 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') {
|
||||
setCorsHeaders(res);
|
||||
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;
|
||||
}
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
sendNoContent(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { app } from 'electron';
|
||||
import { existsSync, cpSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
deleteChannelConfig,
|
||||
getChannelFormValues,
|
||||
listConfiguredChannels,
|
||||
saveChannelConfig,
|
||||
setChannelEnabled,
|
||||
validateChannelConfig,
|
||||
validateChannelCredentials,
|
||||
} from '../../utils/channel-config';
|
||||
import { clearAllBindingsForChannel } from '../../utils/agent-config';
|
||||
import { whatsAppLoginManager } from '../../utils/whatsapp-login';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
function scheduleGatewayChannelRestart(ctx: HostApiContext, reason: string): void {
|
||||
if (ctx.gatewayManager.getStatus().state === 'stopped') {
|
||||
return;
|
||||
}
|
||||
ctx.gatewayManager.debouncedRestart();
|
||||
void reason;
|
||||
}
|
||||
|
||||
async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk');
|
||||
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
||||
|
||||
if (existsSync(targetManifest)) {
|
||||
return { installed: true };
|
||||
}
|
||||
|
||||
const candidateSources = app.isPackaged
|
||||
? [
|
||||
join(process.resourcesPath, 'openclaw-plugins', 'dingtalk'),
|
||||
join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', 'dingtalk'),
|
||||
join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', 'dingtalk'),
|
||||
]
|
||||
: [
|
||||
join(app.getAppPath(), 'build', 'openclaw-plugins', 'dingtalk'),
|
||||
join(process.cwd(), 'build', 'openclaw-plugins', 'dingtalk'),
|
||||
join(__dirname, '../../../build/openclaw-plugins/dingtalk'),
|
||||
];
|
||||
|
||||
const sourceDir = candidateSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json')));
|
||||
if (!sourceDir) {
|
||||
return {
|
||||
installed: false,
|
||||
warning: `Bundled DingTalk plugin mirror not found. Checked: ${candidateSources.join(' | ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
|
||||
rmSync(targetDir, { recursive: true, force: true });
|
||||
cpSync(sourceDir, targetDir, { recursive: true, dereference: true });
|
||||
if (!existsSync(targetManifest)) {
|
||||
return { installed: false, warning: 'Failed to install DingTalk plugin mirror (manifest missing).' };
|
||||
}
|
||||
return { installed: true };
|
||||
} catch {
|
||||
return { installed: false, warning: 'Failed to install bundled DingTalk plugin mirror' };
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWeComPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
const targetDir = join(homedir(), '.openclaw', 'extensions', 'wecom');
|
||||
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
||||
|
||||
if (existsSync(targetManifest)) {
|
||||
return { installed: true };
|
||||
}
|
||||
|
||||
const candidateSources = app.isPackaged
|
||||
? [
|
||||
join(process.resourcesPath, 'openclaw-plugins', 'wecom'),
|
||||
join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', 'wecom'),
|
||||
join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', 'wecom'),
|
||||
]
|
||||
: [
|
||||
join(app.getAppPath(), 'build', 'openclaw-plugins', 'wecom'),
|
||||
join(process.cwd(), 'build', 'openclaw-plugins', 'wecom'),
|
||||
join(__dirname, '../../../build/openclaw-plugins/wecom'),
|
||||
];
|
||||
|
||||
const sourceDir = candidateSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json')));
|
||||
if (!sourceDir) {
|
||||
return {
|
||||
installed: false,
|
||||
warning: `Bundled WeCom plugin mirror not found. Checked: ${candidateSources.join(' | ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
|
||||
rmSync(targetDir, { recursive: true, force: true });
|
||||
cpSync(sourceDir, targetDir, { recursive: true, dereference: true });
|
||||
if (!existsSync(targetManifest)) {
|
||||
return { installed: false, warning: 'Failed to install WeCom plugin mirror (manifest missing).' };
|
||||
}
|
||||
return { installed: true };
|
||||
} catch {
|
||||
return { installed: false, warning: 'Failed to install bundled WeCom plugin mirror' };
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureFeishuPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
const targetDir = join(homedir(), '.openclaw', 'extensions', 'feishu-openclaw-plugin');
|
||||
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
||||
|
||||
if (existsSync(targetManifest)) {
|
||||
return { installed: true };
|
||||
}
|
||||
|
||||
const candidateSources = app.isPackaged
|
||||
? [
|
||||
join(process.resourcesPath, 'openclaw-plugins', 'feishu-openclaw-plugin'),
|
||||
join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', 'feishu-openclaw-plugin'),
|
||||
join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', 'feishu-openclaw-plugin'),
|
||||
]
|
||||
: [
|
||||
join(app.getAppPath(), 'build', 'openclaw-plugins', 'feishu-openclaw-plugin'),
|
||||
join(process.cwd(), 'build', 'openclaw-plugins', 'feishu-openclaw-plugin'),
|
||||
join(__dirname, '../../../build/openclaw-plugins/feishu-openclaw-plugin'),
|
||||
];
|
||||
|
||||
const sourceDir = candidateSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json')));
|
||||
if (!sourceDir) {
|
||||
return {
|
||||
installed: false,
|
||||
warning: `Bundled Feishu plugin mirror not found. Checked: ${candidateSources.join(' | ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
|
||||
rmSync(targetDir, { recursive: true, force: true });
|
||||
cpSync(sourceDir, targetDir, { recursive: true, dereference: true });
|
||||
if (!existsSync(targetManifest)) {
|
||||
return { installed: false, warning: 'Failed to install Feishu plugin mirror (manifest missing).' };
|
||||
}
|
||||
return { installed: true };
|
||||
} catch {
|
||||
return { installed: false, warning: 'Failed to install bundled Feishu plugin mirror' };
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureQQBotPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
const targetDir = join(homedir(), '.openclaw', 'extensions', 'qqbot');
|
||||
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
||||
|
||||
if (existsSync(targetManifest)) {
|
||||
return { installed: true };
|
||||
}
|
||||
|
||||
const candidateSources = app.isPackaged
|
||||
? [
|
||||
join(process.resourcesPath, 'openclaw-plugins', 'qqbot'),
|
||||
join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', 'qqbot'),
|
||||
join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', 'qqbot'),
|
||||
]
|
||||
: [
|
||||
join(app.getAppPath(), 'build', 'openclaw-plugins', 'qqbot'),
|
||||
join(process.cwd(), 'build', 'openclaw-plugins', 'qqbot'),
|
||||
join(__dirname, '../../../build/openclaw-plugins/qqbot'),
|
||||
];
|
||||
|
||||
const sourceDir = candidateSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json')));
|
||||
if (!sourceDir) {
|
||||
return {
|
||||
installed: false,
|
||||
warning: `Bundled QQ Bot plugin mirror not found. Checked: ${candidateSources.join(' | ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
|
||||
rmSync(targetDir, { recursive: true, force: true });
|
||||
cpSync(sourceDir, targetDir, { recursive: true, dereference: true });
|
||||
if (!existsSync(targetManifest)) {
|
||||
return { installed: false, warning: 'Failed to install QQ Bot plugin mirror (manifest missing).' };
|
||||
}
|
||||
return { installed: true };
|
||||
} catch {
|
||||
return { installed: false, warning: 'Failed to install bundled QQ Bot plugin mirror' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleChannelRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/channels/configured' && req.method === 'GET') {
|
||||
sendJson(res, 200, { success: true, channels: await listConfiguredChannels() });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/config/validate' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ channelType: string }>(req);
|
||||
sendJson(res, 200, { success: true, ...(await validateChannelConfig(body.channelType)) });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, valid: false, errors: [String(error)], warnings: [] });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/credentials/validate' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ channelType: string; config: Record<string, string> }>(req);
|
||||
sendJson(res, 200, { success: true, ...(await validateChannelCredentials(body.channelType, body.config)) });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, valid: false, errors: [String(error)], warnings: [] });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/whatsapp/start' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ accountId: string }>(req);
|
||||
await whatsAppLoginManager.start(body.accountId);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/whatsapp/cancel' && req.method === 'POST') {
|
||||
try {
|
||||
await whatsAppLoginManager.stop();
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/config' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ channelType: string; config: Record<string, unknown>; accountId?: string }>(req);
|
||||
if (body.channelType === 'dingtalk') {
|
||||
const installResult = await ensureDingTalkPluginInstalled();
|
||||
if (!installResult.installed) {
|
||||
sendJson(res, 500, { success: false, error: installResult.warning || 'DingTalk plugin install failed' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (body.channelType === 'wecom') {
|
||||
const installResult = await ensureWeComPluginInstalled();
|
||||
if (!installResult.installed) {
|
||||
sendJson(res, 500, { success: false, error: installResult.warning || 'WeCom plugin install failed' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (body.channelType === 'qqbot') {
|
||||
const installResult = await ensureQQBotPluginInstalled();
|
||||
if (!installResult.installed) {
|
||||
sendJson(res, 500, { success: false, error: installResult.warning || 'QQ Bot plugin install failed' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (body.channelType === 'feishu') {
|
||||
const installResult = await ensureFeishuPluginInstalled();
|
||||
if (!installResult.installed) {
|
||||
sendJson(res, 500, { success: false, error: installResult.warning || 'Feishu plugin install failed' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
await saveChannelConfig(body.channelType, body.config, body.accountId);
|
||||
scheduleGatewayChannelRestart(ctx, `channel:saveConfig:${body.channelType}`);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/config/enabled' && req.method === 'PUT') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ channelType: string; enabled: boolean }>(req);
|
||||
await setChannelEnabled(body.channelType, body.enabled);
|
||||
scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${body.channelType}`);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/channels/config/') && req.method === 'GET') {
|
||||
try {
|
||||
const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length));
|
||||
const accountId = url.searchParams.get('accountId') || undefined;
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
values: await getChannelFormValues(channelType, accountId),
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/channels/config/') && req.method === 'DELETE') {
|
||||
try {
|
||||
const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length));
|
||||
await deleteChannelConfig(channelType);
|
||||
await clearAllBindingsForChannel(channelType);
|
||||
scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${channelType}`);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ctx;
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
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';
|
||||
|
||||
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 };
|
||||
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);
|
||||
}
|
||||
|
||||
function transformCronJob(job: GatewayCronJob) {
|
||||
const message = job.payload?.message || job.payload?.text || '';
|
||||
const channelType = job.delivery?.channel;
|
||||
const target = channelType
|
||||
? { channelType, channelId: channelType, channelName: channelType }
|
||||
: 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;
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
message,
|
||||
schedule: job.schedule,
|
||||
target,
|
||||
enabled: job.enabled,
|
||||
createdAt: new Date(job.createdAtMs).toISOString(),
|
||||
updatedAt: new Date(job.updatedAtMs).toISOString(),
|
||||
lastRun,
|
||||
nextRun,
|
||||
};
|
||||
}
|
||||
|
||||
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 })
|
||||
.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 {
|
||||
const result = await ctx.gatewayManager.rpc('cron.list', { includeDisabled: true });
|
||||
const data = result as { jobs?: GatewayCronJob[] };
|
||||
const jobs = data?.jobs ?? [];
|
||||
for (const job of jobs) {
|
||||
const isIsolatedAgent =
|
||||
(job.sessionTarget === 'isolated' || !job.sessionTarget) &&
|
||||
job.payload?.kind === 'agentTurn';
|
||||
const needsRepair =
|
||||
isIsolatedAgent &&
|
||||
job.delivery?.mode === 'announce' &&
|
||||
!job.delivery?.channel;
|
||||
if (needsRepair) {
|
||||
try {
|
||||
await ctx.gatewayManager.rpc('cron.update', {
|
||||
id: job.id,
|
||||
patch: { delivery: { mode: 'none' } },
|
||||
});
|
||||
job.delivery = { mode: 'none' };
|
||||
if (job.state?.lastError?.includes('Channel is required')) {
|
||||
job.state.lastError = undefined;
|
||||
job.state.lastStatus = 'ok';
|
||||
}
|
||||
} catch {
|
||||
// ignore per-job repair failure
|
||||
}
|
||||
}
|
||||
}
|
||||
sendJson(res, 200, jobs.map(transformCronJob));
|
||||
} 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; enabled?: boolean }>(req);
|
||||
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',
|
||||
delivery: { mode: 'none' },
|
||||
});
|
||||
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 = { ...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;
|
||||
}
|
||||
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.update', { id, patch }));
|
||||
} 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { PORTS } from '../../utils/config';
|
||||
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 = `http://127.0.0.1:${port}/?token=${encodeURIComponent(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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
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>();
|
||||
|
||||
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);
|
||||
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 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);
|
||||
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 as any }));
|
||||
} 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 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;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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';
|
||||
|
||||
export async function handleSessionRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
_ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { applyProxySettings } from '../../main/proxy';
|
||||
import { syncLaunchAtStartupSettingFromStore } from '../../main/launch-at-startup';
|
||||
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 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;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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/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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||
import { PORTS } from '../utils/config';
|
||||
import { logger } from '../utils/logger';
|
||||
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 { sendJson } from './route-utils';
|
||||
|
||||
type RouteHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
) => Promise<boolean>;
|
||||
|
||||
const routeHandlers: RouteHandler[] = [
|
||||
handleAppRoutes,
|
||||
handleGatewayRoutes,
|
||||
handleSettingsRoutes,
|
||||
handleProviderRoutes,
|
||||
handleAgentRoutes,
|
||||
handleChannelRoutes,
|
||||
handleSkillRoutes,
|
||||
handleFileRoutes,
|
||||
handleSessionRoutes,
|
||||
handleCronRoutes,
|
||||
handleLogRoutes,
|
||||
handleUsageRoutes,
|
||||
];
|
||||
|
||||
export function startHostApiServer(ctx: HostApiContext, port = PORTS.CLAWX_HOST_API): Server {
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`);
|
||||
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.listen(port, '127.0.0.1', () => {
|
||||
logger.info(`Host API server listening on http://127.0.0.1:${port}`);
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
+132
-19
@@ -6,7 +6,7 @@ import { spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { app, shell } from 'electron';
|
||||
import { getOpenClawConfigDir, ensureDir, getClawHubCliBinPath, getClawHubCliEntryPath } from '../utils/paths';
|
||||
import { getOpenClawConfigDir, ensureDir, getClawHubCliBinPath, getClawHubCliEntryPath, quoteForCmd } from '../utils/paths';
|
||||
|
||||
export interface ClawHubSearchParams {
|
||||
query: string;
|
||||
@@ -33,6 +33,13 @@ export interface ClawHubSkillResult {
|
||||
stars?: number;
|
||||
}
|
||||
|
||||
export interface ClawHubInstalledSkillResult {
|
||||
slug: string;
|
||||
version: string;
|
||||
source?: string;
|
||||
baseDir?: string;
|
||||
}
|
||||
|
||||
export class ClawHubService {
|
||||
private workDir: string;
|
||||
private cliPath: string;
|
||||
@@ -67,6 +74,55 @@ export class ClawHubService {
|
||||
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];
|
||||
const nameMatch = body.match(/^\s*name\s*:\s*["']?([^"'\n]+)["']?\s*$/m);
|
||||
if (!nameMatch) return null;
|
||||
const name = nameMatch[1].trim();
|
||||
return name || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSkillDirByManifestName(candidates: string[]): string | null {
|
||||
const skillsRoot = path.join(this.workDir, 'skills');
|
||||
if (!fs.existsSync(skillsRoot)) return null;
|
||||
|
||||
const wanted = new Set(
|
||||
candidates
|
||||
.map((v) => v.trim().toLowerCase())
|
||||
.filter((v) => v.length > 0),
|
||||
);
|
||||
if (wanted.size === 0) return null;
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(skillsRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const skillDir = path.join(skillsRoot, entry.name);
|
||||
const skillManifestPath = path.join(skillDir, 'SKILL.md');
|
||||
if (!fs.existsSync(skillManifestPath)) continue;
|
||||
|
||||
const frontmatterName = this.extractFrontmatterName(skillManifestPath);
|
||||
if (!frontmatterName) continue;
|
||||
if (wanted.has(frontmatterName.toLowerCase())) {
|
||||
return skillDir;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a ClawHub CLI command
|
||||
*/
|
||||
@@ -87,21 +143,26 @@ export class ClawHubService {
|
||||
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 = {
|
||||
...process.env,
|
||||
...baseEnv,
|
||||
CI: 'true',
|
||||
FORCE_COLOR: '0', // Disable colors for easier parsing
|
||||
FORCE_COLOR: '0',
|
||||
};
|
||||
if (this.useNodeRunner) {
|
||||
env.ELECTRON_RUN_AS_NODE = '1';
|
||||
}
|
||||
const child = spawn(this.cliPath, commandArgs, {
|
||||
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: isWin && !this.useNodeRunner,
|
||||
shell: useShell,
|
||||
env: {
|
||||
...env,
|
||||
CLAWHUB_WORKDIR: this.workDir,
|
||||
},
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
@@ -158,7 +219,7 @@ export class ClawHubService {
|
||||
|
||||
// Format could be: slug vversion description (score)
|
||||
// Or sometimes: slug vversion description
|
||||
const match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)\s+(.+)$/);
|
||||
let match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const slug = match[1];
|
||||
const version = match[2];
|
||||
@@ -174,11 +235,29 @@ export class ClawHubService {
|
||||
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);
|
||||
return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +293,7 @@ export class ClawHubService {
|
||||
}).filter((s): s is ClawHubSkillResult => s !== null);
|
||||
} catch (error) {
|
||||
console.error('ClawHub explore error:', error);
|
||||
return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,7 +346,7 @@ export class ClawHubService {
|
||||
/**
|
||||
* List installed skills
|
||||
*/
|
||||
async listInstalled(): Promise<Array<{ slug: string; version: string }>> {
|
||||
async listInstalled(): Promise<ClawHubInstalledSkillResult[]> {
|
||||
try {
|
||||
const output = await this.runCommand(['list']);
|
||||
if (!output || output.includes('No installed skills')) {
|
||||
@@ -279,40 +358,59 @@ export class ClawHubService {
|
||||
const cleanLine = this.stripAnsi(line);
|
||||
const match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)/);
|
||||
if (match) {
|
||||
const slug = match[1];
|
||||
return {
|
||||
slug: match[1],
|
||||
slug,
|
||||
version: match[2],
|
||||
source: 'openclaw-managed',
|
||||
baseDir: path.join(this.workDir, 'skills', slug),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter((s): s is { slug: string; version: string } => s !== 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)
|
||||
.map(v => v.trim());
|
||||
const uniqueCandidates = [...new Set(candidates)];
|
||||
if (preferredBaseDir && preferredBaseDir.trim() && fs.existsSync(preferredBaseDir.trim())) {
|
||||
return preferredBaseDir.trim();
|
||||
}
|
||||
const directSkillDir = uniqueCandidates
|
||||
.map((id) => path.join(this.workDir, 'skills', id))
|
||||
.find((dir) => fs.existsSync(dir));
|
||||
return directSkillDir || this.resolveSkillDirByManifestName(uniqueCandidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open skill README/manual in default editor
|
||||
*/
|
||||
async openSkillReadme(slug: string): Promise<boolean> {
|
||||
const skillDir = path.join(this.workDir, 'skills', slug);
|
||||
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 = '';
|
||||
|
||||
for (const file of possibleFiles) {
|
||||
const filePath = path.join(skillDir, file);
|
||||
if (fs.existsSync(filePath)) {
|
||||
targetFile = filePath;
|
||||
break;
|
||||
if (skillDir) {
|
||||
for (const file of possibleFiles) {
|
||||
const filePath = path.join(skillDir, file);
|
||||
if (fs.existsSync(filePath)) {
|
||||
targetFile = filePath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetFile) {
|
||||
// If no md file, just open the directory
|
||||
if (fs.existsSync(skillDir)) {
|
||||
if (skillDir) {
|
||||
targetFile = skillDir;
|
||||
} else {
|
||||
throw new Error('Skill directory not found');
|
||||
@@ -328,4 +426,19 @@ export class ClawHubService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
throw new Error('Skill directory not found');
|
||||
}
|
||||
const openResult = await shell.openPath(skillDir);
|
||||
if (openResult) {
|
||||
throw new Error(openResult);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { GatewayManager, GatewayStatus } from './manager';
|
||||
/**
|
||||
* Channel types supported by OpenClaw
|
||||
*/
|
||||
export type ChannelType = 'whatsapp' | 'telegram' | 'discord' | 'wechat';
|
||||
export type ChannelType = 'whatsapp' | 'dingtalk' | 'telegram' | 'discord' | 'wechat';
|
||||
|
||||
/**
|
||||
* Channel status
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { app } from 'electron';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { getAllSettings } from '../utils/store';
|
||||
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
|
||||
import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry';
|
||||
import { getOpenClawDir, getOpenClawEntryPath, isOpenClawPresent } from '../utils/paths';
|
||||
import { getUvMirrorEnv } from '../utils/uv-env';
|
||||
import { listConfiguredChannels } from '../utils/channel-config';
|
||||
import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, sanitizeOpenClawConfig } from '../utils/openclaw-auth';
|
||||
import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
|
||||
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
|
||||
import { logger } from '../utils/logger';
|
||||
import { prependPathEntry } from '../utils/env-path';
|
||||
|
||||
export interface GatewayLaunchContext {
|
||||
appSettings: Awaited<ReturnType<typeof getAllSettings>>;
|
||||
openclawDir: string;
|
||||
entryScript: string;
|
||||
gatewayArgs: string[];
|
||||
forkEnv: Record<string, string | undefined>;
|
||||
mode: 'dev' | 'packaged';
|
||||
binPathExists: boolean;
|
||||
loadedProviderKeyCount: number;
|
||||
proxySummary: string;
|
||||
channelStartupSummary: string;
|
||||
}
|
||||
|
||||
export async function syncGatewayConfigBeforeLaunch(
|
||||
appSettings: Awaited<ReturnType<typeof getAllSettings>>,
|
||||
): Promise<void> {
|
||||
await syncProxyConfigToOpenClaw(appSettings);
|
||||
|
||||
try {
|
||||
await sanitizeOpenClawConfig();
|
||||
} catch (err) {
|
||||
logger.warn('Failed to sanitize openclaw.json:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
await syncGatewayTokenToConfig(appSettings.gatewayToken);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to sync gateway token to openclaw.json:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
await syncBrowserConfigToOpenClaw();
|
||||
} catch (err) {
|
||||
logger.warn('Failed to sync browser config to openclaw.json:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviderEnv(): Promise<{ providerEnv: Record<string, string>; loadedProviderKeyCount: number }> {
|
||||
const providerEnv: Record<string, string> = {};
|
||||
const providerTypes = getKeyableProviderTypes();
|
||||
let loadedProviderKeyCount = 0;
|
||||
|
||||
try {
|
||||
const defaultProviderId = await getDefaultProvider();
|
||||
if (defaultProviderId) {
|
||||
const defaultProvider = await getProvider(defaultProviderId);
|
||||
const defaultProviderType = defaultProvider?.type;
|
||||
const defaultProviderKey = await getApiKey(defaultProviderId);
|
||||
if (defaultProviderType && defaultProviderKey) {
|
||||
const envVar = getProviderEnvVar(defaultProviderType);
|
||||
if (envVar) {
|
||||
providerEnv[envVar] = defaultProviderKey;
|
||||
loadedProviderKeyCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to load default provider key for environment injection:', err);
|
||||
}
|
||||
|
||||
for (const providerType of providerTypes) {
|
||||
try {
|
||||
const key = await getApiKey(providerType);
|
||||
if (key) {
|
||||
const envVar = getProviderEnvVar(providerType);
|
||||
if (envVar) {
|
||||
providerEnv[envVar] = key;
|
||||
loadedProviderKeyCount++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to load API key for ${providerType}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return { providerEnv, loadedProviderKeyCount };
|
||||
}
|
||||
|
||||
async function resolveChannelStartupPolicy(): Promise<{
|
||||
skipChannels: boolean;
|
||||
channelStartupSummary: string;
|
||||
}> {
|
||||
try {
|
||||
const configuredChannels = await listConfiguredChannels();
|
||||
if (configuredChannels.length === 0) {
|
||||
return {
|
||||
skipChannels: true,
|
||||
channelStartupSummary: 'skipped(no configured channels)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
skipChannels: false,
|
||||
channelStartupSummary: `enabled(${configuredChannels.join(',')})`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn('Failed to determine configured channels for gateway launch:', error);
|
||||
return {
|
||||
skipChannels: false,
|
||||
channelStartupSummary: 'enabled(unknown)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareGatewayLaunchContext(port: number): Promise<GatewayLaunchContext> {
|
||||
const openclawDir = getOpenClawDir();
|
||||
const entryScript = getOpenClawEntryPath();
|
||||
|
||||
if (!isOpenClawPresent()) {
|
||||
throw new Error(`OpenClaw package not found at: ${openclawDir}`);
|
||||
}
|
||||
|
||||
const appSettings = await getAllSettings();
|
||||
await syncGatewayConfigBeforeLaunch(appSettings);
|
||||
|
||||
if (!existsSync(entryScript)) {
|
||||
throw new Error(`OpenClaw entry script not found at: ${entryScript}`);
|
||||
}
|
||||
|
||||
const gatewayArgs = ['gateway', '--port', String(port), '--token', appSettings.gatewayToken, '--allow-unconfigured'];
|
||||
const mode = app.isPackaged ? 'packaged' : 'dev';
|
||||
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
const target = `${platform}-${arch}`;
|
||||
const binPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'bin')
|
||||
: path.join(process.cwd(), 'resources', 'bin', target);
|
||||
const binPathExists = existsSync(binPath);
|
||||
|
||||
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
|
||||
? `http=${resolvedProxy.httpProxy || '-'}, https=${resolvedProxy.httpsProxy || '-'}, all=${resolvedProxy.allProxy || '-'}`
|
||||
: 'disabled';
|
||||
|
||||
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
|
||||
const baseEnvRecord = baseEnv as Record<string, string | undefined>;
|
||||
const baseEnvPatched = binPathExists
|
||||
? prependPathEntry(baseEnvRecord, binPath).env
|
||||
: baseEnvRecord;
|
||||
const forkEnv: Record<string, string | undefined> = {
|
||||
...baseEnvPatched,
|
||||
...providerEnv,
|
||||
...uvEnv,
|
||||
...proxyEnv,
|
||||
OPENCLAW_GATEWAY_TOKEN: appSettings.gatewayToken,
|
||||
OPENCLAW_SKIP_CHANNELS: skipChannels ? '1' : '',
|
||||
CLAWDBOT_SKIP_CHANNELS: skipChannels ? '1' : '',
|
||||
OPENCLAW_NO_RESPAWN: '1',
|
||||
};
|
||||
|
||||
return {
|
||||
appSettings,
|
||||
openclawDir,
|
||||
entryScript,
|
||||
gatewayArgs,
|
||||
forkEnv,
|
||||
mode,
|
||||
binPathExists,
|
||||
loadedProviderKeyCount,
|
||||
proxySummary,
|
||||
channelStartupSummary,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
type HealthResult = { ok: boolean; error?: string };
|
||||
|
||||
export class GatewayConnectionMonitor {
|
||||
private pingInterval: NodeJS.Timeout | null = null;
|
||||
private healthCheckInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
startPing(sendPing: () => void, intervalMs = 30000): void {
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval);
|
||||
}
|
||||
|
||||
this.pingInterval = setInterval(() => {
|
||||
sendPing();
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
startHealthCheck(options: {
|
||||
shouldCheck: () => boolean;
|
||||
checkHealth: () => Promise<HealthResult>;
|
||||
onUnhealthy: (errorMessage: string) => void;
|
||||
onError: (error: unknown) => void;
|
||||
intervalMs?: number;
|
||||
}): void {
|
||||
if (this.healthCheckInterval) {
|
||||
clearInterval(this.healthCheckInterval);
|
||||
}
|
||||
|
||||
this.healthCheckInterval = setInterval(async () => {
|
||||
if (!options.shouldCheck()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const health = await options.checkHealth();
|
||||
if (!health.ok) {
|
||||
const errorMessage = health.error ?? 'Health check failed';
|
||||
logger.warn(`Gateway health check failed: ${errorMessage}`);
|
||||
options.onUnhealthy(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Gateway health check error:', error);
|
||||
options.onError(error);
|
||||
}
|
||||
}, options.intervalMs ?? 30000);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval);
|
||||
this.pingInterval = null;
|
||||
}
|
||||
if (this.healthCheckInterval) {
|
||||
clearInterval(this.healthCheckInterval);
|
||||
this.healthCheckInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { GatewayEventType, type JsonRpcNotification } from './protocol';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
type GatewayEventEmitter = {
|
||||
emit: (event: string, payload: unknown) => boolean;
|
||||
};
|
||||
|
||||
export function dispatchProtocolEvent(
|
||||
emitter: GatewayEventEmitter,
|
||||
event: string,
|
||||
payload: unknown,
|
||||
): void {
|
||||
switch (event) {
|
||||
case 'tick':
|
||||
break;
|
||||
case 'chat':
|
||||
emitter.emit('chat:message', { message: payload });
|
||||
break;
|
||||
case 'agent': {
|
||||
const p = payload as Record<string, unknown>;
|
||||
const data = (p.data && typeof p.data === 'object') ? p.data as Record<string, unknown> : {};
|
||||
const chatEvent: Record<string, unknown> = {
|
||||
...data,
|
||||
runId: p.runId ?? data.runId,
|
||||
sessionKey: p.sessionKey ?? data.sessionKey,
|
||||
state: p.state ?? data.state,
|
||||
message: p.message ?? data.message,
|
||||
};
|
||||
if (chatEvent.state || chatEvent.message) {
|
||||
emitter.emit('chat:message', { message: chatEvent });
|
||||
}
|
||||
emitter.emit('notification', { method: event, params: payload });
|
||||
break;
|
||||
}
|
||||
case 'channel.status':
|
||||
emitter.emit('channel:status', payload as { channelId: string; status: string });
|
||||
break;
|
||||
default:
|
||||
emitter.emit('notification', { method: event, params: payload });
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchJsonRpcNotification(
|
||||
emitter: GatewayEventEmitter,
|
||||
notification: JsonRpcNotification,
|
||||
): void {
|
||||
emitter.emit('notification', notification);
|
||||
switch (notification.method) {
|
||||
case GatewayEventType.CHANNEL_STATUS_CHANGED:
|
||||
emitter.emit('channel:status', notification.params as { channelId: string; status: string });
|
||||
break;
|
||||
case GatewayEventType.MESSAGE_RECEIVED:
|
||||
emitter.emit('chat:message', notification.params as { message: unknown });
|
||||
break;
|
||||
case GatewayEventType.ERROR: {
|
||||
const errorData = notification.params as { message?: string };
|
||||
emitter.emit('error', new Error(errorData.message || 'Gateway error'));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
logger.debug(`Unknown Gateway notification: ${notification.method}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { logger } from '../utils/logger';
|
||||
import { isLifecycleSuperseded, nextLifecycleEpoch } from './process-policy';
|
||||
|
||||
export class LifecycleSupersededError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'LifecycleSupersededError';
|
||||
}
|
||||
}
|
||||
|
||||
export class GatewayLifecycleController {
|
||||
private epoch = 0;
|
||||
|
||||
getCurrentEpoch(): number {
|
||||
return this.epoch;
|
||||
}
|
||||
|
||||
bump(reason: string): number {
|
||||
this.epoch = nextLifecycleEpoch(this.epoch);
|
||||
logger.debug(`Gateway lifecycle epoch advanced to ${this.epoch} (${reason})`);
|
||||
return this.epoch;
|
||||
}
|
||||
|
||||
assert(expectedEpoch: number, phase: string): void {
|
||||
if (isLifecycleSuperseded(expectedEpoch, this.epoch)) {
|
||||
throw new LifecycleSupersededError(
|
||||
`Gateway ${phase} superseded (expectedEpoch=${expectedEpoch}, currentEpoch=${this.epoch})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+467
-751
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
import { app, utilityProcess } from 'electron';
|
||||
import { existsSync, writeFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import type { GatewayLaunchContext } from './config-sync';
|
||||
import type { GatewayLifecycleState } from './process-policy';
|
||||
import { logger } from '../utils/logger';
|
||||
import { appendNodeRequireToNodeOptions } from '../utils/paths';
|
||||
|
||||
const GATEWAY_FETCH_PRELOAD_SOURCE = `'use strict';
|
||||
(function () {
|
||||
var _f = globalThis.fetch;
|
||||
if (typeof _f !== 'function') return;
|
||||
if (globalThis.__clawxFetchPatched) return;
|
||||
globalThis.__clawxFetchPatched = true;
|
||||
|
||||
globalThis.fetch = function clawxFetch(input, init) {
|
||||
var url =
|
||||
typeof input === 'string' ? input
|
||||
: input && typeof input === 'object' && typeof input.url === 'string'
|
||||
? input.url : '';
|
||||
|
||||
if (url.indexOf('openrouter.ai') !== -1) {
|
||||
init = init ? Object.assign({}, init) : {};
|
||||
var prev = init.headers;
|
||||
var flat = {};
|
||||
if (prev && typeof prev.forEach === 'function') {
|
||||
prev.forEach(function (v, k) { flat[k] = v; });
|
||||
} else if (prev && typeof prev === 'object') {
|
||||
Object.assign(flat, prev);
|
||||
}
|
||||
delete flat['http-referer'];
|
||||
delete flat['HTTP-Referer'];
|
||||
delete flat['x-title'];
|
||||
delete flat['X-Title'];
|
||||
flat['HTTP-Referer'] = 'https://claw-x.com';
|
||||
flat['X-Title'] = 'ClawX';
|
||||
init.headers = flat;
|
||||
}
|
||||
return _f.call(globalThis, input, init);
|
||||
};
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
var cp = require('child_process');
|
||||
if (!cp.__clawxPatched) {
|
||||
cp.__clawxPatched = true;
|
||||
['spawn', 'exec', 'execFile', 'fork', 'spawnSync', 'execSync', 'execFileSync'].forEach(function(method) {
|
||||
var original = cp[method];
|
||||
if (typeof original !== 'function') return;
|
||||
cp[method] = function() {
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
var optIdx = -1;
|
||||
for (var i = 1; i < args.length; i++) {
|
||||
var a = args[i];
|
||||
if (a && typeof a === 'object' && !Array.isArray(a)) {
|
||||
optIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (optIdx >= 0) {
|
||||
args[optIdx].windowsHide = true;
|
||||
} else {
|
||||
var opts = { windowsHide: true };
|
||||
if (typeof args[args.length - 1] === 'function') {
|
||||
args.splice(args.length - 1, 0, opts);
|
||||
} else {
|
||||
args.push(opts);
|
||||
}
|
||||
}
|
||||
return original.apply(this, args);
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
function ensureGatewayFetchPreload(): string {
|
||||
const dest = path.join(app.getPath('userData'), 'gateway-fetch-preload.cjs');
|
||||
try {
|
||||
writeFileSync(dest, GATEWAY_FETCH_PRELOAD_SOURCE, 'utf-8');
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
return dest;
|
||||
}
|
||||
|
||||
export async function launchGatewayProcess(options: {
|
||||
port: number;
|
||||
launchContext: GatewayLaunchContext;
|
||||
sanitizeSpawnArgs: (args: string[]) => string[];
|
||||
getCurrentState: () => GatewayLifecycleState;
|
||||
getShouldReconnect: () => boolean;
|
||||
onStderrLine: (line: string) => void;
|
||||
onSpawn: (pid: number | undefined) => void;
|
||||
onExit: (child: Electron.UtilityProcess, code: number | null) => void;
|
||||
onError: (error: Error) => void;
|
||||
}): Promise<{ child: Electron.UtilityProcess; lastSpawnSummary: string }> {
|
||||
const {
|
||||
openclawDir,
|
||||
entryScript,
|
||||
gatewayArgs,
|
||||
forkEnv,
|
||||
mode,
|
||||
binPathExists,
|
||||
loadedProviderKeyCount,
|
||||
proxySummary,
|
||||
channelStartupSummary,
|
||||
} = options.launchContext;
|
||||
|
||||
logger.info(
|
||||
`Starting Gateway process (mode=${mode}, port=${options.port}, entry="${entryScript}", args="${options.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount}, channels=${channelStartupSummary}, proxy=${proxySummary})`,
|
||||
);
|
||||
const lastSpawnSummary = `mode=${mode}, entry="${entryScript}", args="${options.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}"`;
|
||||
|
||||
const runtimeEnv = { ...forkEnv };
|
||||
if (!app.isPackaged) {
|
||||
try {
|
||||
const preloadPath = ensureGatewayFetchPreload();
|
||||
if (existsSync(preloadPath)) {
|
||||
runtimeEnv.NODE_OPTIONS = appendNodeRequireToNodeOptions(
|
||||
runtimeEnv.NODE_OPTIONS,
|
||||
preloadPath,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to set up OpenRouter headers preload:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return await new Promise<{ child: Electron.UtilityProcess; lastSpawnSummary: string }>((resolve, reject) => {
|
||||
const child = utilityProcess.fork(entryScript, gatewayArgs, {
|
||||
cwd: openclawDir,
|
||||
stdio: 'pipe',
|
||||
env: runtimeEnv as NodeJS.ProcessEnv,
|
||||
serviceName: 'OpenClaw Gateway',
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
const resolveOnce = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({ child, lastSpawnSummary });
|
||||
};
|
||||
const rejectOnce = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
};
|
||||
|
||||
child.on('error', (error) => {
|
||||
logger.error('Gateway process spawn error:', error);
|
||||
options.onError(error);
|
||||
rejectOnce(error);
|
||||
});
|
||||
|
||||
child.on('exit', (code: number) => {
|
||||
const expectedExit = !options.getShouldReconnect() || options.getCurrentState() === 'stopped';
|
||||
const level = expectedExit ? logger.info : logger.warn;
|
||||
level(`Gateway process exited (code=${code}, expected=${expectedExit ? 'yes' : 'no'})`);
|
||||
options.onExit(child, code);
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
const raw = data.toString();
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
options.onStderrLine(line);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('spawn', () => {
|
||||
logger.info(`Gateway process started (pid=${child.pid})`);
|
||||
options.onSpawn(child.pid);
|
||||
resolveOnce();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
export interface ReconnectConfig {
|
||||
maxAttempts: number;
|
||||
baseDelay: number;
|
||||
maxDelay: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
|
||||
maxAttempts: 10,
|
||||
baseDelay: 1000,
|
||||
maxDelay: 30000,
|
||||
};
|
||||
|
||||
export function nextLifecycleEpoch(currentEpoch: number): number {
|
||||
return currentEpoch + 1;
|
||||
}
|
||||
|
||||
export function isLifecycleSuperseded(expectedEpoch: number, currentEpoch: number): boolean {
|
||||
return expectedEpoch !== currentEpoch;
|
||||
}
|
||||
|
||||
export interface ReconnectAttemptContext {
|
||||
scheduledEpoch: number;
|
||||
currentEpoch: number;
|
||||
shouldReconnect: boolean;
|
||||
}
|
||||
|
||||
export function getReconnectSkipReason(context: ReconnectAttemptContext): string | null {
|
||||
if (!context.shouldReconnect) {
|
||||
return 'auto-reconnect disabled';
|
||||
}
|
||||
if (isLifecycleSuperseded(context.scheduledEpoch, context.currentEpoch)) {
|
||||
return `stale reconnect callback (scheduledEpoch=${context.scheduledEpoch}, currentEpoch=${context.currentEpoch})`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ReconnectScheduleContext {
|
||||
shouldReconnect: boolean;
|
||||
hasReconnectTimer: boolean;
|
||||
reconnectAttempts: number;
|
||||
maxAttempts: number;
|
||||
baseDelay: number;
|
||||
maxDelay: number;
|
||||
}
|
||||
|
||||
export type ReconnectScheduleDecision =
|
||||
| { action: 'skip'; reason: string }
|
||||
| { action: 'already-scheduled' }
|
||||
| { action: 'fail'; attempts: number; maxAttempts: number }
|
||||
| { action: 'schedule'; nextAttempt: number; maxAttempts: number; delay: number };
|
||||
|
||||
export function getReconnectScheduleDecision(
|
||||
context: ReconnectScheduleContext,
|
||||
): ReconnectScheduleDecision {
|
||||
if (!context.shouldReconnect) {
|
||||
return { action: 'skip', reason: 'auto-reconnect disabled' };
|
||||
}
|
||||
|
||||
if (context.hasReconnectTimer) {
|
||||
return { action: 'already-scheduled' };
|
||||
}
|
||||
|
||||
if (context.reconnectAttempts >= context.maxAttempts) {
|
||||
return {
|
||||
action: 'fail',
|
||||
attempts: context.reconnectAttempts,
|
||||
maxAttempts: context.maxAttempts,
|
||||
};
|
||||
}
|
||||
|
||||
const delay = Math.min(
|
||||
context.baseDelay * Math.pow(2, context.reconnectAttempts),
|
||||
context.maxDelay,
|
||||
);
|
||||
|
||||
return {
|
||||
action: 'schedule',
|
||||
nextAttempt: context.reconnectAttempts + 1,
|
||||
maxAttempts: context.maxAttempts,
|
||||
delay,
|
||||
};
|
||||
}
|
||||
|
||||
export type GatewayLifecycleState = 'stopped' | 'starting' | 'running' | 'error' | 'reconnecting';
|
||||
|
||||
export interface RestartDeferralContext {
|
||||
state: GatewayLifecycleState;
|
||||
startLock: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart requests should not interrupt an in-flight startup/reconnect flow.
|
||||
* Doing so can kill a just-spawned process and leave the manager stopped.
|
||||
*/
|
||||
export function shouldDeferRestart(context: RestartDeferralContext): boolean {
|
||||
return context.startLock || context.state === 'starting' || context.state === 'reconnecting';
|
||||
}
|
||||
|
||||
export interface DeferredRestartActionContext extends RestartDeferralContext {
|
||||
hasPendingRestart: boolean;
|
||||
shouldReconnect: boolean;
|
||||
}
|
||||
|
||||
export type DeferredRestartAction = 'none' | 'wait' | 'drop' | 'execute';
|
||||
|
||||
/**
|
||||
* Decide what to do with a pending deferred restart once lifecycle changes.
|
||||
*
|
||||
* A deferred restart is an explicit restart() call that was postponed because
|
||||
* the manager was mid-startup/reconnect. When the in-flight operation settles
|
||||
* we must honour the request — even if the gateway is now running — because
|
||||
* the caller may have changed config (e.g. provider switch) that the current
|
||||
* process hasn't picked up.
|
||||
*/
|
||||
export function getDeferredRestartAction(context: DeferredRestartActionContext): DeferredRestartAction {
|
||||
if (!context.hasPendingRestart) return 'none';
|
||||
if (shouldDeferRestart(context)) return 'wait';
|
||||
if (!context.shouldReconnect) return 'drop';
|
||||
return 'execute';
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export interface PendingGatewayRequest {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
export function clearPendingGatewayRequests(
|
||||
pendingRequests: Map<string, PendingGatewayRequest>,
|
||||
error: Error,
|
||||
): void {
|
||||
for (const [, request] of pendingRequests) {
|
||||
clearTimeout(request.timeout);
|
||||
request.reject(error);
|
||||
}
|
||||
pendingRequests.clear();
|
||||
}
|
||||
|
||||
export function resolvePendingGatewayRequest(
|
||||
pendingRequests: Map<string, PendingGatewayRequest>,
|
||||
id: string,
|
||||
value: unknown,
|
||||
): boolean {
|
||||
const request = pendingRequests.get(id);
|
||||
if (!request) return false;
|
||||
clearTimeout(request.timeout);
|
||||
pendingRequests.delete(id);
|
||||
request.resolve(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function rejectPendingGatewayRequest(
|
||||
pendingRequests: Map<string, PendingGatewayRequest>,
|
||||
id: string,
|
||||
error: Error,
|
||||
): boolean {
|
||||
const request = pendingRequests.get(id);
|
||||
if (!request) return false;
|
||||
clearTimeout(request.timeout);
|
||||
pendingRequests.delete(id);
|
||||
request.reject(error);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { logger } from '../utils/logger';
|
||||
import {
|
||||
getDeferredRestartAction,
|
||||
shouldDeferRestart,
|
||||
type GatewayLifecycleState,
|
||||
} from './process-policy';
|
||||
|
||||
type RestartDeferralState = {
|
||||
state: GatewayLifecycleState;
|
||||
startLock: boolean;
|
||||
};
|
||||
|
||||
type DeferredRestartContext = RestartDeferralState & {
|
||||
shouldReconnect: boolean;
|
||||
};
|
||||
|
||||
export class GatewayRestartController {
|
||||
private deferredRestartPending = false;
|
||||
private restartDebounceTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
isRestartDeferred(context: RestartDeferralState): boolean {
|
||||
return shouldDeferRestart(context);
|
||||
}
|
||||
|
||||
markDeferredRestart(reason: string, context: RestartDeferralState): void {
|
||||
if (!this.deferredRestartPending) {
|
||||
logger.info(
|
||||
`Deferring Gateway restart (${reason}) until startup/reconnect settles (state=${context.state}, startLock=${context.startLock})`,
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Gateway restart already deferred; keeping pending request (${reason}, state=${context.state}, startLock=${context.startLock})`,
|
||||
);
|
||||
}
|
||||
this.deferredRestartPending = true;
|
||||
}
|
||||
|
||||
flushDeferredRestart(
|
||||
trigger: string,
|
||||
context: DeferredRestartContext,
|
||||
executeRestart: () => void,
|
||||
): void {
|
||||
const action = getDeferredRestartAction({
|
||||
hasPendingRestart: this.deferredRestartPending,
|
||||
state: context.state,
|
||||
startLock: context.startLock,
|
||||
shouldReconnect: context.shouldReconnect,
|
||||
});
|
||||
|
||||
if (action === 'none') return;
|
||||
if (action === 'wait') {
|
||||
logger.debug(
|
||||
`Deferred Gateway restart still waiting (${trigger}, state=${context.state}, startLock=${context.startLock})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.deferredRestartPending = false;
|
||||
if (action === 'drop') {
|
||||
logger.info(
|
||||
`Dropping deferred Gateway restart (${trigger}) because lifecycle already recovered (state=${context.state}, shouldReconnect=${context.shouldReconnect})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Executing deferred Gateway restart now (${trigger})`);
|
||||
executeRestart();
|
||||
}
|
||||
|
||||
debouncedRestart(delayMs: number, executeRestart: () => void): void {
|
||||
if (this.restartDebounceTimer) {
|
||||
clearTimeout(this.restartDebounceTimer);
|
||||
}
|
||||
logger.debug(`Gateway restart debounced (will fire in ${delayMs}ms)`);
|
||||
this.restartDebounceTimer = setTimeout(() => {
|
||||
this.restartDebounceTimer = null;
|
||||
executeRestart();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
clearDebounceTimer(): void {
|
||||
if (this.restartDebounceTimer) {
|
||||
clearTimeout(this.restartDebounceTimer);
|
||||
this.restartDebounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
resetDeferredRestart(): void {
|
||||
this.deferredRestartPending = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { logger } from '../utils/logger';
|
||||
import { LifecycleSupersededError } from './lifecycle-controller';
|
||||
import { getGatewayStartupRecoveryAction } from './startup-recovery';
|
||||
|
||||
export interface ExistingGatewayInfo {
|
||||
port: number;
|
||||
externalToken?: string;
|
||||
}
|
||||
|
||||
type StartupHooks = {
|
||||
port: number;
|
||||
ownedPid?: number;
|
||||
shouldWaitForPortFree: boolean;
|
||||
maxStartAttempts?: number;
|
||||
resetStartupStderrLines: () => void;
|
||||
getStartupStderrLines: () => string[];
|
||||
assertLifecycle: (phase: string) => void;
|
||||
findExistingGateway: (port: number, ownedPid?: number) => Promise<ExistingGatewayInfo | null>;
|
||||
connect: (port: number, externalToken?: string) => Promise<void>;
|
||||
onConnectedToExistingGateway: () => void;
|
||||
waitForPortFree: (port: number) => Promise<void>;
|
||||
startProcess: () => Promise<void>;
|
||||
waitForReady: (port: number) => Promise<void>;
|
||||
onConnectedToManagedGateway: () => void;
|
||||
runDoctorRepair: () => Promise<boolean>;
|
||||
onDoctorRepairSuccess: () => void;
|
||||
delay: (ms: number) => Promise<void>;
|
||||
};
|
||||
|
||||
export async function runGatewayStartupSequence(hooks: StartupHooks): Promise<void> {
|
||||
let configRepairAttempted = false;
|
||||
let startAttempts = 0;
|
||||
const maxStartAttempts = hooks.maxStartAttempts ?? 3;
|
||||
|
||||
while (true) {
|
||||
startAttempts++;
|
||||
hooks.assertLifecycle('start');
|
||||
hooks.resetStartupStderrLines();
|
||||
|
||||
try {
|
||||
logger.debug('Checking for existing Gateway...');
|
||||
const existing = await hooks.findExistingGateway(hooks.port, hooks.ownedPid);
|
||||
hooks.assertLifecycle('start/find-existing');
|
||||
if (existing) {
|
||||
logger.debug(`Found existing Gateway on port ${existing.port}`);
|
||||
await hooks.connect(existing.port, existing.externalToken);
|
||||
hooks.assertLifecycle('start/connect-existing');
|
||||
hooks.onConnectedToExistingGateway();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('No existing Gateway found, starting new process...');
|
||||
|
||||
if (hooks.shouldWaitForPortFree) {
|
||||
await hooks.waitForPortFree(hooks.port);
|
||||
hooks.assertLifecycle('start/wait-port');
|
||||
}
|
||||
|
||||
await hooks.startProcess();
|
||||
hooks.assertLifecycle('start/start-process');
|
||||
|
||||
await hooks.waitForReady(hooks.port);
|
||||
hooks.assertLifecycle('start/wait-ready');
|
||||
|
||||
await hooks.connect(hooks.port);
|
||||
hooks.assertLifecycle('start/connect');
|
||||
|
||||
hooks.onConnectedToManagedGateway();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error instanceof LifecycleSupersededError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const recoveryAction = getGatewayStartupRecoveryAction({
|
||||
startupError: error,
|
||||
startupStderrLines: hooks.getStartupStderrLines(),
|
||||
configRepairAttempted,
|
||||
attempt: startAttempts,
|
||||
maxAttempts: maxStartAttempts,
|
||||
});
|
||||
|
||||
if (recoveryAction === 'repair') {
|
||||
configRepairAttempted = true;
|
||||
logger.warn(
|
||||
'Detected invalid OpenClaw config during Gateway startup; running doctor repair before retry',
|
||||
);
|
||||
const repaired = await hooks.runDoctorRepair();
|
||||
if (repaired) {
|
||||
logger.info('OpenClaw doctor repair completed; retrying Gateway startup');
|
||||
hooks.onDoctorRepairSuccess();
|
||||
continue;
|
||||
}
|
||||
logger.error('OpenClaw doctor repair failed; not retrying Gateway startup');
|
||||
}
|
||||
|
||||
if (recoveryAction === 'retry') {
|
||||
logger.warn(`Transient start error: ${String(error)}. Retrying... (${startAttempts}/${maxStartAttempts})`);
|
||||
await hooks.delay(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Gateway startup recovery heuristics.
|
||||
*
|
||||
* This module is intentionally dependency-free so it can be unit-tested
|
||||
* without Electron/runtime mocks.
|
||||
*/
|
||||
|
||||
const INVALID_CONFIG_PATTERNS: RegExp[] = [
|
||||
/\binvalid config\b/i,
|
||||
/\bconfig invalid\b/i,
|
||||
/\bunrecognized key\b/i,
|
||||
/\brun:\s*openclaw doctor --fix\b/i,
|
||||
];
|
||||
|
||||
const TRANSIENT_START_ERROR_PATTERNS: RegExp[] = [
|
||||
/WebSocket closed before handshake/i,
|
||||
/ECONNREFUSED/i,
|
||||
/Gateway process exited before becoming ready/i,
|
||||
/Timed out waiting for connect\.challenge/i,
|
||||
/Connect handshake timeout/i,
|
||||
];
|
||||
|
||||
function normalizeLogLine(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when text appears to indicate OpenClaw config validation failure.
|
||||
*/
|
||||
export function isInvalidConfigSignal(text: string): boolean {
|
||||
const normalized = normalizeLogLine(text);
|
||||
if (!normalized) return false;
|
||||
return INVALID_CONFIG_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when either startup stderr lines or startup error message
|
||||
* indicate an OpenClaw config validation failure.
|
||||
*/
|
||||
export function hasInvalidConfigFailureSignal(
|
||||
startupError: unknown,
|
||||
startupStderrLines: string[],
|
||||
): boolean {
|
||||
for (const line of startupStderrLines) {
|
||||
if (isInvalidConfigSignal(line)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const errorText = startupError instanceof Error
|
||||
? `${startupError.name}: ${startupError.message}`
|
||||
: String(startupError ?? '');
|
||||
|
||||
return isInvalidConfigSignal(errorText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry guard for one-time config repair during a single startup flow.
|
||||
*/
|
||||
export function shouldAttemptConfigAutoRepair(
|
||||
startupError: unknown,
|
||||
startupStderrLines: string[],
|
||||
alreadyAttempted: boolean,
|
||||
): boolean {
|
||||
if (alreadyAttempted) return false;
|
||||
return hasInvalidConfigFailureSignal(startupError, startupStderrLines);
|
||||
}
|
||||
|
||||
export function isTransientGatewayStartError(error: unknown): boolean {
|
||||
const errorText = error instanceof Error
|
||||
? `${error.name}: ${error.message}`
|
||||
: String(error ?? '');
|
||||
return TRANSIENT_START_ERROR_PATTERNS.some((pattern) => pattern.test(errorText));
|
||||
}
|
||||
|
||||
export type GatewayStartupRecoveryAction = 'repair' | 'retry' | 'fail';
|
||||
|
||||
export function getGatewayStartupRecoveryAction(options: {
|
||||
startupError: unknown;
|
||||
startupStderrLines: string[];
|
||||
configRepairAttempted: boolean;
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
}): GatewayStartupRecoveryAction {
|
||||
if (shouldAttemptConfigAutoRepair(
|
||||
options.startupError,
|
||||
options.startupStderrLines,
|
||||
options.configRepairAttempted,
|
||||
)) {
|
||||
return 'repair';
|
||||
}
|
||||
|
||||
if (options.attempt < options.maxAttempts && isTransientGatewayStartError(options.startupError)) {
|
||||
return 'retry';
|
||||
}
|
||||
|
||||
return 'fail';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export type GatewayStderrClassification = {
|
||||
level: 'drop' | 'debug' | 'warn';
|
||||
normalized: string;
|
||||
};
|
||||
|
||||
const MAX_STDERR_LINES = 120;
|
||||
|
||||
export function classifyGatewayStderrMessage(message: string): GatewayStderrClassification {
|
||||
const msg = message.trim();
|
||||
if (!msg) {
|
||||
return { level: 'drop', normalized: msg };
|
||||
}
|
||||
|
||||
// Known noisy lines that are not actionable for Gateway lifecycle debugging.
|
||||
if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) {
|
||||
return { level: 'drop', normalized: msg };
|
||||
}
|
||||
if (msg.includes('closed before connect') && msg.includes('token mismatch')) {
|
||||
return { level: 'drop', normalized: msg };
|
||||
}
|
||||
|
||||
// Downgrade frequent non-fatal noise.
|
||||
if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg };
|
||||
if (msg.includes('DeprecationWarning')) return { level: 'debug', normalized: msg };
|
||||
if (msg.includes('Debugger attached')) return { level: 'debug', normalized: msg };
|
||||
|
||||
// Electron restricts NODE_OPTIONS in packaged apps; this is expected and harmless.
|
||||
if (msg.includes('node: --require is not allowed in NODE_OPTIONS')) {
|
||||
return { level: 'debug', normalized: msg };
|
||||
}
|
||||
|
||||
return { level: 'warn', normalized: msg };
|
||||
}
|
||||
|
||||
export function recordGatewayStartupStderrLine(lines: string[], line: string): void {
|
||||
const normalized = line.trim();
|
||||
if (!normalized) return;
|
||||
lines.push(normalized);
|
||||
if (lines.length > MAX_STDERR_LINES) {
|
||||
lines.splice(0, lines.length - MAX_STDERR_LINES);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { PORTS } from '../utils/config';
|
||||
import { logger } from '../utils/logger';
|
||||
import type { GatewayStatus } from './manager';
|
||||
|
||||
type GatewayStateHooks = {
|
||||
emitStatus: (status: GatewayStatus) => void;
|
||||
onTransition?: (previousState: GatewayStatus['state'], nextState: GatewayStatus['state']) => void;
|
||||
};
|
||||
|
||||
export class GatewayStateController {
|
||||
private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY };
|
||||
|
||||
constructor(private readonly hooks: GatewayStateHooks) {}
|
||||
|
||||
getStatus(): GatewayStatus {
|
||||
return { ...this.status };
|
||||
}
|
||||
|
||||
isConnected(isSocketOpen: boolean): boolean {
|
||||
return this.status.state === 'running' && isSocketOpen;
|
||||
}
|
||||
|
||||
setStatus(update: Partial<GatewayStatus>): void {
|
||||
const previousState = this.status.state;
|
||||
this.status = { ...this.status, ...update };
|
||||
|
||||
if (this.status.state === 'running' && this.status.connectedAt) {
|
||||
this.status.uptime = Date.now() - this.status.connectedAt;
|
||||
}
|
||||
|
||||
this.hooks.emitStatus(this.status);
|
||||
|
||||
if (previousState !== this.status.state) {
|
||||
logger.debug(`Gateway state changed: ${previousState} -> ${this.status.state}`);
|
||||
this.hooks.onTransition?.(previousState, this.status.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { app, utilityProcess } from 'electron';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import WebSocket from 'ws';
|
||||
import { getOpenClawDir, getOpenClawEntryPath } from '../utils/paths';
|
||||
import { getUvMirrorEnv } from '../utils/uv-env';
|
||||
import { isPythonReady, setupManagedPython } from '../utils/uv-setup';
|
||||
import { logger } from '../utils/logger';
|
||||
import { prependPathEntry } from '../utils/env-path';
|
||||
|
||||
export function warmupManagedPythonReadiness(): void {
|
||||
void isPythonReady().then((pythonReady) => {
|
||||
if (!pythonReady) {
|
||||
logger.info('Python environment missing or incomplete, attempting background repair...');
|
||||
void setupManagedPython().catch((err) => {
|
||||
logger.error('Background Python repair failed:', err);
|
||||
});
|
||||
}
|
||||
}).catch((err) => {
|
||||
logger.error('Failed to check Python environment:', err);
|
||||
});
|
||||
}
|
||||
|
||||
export async function terminateOwnedGatewayProcess(child: Electron.UtilityProcess): Promise<void> {
|
||||
let exited = false;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
child.once('exit', () => {
|
||||
exited = true;
|
||||
resolve();
|
||||
});
|
||||
|
||||
const pid = child.pid;
|
||||
logger.info(`Sending kill to Gateway process (pid=${pid ?? 'unknown'})`);
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// ignore if already exited
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!exited) {
|
||||
logger.warn(`Gateway did not exit in time, force-killing (pid=${pid ?? 'unknown'})`);
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function unloadLaunchctlGatewayService(): Promise<void> {
|
||||
if (process.platform !== 'darwin') return;
|
||||
|
||||
try {
|
||||
const uid = process.getuid?.();
|
||||
if (uid === undefined) return;
|
||||
|
||||
const launchdLabel = 'ai.openclaw.gateway';
|
||||
const serviceTarget = `gui/${uid}/${launchdLabel}`;
|
||||
const cp = await import('child_process');
|
||||
const fsPromises = await import('fs/promises');
|
||||
const os = await import('os');
|
||||
|
||||
const loaded = await new Promise<boolean>((resolve) => {
|
||||
cp.exec(`launchctl print ${serviceTarget}`, { timeout: 5000 }, (err) => {
|
||||
resolve(!err);
|
||||
});
|
||||
});
|
||||
|
||||
if (!loaded) return;
|
||||
|
||||
logger.info(`Unloading launchctl service ${serviceTarget} to prevent auto-respawn`);
|
||||
await new Promise<void>((resolve) => {
|
||||
cp.exec(`launchctl bootout ${serviceTarget}`, { timeout: 10000 }, (err) => {
|
||||
if (err) {
|
||||
logger.warn(`Failed to bootout launchctl service: ${err.message}`);
|
||||
} else {
|
||||
logger.info('Successfully unloaded launchctl gateway service');
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
try {
|
||||
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${launchdLabel}.plist`);
|
||||
await fsPromises.access(plistPath);
|
||||
await fsPromises.unlink(plistPath);
|
||||
logger.info(`Removed legacy launchd plist to prevent reload on next login: ${plistPath}`);
|
||||
} catch {
|
||||
// File doesn't exist or can't be removed -- not fatal
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Error while unloading launchctl gateway service:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForPortFree(port: number, timeoutMs = 30000): Promise<void> {
|
||||
const net = await import('net');
|
||||
const start = Date.now();
|
||||
const pollInterval = 500;
|
||||
let logged = false;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const available = await new Promise<boolean>((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', () => resolve(false));
|
||||
server.once('listening', () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
server.listen(port, '127.0.0.1');
|
||||
});
|
||||
|
||||
if (available) {
|
||||
const elapsed = Date.now() - start;
|
||||
if (elapsed > pollInterval) {
|
||||
logger.info(`Port ${port} became available after ${elapsed}ms`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!logged) {
|
||||
logger.info(`Waiting for port ${port} to become available (Windows TCP TIME_WAIT)...`);
|
||||
logged = true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
logger.warn(`Port ${port} still occupied after ${timeoutMs}ms, proceeding anyway`);
|
||||
}
|
||||
|
||||
async function getListeningProcessIds(port: number): Promise<string[]> {
|
||||
const cmd = process.platform === 'win32'
|
||||
? `netstat -ano | findstr :${port}`
|
||||
: `lsof -i :${port} -sTCP:LISTEN -t`;
|
||||
|
||||
const cp = await import('child_process');
|
||||
const { stdout } = await new Promise<{ stdout: string }>((resolve) => {
|
||||
cp.exec(cmd, { timeout: 5000, windowsHide: true }, (err, stdout) => {
|
||||
if (err) {
|
||||
resolve({ stdout: '' });
|
||||
} else {
|
||||
resolve({ stdout });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const pids: string[] = [];
|
||||
for (const line of stdout.trim().split(/\r?\n/)) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 5 && parts[3] === 'LISTENING') {
|
||||
pids.push(parts[4]);
|
||||
}
|
||||
}
|
||||
return [...new Set(pids)];
|
||||
}
|
||||
|
||||
return [...new Set(stdout.trim().split(/\r?\n/).map((value) => value.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
async function terminateOrphanedProcessIds(port: number, pids: string[]): Promise<void> {
|
||||
logger.info(`Found orphaned process listening on port ${port} (PIDs: ${pids.join(', ')}), attempting to kill...`);
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
await unloadLaunchctlGatewayService();
|
||||
}
|
||||
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const cp = await import('child_process');
|
||||
await new Promise<void>((resolve) => {
|
||||
cp.exec(
|
||||
`taskkill /F /PID ${pid} /T`,
|
||||
{ timeout: 5000, windowsHide: true },
|
||||
() => resolve(),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
process.kill(parseInt(pid, 10), 'SIGTERM');
|
||||
}
|
||||
} catch {
|
||||
// Ignore processes that have already exited.
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, process.platform === 'win32' ? 2000 : 3000));
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
process.kill(parseInt(pid, 10), 0);
|
||||
process.kill(parseInt(pid, 10), 'SIGKILL');
|
||||
} catch {
|
||||
// Already exited.
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
export async function findExistingGatewayProcess(options: {
|
||||
port: number;
|
||||
ownedPid?: number;
|
||||
}): Promise<{ port: number; externalToken?: string } | null> {
|
||||
const { port, ownedPid } = options;
|
||||
|
||||
try {
|
||||
try {
|
||||
const pids = await getListeningProcessIds(port);
|
||||
if (pids.length > 0 && (!ownedPid || !pids.includes(String(ownedPid)))) {
|
||||
await terminateOrphanedProcessIds(port, pids);
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Error checking for existing process on port:', err);
|
||||
}
|
||||
|
||||
return await new Promise<{ port: number; externalToken?: string } | null>((resolve) => {
|
||||
const testWs = new WebSocket(`ws://localhost:${port}/ws`);
|
||||
const timeout = setTimeout(() => {
|
||||
testWs.close();
|
||||
resolve(null);
|
||||
}, 2000);
|
||||
|
||||
testWs.on('open', () => {
|
||||
clearTimeout(timeout);
|
||||
testWs.close();
|
||||
resolve({ port });
|
||||
});
|
||||
|
||||
testWs.on('error', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runOpenClawDoctorRepair(): Promise<boolean> {
|
||||
const openclawDir = getOpenClawDir();
|
||||
const entryScript = getOpenClawEntryPath();
|
||||
if (!existsSync(entryScript)) {
|
||||
logger.error(`Cannot run OpenClaw doctor repair: entry script not found at ${entryScript}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
const target = `${platform}-${arch}`;
|
||||
const binPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'bin')
|
||||
: path.join(process.cwd(), 'resources', 'bin', target);
|
||||
const binPathExists = existsSync(binPath);
|
||||
const baseProcessEnv = process.env as Record<string, string | undefined>;
|
||||
const baseEnvPatched = binPathExists
|
||||
? prependPathEntry(baseProcessEnv, binPath).env
|
||||
: baseProcessEnv;
|
||||
|
||||
const uvEnv = await getUvMirrorEnv();
|
||||
const doctorArgs = ['doctor', '--fix', '--yes', '--non-interactive'];
|
||||
logger.info(
|
||||
`Running OpenClaw doctor repair (entry="${entryScript}", args="${doctorArgs.join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'})`,
|
||||
);
|
||||
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const forkEnv: Record<string, string | undefined> = {
|
||||
...baseEnvPatched,
|
||||
...uvEnv,
|
||||
OPENCLAW_NO_RESPAWN: '1',
|
||||
};
|
||||
|
||||
const child = utilityProcess.fork(entryScript, doctorArgs, {
|
||||
cwd: openclawDir,
|
||||
stdio: 'pipe',
|
||||
env: forkEnv as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
const finish = (ok: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(ok);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
logger.error('OpenClaw doctor repair timed out after 120000ms');
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
finish(false);
|
||||
}, 120000);
|
||||
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
logger.error('Failed to spawn OpenClaw doctor repair process:', err);
|
||||
finish(false);
|
||||
});
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
const raw = data.toString();
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const normalized = line.trim();
|
||||
if (!normalized) continue;
|
||||
logger.debug(`[Gateway doctor stdout] ${normalized}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
const raw = data.toString();
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const normalized = line.trim();
|
||||
if (!normalized) continue;
|
||||
logger.warn(`[Gateway doctor stderr] ${normalized}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('exit', (code: number) => {
|
||||
clearTimeout(timeout);
|
||||
if (code === 0) {
|
||||
logger.info('OpenClaw doctor repair completed successfully');
|
||||
finish(true);
|
||||
return;
|
||||
}
|
||||
logger.warn(`OpenClaw doctor repair exited (code=${code})`);
|
||||
finish(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import WebSocket from 'ws';
|
||||
import type { DeviceIdentity } from '../utils/device-identity';
|
||||
import type { PendingGatewayRequest } from './request-store';
|
||||
import {
|
||||
buildDeviceAuthPayload,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
signDevicePayload,
|
||||
} from '../utils/device-identity';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export async function probeGatewayReady(
|
||||
port: number,
|
||||
timeoutMs = 1500,
|
||||
): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const testWs = new WebSocket(`ws://localhost:${port}/ws`);
|
||||
let settled = false;
|
||||
|
||||
const resolveOnce = (value: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
testWs.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
resolveOnce(false);
|
||||
}, timeoutMs);
|
||||
|
||||
testWs.on('open', () => {
|
||||
// Do not resolve on plain socket open. The gateway can accept the TCP/WebSocket
|
||||
// connection before it is ready to issue protocol challenges, which previously
|
||||
// caused a false "ready" result and then a full connect() stall.
|
||||
});
|
||||
|
||||
testWs.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString()) as { type?: string; event?: string };
|
||||
if (message.type === 'event' && message.event === 'connect.challenge') {
|
||||
resolveOnce(true);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed probe payloads
|
||||
}
|
||||
});
|
||||
|
||||
testWs.on('error', () => {
|
||||
resolveOnce(false);
|
||||
});
|
||||
|
||||
testWs.on('close', () => {
|
||||
resolveOnce(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForGatewayReady(options: {
|
||||
port: number;
|
||||
getProcessExitCode: () => number | null;
|
||||
retries?: number;
|
||||
intervalMs?: number;
|
||||
}): Promise<void> {
|
||||
const retries = options.retries ?? 2400;
|
||||
const intervalMs = options.intervalMs ?? 200;
|
||||
|
||||
for (let i = 0; i < retries; i++) {
|
||||
const exitCode = options.getProcessExitCode();
|
||||
if (exitCode !== null) {
|
||||
logger.error(`Gateway process exited before ready (code=${exitCode})`);
|
||||
throw new Error(`Gateway process exited before becoming ready (code=${exitCode})`);
|
||||
}
|
||||
|
||||
try {
|
||||
const ready = await probeGatewayReady(options.port, 1500);
|
||||
if (ready) {
|
||||
logger.debug(`Gateway ready after ${i + 1} attempt(s)`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Gateway not ready yet.
|
||||
}
|
||||
|
||||
if (i > 0 && i % 10 === 0) {
|
||||
logger.debug(`Still waiting for Gateway... (attempt ${i + 1}/${retries})`);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
|
||||
logger.error(`Gateway failed to become ready after ${retries} attempts on port ${options.port}`);
|
||||
throw new Error(`Gateway failed to start after ${retries} retries (port ${options.port})`);
|
||||
}
|
||||
|
||||
export function buildGatewayConnectFrame(options: {
|
||||
challengeNonce: string;
|
||||
token: string;
|
||||
deviceIdentity: DeviceIdentity | null;
|
||||
platform: string;
|
||||
}): { connectId: string; frame: Record<string, unknown> } {
|
||||
const connectId = `connect-${Date.now()}`;
|
||||
const role = 'operator';
|
||||
const scopes = ['operator.admin'];
|
||||
const signedAtMs = Date.now();
|
||||
const clientId = 'gateway-client';
|
||||
const clientMode = 'ui';
|
||||
|
||||
const device = (() => {
|
||||
if (!options.deviceIdentity) return undefined;
|
||||
|
||||
const payload = buildDeviceAuthPayload({
|
||||
deviceId: options.deviceIdentity.deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopes,
|
||||
signedAtMs,
|
||||
token: options.token ?? null,
|
||||
nonce: options.challengeNonce,
|
||||
});
|
||||
const signature = signDevicePayload(options.deviceIdentity.privateKeyPem, payload);
|
||||
return {
|
||||
id: options.deviceIdentity.deviceId,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(options.deviceIdentity.publicKeyPem),
|
||||
signature,
|
||||
signedAt: signedAtMs,
|
||||
nonce: options.challengeNonce,
|
||||
};
|
||||
})();
|
||||
|
||||
return {
|
||||
connectId,
|
||||
frame: {
|
||||
type: 'req',
|
||||
id: connectId,
|
||||
method: 'connect',
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: clientId,
|
||||
displayName: 'ClawX',
|
||||
version: '0.1.0',
|
||||
platform: options.platform,
|
||||
mode: clientMode,
|
||||
},
|
||||
auth: {
|
||||
token: options.token,
|
||||
},
|
||||
caps: [],
|
||||
role,
|
||||
scopes,
|
||||
device,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function connectGatewaySocket(options: {
|
||||
port: number;
|
||||
deviceIdentity: DeviceIdentity | null;
|
||||
platform: string;
|
||||
pendingRequests: Map<string, PendingGatewayRequest>;
|
||||
getToken: () => Promise<string>;
|
||||
onHandshakeComplete: (ws: WebSocket) => void;
|
||||
onMessage: (message: unknown) => void;
|
||||
onCloseAfterHandshake: () => void;
|
||||
}): Promise<WebSocket> {
|
||||
logger.debug(`Connecting Gateway WebSocket (ws://localhost:${options.port}/ws)`);
|
||||
|
||||
return await new Promise<WebSocket>((resolve, reject) => {
|
||||
const wsUrl = `ws://localhost:${options.port}/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
let handshakeComplete = false;
|
||||
let connectId: string | null = null;
|
||||
let handshakeTimeout: NodeJS.Timeout | null = null;
|
||||
let challengeTimer: NodeJS.Timeout | null = null;
|
||||
let challengeReceived = false;
|
||||
let settled = false;
|
||||
|
||||
const cleanupHandshakeRequest = () => {
|
||||
if (challengeTimer) {
|
||||
clearTimeout(challengeTimer);
|
||||
challengeTimer = null;
|
||||
}
|
||||
if (handshakeTimeout) {
|
||||
clearTimeout(handshakeTimeout);
|
||||
handshakeTimeout = null;
|
||||
}
|
||||
if (connectId && options.pendingRequests.has(connectId)) {
|
||||
const request = options.pendingRequests.get(connectId);
|
||||
if (request) {
|
||||
clearTimeout(request.timeout);
|
||||
}
|
||||
options.pendingRequests.delete(connectId);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveOnce = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanupHandshakeRequest();
|
||||
resolve(ws);
|
||||
};
|
||||
|
||||
const rejectOnce = (error: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanupHandshakeRequest();
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
};
|
||||
|
||||
const sendConnectHandshake = async (challengeNonce: string) => {
|
||||
logger.debug('Sending connect handshake with challenge nonce');
|
||||
|
||||
const currentToken = await options.getToken();
|
||||
const connectPayload = buildGatewayConnectFrame({
|
||||
challengeNonce,
|
||||
token: currentToken,
|
||||
deviceIdentity: options.deviceIdentity,
|
||||
platform: options.platform,
|
||||
});
|
||||
connectId = connectPayload.connectId;
|
||||
|
||||
ws.send(JSON.stringify(connectPayload.frame));
|
||||
|
||||
const requestTimeout = setTimeout(() => {
|
||||
if (!handshakeComplete) {
|
||||
logger.error('Gateway connect handshake timed out');
|
||||
ws.close();
|
||||
rejectOnce(new Error('Connect handshake timeout'));
|
||||
}
|
||||
}, 10000);
|
||||
handshakeTimeout = requestTimeout;
|
||||
|
||||
options.pendingRequests.set(connectId, {
|
||||
resolve: () => {
|
||||
handshakeComplete = true;
|
||||
logger.debug('Gateway connect handshake completed');
|
||||
options.onHandshakeComplete(ws);
|
||||
resolveOnce();
|
||||
},
|
||||
reject: (error) => {
|
||||
logger.error('Gateway connect handshake failed:', error);
|
||||
rejectOnce(error);
|
||||
},
|
||||
timeout: requestTimeout,
|
||||
});
|
||||
};
|
||||
|
||||
challengeTimer = setTimeout(() => {
|
||||
if (!challengeReceived && !settled) {
|
||||
logger.error('Gateway connect.challenge not received within timeout');
|
||||
ws.close();
|
||||
rejectOnce(new Error('Timed out waiting for connect.challenge from Gateway'));
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
ws.on('open', () => {
|
||||
logger.debug('Gateway WebSocket opened, waiting for connect.challenge...');
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
if (
|
||||
!challengeReceived &&
|
||||
typeof message === 'object' && message !== null &&
|
||||
message.type === 'event' && message.event === 'connect.challenge'
|
||||
) {
|
||||
challengeReceived = true;
|
||||
if (challengeTimer) {
|
||||
clearTimeout(challengeTimer);
|
||||
challengeTimer = null;
|
||||
}
|
||||
const nonce = message.payload?.nonce as string | undefined;
|
||||
if (!nonce) {
|
||||
rejectOnce(new Error('Gateway connect.challenge missing nonce'));
|
||||
return;
|
||||
}
|
||||
logger.debug('Received connect.challenge, sending handshake');
|
||||
void sendConnectHandshake(nonce);
|
||||
return;
|
||||
}
|
||||
|
||||
options.onMessage(message);
|
||||
} catch (error) {
|
||||
logger.debug('Failed to parse Gateway WebSocket message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
const reasonStr = reason?.toString() || 'unknown';
|
||||
logger.warn(`Gateway WebSocket closed (code=${code}, reason=${reasonStr}, handshake=${handshakeComplete ? 'ok' : 'pending'})`);
|
||||
if (!handshakeComplete) {
|
||||
rejectOnce(new Error(`WebSocket closed before handshake: ${reasonStr}`));
|
||||
return;
|
||||
}
|
||||
cleanupHandshakeRequest();
|
||||
options.onCloseAfterHandshake();
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
if (error.message?.includes('closed before handshake') || (error as NodeJS.ErrnoException).code === 'ECONNREFUSED') {
|
||||
logger.debug(`Gateway WebSocket connection error (transient): ${error.message}`);
|
||||
} else {
|
||||
logger.error('Gateway WebSocket error:', error);
|
||||
}
|
||||
if (!handshakeComplete) {
|
||||
rejectOnce(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Application quit state.
|
||||
*
|
||||
* Exposed as a function accessor (not a bare `export let`) so that every
|
||||
* import site reads the *live* value. With `export let`, bundlers that
|
||||
* compile to CJS may snapshot the variable at import time, causing
|
||||
* `isQuitting` to stay `false` forever and preventing the window from
|
||||
* closing on Windows/Linux.
|
||||
*/
|
||||
let _isQuitting = false;
|
||||
|
||||
export function isQuitting(): boolean {
|
||||
return _isQuitting;
|
||||
}
|
||||
|
||||
export function setQuitting(value = true): void {
|
||||
_isQuitting = value;
|
||||
}
|
||||
+240
-44
@@ -3,6 +3,7 @@
|
||||
* 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';
|
||||
@@ -12,16 +13,62 @@ import { createMenu } from './menu';
|
||||
import { appUpdater, registerUpdateHandlers } from './updater';
|
||||
import { logger } from '../utils/logger';
|
||||
import { warmupNetworkOptimization } from '../utils/uv-env';
|
||||
import { initTelemetry } from '../utils/telemetry';
|
||||
|
||||
import { ClawHubService } from '../gateway/clawhub';
|
||||
import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace';
|
||||
import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToProfile } from '../utils/openclaw-cli';
|
||||
import { isQuitting, setQuitting } from './app-state';
|
||||
import { applyProxySettings } from './proxy';
|
||||
import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup';
|
||||
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';
|
||||
import { syncAllProviderAuthToRuntime } from '../services/providers/provider-runtime-sync';
|
||||
|
||||
// Disable GPU acceleration for better compatibility
|
||||
// Disable GPU hardware acceleration globally for maximum stability across
|
||||
// all GPU configurations (no GPU, integrated, discrete).
|
||||
//
|
||||
// Rationale (following VS Code's philosophy):
|
||||
// - Page/file loading is async data fetching — zero GPU dependency.
|
||||
// - The original per-platform GPU branching was added to avoid CPU rendering
|
||||
// competing with sync I/O on Windows, but all file I/O is now async
|
||||
// (fs/promises), so that concern no longer applies.
|
||||
// - Software rendering is deterministic across all hardware; GPU compositing
|
||||
// behaviour varies between vendors (Intel, AMD, NVIDIA, Apple Silicon) and
|
||||
// driver versions, making it the #1 source of rendering bugs in Electron.
|
||||
//
|
||||
// Users who want GPU acceleration can pass `--enable-gpu` on the CLI or
|
||||
// set `"disable-hardware-acceleration": false` in the app config (future).
|
||||
app.disableHardwareAcceleration();
|
||||
|
||||
// On Linux, set CHROME_DESKTOP so Chromium can find the correct .desktop file.
|
||||
// On Wayland this maps the running window to clawx.desktop (→ icon + app grouping);
|
||||
// on X11 it supplements the StartupWMClass matching.
|
||||
// Must be called before app.whenReady() / before any window is created.
|
||||
if (process.platform === 'linux') {
|
||||
app.setDesktopName('clawx.desktop');
|
||||
}
|
||||
|
||||
// Prevent multiple instances of the app from running simultaneously.
|
||||
// Without this, two instances each spawn their own gateway process on the
|
||||
// same port, then each treats the other's gateway as "orphaned" and kills
|
||||
// it — creating an infinite kill/restart loop on Windows.
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
if (!gotTheLock) {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
// Global references
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
const gatewayManager = new GatewayManager();
|
||||
const clawHubService = new ClawHubService();
|
||||
const hostEventBus = new HostEventBus();
|
||||
let hostApiServer: Server | null = null;
|
||||
|
||||
/**
|
||||
* Resolve the icons directory path (works in both dev and packaged mode)
|
||||
@@ -111,6 +158,13 @@ async function initialize(): Promise<void> {
|
||||
// Warm up network optimization (non-blocking)
|
||||
void warmupNetworkOptimization();
|
||||
|
||||
// Initialize Telemetry early
|
||||
await initTelemetry();
|
||||
|
||||
// Apply persisted proxy settings before creating windows or network requests.
|
||||
await applyProxySettings();
|
||||
await syncLaunchAtStartupSettingFromStore();
|
||||
|
||||
// Set application menu
|
||||
createMenu();
|
||||
|
||||
@@ -120,75 +174,210 @@ async function initialize(): Promise<void> {
|
||||
// Create system tray
|
||||
createTray(mainWindow);
|
||||
|
||||
// Inject OpenRouter site headers (HTTP-Referer & X-Title) for rankings on openrouter.ai
|
||||
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||
{ urls: ['https://openrouter.ai/*'] },
|
||||
// Override security headers ONLY for the OpenClaw Gateway Control UI.
|
||||
// The URL filter ensures this callback only fires for gateway requests,
|
||||
// avoiding unnecessary overhead on every other HTTP response.
|
||||
session.defaultSession.webRequest.onHeadersReceived(
|
||||
{ urls: ['http://127.0.0.1:18789/*', 'http://localhost:18789/*'] },
|
||||
(details, callback) => {
|
||||
details.requestHeaders['HTTP-Referer'] = 'https://claw-x.com';
|
||||
details.requestHeaders['X-Title'] = 'ClawX';
|
||||
callback({ requestHeaders: details.requestHeaders });
|
||||
const headers = { ...details.responseHeaders };
|
||||
delete headers['X-Frame-Options'];
|
||||
delete headers['x-frame-options'];
|
||||
if (headers['Content-Security-Policy']) {
|
||||
headers['Content-Security-Policy'] = headers['Content-Security-Policy'].map(
|
||||
(csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *")
|
||||
);
|
||||
}
|
||||
if (headers['content-security-policy']) {
|
||||
headers['content-security-policy'] = headers['content-security-policy'].map(
|
||||
(csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *")
|
||||
);
|
||||
}
|
||||
callback({ responseHeaders: headers });
|
||||
},
|
||||
);
|
||||
|
||||
// Override security headers ONLY for the OpenClaw Gateway Control UI
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||
const isGatewayUrl = details.url.includes('127.0.0.1:18789') || details.url.includes('localhost:18789');
|
||||
|
||||
if (!isGatewayUrl) {
|
||||
callback({ responseHeaders: details.responseHeaders });
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = { ...details.responseHeaders };
|
||||
delete headers['X-Frame-Options'];
|
||||
delete headers['x-frame-options'];
|
||||
if (headers['Content-Security-Policy']) {
|
||||
headers['Content-Security-Policy'] = headers['Content-Security-Policy'].map(
|
||||
(csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *")
|
||||
);
|
||||
}
|
||||
if (headers['content-security-policy']) {
|
||||
headers['content-security-policy'] = headers['content-security-policy'].map(
|
||||
(csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *")
|
||||
);
|
||||
}
|
||||
callback({ responseHeaders: headers });
|
||||
});
|
||||
|
||||
// Register IPC handlers
|
||||
registerIpcHandlers(gatewayManager, clawHubService, mainWindow);
|
||||
|
||||
hostApiServer = startHostApiServer({
|
||||
gatewayManager,
|
||||
clawHubService,
|
||||
eventBus: hostEventBus,
|
||||
mainWindow,
|
||||
});
|
||||
|
||||
// Register update handlers
|
||||
registerUpdateHandlers(appUpdater, mainWindow);
|
||||
|
||||
// Note: Auto-check for updates is driven by the renderer (update store init)
|
||||
// so it respects the user's "Auto-check for updates" setting.
|
||||
|
||||
// Handle window close
|
||||
// Minimize to tray on close instead of quitting (macOS & Windows)
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!isQuitting()) {
|
||||
event.preventDefault();
|
||||
mainWindow?.hide();
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
// Start Gateway automatically
|
||||
try {
|
||||
logger.debug('Auto-starting Gateway...');
|
||||
await gatewayManager.start();
|
||||
logger.info('Gateway auto-start succeeded');
|
||||
} catch (error) {
|
||||
logger.error('Gateway auto-start failed:', error);
|
||||
mainWindow?.webContents.send('gateway:error', String(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.
|
||||
void repairClawXOnlyBootstrapFiles().catch((error) => {
|
||||
logger.warn('Failed to repair bootstrap files:', error);
|
||||
});
|
||||
|
||||
// Pre-deploy built-in skills (feishu-doc, feishu-drive, feishu-perm, feishu-wiki)
|
||||
// to ~/.openclaw/skills/ so they are immediately available without manual install.
|
||||
void ensureBuiltinSkillsInstalled().catch((error) => {
|
||||
logger.warn('Failed to install built-in skills:', error);
|
||||
});
|
||||
|
||||
// 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.
|
||||
void ensurePreinstalledSkillsInstalled().catch((error) => {
|
||||
logger.warn('Failed to install preinstalled skills:', error);
|
||||
});
|
||||
|
||||
// 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 }) => {
|
||||
hostEventBus.emit('gateway:status', status);
|
||||
if (status.state === 'running') {
|
||||
void ensureClawXContext().catch((error) => {
|
||||
logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
gatewayManager.on('error', (error) => {
|
||||
hostEventBus.emit('gateway:error', { message: error.message });
|
||||
});
|
||||
|
||||
gatewayManager.on('notification', (notification) => {
|
||||
hostEventBus.emit('gateway:notification', notification);
|
||||
});
|
||||
|
||||
gatewayManager.on('chat:message', (data) => {
|
||||
hostEventBus.emit('gateway:chat-message', data);
|
||||
});
|
||||
|
||||
gatewayManager.on('channel:status', (data) => {
|
||||
hostEventBus.emit('gateway:channel-status', data);
|
||||
});
|
||||
|
||||
gatewayManager.on('exit', (code) => {
|
||||
hostEventBus.emit('gateway:exit', { code });
|
||||
});
|
||||
|
||||
deviceOAuthManager.on('oauth:code', (payload) => {
|
||||
hostEventBus.emit('oauth:code', payload);
|
||||
});
|
||||
|
||||
deviceOAuthManager.on('oauth:start', (payload) => {
|
||||
hostEventBus.emit('oauth:start', payload);
|
||||
});
|
||||
|
||||
deviceOAuthManager.on('oauth:success', (payload) => {
|
||||
hostEventBus.emit('oauth:success', { ...payload, success: true });
|
||||
});
|
||||
|
||||
deviceOAuthManager.on('oauth:error', (error) => {
|
||||
hostEventBus.emit('oauth:error', error);
|
||||
});
|
||||
|
||||
browserOAuthManager.on('oauth:start', (payload) => {
|
||||
hostEventBus.emit('oauth:start', payload);
|
||||
});
|
||||
|
||||
browserOAuthManager.on('oauth:code', (payload) => {
|
||||
hostEventBus.emit('oauth:code', payload);
|
||||
});
|
||||
|
||||
browserOAuthManager.on('oauth:success', (payload) => {
|
||||
hostEventBus.emit('oauth:success', { ...payload, success: true });
|
||||
});
|
||||
|
||||
browserOAuthManager.on('oauth:error', (error) => {
|
||||
hostEventBus.emit('oauth:error', error);
|
||||
});
|
||||
|
||||
whatsAppLoginManager.on('qr', (data) => {
|
||||
hostEventBus.emit('channel:whatsapp-qr', data);
|
||||
});
|
||||
|
||||
whatsAppLoginManager.on('success', (data) => {
|
||||
hostEventBus.emit('channel:whatsapp-success', data);
|
||||
});
|
||||
|
||||
whatsAppLoginManager.on('error', (error) => {
|
||||
hostEventBus.emit('channel:whatsapp-error', error);
|
||||
});
|
||||
|
||||
// Start Gateway automatically (this seeds missing bootstrap files with full templates)
|
||||
const gatewayAutoStart = await getSetting('gatewayAutoStart');
|
||||
if (gatewayAutoStart) {
|
||||
try {
|
||||
await syncAllProviderAuthToRuntime();
|
||||
logger.debug('Auto-starting Gateway...');
|
||||
await gatewayManager.start();
|
||||
logger.info('Gateway auto-start succeeded');
|
||||
} catch (error) {
|
||||
logger.error('Gateway auto-start failed:', error);
|
||||
mainWindow?.webContents.send('gateway:error', String(error));
|
||||
}
|
||||
} else {
|
||||
logger.info('Gateway auto-start disabled in settings');
|
||||
}
|
||||
|
||||
// Merge ClawX context snippets into the workspace bootstrap files.
|
||||
// The gateway seeds workspace files asynchronously after its HTTP server
|
||||
// is ready, so ensureClawXContext will retry until the target files appear.
|
||||
void ensureClawXContext().catch((error) => {
|
||||
logger.warn('Failed to merge ClawX context into workspace:', error);
|
||||
});
|
||||
|
||||
// Auto-install openclaw CLI and shell completions (non-blocking).
|
||||
void autoInstallCliIfNeeded((installedPath) => {
|
||||
mainWindow?.webContents.send('openclaw:cli-installed', installedPath);
|
||||
}).then(() => {
|
||||
generateCompletionCache();
|
||||
installCompletionToProfile();
|
||||
}).catch((error) => {
|
||||
logger.warn('CLI auto-install failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// When a second instance is launched, focus the existing window instead.
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Application lifecycle
|
||||
app.whenReady().then(() => {
|
||||
initialize();
|
||||
void initialize().catch((error) => {
|
||||
logger.error('Application initialization failed:', error);
|
||||
});
|
||||
|
||||
// Register activate handler AFTER app is ready to prevent
|
||||
// "Cannot create BrowserWindow before app is ready" on macOS.
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
mainWindow = createWindow();
|
||||
} else if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
// On macOS, clicking the dock icon should show the window if it's hidden
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -199,8 +388,15 @@ app.on('window-all-closed', () => {
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
await gatewayManager.stop();
|
||||
app.on('before-quit', () => {
|
||||
setQuitting();
|
||||
hostEventBus.closeAll();
|
||||
hostApiServer?.close();
|
||||
// Fire-and-forget: do not await gatewayManager.stop() here.
|
||||
// Awaiting inside before-quit can stall Electron's quit sequence.
|
||||
void gatewayManager.stop().catch((err) => {
|
||||
logger.warn('gatewayManager.stop() error during quit:', err);
|
||||
});
|
||||
});
|
||||
|
||||
// Export for testing
|
||||
|
||||
+1474
-413
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
import { app } from 'electron';
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { logger } from '../utils/logger';
|
||||
import { getSetting } from '../utils/store';
|
||||
|
||||
const LINUX_AUTOSTART_FILE = join('.config', 'autostart', 'clawx.desktop');
|
||||
|
||||
function quoteDesktopArg(value: string): string {
|
||||
if (!value) return '""';
|
||||
const escaped = value.replace(/(["\\`$])/g, '\\$1');
|
||||
if (/[\s"'\\`$]/.test(value)) {
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getLinuxExecCommand(): string {
|
||||
if (app.isPackaged) {
|
||||
return quoteDesktopArg(process.execPath);
|
||||
}
|
||||
|
||||
const launchArgs = process.argv.slice(1).filter(Boolean);
|
||||
const cmdParts = [process.execPath, ...launchArgs].map(quoteDesktopArg);
|
||||
return cmdParts.join(' ');
|
||||
}
|
||||
|
||||
function getLinuxDesktopEntry(): string {
|
||||
return [
|
||||
'[Desktop Entry]',
|
||||
'Type=Application',
|
||||
'Version=1.0',
|
||||
'Name=ClawX',
|
||||
'Comment=ClawX - AI Assistant',
|
||||
`Exec=${getLinuxExecCommand()}`,
|
||||
'Terminal=false',
|
||||
'Categories=Utility;',
|
||||
'X-GNOME-Autostart-enabled=true',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function applyLinuxLaunchAtStartup(enabled: boolean): Promise<void> {
|
||||
const targetPath = join(app.getPath('home'), LINUX_AUTOSTART_FILE);
|
||||
if (enabled) {
|
||||
await mkdir(dirname(targetPath), { recursive: true });
|
||||
await writeFile(targetPath, getLinuxDesktopEntry(), 'utf8');
|
||||
logger.info(`Launch-at-startup enabled via desktop entry: ${targetPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await rm(targetPath, { force: true });
|
||||
logger.info(`Launch-at-startup disabled and desktop entry removed: ${targetPath}`);
|
||||
}
|
||||
|
||||
function applyWindowsOrMacLaunchAtStartup(enabled: boolean): void {
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin: enabled,
|
||||
openAsHidden: false,
|
||||
});
|
||||
logger.info(`Launch-at-startup ${enabled ? 'enabled' : 'disabled'} via login items`);
|
||||
}
|
||||
|
||||
export async function applyLaunchAtStartupSetting(enabled: boolean): Promise<void> {
|
||||
try {
|
||||
if (process.platform === 'linux') {
|
||||
await applyLinuxLaunchAtStartup(enabled);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32' || process.platform === 'darwin') {
|
||||
applyWindowsOrMacLaunchAtStartup(enabled);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(`Launch-at-startup unsupported on platform: ${process.platform}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to apply launch-at-startup=${enabled}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncLaunchAtStartupSettingFromStore(): Promise<void> {
|
||||
const launchAtStartup = await getSetting('launchAtStartup');
|
||||
await applyLaunchAtStartupSetting(Boolean(launchAtStartup));
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { getProviderConfig } from '../utils/provider-registry';
|
||||
import { getOpenClawProviderKeyForType, isOAuthProviderType } from '../utils/provider-keys';
|
||||
import type { ProviderConfig } from '../utils/secure-storage';
|
||||
|
||||
export interface AgentProviderUpdatePayload {
|
||||
providerKey: string;
|
||||
entry: {
|
||||
baseUrl: string;
|
||||
api: string;
|
||||
apiKey: string | undefined;
|
||||
models: Array<{ id: string; name: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export function getModelIdFromRef(modelRef: string | undefined, providerKey: string): string | undefined {
|
||||
if (!modelRef) return undefined;
|
||||
if (modelRef.startsWith(`${providerKey}/`)) {
|
||||
return modelRef.slice(providerKey.length + 1);
|
||||
}
|
||||
return modelRef;
|
||||
}
|
||||
|
||||
export function buildNonOAuthAgentProviderUpdate(
|
||||
provider: ProviderConfig,
|
||||
providerId: string,
|
||||
modelRef: string | undefined
|
||||
): AgentProviderUpdatePayload | null {
|
||||
if (provider.type === 'custom' || provider.type === 'ollama' || isOAuthProviderType(provider.type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerKey = getOpenClawProviderKeyForType(provider.type, providerId);
|
||||
const meta = getProviderConfig(provider.type);
|
||||
const baseUrl = provider.baseUrl || meta?.baseUrl;
|
||||
const api = meta?.api;
|
||||
if (!baseUrl || !api) return null;
|
||||
|
||||
const modelId = getModelIdFromRef(modelRef, providerKey);
|
||||
return {
|
||||
providerKey,
|
||||
entry: {
|
||||
baseUrl,
|
||||
api,
|
||||
apiKey: meta?.apiKeyEnv,
|
||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { session } from 'electron';
|
||||
import { getAllSettings, type AppSettings } from '../utils/store';
|
||||
import { buildElectronProxyConfig } from '../utils/proxy';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export async function applyProxySettings(
|
||||
partialSettings?: Pick<AppSettings, 'proxyEnabled' | 'proxyServer' | 'proxyBypassRules'>
|
||||
): Promise<void> {
|
||||
const settings = partialSettings ?? await getAllSettings();
|
||||
const config = buildElectronProxyConfig(settings);
|
||||
|
||||
await session.defaultSession.setProxy(config);
|
||||
try {
|
||||
await session.defaultSession.closeAllConnections();
|
||||
} catch (error) {
|
||||
logger.debug('Failed to close existing connections after proxy update:', error);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Applied Electron proxy (${config.mode}${config.proxyRules ? `, server=${config.proxyRules}` : ''}${config.proxyBypassRules ? `, bypass=${config.proxyBypassRules}` : ''})`
|
||||
);
|
||||
}
|
||||
+13
-4
@@ -59,14 +59,17 @@ export function createTray(mainWindow: BrowserWindow): Tray {
|
||||
// Set tooltip
|
||||
tray.setToolTip('ClawX - AI Assistant');
|
||||
|
||||
const showWindow = () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
};
|
||||
|
||||
// Create context menu
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Show ClawX',
|
||||
click: () => {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
},
|
||||
click: showWindow,
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
@@ -90,6 +93,7 @@ export function createTray(mainWindow: BrowserWindow): Tray {
|
||||
{
|
||||
label: 'Open Dashboard',
|
||||
click: () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.show();
|
||||
mainWindow.webContents.send('navigate', '/');
|
||||
},
|
||||
@@ -97,6 +101,7 @@ export function createTray(mainWindow: BrowserWindow): Tray {
|
||||
{
|
||||
label: 'Open Chat',
|
||||
click: () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.show();
|
||||
mainWindow.webContents.send('navigate', '/chat');
|
||||
},
|
||||
@@ -104,6 +109,7 @@ export function createTray(mainWindow: BrowserWindow): Tray {
|
||||
{
|
||||
label: 'Open Settings',
|
||||
click: () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.show();
|
||||
mainWindow.webContents.send('navigate', '/settings');
|
||||
},
|
||||
@@ -116,6 +122,7 @@ export function createTray(mainWindow: BrowserWindow): Tray {
|
||||
{
|
||||
label: 'Check for Updates...',
|
||||
click: () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.webContents.send('update:check');
|
||||
},
|
||||
},
|
||||
@@ -134,6 +141,7 @@ export function createTray(mainWindow: BrowserWindow): Tray {
|
||||
|
||||
// Click to show window (Windows/Linux)
|
||||
tray.on('click', () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide();
|
||||
} else {
|
||||
@@ -144,6 +152,7 @@ export function createTray(mainWindow: BrowserWindow): Tray {
|
||||
|
||||
// Double-click to show window (Windows)
|
||||
tray.on('double-click', () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
});
|
||||
|
||||
+79
-11
@@ -8,7 +8,9 @@
|
||||
*/
|
||||
import { autoUpdater, UpdateInfo, ProgressInfo, UpdateDownloadedEvent } from 'electron-updater';
|
||||
import { BrowserWindow, app, ipcMain } from 'electron';
|
||||
import { logger } from '../utils/logger';
|
||||
import { EventEmitter } from 'events';
|
||||
import { setQuitting } from './app-state';
|
||||
|
||||
/** Base CDN URL (without trailing channel path) */
|
||||
const OSS_BASE_URL = 'https://oss.intelli-spectrum.com';
|
||||
@@ -42,20 +44,29 @@ function detectChannel(version: string): string {
|
||||
export class AppUpdater extends EventEmitter {
|
||||
private mainWindow: BrowserWindow | null = null;
|
||||
private status: UpdateStatus = { status: 'idle' };
|
||||
private autoInstallTimer: NodeJS.Timeout | null = null;
|
||||
private autoInstallCountdown = 0;
|
||||
|
||||
/** Delay (in seconds) before auto-installing a downloaded update. */
|
||||
private static readonly AUTO_INSTALL_DELAY_SECONDS = 5;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// EventEmitter treats an unhandled 'error' event as fatal. Keep a default
|
||||
// listener so updater failures surface in logs/UI without terminating main.
|
||||
this.on('error', (error: Error) => {
|
||||
logger.error('[Updater] AppUpdater emitted error:', error);
|
||||
});
|
||||
|
||||
// Configure auto-updater
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// Use logger
|
||||
autoUpdater.logger = {
|
||||
info: (msg: string) => console.log('[Updater]', msg),
|
||||
warn: (msg: string) => console.warn('[Updater]', msg),
|
||||
error: (msg: string) => console.error('[Updater]', msg),
|
||||
debug: (msg: string) => console.debug('[Updater]', msg),
|
||||
info: (msg: string) => logger.info('[Updater]', msg),
|
||||
warn: (msg: string) => logger.warn('[Updater]', msg),
|
||||
error: (msg: string) => logger.error('[Updater]', msg),
|
||||
debug: (msg: string) => logger.debug('[Updater]', msg),
|
||||
};
|
||||
|
||||
// Override feed URL for prerelease channels so that
|
||||
@@ -64,7 +75,7 @@ export class AppUpdater extends EventEmitter {
|
||||
const channel = detectChannel(version);
|
||||
const feedUrl = `${OSS_BASE_URL}/${channel}`;
|
||||
|
||||
console.log(`[Updater] Version: ${version}, channel: ${channel}, feedUrl: ${feedUrl}`);
|
||||
logger.info(`[Updater] Version: ${version}, channel: ${channel}, feedUrl: ${feedUrl}`);
|
||||
|
||||
// Set channel so electron-updater requests the correct yml filename.
|
||||
// e.g. channel "alpha" → requests alpha-mac.yml, channel "latest" → requests latest-mac.yml
|
||||
@@ -120,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) => {
|
||||
@@ -132,7 +147,12 @@ export class AppUpdater extends EventEmitter {
|
||||
* Update status and notify renderer
|
||||
*/
|
||||
private updateStatus(newStatus: Partial<UpdateStatus>): void {
|
||||
this.status = { ...this.status, ...newStatus };
|
||||
this.status = {
|
||||
status: newStatus.status ?? this.status.status,
|
||||
info: newStatus.info,
|
||||
progress: newStatus.progress,
|
||||
error: newStatus.error,
|
||||
};
|
||||
this.sendToRenderer('update:status-changed', this.status);
|
||||
}
|
||||
|
||||
@@ -175,7 +195,7 @@ export class AppUpdater extends EventEmitter {
|
||||
|
||||
return result.updateInfo || null;
|
||||
} catch (error) {
|
||||
console.error('[Updater] Check for updates failed:', error);
|
||||
logger.error('[Updater] Check for updates failed:', error);
|
||||
this.updateStatus({ status: 'error', error: (error as Error).message || String(error) });
|
||||
throw error;
|
||||
}
|
||||
@@ -188,18 +208,60 @@ export class AppUpdater extends EventEmitter {
|
||||
try {
|
||||
await autoUpdater.downloadUpdate();
|
||||
} catch (error) {
|
||||
console.error('[Updater] Download update failed:', error);
|
||||
logger.error('[Updater] Download update failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install update and restart app
|
||||
* Install update and restart.
|
||||
*
|
||||
* On macOS, electron-updater delegates to Squirrel.Mac (ShipIt). The
|
||||
* native quitAndInstall() spawns ShipIt then internally calls app.quit().
|
||||
* However, the tray close handler in index.ts intercepts window close
|
||||
* and hides to tray unless isQuitting is true. Squirrel's internal quit
|
||||
* sometimes fails to trigger before-quit in time, so we set isQuitting
|
||||
* BEFORE calling quitAndInstall(). This lets the native quit flow close
|
||||
* the window cleanly while ShipIt runs independently to replace the app.
|
||||
*/
|
||||
quitAndInstall(): void {
|
||||
logger.info('[Updater] quitAndInstall called');
|
||||
setQuitting();
|
||||
autoUpdater.quitAndInstall();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a countdown that auto-installs the downloaded update.
|
||||
* Sends `update:auto-install-countdown` events to the renderer each second.
|
||||
*/
|
||||
private startAutoInstallCountdown(): void {
|
||||
this.clearAutoInstallTimer();
|
||||
this.autoInstallCountdown = AppUpdater.AUTO_INSTALL_DELAY_SECONDS;
|
||||
this.sendToRenderer('update:auto-install-countdown', { seconds: this.autoInstallCountdown });
|
||||
|
||||
this.autoInstallTimer = setInterval(() => {
|
||||
this.autoInstallCountdown--;
|
||||
this.sendToRenderer('update:auto-install-countdown', { seconds: this.autoInstallCountdown });
|
||||
|
||||
if (this.autoInstallCountdown <= 0) {
|
||||
this.clearAutoInstallTimer();
|
||||
this.quitAndInstall();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
cancelAutoInstall(): void {
|
||||
this.clearAutoInstallTimer();
|
||||
this.sendToRenderer('update:auto-install-countdown', { seconds: -1, cancelled: true });
|
||||
}
|
||||
|
||||
private clearAutoInstallTimer(): void {
|
||||
if (this.autoInstallTimer) {
|
||||
clearInterval(this.autoInstallTimer);
|
||||
this.autoInstallTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set update channel (stable, beta, dev)
|
||||
*/
|
||||
@@ -280,6 +342,12 @@ export function registerUpdateHandlers(
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Cancel pending auto-install countdown
|
||||
ipcMain.handle('update:cancelAutoInstall', () => {
|
||||
updater.cancelAutoInstall();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
||||
@@ -21,6 +21,8 @@ const electronAPI = {
|
||||
'gateway:stop',
|
||||
'gateway:restart',
|
||||
'gateway:rpc',
|
||||
'gateway:httpProxy',
|
||||
'hostapi:fetch',
|
||||
'gateway:health',
|
||||
'gateway:getControlUiUrl',
|
||||
// OpenClaw
|
||||
@@ -41,6 +43,7 @@ const electronAPI = {
|
||||
'app:platform',
|
||||
'app:quit',
|
||||
'app:relaunch',
|
||||
'app:request',
|
||||
// Window controls
|
||||
'window:minimize',
|
||||
'window:maximize',
|
||||
@@ -49,8 +52,10 @@ const electronAPI = {
|
||||
// Settings
|
||||
'settings:get',
|
||||
'settings:set',
|
||||
'settings:setMany',
|
||||
'settings:getAll',
|
||||
'settings:reset',
|
||||
'usage:recentTokenHistory',
|
||||
// Update
|
||||
'update:status',
|
||||
'update:version',
|
||||
@@ -59,6 +64,7 @@ const electronAPI = {
|
||||
'update:install',
|
||||
'update:setChannel',
|
||||
'update:setAutoDownload',
|
||||
'update:cancelAutoInstall',
|
||||
// Env
|
||||
'env:getConfig',
|
||||
'env:setApiKey',
|
||||
@@ -76,6 +82,8 @@ const electronAPI = {
|
||||
'provider:setDefault',
|
||||
'provider:getDefault',
|
||||
'provider:validateKey',
|
||||
'provider:requestOAuth',
|
||||
'provider:cancelOAuth',
|
||||
// Cron
|
||||
'cron:list',
|
||||
'cron:create',
|
||||
@@ -119,14 +127,16 @@ const electronAPI = {
|
||||
'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',
|
||||
'openclaw:installCliMac',
|
||||
];
|
||||
|
||||
if (validChannels.includes(channel)) {
|
||||
@@ -159,7 +169,12 @@ const electronAPI = {
|
||||
'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)) {
|
||||
@@ -198,6 +213,10 @@ const electronAPI = {
|
||||
'update:progress',
|
||||
'update:downloaded',
|
||||
'update:error',
|
||||
'update:auto-install-countdown',
|
||||
'oauth:code',
|
||||
'oauth:success',
|
||||
'oauth:error',
|
||||
];
|
||||
|
||||
if (validChannels.includes(channel)) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ProviderConfig } from '../../shared/providers/types';
|
||||
import {
|
||||
getDefaultProviderAccountId,
|
||||
providerConfigToAccount,
|
||||
saveProviderAccount,
|
||||
} from './provider-store';
|
||||
import { getClawXProviderStore } from './store-instance';
|
||||
|
||||
const PROVIDER_STORE_SCHEMA_VERSION = 1;
|
||||
|
||||
export async function ensureProviderStoreMigrated(): Promise<void> {
|
||||
const store = await getClawXProviderStore();
|
||||
const schemaVersion = Number(store.get('schemaVersion') ?? 0);
|
||||
|
||||
if (schemaVersion >= PROVIDER_STORE_SCHEMA_VERSION) {
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyProviders = (store.get('providers') ?? {}) as Record<string, ProviderConfig>;
|
||||
const defaultProviderId = (store.get('defaultProvider') ?? null) as string | null;
|
||||
const existingDefaultAccountId = await getDefaultProviderAccountId();
|
||||
|
||||
for (const provider of Object.values(legacyProviders)) {
|
||||
const account = providerConfigToAccount(provider, {
|
||||
isDefault: provider.id === defaultProviderId,
|
||||
});
|
||||
await saveProviderAccount(account);
|
||||
}
|
||||
|
||||
if (!existingDefaultAccountId && defaultProviderId) {
|
||||
store.set('defaultProviderAccountId', defaultProviderId);
|
||||
}
|
||||
|
||||
store.set('schemaVersion', PROVIDER_STORE_SCHEMA_VERSION);
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
import type { GatewayManager } from '../../gateway/manager';
|
||||
import { getProviderAccount, listProviderAccounts } from './provider-store';
|
||||
import { getProviderSecret } from '../secrets/secret-store';
|
||||
import type { ProviderConfig } from '../../utils/secure-storage';
|
||||
import { getAllProviders, getApiKey, getDefaultProvider, getProvider } from '../../utils/secure-storage';
|
||||
import { getProviderConfig, getProviderDefaultModel } from '../../utils/provider-registry';
|
||||
import {
|
||||
removeProviderFromOpenClaw,
|
||||
saveOAuthTokenToOpenClaw,
|
||||
saveProviderKeyToOpenClaw,
|
||||
setOpenClawDefaultModel,
|
||||
setOpenClawDefaultModelWithOverride,
|
||||
syncProviderConfigToOpenClaw,
|
||||
updateAgentModelProvider,
|
||||
} from '../../utils/openclaw-auth';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
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.3-codex`;
|
||||
|
||||
type RuntimeProviderSyncContext = {
|
||||
runtimeProviderKey: string;
|
||||
meta: ReturnType<typeof getProviderConfig>;
|
||||
api: string;
|
||||
};
|
||||
|
||||
function normalizeProviderBaseUrl(
|
||||
config: ProviderConfig,
|
||||
baseUrl?: string,
|
||||
apiProtocol?: string,
|
||||
): string | undefined {
|
||||
if (!baseUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = baseUrl.trim().replace(/\/+$/, '');
|
||||
|
||||
if (config.type === 'minimax-portal' || config.type === 'minimax-portal-cn') {
|
||||
return normalized.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
|
||||
}
|
||||
|
||||
if (config.type === 'custom' || config.type === 'ollama') {
|
||||
const protocol = apiProtocol || config.apiProtocol || 'openai-completions';
|
||||
if (protocol === 'openai-responses') {
|
||||
return normalized.replace(/\/responses?$/i, '');
|
||||
}
|
||||
if (protocol === 'openai-completions') {
|
||||
return normalized.replace(/\/chat\/completions$/i, '');
|
||||
}
|
||||
if (protocol === 'anthropic-messages') {
|
||||
return normalized.replace(/\/v1\/messages$/i, '').replace(/\/messages$/i, '');
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function shouldUseExplicitDefaultOverride(config: ProviderConfig, runtimeProviderKey: string): boolean {
|
||||
return Boolean(config.baseUrl || config.apiProtocol || runtimeProviderKey !== config.type);
|
||||
}
|
||||
|
||||
export function getOpenClawProviderKey(type: string, providerId: string): string {
|
||||
if (type === 'custom' || type === 'ollama') {
|
||||
const suffix = providerId.replace(/-/g, '').slice(0, 8);
|
||||
return `${type}-${suffix}`;
|
||||
}
|
||||
if (type === 'minimax-portal-cn') {
|
||||
return 'minimax-portal';
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
async function resolveRuntimeProviderKey(config: ProviderConfig): Promise<string> {
|
||||
const account = await getProviderAccount(config.id);
|
||||
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);
|
||||
}
|
||||
|
||||
async function getBrowserOAuthRuntimeProvider(config: ProviderConfig): Promise<string | null> {
|
||||
const account = await getProviderAccount(config.id);
|
||||
if (account?.authMode !== 'oauth_browser') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const secret = await getProviderSecret(config.id);
|
||||
if (secret?.type !== 'oauth') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config.type === 'google') {
|
||||
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
|
||||
}
|
||||
if (config.type === 'openai') {
|
||||
return OPENAI_OAUTH_RUNTIME_PROVIDER;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getProviderModelRef(config: ProviderConfig): string | undefined {
|
||||
const providerKey = getOpenClawProviderKey(config.type, config.id);
|
||||
|
||||
if (config.model) {
|
||||
return config.model.startsWith(`${providerKey}/`)
|
||||
? config.model
|
||||
: `${providerKey}/${config.model}`;
|
||||
}
|
||||
|
||||
const defaultModel = getProviderDefaultModel(config.type);
|
||||
if (!defaultModel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return defaultModel.startsWith(`${providerKey}/`)
|
||||
? defaultModel
|
||||
: `${providerKey}/${defaultModel}`;
|
||||
}
|
||||
|
||||
export async function getProviderFallbackModelRefs(config: ProviderConfig): Promise<string[]> {
|
||||
const allProviders = await getAllProviders();
|
||||
const providerMap = new Map(allProviders.map((provider) => [provider.id, provider]));
|
||||
const seen = new Set<string>();
|
||||
const results: string[] = [];
|
||||
const providerKey = getOpenClawProviderKey(config.type, config.id);
|
||||
|
||||
for (const fallbackModel of config.fallbackModels ?? []) {
|
||||
const normalizedModel = fallbackModel.trim();
|
||||
if (!normalizedModel) continue;
|
||||
|
||||
const modelRef = normalizedModel.startsWith(`${providerKey}/`)
|
||||
? normalizedModel
|
||||
: `${providerKey}/${normalizedModel}`;
|
||||
|
||||
if (seen.has(modelRef)) continue;
|
||||
seen.add(modelRef);
|
||||
results.push(modelRef);
|
||||
}
|
||||
|
||||
for (const fallbackId of config.fallbackProviderIds ?? []) {
|
||||
if (!fallbackId || fallbackId === config.id) continue;
|
||||
|
||||
const fallbackProvider = providerMap.get(fallbackId);
|
||||
if (!fallbackProvider) continue;
|
||||
|
||||
const modelRef = getProviderModelRef(fallbackProvider);
|
||||
if (!modelRef || seen.has(modelRef)) continue;
|
||||
|
||||
seen.add(modelRef);
|
||||
results.push(modelRef);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function scheduleGatewayRestart(
|
||||
gatewayManager: GatewayManager | undefined,
|
||||
message: string,
|
||||
options?: { delayMs?: number; onlyIfRunning?: boolean },
|
||||
): void {
|
||||
if (!gatewayManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.onlyIfRunning && gatewayManager.getStatus().state === 'stopped') {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(message);
|
||||
gatewayManager.debouncedRestart(options?.delayMs);
|
||||
}
|
||||
|
||||
export async function syncProviderApiKeyToRuntime(
|
||||
providerType: string,
|
||||
providerId: string,
|
||||
apiKey: string,
|
||||
): Promise<void> {
|
||||
const ock = getOpenClawProviderKey(providerType, providerId);
|
||||
await saveProviderKeyToOpenClaw(ock, apiKey);
|
||||
}
|
||||
|
||||
export async function syncAllProviderAuthToRuntime(): Promise<void> {
|
||||
const accounts = await listProviderAccounts();
|
||||
|
||||
for (const account of accounts) {
|
||||
const runtimeProviderKey = await resolveRuntimeProviderKey({
|
||||
id: account.id,
|
||||
name: account.label,
|
||||
type: account.vendorId,
|
||||
baseUrl: account.baseUrl,
|
||||
model: account.model,
|
||||
fallbackModels: account.fallbackModels,
|
||||
fallbackProviderIds: account.fallbackAccountIds,
|
||||
enabled: account.enabled,
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
});
|
||||
|
||||
const secret = await getProviderSecret(account.id);
|
||||
if (!secret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (secret.type === 'api_key') {
|
||||
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (secret.type === 'local' && secret.apiKey) {
|
||||
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (secret.type === 'oauth') {
|
||||
await saveOAuthTokenToOpenClaw(runtimeProviderKey, {
|
||||
access: secret.accessToken,
|
||||
refresh: secret.refreshToken,
|
||||
expires: secret.expiresAt,
|
||||
email: secret.email,
|
||||
projectId: secret.subject,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncProviderSecretToRuntime(
|
||||
config: ProviderConfig,
|
||||
runtimeProviderKey: string,
|
||||
apiKey: string | undefined,
|
||||
): Promise<void> {
|
||||
const secret = await getProviderSecret(config.id);
|
||||
if (apiKey !== undefined) {
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await saveProviderKeyToOpenClaw(runtimeProviderKey, trimmedKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (secret?.type === 'api_key') {
|
||||
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (secret?.type === 'oauth') {
|
||||
await saveOAuthTokenToOpenClaw(runtimeProviderKey, {
|
||||
access: secret.accessToken,
|
||||
refresh: secret.refreshToken,
|
||||
expires: secret.expiresAt,
|
||||
email: secret.email,
|
||||
projectId: secret.subject,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (secret?.type === 'local' && secret.apiKey) {
|
||||
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRuntimeSyncContext(config: ProviderConfig): Promise<RuntimeProviderSyncContext | null> {
|
||||
const runtimeProviderKey = await resolveRuntimeProviderKey(config);
|
||||
const meta = getProviderConfig(config.type);
|
||||
const api = config.apiProtocol || (config.type === 'custom' ? 'openai-completions' : meta?.api);
|
||||
if (!api) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
runtimeProviderKey,
|
||||
meta,
|
||||
api,
|
||||
};
|
||||
}
|
||||
|
||||
async function syncRuntimeProviderConfig(
|
||||
config: ProviderConfig,
|
||||
context: RuntimeProviderSyncContext,
|
||||
): Promise<void> {
|
||||
await syncProviderConfigToOpenClaw(context.runtimeProviderKey, config.model, {
|
||||
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl, context.api),
|
||||
api: context.api,
|
||||
apiKeyEnv: context.meta?.apiKeyEnv,
|
||||
headers: context.meta?.headers,
|
||||
});
|
||||
}
|
||||
|
||||
async function syncCustomProviderAgentModel(
|
||||
config: ProviderConfig,
|
||||
runtimeProviderKey: string,
|
||||
apiKey: string | undefined,
|
||||
): Promise<void> {
|
||||
if (config.type !== 'custom') {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id);
|
||||
if (!resolvedKey || !config.baseUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modelId = config.model;
|
||||
await updateAgentModelProvider(runtimeProviderKey, {
|
||||
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl, config.apiProtocol || 'openai-completions'),
|
||||
api: config.apiProtocol || 'openai-completions',
|
||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||
apiKey: resolvedKey,
|
||||
});
|
||||
}
|
||||
|
||||
async function syncProviderToRuntime(
|
||||
config: ProviderConfig,
|
||||
apiKey: string | undefined,
|
||||
): Promise<RuntimeProviderSyncContext | null> {
|
||||
const context = await resolveRuntimeSyncContext(config);
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await syncProviderSecretToRuntime(config, context.runtimeProviderKey, apiKey);
|
||||
await syncRuntimeProviderConfig(config, context);
|
||||
await syncCustomProviderAgentModel(config, context.runtimeProviderKey, apiKey);
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function syncSavedProviderToRuntime(
|
||||
config: ProviderConfig,
|
||||
apiKey: string | undefined,
|
||||
gatewayManager?: GatewayManager,
|
||||
): Promise<void> {
|
||||
const context = await syncProviderToRuntime(config, apiKey);
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleGatewayRestart(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway restart after saving provider "${context.runtimeProviderKey}" config`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncUpdatedProviderToRuntime(
|
||||
config: ProviderConfig,
|
||||
apiKey: string | undefined,
|
||||
gatewayManager?: GatewayManager,
|
||||
): Promise<void> {
|
||||
const context = await syncProviderToRuntime(config, apiKey);
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ock = context.runtimeProviderKey;
|
||||
const fallbackModels = await getProviderFallbackModelRefs(config);
|
||||
|
||||
const defaultProviderId = await getDefaultProvider();
|
||||
if (defaultProviderId === config.id) {
|
||||
const modelOverride = config.model ? `${ock}/${config.model}` : undefined;
|
||||
if (config.type !== 'custom') {
|
||||
if (shouldUseExplicitDefaultOverride(config, ock)) {
|
||||
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl, context.api),
|
||||
api: context.api,
|
||||
apiKeyEnv: context.meta?.apiKeyEnv,
|
||||
headers: context.meta?.headers,
|
||||
}, fallbackModels);
|
||||
} else {
|
||||
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
|
||||
}
|
||||
} else {
|
||||
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl, config.apiProtocol || 'openai-completions'),
|
||||
api: config.apiProtocol || 'openai-completions',
|
||||
}, fallbackModels);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleGatewayRestart(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway restart after updating provider "${ock}" config`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncDeletedProviderToRuntime(
|
||||
provider: ProviderConfig | null,
|
||||
providerId: string,
|
||||
gatewayManager?: GatewayManager,
|
||||
runtimeProviderKey?: string,
|
||||
): Promise<void> {
|
||||
if (!provider?.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
|
||||
await removeProviderFromOpenClaw(ock);
|
||||
|
||||
scheduleGatewayRestart(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway restart after deleting provider "${ock}"`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncDeletedProviderApiKeyToRuntime(
|
||||
provider: ProviderConfig | null,
|
||||
providerId: string,
|
||||
runtimeProviderKey?: string,
|
||||
): Promise<void> {
|
||||
if (!provider?.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
|
||||
await removeProviderFromOpenClaw(ock);
|
||||
}
|
||||
|
||||
export async function syncDefaultProviderToRuntime(
|
||||
providerId: string,
|
||||
gatewayManager?: GatewayManager,
|
||||
): Promise<void> {
|
||||
const provider = await getProvider(providerId);
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ock = await resolveRuntimeProviderKey(provider);
|
||||
const providerKey = await getApiKey(providerId);
|
||||
const fallbackModels = await getProviderFallbackModelRefs(provider);
|
||||
const oauthTypes = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
|
||||
const browserOAuthRuntimeProvider = await getBrowserOAuthRuntimeProvider(provider);
|
||||
const isOAuthProvider = (oauthTypes.includes(provider.type) && !providerKey) || Boolean(browserOAuthRuntimeProvider);
|
||||
|
||||
if (!isOAuthProvider) {
|
||||
const modelOverride = provider.model
|
||||
? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`)
|
||||
: undefined;
|
||||
|
||||
if (provider.type === 'custom') {
|
||||
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||
baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'),
|
||||
api: provider.apiProtocol || 'openai-completions',
|
||||
}, fallbackModels);
|
||||
} else if (shouldUseExplicitDefaultOverride(provider, ock)) {
|
||||
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||
baseUrl: normalizeProviderBaseUrl(
|
||||
provider,
|
||||
provider.baseUrl || getProviderConfig(provider.type)?.baseUrl,
|
||||
provider.apiProtocol || getProviderConfig(provider.type)?.api,
|
||||
),
|
||||
api: provider.apiProtocol || getProviderConfig(provider.type)?.api,
|
||||
apiKeyEnv: getProviderConfig(provider.type)?.apiKeyEnv,
|
||||
headers: getProviderConfig(provider.type)?.headers,
|
||||
}, fallbackModels);
|
||||
} else {
|
||||
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
|
||||
}
|
||||
|
||||
if (providerKey) {
|
||||
await saveProviderKeyToOpenClaw(ock, providerKey);
|
||||
}
|
||||
} else {
|
||||
if (browserOAuthRuntimeProvider) {
|
||||
const secret = await getProviderSecret(provider.id);
|
||||
if (secret?.type === 'oauth') {
|
||||
await saveOAuthTokenToOpenClaw(browserOAuthRuntimeProvider, {
|
||||
access: secret.accessToken,
|
||||
refresh: secret.refreshToken,
|
||||
expires: secret.expiresAt,
|
||||
email: secret.email,
|
||||
projectId: secret.subject,
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
: `${browserOAuthRuntimeProvider}/${provider.model}`)
|
||||
: defaultModelRef;
|
||||
|
||||
await setOpenClawDefaultModel(browserOAuthRuntimeProvider, modelOverride, fallbackModels);
|
||||
logger.info(`Configured openclaw.json for browser OAuth provider "${provider.id}"`);
|
||||
scheduleGatewayRestart(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway restart after provider switch to "${browserOAuthRuntimeProvider}"`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultBaseUrl = provider.type === 'minimax-portal'
|
||||
? 'https://api.minimax.io/anthropic'
|
||||
: (provider.type === 'minimax-portal-cn' ? 'https://api.minimaxi.com/anthropic' : 'https://portal.qwen.ai/v1');
|
||||
const api: 'anthropic-messages' | 'openai-completions' =
|
||||
(provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn')
|
||||
? 'anthropic-messages'
|
||||
: 'openai-completions';
|
||||
|
||||
let baseUrl = provider.baseUrl || defaultBaseUrl;
|
||||
if ((provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') && baseUrl) {
|
||||
baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
|
||||
}
|
||||
|
||||
const targetProviderKey = (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn')
|
||||
? 'minimax-portal'
|
||||
: provider.type;
|
||||
|
||||
await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), {
|
||||
baseUrl,
|
||||
api,
|
||||
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
|
||||
apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
|
||||
}, fallbackModels);
|
||||
|
||||
logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`);
|
||||
|
||||
try {
|
||||
const defaultModelId = provider.model?.split('/').pop();
|
||||
await updateAgentModelProvider(targetProviderKey, {
|
||||
baseUrl,
|
||||
api,
|
||||
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
|
||||
apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
|
||||
models: defaultModelId ? [{ id: defaultModelId, name: defaultModelId }] : [],
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
provider.type === 'custom' &&
|
||||
providerKey &&
|
||||
provider.baseUrl
|
||||
) {
|
||||
const modelId = provider.model;
|
||||
await updateAgentModelProvider(ock, {
|
||||
baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'),
|
||||
api: provider.apiProtocol || 'openai-completions',
|
||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||
apiKey: providerKey,
|
||||
});
|
||||
}
|
||||
|
||||
scheduleGatewayRestart(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway restart after provider switch to "${ock}"`,
|
||||
{ onlyIfRunning: true },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
PROVIDER_DEFINITIONS,
|
||||
getProviderDefinition,
|
||||
} from '../../shared/providers/registry';
|
||||
import type {
|
||||
ProviderAccount,
|
||||
ProviderConfig,
|
||||
ProviderDefinition,
|
||||
} from '../../shared/providers/types';
|
||||
import { ensureProviderStoreMigrated } from './provider-migration';
|
||||
import {
|
||||
getDefaultProviderAccountId,
|
||||
getProviderAccount,
|
||||
listProviderAccounts,
|
||||
providerAccountToConfig,
|
||||
providerConfigToAccount,
|
||||
saveProviderAccount,
|
||||
setDefaultProviderAccount,
|
||||
} from './provider-store';
|
||||
import {
|
||||
deleteApiKey,
|
||||
deleteProvider,
|
||||
getApiKey,
|
||||
hasApiKey,
|
||||
saveProvider,
|
||||
setDefaultProvider,
|
||||
storeApiKey,
|
||||
} from '../../utils/secure-storage';
|
||||
import type { ProviderWithKeyInfo } from '../../shared/providers/types';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
function maskApiKey(apiKey: string | null): string | null {
|
||||
if (!apiKey) return null;
|
||||
if (apiKey.length > 12) {
|
||||
return `${apiKey.substring(0, 4)}${'*'.repeat(apiKey.length - 8)}${apiKey.substring(apiKey.length - 4)}`;
|
||||
}
|
||||
return '*'.repeat(apiKey.length);
|
||||
}
|
||||
|
||||
const legacyProviderApiWarned = new Set<string>();
|
||||
|
||||
function logLegacyProviderApiUsage(method: string, replacement: string): void {
|
||||
if (legacyProviderApiWarned.has(method)) {
|
||||
return;
|
||||
}
|
||||
legacyProviderApiWarned.add(method);
|
||||
logger.warn(
|
||||
`[provider-migration] Legacy provider API "${method}" is deprecated. Migrate to "${replacement}".`,
|
||||
);
|
||||
}
|
||||
|
||||
export class ProviderService {
|
||||
async listVendors(): Promise<ProviderDefinition[]> {
|
||||
return PROVIDER_DEFINITIONS;
|
||||
}
|
||||
|
||||
async listAccounts(): Promise<ProviderAccount[]> {
|
||||
await ensureProviderStoreMigrated();
|
||||
return listProviderAccounts();
|
||||
}
|
||||
|
||||
async getAccount(accountId: string): Promise<ProviderAccount | null> {
|
||||
await ensureProviderStoreMigrated();
|
||||
return getProviderAccount(accountId);
|
||||
}
|
||||
|
||||
async getDefaultAccountId(): Promise<string | undefined> {
|
||||
await ensureProviderStoreMigrated();
|
||||
return getDefaultProviderAccountId();
|
||||
}
|
||||
|
||||
async createAccount(account: ProviderAccount, apiKey?: string): Promise<ProviderAccount> {
|
||||
await ensureProviderStoreMigrated();
|
||||
await saveProvider(providerAccountToConfig(account));
|
||||
await saveProviderAccount(account);
|
||||
if (apiKey !== undefined && apiKey.trim()) {
|
||||
await storeApiKey(account.id, apiKey.trim());
|
||||
}
|
||||
return (await getProviderAccount(account.id)) ?? account;
|
||||
}
|
||||
|
||||
async updateAccount(
|
||||
accountId: string,
|
||||
patch: Partial<ProviderAccount>,
|
||||
apiKey?: string,
|
||||
): Promise<ProviderAccount> {
|
||||
await ensureProviderStoreMigrated();
|
||||
const existing = await getProviderAccount(accountId);
|
||||
if (!existing) {
|
||||
throw new Error('Provider account not found');
|
||||
}
|
||||
|
||||
const nextAccount: ProviderAccount = {
|
||||
...existing,
|
||||
...patch,
|
||||
id: accountId,
|
||||
updatedAt: patch.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
|
||||
await saveProvider(providerAccountToConfig(nextAccount));
|
||||
await saveProviderAccount(nextAccount);
|
||||
if (apiKey !== undefined) {
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await storeApiKey(accountId, trimmedKey);
|
||||
} else {
|
||||
await deleteApiKey(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
return (await getProviderAccount(accountId)) ?? nextAccount;
|
||||
}
|
||||
|
||||
async deleteAccount(accountId: string): Promise<boolean> {
|
||||
await ensureProviderStoreMigrated();
|
||||
return deleteProvider(accountId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use listAccounts() and map account data in callers.
|
||||
*/
|
||||
async listLegacyProviders(): Promise<ProviderConfig[]> {
|
||||
logLegacyProviderApiUsage('listLegacyProviders', 'listAccounts');
|
||||
await ensureProviderStoreMigrated();
|
||||
const accounts = await listProviderAccounts();
|
||||
return accounts.map(providerAccountToConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
results.push({
|
||||
...provider,
|
||||
hasKey: !!apiKey,
|
||||
keyMasked: maskApiKey(apiKey),
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
if (existing) {
|
||||
await this.updateAccount(config.id, account);
|
||||
return;
|
||||
}
|
||||
await this.createAccount(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use deleteAccount(accountId).
|
||||
*/
|
||||
async deleteLegacyProvider(providerId: string): Promise<boolean> {
|
||||
logLegacyProviderApiUsage('deleteLegacyProvider', 'deleteAccount');
|
||||
await ensureProviderStoreMigrated();
|
||||
await this.deleteAccount(providerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use setDefaultAccount(accountId).
|
||||
*/
|
||||
async setDefaultLegacyProvider(providerId: string): Promise<void> {
|
||||
logLegacyProviderApiUsage('setDefaultLegacyProvider', 'setDefaultAccount');
|
||||
await this.setDefaultAccount(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getDefaultAccountId().
|
||||
*/
|
||||
async getDefaultLegacyProvider(): Promise<string | undefined> {
|
||||
logLegacyProviderApiUsage('getDefaultLegacyProvider', 'getDefaultAccountId');
|
||||
return this.getDefaultAccountId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use secret-store APIs by accountId.
|
||||
*/
|
||||
async setLegacyProviderApiKey(providerId: string, apiKey: string): Promise<boolean> {
|
||||
logLegacyProviderApiUsage('setLegacyProviderApiKey', 'setProviderSecret(accountId, api_key)');
|
||||
return storeApiKey(providerId, apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use secret-store APIs by accountId.
|
||||
*/
|
||||
async getLegacyProviderApiKey(providerId: string): Promise<string | null> {
|
||||
logLegacyProviderApiUsage('getLegacyProviderApiKey', 'getProviderSecret(accountId)');
|
||||
return getApiKey(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use secret-store APIs by accountId.
|
||||
*/
|
||||
async deleteLegacyProviderApiKey(providerId: string): Promise<boolean> {
|
||||
logLegacyProviderApiUsage('deleteLegacyProviderApiKey', 'deleteProviderSecret(accountId)');
|
||||
return deleteApiKey(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use secret-store APIs by accountId.
|
||||
*/
|
||||
async hasLegacyProviderApiKey(providerId: string): Promise<boolean> {
|
||||
logLegacyProviderApiUsage('hasLegacyProviderApiKey', 'getProviderSecret(accountId)');
|
||||
return hasApiKey(providerId);
|
||||
}
|
||||
|
||||
async setDefaultAccount(accountId: string): Promise<void> {
|
||||
await ensureProviderStoreMigrated();
|
||||
await setDefaultProviderAccount(accountId);
|
||||
await setDefaultProvider(accountId);
|
||||
}
|
||||
|
||||
getVendorDefinition(vendorId: string): ProviderDefinition | undefined {
|
||||
return getProviderDefinition(vendorId);
|
||||
}
|
||||
}
|
||||
|
||||
const providerService = new ProviderService();
|
||||
|
||||
export function getProviderService(): ProviderService {
|
||||
return providerService;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { ProviderAccount, ProviderConfig, ProviderType } from '../../shared/providers/types';
|
||||
import { getProviderDefinition } from '../../shared/providers/registry';
|
||||
import { getClawXProviderStore } from './store-instance';
|
||||
|
||||
const PROVIDER_STORE_SCHEMA_VERSION = 1;
|
||||
|
||||
function inferAuthMode(type: ProviderType): ProviderAccount['authMode'] {
|
||||
if (type === 'ollama') {
|
||||
return 'local';
|
||||
}
|
||||
|
||||
const definition = getProviderDefinition(type);
|
||||
if (definition?.defaultAuthMode) {
|
||||
return definition.defaultAuthMode;
|
||||
}
|
||||
|
||||
return 'api_key';
|
||||
}
|
||||
|
||||
export function providerConfigToAccount(
|
||||
config: ProviderConfig,
|
||||
options?: { isDefault?: boolean },
|
||||
): ProviderAccount {
|
||||
return {
|
||||
id: config.id,
|
||||
vendorId: config.type,
|
||||
label: config.name,
|
||||
authMode: inferAuthMode(config.type),
|
||||
baseUrl: config.baseUrl,
|
||||
apiProtocol: config.apiProtocol || (config.type === 'custom' || config.type === 'ollama'
|
||||
? 'openai-completions'
|
||||
: getProviderDefinition(config.type)?.providerConfig?.api),
|
||||
model: config.model,
|
||||
fallbackModels: config.fallbackModels,
|
||||
fallbackAccountIds: config.fallbackProviderIds,
|
||||
enabled: config.enabled,
|
||||
isDefault: options?.isDefault ?? false,
|
||||
createdAt: config.createdAt,
|
||||
updatedAt: config.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function providerAccountToConfig(account: ProviderAccount): ProviderConfig {
|
||||
return {
|
||||
id: account.id,
|
||||
name: account.label,
|
||||
type: account.vendorId,
|
||||
baseUrl: account.baseUrl,
|
||||
apiProtocol: account.apiProtocol,
|
||||
model: account.model,
|
||||
fallbackModels: account.fallbackModels,
|
||||
fallbackProviderIds: account.fallbackAccountIds,
|
||||
enabled: account.enabled,
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listProviderAccounts(): Promise<ProviderAccount[]> {
|
||||
const store = await getClawXProviderStore();
|
||||
const accounts = store.get('providerAccounts') as Record<string, ProviderAccount> | undefined;
|
||||
return Object.values(accounts ?? {});
|
||||
}
|
||||
|
||||
export async function getProviderAccount(accountId: string): Promise<ProviderAccount | null> {
|
||||
const store = await getClawXProviderStore();
|
||||
const accounts = store.get('providerAccounts') as Record<string, ProviderAccount> | undefined;
|
||||
return accounts?.[accountId] ?? null;
|
||||
}
|
||||
|
||||
export async function saveProviderAccount(account: ProviderAccount): Promise<void> {
|
||||
const store = await getClawXProviderStore();
|
||||
const accounts = (store.get('providerAccounts') ?? {}) as Record<string, ProviderAccount>;
|
||||
accounts[account.id] = account;
|
||||
store.set('providerAccounts', accounts);
|
||||
store.set('schemaVersion', PROVIDER_STORE_SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
export async function deleteProviderAccount(accountId: string): Promise<void> {
|
||||
const store = await getClawXProviderStore();
|
||||
const accounts = (store.get('providerAccounts') ?? {}) as Record<string, ProviderAccount>;
|
||||
delete accounts[accountId];
|
||||
store.set('providerAccounts', accounts);
|
||||
|
||||
if (store.get('defaultProviderAccountId') === accountId) {
|
||||
store.delete('defaultProviderAccountId');
|
||||
}
|
||||
}
|
||||
|
||||
export async function setDefaultProviderAccount(accountId: string): Promise<void> {
|
||||
const store = await getClawXProviderStore();
|
||||
store.set('defaultProviderAccountId', accountId);
|
||||
|
||||
const accounts = (store.get('providerAccounts') ?? {}) as Record<string, ProviderAccount>;
|
||||
for (const account of Object.values(accounts)) {
|
||||
account.isDefault = account.id === accountId;
|
||||
}
|
||||
store.set('providerAccounts', accounts);
|
||||
}
|
||||
|
||||
export async function getDefaultProviderAccountId(): Promise<string | undefined> {
|
||||
const store = await getClawXProviderStore();
|
||||
return store.get('defaultProviderAccountId') as string | undefined;
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
||||
import { getProviderConfig } from '../../utils/provider-registry';
|
||||
|
||||
type ValidationProfile =
|
||||
| 'openai-completions'
|
||||
| 'openai-responses'
|
||||
| 'google-query-key'
|
||||
| 'anthropic-header'
|
||||
| 'openrouter'
|
||||
| 'none';
|
||||
|
||||
type ValidationResult = { valid: boolean; error?: string; status?: number };
|
||||
|
||||
function logValidationStatus(provider: string, status: number): void {
|
||||
console.log(`[clawx-validate] ${provider} HTTP ${status}`);
|
||||
}
|
||||
|
||||
function maskSecret(secret: string): string {
|
||||
if (!secret) return '';
|
||||
if (secret.length <= 8) return `${secret.slice(0, 2)}***`;
|
||||
return `${secret.slice(0, 4)}***${secret.slice(-4)}`;
|
||||
}
|
||||
|
||||
function sanitizeValidationUrl(rawUrl: string): string {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
const key = url.searchParams.get('key');
|
||||
if (key) url.searchParams.set('key', maskSecret(key));
|
||||
return url.toString();
|
||||
} catch {
|
||||
return rawUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
|
||||
const next = { ...headers };
|
||||
if (next.Authorization?.startsWith('Bearer ')) {
|
||||
const token = next.Authorization.slice('Bearer '.length);
|
||||
next.Authorization = `Bearer ${maskSecret(token)}`;
|
||||
}
|
||||
if (next['x-api-key']) {
|
||||
next['x-api-key'] = maskSecret(next['x-api-key']);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function buildOpenAiModelsUrl(baseUrl: string): string {
|
||||
return `${normalizeBaseUrl(baseUrl)}/models?limit=1`;
|
||||
}
|
||||
|
||||
function resolveOpenAiProbeUrls(
|
||||
baseUrl: string,
|
||||
apiProtocol: 'openai-completions' | 'openai-responses',
|
||||
): { modelsUrl: string; probeUrl: string } {
|
||||
const normalizedBase = normalizeBaseUrl(baseUrl);
|
||||
const endpointSuffixPattern = /(\/responses?|\/chat\/completions)$/;
|
||||
const rootBase = normalizedBase.replace(endpointSuffixPattern, '');
|
||||
const modelsUrl = buildOpenAiModelsUrl(rootBase);
|
||||
|
||||
if (apiProtocol === 'openai-responses') {
|
||||
const probeUrl = /(\/responses?)$/.test(normalizedBase)
|
||||
? normalizedBase
|
||||
: `${rootBase}/responses`;
|
||||
return { modelsUrl, probeUrl };
|
||||
}
|
||||
|
||||
const probeUrl = /\/chat\/completions$/.test(normalizedBase)
|
||||
? normalizedBase
|
||||
: `${rootBase}/chat/completions`;
|
||||
return { modelsUrl, probeUrl };
|
||||
}
|
||||
|
||||
function logValidationRequest(
|
||||
provider: string,
|
||||
method: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): void {
|
||||
console.log(
|
||||
`[clawx-validate] ${provider} request ${method} ${sanitizeValidationUrl(url)} headers=${JSON.stringify(sanitizeHeaders(headers))}`,
|
||||
);
|
||||
}
|
||||
|
||||
function getValidationProfile(
|
||||
providerType: string,
|
||||
options?: { apiProtocol?: string }
|
||||
): ValidationProfile {
|
||||
const providerApi = options?.apiProtocol || getProviderConfig(providerType)?.api;
|
||||
if (providerApi === 'anthropic-messages') {
|
||||
return 'anthropic-header';
|
||||
}
|
||||
if (providerApi === 'openai-responses') {
|
||||
return 'openai-responses';
|
||||
}
|
||||
if (providerApi === 'openai-completions') {
|
||||
return 'openai-completions';
|
||||
}
|
||||
|
||||
switch (providerType) {
|
||||
case 'anthropic':
|
||||
return 'anthropic-header';
|
||||
case 'google':
|
||||
return 'google-query-key';
|
||||
case 'openrouter':
|
||||
return 'openrouter';
|
||||
case 'ollama':
|
||||
return 'none';
|
||||
default:
|
||||
return 'openai-completions';
|
||||
}
|
||||
}
|
||||
|
||||
async function performProviderValidationRequest(
|
||||
providerLabel: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<ValidationResult> {
|
||||
try {
|
||||
logValidationRequest(providerLabel, 'GET', url, headers);
|
||||
const response = await proxyAwareFetch(url, { headers });
|
||||
logValidationStatus(providerLabel, response.status);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const result = classifyAuthResponse(response.status, data);
|
||||
return { ...result, status: response.status };
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function classifyAuthResponse(
|
||||
status: number,
|
||||
data: unknown,
|
||||
): { valid: boolean; error?: string } {
|
||||
if (status >= 200 && status < 300) return { valid: true };
|
||||
if (status === 429) return { valid: true };
|
||||
if (status === 401 || status === 403) return { valid: false, error: 'Invalid API key' };
|
||||
|
||||
const obj = data as { error?: { message?: string }; message?: string } | null;
|
||||
const msg = obj?.error?.message || obj?.message || `API error: ${status}`;
|
||||
return { valid: false, error: msg };
|
||||
}
|
||||
|
||||
async function validateOpenAiCompatibleKey(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
apiProtocol: 'openai-completions' | 'openai-responses',
|
||||
baseUrl?: string,
|
||||
): Promise<ValidationResult> {
|
||||
const trimmedBaseUrl = baseUrl?.trim();
|
||||
if (!trimmedBaseUrl) {
|
||||
return { valid: false, error: `Base URL is required for provider "${providerType}" validation` };
|
||||
}
|
||||
|
||||
const headers = { Authorization: `Bearer ${apiKey}` };
|
||||
const { modelsUrl, probeUrl } = resolveOpenAiProbeUrls(trimmedBaseUrl, apiProtocol);
|
||||
const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers);
|
||||
|
||||
if (modelsResult.status === 404) {
|
||||
console.log(
|
||||
`[clawx-validate] ${providerType} /models returned 404, falling back to ${apiProtocol} probe`,
|
||||
);
|
||||
if (apiProtocol === 'openai-responses') {
|
||||
return await performResponsesProbe(providerType, probeUrl, headers);
|
||||
}
|
||||
return await performChatCompletionsProbe(providerType, probeUrl, headers);
|
||||
}
|
||||
|
||||
return modelsResult;
|
||||
}
|
||||
|
||||
async function performResponsesProbe(
|
||||
providerLabel: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<ValidationResult> {
|
||||
try {
|
||||
logValidationRequest(providerLabel, 'POST', url, headers);
|
||||
const response = await proxyAwareFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'validation-probe',
|
||||
input: 'hi',
|
||||
}),
|
||||
});
|
||||
logValidationStatus(providerLabel, response.status);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: 'Invalid API key' };
|
||||
}
|
||||
if (
|
||||
(response.status >= 200 && response.status < 300) ||
|
||||
response.status === 400 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return { valid: true };
|
||||
}
|
||||
return classifyAuthResponse(response.status, data);
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function performChatCompletionsProbe(
|
||||
providerLabel: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<ValidationResult> {
|
||||
try {
|
||||
logValidationRequest(providerLabel, 'POST', url, headers);
|
||||
const response = await proxyAwareFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'validation-probe',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
});
|
||||
logValidationStatus(providerLabel, response.status);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: 'Invalid API key' };
|
||||
}
|
||||
if (
|
||||
(response.status >= 200 && response.status < 300) ||
|
||||
response.status === 400 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return { valid: true };
|
||||
}
|
||||
return classifyAuthResponse(response.status, data);
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function performAnthropicMessagesProbe(
|
||||
providerLabel: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<ValidationResult> {
|
||||
try {
|
||||
logValidationRequest(providerLabel, 'POST', url, headers);
|
||||
const response = await proxyAwareFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'validation-probe',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
});
|
||||
logValidationStatus(providerLabel, response.status);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: 'Invalid API key' };
|
||||
}
|
||||
if (
|
||||
(response.status >= 200 && response.status < 300) ||
|
||||
response.status === 400 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return { valid: true };
|
||||
}
|
||||
return classifyAuthResponse(response.status, data);
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function validateGoogleQueryKey(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
baseUrl?: string,
|
||||
): Promise<ValidationResult> {
|
||||
const base = normalizeBaseUrl(baseUrl || 'https://generativelanguage.googleapis.com/v1beta');
|
||||
const url = `${base}/models?pageSize=1&key=${encodeURIComponent(apiKey)}`;
|
||||
return await performProviderValidationRequest(providerType, url, {});
|
||||
}
|
||||
|
||||
async function validateAnthropicHeaderKey(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
baseUrl?: string,
|
||||
): Promise<ValidationResult> {
|
||||
const rawBase = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1');
|
||||
const base = rawBase.endsWith('/v1') ? rawBase : `${rawBase}/v1`;
|
||||
const url = `${base}/models?limit=1`;
|
||||
const headers = {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
};
|
||||
|
||||
const modelsResult = await performProviderValidationRequest(providerType, url, headers);
|
||||
|
||||
// If the endpoint doesn't implement /models (like Minimax Anthropic compatibility), fallback to a /messages probe.
|
||||
if (
|
||||
modelsResult.status === 404 ||
|
||||
modelsResult.status === 400 ||
|
||||
modelsResult.error?.includes('API error: 404') ||
|
||||
modelsResult.error?.includes('API error: 400')
|
||||
) {
|
||||
console.log(
|
||||
`[clawx-validate] ${providerType} /models returned error, falling back to /messages probe`,
|
||||
);
|
||||
const messagesUrl = `${base}/messages`;
|
||||
return await performAnthropicMessagesProbe(providerType, messagesUrl, headers);
|
||||
}
|
||||
|
||||
return modelsResult;
|
||||
}
|
||||
|
||||
async function validateOpenRouterKey(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
): Promise<ValidationResult> {
|
||||
const url = 'https://openrouter.ai/api/v1/auth/key';
|
||||
const headers = { Authorization: `Bearer ${apiKey}` };
|
||||
return await performProviderValidationRequest(providerType, url, headers);
|
||||
}
|
||||
|
||||
export async function validateApiKeyWithProvider(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
options?: { baseUrl?: string; apiProtocol?: string },
|
||||
): Promise<ValidationResult> {
|
||||
const profile = getValidationProfile(providerType, options);
|
||||
const resolvedBaseUrl = options?.baseUrl || getProviderConfig(providerType)?.baseUrl;
|
||||
|
||||
if (profile === 'none') {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (!trimmedKey) {
|
||||
return { valid: false, error: 'API key is required' };
|
||||
}
|
||||
|
||||
try {
|
||||
switch (profile) {
|
||||
case 'openai-completions':
|
||||
return await validateOpenAiCompatibleKey(
|
||||
providerType,
|
||||
trimmedKey,
|
||||
'openai-completions',
|
||||
resolvedBaseUrl,
|
||||
);
|
||||
case 'openai-responses':
|
||||
return await validateOpenAiCompatibleKey(
|
||||
providerType,
|
||||
trimmedKey,
|
||||
'openai-responses',
|
||||
resolvedBaseUrl,
|
||||
);
|
||||
case 'google-query-key':
|
||||
return await validateGoogleQueryKey(providerType, trimmedKey, resolvedBaseUrl);
|
||||
case 'anthropic-header':
|
||||
return await validateAnthropicHeaderKey(providerType, trimmedKey, resolvedBaseUrl);
|
||||
case 'openrouter':
|
||||
return await validateOpenRouterKey(providerType, trimmedKey);
|
||||
default:
|
||||
return { valid: false, error: `Unsupported validation profile for provider: ${providerType}` };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { valid: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Lazy-load electron-store (ESM module) from the main process only.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let providerStore: any = null;
|
||||
|
||||
export async function getClawXProviderStore() {
|
||||
if (!providerStore) {
|
||||
const Store = (await import('electron-store')).default;
|
||||
providerStore = new Store({
|
||||
name: 'clawx-providers',
|
||||
defaults: {
|
||||
schemaVersion: 0,
|
||||
providers: {} as Record<string, unknown>,
|
||||
providerAccounts: {} as Record<string, unknown>,
|
||||
apiKeys: {} as Record<string, string>,
|
||||
providerSecrets: {} as Record<string, unknown>,
|
||||
defaultProvider: null as string | null,
|
||||
defaultProviderAccountId: null as string | null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return providerStore;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { ProviderSecret } from '../../shared/providers/types';
|
||||
import { getClawXProviderStore } from '../providers/store-instance';
|
||||
|
||||
export interface SecretStore {
|
||||
get(accountId: string): Promise<ProviderSecret | null>;
|
||||
set(secret: ProviderSecret): Promise<void>;
|
||||
delete(accountId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class ElectronStoreSecretStore implements SecretStore {
|
||||
async get(accountId: string): Promise<ProviderSecret | null> {
|
||||
const store = await getClawXProviderStore();
|
||||
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
|
||||
const secret = secrets[accountId];
|
||||
if (secret) {
|
||||
return secret;
|
||||
}
|
||||
|
||||
const apiKeys = (store.get('apiKeys') ?? {}) as Record<string, string>;
|
||||
const apiKey = apiKeys[accountId];
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'api_key',
|
||||
accountId,
|
||||
apiKey,
|
||||
};
|
||||
}
|
||||
|
||||
async set(secret: ProviderSecret): Promise<void> {
|
||||
const store = await getClawXProviderStore();
|
||||
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
|
||||
secrets[secret.accountId] = secret;
|
||||
store.set('providerSecrets', secrets);
|
||||
|
||||
// Keep legacy apiKeys in sync until the rest of the app moves to account-based secrets.
|
||||
const apiKeys = (store.get('apiKeys') ?? {}) as Record<string, string>;
|
||||
if (secret.type === 'api_key') {
|
||||
apiKeys[secret.accountId] = secret.apiKey;
|
||||
} else if (secret.type === 'local') {
|
||||
if (secret.apiKey) {
|
||||
apiKeys[secret.accountId] = secret.apiKey;
|
||||
} else {
|
||||
delete apiKeys[secret.accountId];
|
||||
}
|
||||
} else {
|
||||
delete apiKeys[secret.accountId];
|
||||
}
|
||||
store.set('apiKeys', apiKeys);
|
||||
}
|
||||
|
||||
async delete(accountId: string): Promise<void> {
|
||||
const store = await getClawXProviderStore();
|
||||
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
|
||||
delete secrets[accountId];
|
||||
store.set('providerSecrets', secrets);
|
||||
|
||||
const apiKeys = (store.get('apiKeys') ?? {}) as Record<string, string>;
|
||||
delete apiKeys[accountId];
|
||||
store.set('apiKeys', apiKeys);
|
||||
}
|
||||
}
|
||||
|
||||
const secretStore = new ElectronStoreSecretStore();
|
||||
|
||||
export function getSecretStore(): SecretStore {
|
||||
return secretStore;
|
||||
}
|
||||
|
||||
export async function getProviderSecret(accountId: string): Promise<ProviderSecret | null> {
|
||||
return getSecretStore().get(accountId);
|
||||
}
|
||||
|
||||
export async function setProviderSecret(secret: ProviderSecret): Promise<void> {
|
||||
await getSecretStore().set(secret);
|
||||
}
|
||||
|
||||
export async function deleteProviderSecret(accountId: string): Promise<void> {
|
||||
await getSecretStore().delete(accountId);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import type {
|
||||
ProviderBackendConfig,
|
||||
ProviderDefinition,
|
||||
ProviderType,
|
||||
ProviderTypeInfo,
|
||||
} from './types';
|
||||
|
||||
export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
|
||||
{
|
||||
id: 'anthropic',
|
||||
name: 'Anthropic',
|
||||
icon: '🤖',
|
||||
placeholder: 'sk-ant-api03-...',
|
||||
model: 'Claude',
|
||||
requiresApiKey: true,
|
||||
category: 'official',
|
||||
envVar: 'ANTHROPIC_API_KEY',
|
||||
defaultModelId: 'claude-opus-4-6',
|
||||
supportedAuthModes: ['api_key'],
|
||||
defaultAuthMode: 'api_key',
|
||||
supportsMultipleAccounts: true,
|
||||
},
|
||||
{
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
icon: '💚',
|
||||
placeholder: 'sk-proj-...',
|
||||
model: 'GPT',
|
||||
requiresApiKey: true,
|
||||
category: 'official',
|
||||
envVar: 'OPENAI_API_KEY',
|
||||
defaultModelId: 'gpt-5.2',
|
||||
isOAuth: true,
|
||||
supportsApiKey: true,
|
||||
supportedAuthModes: ['api_key', 'oauth_browser'],
|
||||
defaultAuthMode: 'api_key',
|
||||
supportsMultipleAccounts: true,
|
||||
providerConfig: {
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
api: 'openai-responses',
|
||||
apiKeyEnv: 'OPENAI_API_KEY',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
icon: '🔷',
|
||||
placeholder: 'AIza...',
|
||||
model: 'Gemini',
|
||||
requiresApiKey: true,
|
||||
category: 'official',
|
||||
envVar: 'GEMINI_API_KEY',
|
||||
defaultModelId: 'gemini-3.1-pro-preview',
|
||||
isOAuth: true,
|
||||
supportsApiKey: true,
|
||||
supportedAuthModes: ['api_key', 'oauth_browser'],
|
||||
defaultAuthMode: 'api_key',
|
||||
supportsMultipleAccounts: true,
|
||||
},
|
||||
{
|
||||
id: 'openrouter',
|
||||
name: 'OpenRouter',
|
||||
icon: '🌐',
|
||||
placeholder: 'sk-or-v1-...',
|
||||
model: 'Multi-Model',
|
||||
requiresApiKey: true,
|
||||
showModelId: true,
|
||||
modelIdPlaceholder: 'anthropic/claude-opus-4.6',
|
||||
defaultModelId: 'anthropic/claude-opus-4.6',
|
||||
category: 'compatible',
|
||||
envVar: 'OPENROUTER_API_KEY',
|
||||
supportedAuthModes: ['api_key'],
|
||||
defaultAuthMode: 'api_key',
|
||||
supportsMultipleAccounts: true,
|
||||
providerConfig: {
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
api: 'openai-completions',
|
||||
apiKeyEnv: 'OPENROUTER_API_KEY',
|
||||
headers: {
|
||||
'HTTP-Referer': 'https://claw-x.com',
|
||||
'X-Title': 'ClawX',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ark',
|
||||
name: 'ByteDance Ark',
|
||||
icon: 'A',
|
||||
placeholder: 'your-ark-api-key',
|
||||
model: 'Doubao',
|
||||
requiresApiKey: true,
|
||||
defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
|
||||
showBaseUrl: true,
|
||||
showModelId: true,
|
||||
modelIdPlaceholder: 'ep-20260228000000-xxxxx',
|
||||
category: 'official',
|
||||
envVar: 'ARK_API_KEY',
|
||||
supportedAuthModes: ['api_key'],
|
||||
defaultAuthMode: 'api_key',
|
||||
supportsMultipleAccounts: true,
|
||||
providerConfig: {
|
||||
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
|
||||
api: 'openai-completions',
|
||||
apiKeyEnv: 'ARK_API_KEY',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'moonshot',
|
||||
name: 'Moonshot (CN)',
|
||||
icon: '🌙',
|
||||
placeholder: 'sk-...',
|
||||
model: 'Kimi',
|
||||
requiresApiKey: true,
|
||||
defaultBaseUrl: 'https://api.moonshot.cn/v1',
|
||||
defaultModelId: 'kimi-k2.5',
|
||||
category: 'official',
|
||||
envVar: 'MOONSHOT_API_KEY',
|
||||
supportedAuthModes: ['api_key'],
|
||||
defaultAuthMode: 'api_key',
|
||||
supportsMultipleAccounts: true,
|
||||
providerConfig: {
|
||||
baseUrl: 'https://api.moonshot.cn/v1',
|
||||
api: 'openai-completions',
|
||||
apiKeyEnv: 'MOONSHOT_API_KEY',
|
||||
models: [
|
||||
{
|
||||
id: 'kimi-k2.5',
|
||||
name: 'Kimi K2.5',
|
||||
reasoning: false,
|
||||
input: ['text'],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 256000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'siliconflow',
|
||||
name: 'SiliconFlow (CN)',
|
||||
icon: '🌊',
|
||||
placeholder: 'sk-...',
|
||||
model: 'Multi-Model',
|
||||
requiresApiKey: true,
|
||||
defaultBaseUrl: 'https://api.siliconflow.cn/v1',
|
||||
showModelId: true,
|
||||
showModelIdInDevModeOnly: true,
|
||||
modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3',
|
||||
defaultModelId: 'deepseek-ai/DeepSeek-V3',
|
||||
category: 'compatible',
|
||||
envVar: 'SILICONFLOW_API_KEY',
|
||||
supportedAuthModes: ['api_key'],
|
||||
defaultAuthMode: 'api_key',
|
||||
supportsMultipleAccounts: true,
|
||||
providerConfig: {
|
||||
baseUrl: 'https://api.siliconflow.cn/v1',
|
||||
api: 'openai-completions',
|
||||
apiKeyEnv: 'SILICONFLOW_API_KEY',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'minimax-portal',
|
||||
name: 'MiniMax (Global)',
|
||||
icon: '☁️',
|
||||
placeholder: 'sk-...',
|
||||
model: 'MiniMax',
|
||||
requiresApiKey: false,
|
||||
isOAuth: true,
|
||||
supportsApiKey: true,
|
||||
defaultModelId: 'MiniMax-M2.5',
|
||||
apiKeyUrl: 'https://intl.minimaxi.com/',
|
||||
category: 'official',
|
||||
envVar: 'MINIMAX_API_KEY',
|
||||
supportedAuthModes: ['oauth_device', 'api_key'],
|
||||
defaultAuthMode: 'oauth_device',
|
||||
supportsMultipleAccounts: true,
|
||||
providerConfig: {
|
||||
baseUrl: 'https://api.minimax.io/anthropic',
|
||||
api: 'anthropic-messages',
|
||||
apiKeyEnv: 'MINIMAX_API_KEY',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'minimax-portal-cn',
|
||||
name: 'MiniMax (CN)',
|
||||
icon: '☁️',
|
||||
placeholder: 'sk-...',
|
||||
model: 'MiniMax',
|
||||
requiresApiKey: false,
|
||||
isOAuth: true,
|
||||
supportsApiKey: true,
|
||||
defaultModelId: 'MiniMax-M2.5',
|
||||
apiKeyUrl: 'https://platform.minimaxi.com/',
|
||||
category: 'official',
|
||||
envVar: 'MINIMAX_CN_API_KEY',
|
||||
supportedAuthModes: ['oauth_device', 'api_key'],
|
||||
defaultAuthMode: 'oauth_device',
|
||||
supportsMultipleAccounts: true,
|
||||
providerConfig: {
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
api: 'anthropic-messages',
|
||||
apiKeyEnv: 'MINIMAX_CN_API_KEY',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qwen-portal',
|
||||
name: 'Qwen',
|
||||
icon: '☁️',
|
||||
placeholder: 'sk-...',
|
||||
model: 'Qwen',
|
||||
requiresApiKey: false,
|
||||
isOAuth: true,
|
||||
defaultModelId: 'coder-model',
|
||||
category: 'official',
|
||||
envVar: 'QWEN_API_KEY',
|
||||
supportedAuthModes: ['oauth_device'],
|
||||
defaultAuthMode: 'oauth_device',
|
||||
supportsMultipleAccounts: true,
|
||||
providerConfig: {
|
||||
baseUrl: 'https://portal.qwen.ai/v1',
|
||||
api: 'openai-completions',
|
||||
apiKeyEnv: 'QWEN_API_KEY',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ollama',
|
||||
name: 'Ollama',
|
||||
icon: '🦙',
|
||||
placeholder: 'Not required',
|
||||
requiresApiKey: false,
|
||||
defaultBaseUrl: 'http://localhost:11434/v1',
|
||||
showBaseUrl: true,
|
||||
showModelId: true,
|
||||
modelIdPlaceholder: 'qwen3:latest',
|
||||
category: 'local',
|
||||
supportedAuthModes: ['local'],
|
||||
defaultAuthMode: 'local',
|
||||
supportsMultipleAccounts: true,
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
name: 'Custom',
|
||||
icon: '⚙️',
|
||||
placeholder: 'API key...',
|
||||
requiresApiKey: true,
|
||||
showBaseUrl: true,
|
||||
showModelId: true,
|
||||
modelIdPlaceholder: 'your-provider/model-id',
|
||||
category: 'custom',
|
||||
envVar: 'CUSTOM_API_KEY',
|
||||
supportedAuthModes: ['api_key'],
|
||||
defaultAuthMode: 'api_key',
|
||||
supportsMultipleAccounts: true,
|
||||
},
|
||||
];
|
||||
|
||||
const PROVIDER_DEFINITION_MAP = new Map(
|
||||
PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
|
||||
export function getProviderDefinition(
|
||||
type: ProviderType | string,
|
||||
): ProviderDefinition | undefined {
|
||||
return PROVIDER_DEFINITION_MAP.get(type as ProviderType);
|
||||
}
|
||||
|
||||
export function getProviderTypeInfo(
|
||||
type: ProviderType,
|
||||
): ProviderTypeInfo | undefined {
|
||||
return getProviderDefinition(type);
|
||||
}
|
||||
|
||||
export function getProviderEnvVar(type: string): string | undefined {
|
||||
return getProviderDefinition(type)?.envVar;
|
||||
}
|
||||
|
||||
export function getProviderDefaultModel(type: string): string | undefined {
|
||||
return getProviderDefinition(type)?.defaultModelId;
|
||||
}
|
||||
|
||||
export function getProviderBackendConfig(
|
||||
type: string,
|
||||
): ProviderBackendConfig | undefined {
|
||||
return getProviderDefinition(type)?.providerConfig;
|
||||
}
|
||||
|
||||
export function getProviderUiInfoList(): ProviderTypeInfo[] {
|
||||
return PROVIDER_DEFINITIONS;
|
||||
}
|
||||
|
||||
export function getKeyableProviderTypes(): string[] {
|
||||
return PROVIDER_DEFINITIONS.filter((definition) => definition.envVar).map(
|
||||
(definition) => definition.id,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
export const PROVIDER_TYPES = [
|
||||
'anthropic',
|
||||
'openai',
|
||||
'google',
|
||||
'openrouter',
|
||||
'ark',
|
||||
'moonshot',
|
||||
'siliconflow',
|
||||
'minimax-portal',
|
||||
'minimax-portal-cn',
|
||||
'qwen-portal',
|
||||
'ollama',
|
||||
'custom',
|
||||
] as const;
|
||||
|
||||
export const BUILTIN_PROVIDER_TYPES = [
|
||||
'anthropic',
|
||||
'openai',
|
||||
'google',
|
||||
'openrouter',
|
||||
'ark',
|
||||
'moonshot',
|
||||
'siliconflow',
|
||||
'minimax-portal',
|
||||
'minimax-portal-cn',
|
||||
'qwen-portal',
|
||||
'ollama',
|
||||
] as const;
|
||||
|
||||
export type ProviderType = (typeof PROVIDER_TYPES)[number];
|
||||
export type BuiltinProviderType = (typeof BUILTIN_PROVIDER_TYPES)[number];
|
||||
|
||||
export const OLLAMA_PLACEHOLDER_API_KEY = 'ollama-local';
|
||||
|
||||
export type ProviderProtocol =
|
||||
| 'openai-completions'
|
||||
| 'openai-responses'
|
||||
| 'anthropic-messages';
|
||||
|
||||
export type ProviderAuthMode =
|
||||
| 'api_key'
|
||||
| 'oauth_device'
|
||||
| 'oauth_browser'
|
||||
| 'local';
|
||||
|
||||
export type ProviderVendorCategory =
|
||||
| 'official'
|
||||
| 'compatible'
|
||||
| 'local'
|
||||
| 'custom';
|
||||
|
||||
export interface ProviderConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ProviderType;
|
||||
baseUrl?: string;
|
||||
apiProtocol?: ProviderProtocol;
|
||||
model?: string;
|
||||
fallbackModels?: string[];
|
||||
fallbackProviderIds?: string[];
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProviderWithKeyInfo extends ProviderConfig {
|
||||
hasKey: boolean;
|
||||
keyMasked: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderTypeInfo {
|
||||
id: ProviderType;
|
||||
name: string;
|
||||
icon: string;
|
||||
placeholder: string;
|
||||
model?: string;
|
||||
requiresApiKey: boolean;
|
||||
defaultBaseUrl?: string;
|
||||
showBaseUrl?: boolean;
|
||||
showModelId?: boolean;
|
||||
showModelIdInDevModeOnly?: boolean;
|
||||
modelIdPlaceholder?: string;
|
||||
defaultModelId?: string;
|
||||
isOAuth?: boolean;
|
||||
supportsApiKey?: boolean;
|
||||
apiKeyUrl?: string;
|
||||
}
|
||||
|
||||
export interface ProviderModelEntry extends Record<string, unknown> {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ProviderBackendConfig {
|
||||
baseUrl: string;
|
||||
api: ProviderProtocol;
|
||||
apiKeyEnv: string;
|
||||
models?: ProviderModelEntry[];
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ProviderDefinition extends ProviderTypeInfo {
|
||||
category: ProviderVendorCategory;
|
||||
envVar?: string;
|
||||
providerConfig?: ProviderBackendConfig;
|
||||
supportedAuthModes: ProviderAuthMode[];
|
||||
defaultAuthMode: ProviderAuthMode;
|
||||
supportsMultipleAccounts: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderAccount {
|
||||
id: string;
|
||||
vendorId: ProviderType;
|
||||
label: string;
|
||||
authMode: ProviderAuthMode;
|
||||
baseUrl?: string;
|
||||
apiProtocol?: ProviderProtocol;
|
||||
model?: string;
|
||||
fallbackModels?: string[];
|
||||
fallbackAccountIds?: string[];
|
||||
enabled: boolean;
|
||||
isDefault: boolean;
|
||||
metadata?: {
|
||||
region?: string;
|
||||
email?: string;
|
||||
resourceUrl?: string;
|
||||
customModels?: string[];
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type ProviderSecret =
|
||||
| {
|
||||
type: 'api_key';
|
||||
accountId: string;
|
||||
apiKey: string;
|
||||
}
|
||||
| {
|
||||
type: 'oauth';
|
||||
accountId: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
scopes?: string[];
|
||||
email?: string;
|
||||
subject?: string;
|
||||
}
|
||||
| {
|
||||
type: 'local';
|
||||
accountId: string;
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
export interface ModelSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
vendorId: string;
|
||||
accountId?: string;
|
||||
supportsVision?: boolean;
|
||||
supportsReasoning?: boolean;
|
||||
contextWindow?: number;
|
||||
pricing?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
};
|
||||
source: 'builtin' | 'remote' | 'gateway' | 'custom';
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
import { access, copyFile, mkdir, readdir, rm } from 'fs/promises';
|
||||
import { constants } from 'fs';
|
||||
import { join, normalize } from 'path';
|
||||
import { deleteAgentChannelAccounts, listConfiguredChannels, readOpenClawConfig, writeOpenClawConfig } from './channel-config';
|
||||
import { withConfigLock } from './config-mutex';
|
||||
import { expandPath, getOpenClawConfigDir } from './paths';
|
||||
import * as logger from './logger';
|
||||
|
||||
const MAIN_AGENT_ID = 'main';
|
||||
const MAIN_AGENT_NAME = 'Main';
|
||||
const DEFAULT_ACCOUNT_ID = 'default';
|
||||
const DEFAULT_WORKSPACE_PATH = '~/.openclaw/workspace';
|
||||
const AGENT_BOOTSTRAP_FILES = [
|
||||
'AGENTS.md',
|
||||
'SOUL.md',
|
||||
'TOOLS.md',
|
||||
'USER.md',
|
||||
'IDENTITY.md',
|
||||
'HEARTBEAT.md',
|
||||
'BOOT.md',
|
||||
];
|
||||
const AGENT_RUNTIME_FILES = [
|
||||
'auth-profiles.json',
|
||||
'models.json',
|
||||
];
|
||||
|
||||
interface AgentModelConfig {
|
||||
primary?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AgentDefaultsConfig {
|
||||
workspace?: string;
|
||||
model?: string | AgentModelConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AgentListEntry extends Record<string, unknown> {
|
||||
id: string;
|
||||
name?: string;
|
||||
default?: boolean;
|
||||
workspace?: string;
|
||||
agentDir?: string;
|
||||
model?: string | AgentModelConfig;
|
||||
}
|
||||
|
||||
interface AgentsConfig extends Record<string, unknown> {
|
||||
defaults?: AgentDefaultsConfig;
|
||||
list?: AgentListEntry[];
|
||||
}
|
||||
|
||||
interface BindingMatch extends Record<string, unknown> {
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
interface BindingConfig extends Record<string, unknown> {
|
||||
agentId?: string;
|
||||
match?: BindingMatch;
|
||||
}
|
||||
|
||||
interface ChannelSectionConfig extends Record<string, unknown> {
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
defaultAccount?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface AgentConfigDocument extends Record<string, unknown> {
|
||||
agents?: AgentsConfig;
|
||||
bindings?: BindingConfig[];
|
||||
channels?: Record<string, ChannelSectionConfig>;
|
||||
session?: {
|
||||
mainKey?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
modelDisplay: string;
|
||||
inheritedModel: boolean;
|
||||
workspace: string;
|
||||
agentDir: string;
|
||||
mainSessionKey: string;
|
||||
channelTypes: string[];
|
||||
}
|
||||
|
||||
export interface AgentsSnapshot {
|
||||
agents: AgentSummary[];
|
||||
defaultAgentId: string;
|
||||
configuredChannelTypes: string[];
|
||||
channelOwners: Record<string, string>;
|
||||
}
|
||||
|
||||
function formatModelLabel(model: unknown): string | null {
|
||||
if (typeof model === 'string' && model.trim()) {
|
||||
const trimmed = model.trim();
|
||||
const parts = trimmed.split('/');
|
||||
return parts[parts.length - 1] || trimmed;
|
||||
}
|
||||
|
||||
if (model && typeof model === 'object') {
|
||||
const primary = (model as AgentModelConfig).primary;
|
||||
if (typeof primary === 'string' && primary.trim()) {
|
||||
const parts = primary.trim().split('/');
|
||||
return parts[parts.length - 1] || primary.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeAgentName(name: string): string {
|
||||
return name.trim() || 'Agent';
|
||||
}
|
||||
|
||||
function slugifyAgentId(name: string): string {
|
||||
const normalized = name
|
||||
.normalize('NFKD')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[_\s]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
|
||||
if (!normalized) return 'agent';
|
||||
if (normalized === MAIN_AGENT_ID) return 'agent';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDir(path: string): Promise<void> {
|
||||
if (!(await fileExists(path))) {
|
||||
await mkdir(path, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultWorkspacePath(config: AgentConfigDocument): string {
|
||||
const defaults = (config.agents && typeof config.agents === 'object'
|
||||
? (config.agents as AgentsConfig).defaults
|
||||
: undefined);
|
||||
return typeof defaults?.workspace === 'string' && defaults.workspace.trim()
|
||||
? defaults.workspace
|
||||
: DEFAULT_WORKSPACE_PATH;
|
||||
}
|
||||
|
||||
function getDefaultAgentDirPath(agentId: string): string {
|
||||
return `~/.openclaw/agents/${agentId}/agent`;
|
||||
}
|
||||
|
||||
function createImplicitMainEntry(config: AgentConfigDocument): AgentListEntry {
|
||||
return {
|
||||
id: MAIN_AGENT_ID,
|
||||
name: MAIN_AGENT_NAME,
|
||||
default: true,
|
||||
workspace: getDefaultWorkspacePath(config),
|
||||
agentDir: getDefaultAgentDirPath(MAIN_AGENT_ID),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAgentsConfig(config: AgentConfigDocument): {
|
||||
agentsConfig: AgentsConfig;
|
||||
entries: AgentListEntry[];
|
||||
defaultAgentId: string;
|
||||
syntheticMain: boolean;
|
||||
} {
|
||||
const agentsConfig = (config.agents && typeof config.agents === 'object'
|
||||
? { ...(config.agents as AgentsConfig) }
|
||||
: {}) as AgentsConfig;
|
||||
const rawEntries = Array.isArray(agentsConfig.list)
|
||||
? agentsConfig.list.filter((entry): entry is AgentListEntry => (
|
||||
Boolean(entry) && typeof entry === 'object' && typeof entry.id === 'string' && entry.id.trim().length > 0
|
||||
))
|
||||
: [];
|
||||
|
||||
if (rawEntries.length === 0) {
|
||||
const main = createImplicitMainEntry(config);
|
||||
return {
|
||||
agentsConfig,
|
||||
entries: [main],
|
||||
defaultAgentId: MAIN_AGENT_ID,
|
||||
syntheticMain: true,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultEntry = rawEntries.find((entry) => entry.default) ?? rawEntries[0];
|
||||
return {
|
||||
agentsConfig,
|
||||
entries: rawEntries.map((entry) => ({ ...entry })),
|
||||
defaultAgentId: defaultEntry.id,
|
||||
syntheticMain: false,
|
||||
};
|
||||
}
|
||||
|
||||
function isChannelBinding(binding: unknown): binding is BindingConfig {
|
||||
if (!binding || typeof binding !== 'object') return false;
|
||||
const candidate = binding as BindingConfig;
|
||||
if (typeof candidate.agentId !== 'string' || !candidate.agentId) return false;
|
||||
if (!candidate.match || typeof candidate.match !== 'object' || Array.isArray(candidate.match)) return false;
|
||||
if (typeof candidate.match.channel !== 'string' || !candidate.match.channel) return false;
|
||||
const keys = Object.keys(candidate.match);
|
||||
// Accept bindings with just {channel} or {channel, accountId}
|
||||
if (keys.length === 1 && keys[0] === 'channel') return true;
|
||||
if (keys.length === 2 && keys.includes('channel') && keys.includes('accountId')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Normalize agent ID for consistent comparison (bindings vs entries). */
|
||||
function normalizeAgentIdForBinding(id: string): string {
|
||||
return (id ?? '').trim().toLowerCase() || '';
|
||||
}
|
||||
|
||||
function normalizeMainKey(value: unknown): string {
|
||||
if (typeof value !== 'string') return 'main';
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
return trimmed || 'main';
|
||||
}
|
||||
|
||||
function buildAgentMainSessionKey(config: AgentConfigDocument, agentId: string): string {
|
||||
return `agent:${normalizeAgentIdForBinding(agentId) || MAIN_AGENT_ID}:${normalizeMainKey(config.session?.mainKey)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map of channelType -> agentId from bindings.
|
||||
* Account-scoped bindings are preferred; channel-wide bindings serve as fallback.
|
||||
* Multiple agents can own the same channel type (different accounts).
|
||||
*/
|
||||
function getChannelBindingMap(bindings: unknown): {
|
||||
channelToAgent: Map<string, string>;
|
||||
accountToAgent: Map<string, string>;
|
||||
} {
|
||||
const channelToAgent = new Map<string, string>();
|
||||
const accountToAgent = new Map<string, string>();
|
||||
if (!Array.isArray(bindings)) return { channelToAgent, accountToAgent };
|
||||
|
||||
for (const binding of bindings) {
|
||||
if (!isChannelBinding(binding)) continue;
|
||||
const agentId = normalizeAgentIdForBinding(binding.agentId!);
|
||||
const channel = binding.match?.channel;
|
||||
if (!agentId || !channel) continue;
|
||||
|
||||
const accountId = binding.match?.accountId;
|
||||
if (accountId) {
|
||||
accountToAgent.set(`${channel}:${accountId}`, agentId);
|
||||
} else {
|
||||
channelToAgent.set(channel, agentId);
|
||||
}
|
||||
}
|
||||
|
||||
return { channelToAgent, accountToAgent };
|
||||
}
|
||||
|
||||
function upsertBindingsForChannel(
|
||||
bindings: unknown,
|
||||
channelType: string,
|
||||
agentId: string | null,
|
||||
accountId?: string,
|
||||
): BindingConfig[] | undefined {
|
||||
const nextBindings = Array.isArray(bindings)
|
||||
? [...bindings as BindingConfig[]].filter((binding) => {
|
||||
if (!isChannelBinding(binding)) return true;
|
||||
if (binding.match?.channel !== channelType) return true;
|
||||
// Only remove binding that matches the exact accountId scope
|
||||
if (accountId) {
|
||||
return binding.match?.accountId !== accountId;
|
||||
}
|
||||
// No accountId: remove channel-wide binding (legacy)
|
||||
return Boolean(binding.match?.accountId);
|
||||
})
|
||||
: [];
|
||||
|
||||
if (agentId) {
|
||||
const match: BindingMatch = { channel: channelType };
|
||||
if (accountId) {
|
||||
match.accountId = accountId;
|
||||
}
|
||||
nextBindings.push({ agentId, match });
|
||||
}
|
||||
|
||||
return nextBindings.length > 0 ? nextBindings : undefined;
|
||||
}
|
||||
|
||||
async function listExistingAgentIdsOnDisk(): Promise<Set<string>> {
|
||||
const ids = new Set<string>();
|
||||
const agentsDir = join(getOpenClawConfigDir(), 'agents');
|
||||
|
||||
try {
|
||||
if (!(await fileExists(agentsDir))) return ids;
|
||||
const entries = await readdir(agentsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) ids.add(entry.name);
|
||||
}
|
||||
} catch {
|
||||
// ignore discovery failures
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function removeAgentRuntimeDirectory(agentId: string): Promise<void> {
|
||||
const runtimeDir = join(getOpenClawConfigDir(), 'agents', agentId);
|
||||
try {
|
||||
await rm(runtimeDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
logger.warn('Failed to remove agent runtime directory', {
|
||||
agentId,
|
||||
runtimeDir,
|
||||
error: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function trimTrailingSeparators(path: string): string {
|
||||
return path.replace(/[\\/]+$/, '');
|
||||
}
|
||||
|
||||
function getManagedWorkspaceDirectory(agent: AgentListEntry): string | null {
|
||||
if (agent.id === MAIN_AGENT_ID) return null;
|
||||
|
||||
const configuredWorkspace = expandPath(agent.workspace || `~/.openclaw/workspace-${agent.id}`);
|
||||
const managedWorkspace = join(getOpenClawConfigDir(), `workspace-${agent.id}`);
|
||||
const normalizedConfigured = trimTrailingSeparators(normalize(configuredWorkspace));
|
||||
const normalizedManaged = trimTrailingSeparators(normalize(managedWorkspace));
|
||||
|
||||
return normalizedConfigured === normalizedManaged ? configuredWorkspace : null;
|
||||
}
|
||||
|
||||
async function removeAgentWorkspaceDirectory(agent: AgentListEntry): Promise<void> {
|
||||
const workspaceDir = getManagedWorkspaceDirectory(agent);
|
||||
if (!workspaceDir) {
|
||||
logger.warn('Skipping agent workspace deletion for unmanaged path', {
|
||||
agentId: agent.id,
|
||||
workspace: agent.workspace,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(workspaceDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
logger.warn('Failed to remove agent workspace directory', {
|
||||
agentId: agent.id,
|
||||
workspaceDir,
|
||||
error: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function copyBootstrapFiles(sourceWorkspace: string, targetWorkspace: string): Promise<void> {
|
||||
await ensureDir(targetWorkspace);
|
||||
|
||||
for (const fileName of AGENT_BOOTSTRAP_FILES) {
|
||||
const source = join(sourceWorkspace, fileName);
|
||||
const target = join(targetWorkspace, fileName);
|
||||
if (!(await fileExists(source)) || (await fileExists(target))) continue;
|
||||
await copyFile(source, target);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyRuntimeFiles(sourceAgentDir: string, targetAgentDir: string): Promise<void> {
|
||||
await ensureDir(targetAgentDir);
|
||||
|
||||
for (const fileName of AGENT_RUNTIME_FILES) {
|
||||
const source = join(sourceAgentDir, fileName);
|
||||
const target = join(targetAgentDir, fileName);
|
||||
if (!(await fileExists(source)) || (await fileExists(target))) continue;
|
||||
await copyFile(source, target);
|
||||
}
|
||||
}
|
||||
|
||||
async function provisionAgentFilesystem(config: AgentConfigDocument, agent: AgentListEntry): Promise<void> {
|
||||
const { entries } = normalizeAgentsConfig(config);
|
||||
const mainEntry = entries.find((entry) => entry.id === MAIN_AGENT_ID) ?? createImplicitMainEntry(config);
|
||||
const sourceWorkspace = expandPath(mainEntry.workspace || getDefaultWorkspacePath(config));
|
||||
const targetWorkspace = expandPath(agent.workspace || `~/.openclaw/workspace-${agent.id}`);
|
||||
const sourceAgentDir = expandPath(mainEntry.agentDir || getDefaultAgentDirPath(MAIN_AGENT_ID));
|
||||
const targetAgentDir = expandPath(agent.agentDir || getDefaultAgentDirPath(agent.id));
|
||||
const targetSessionsDir = join(getOpenClawConfigDir(), 'agents', agent.id, 'sessions');
|
||||
|
||||
await ensureDir(targetWorkspace);
|
||||
await ensureDir(targetAgentDir);
|
||||
await ensureDir(targetSessionsDir);
|
||||
|
||||
if (targetWorkspace !== sourceWorkspace) {
|
||||
await copyBootstrapFiles(sourceWorkspace, targetWorkspace);
|
||||
}
|
||||
if (targetAgentDir !== sourceAgentDir) {
|
||||
await copyRuntimeFiles(sourceAgentDir, targetAgentDir);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAccountIdForAgent(agentId: string): string {
|
||||
return agentId === MAIN_AGENT_ID ? DEFAULT_ACCOUNT_ID : agentId;
|
||||
}
|
||||
|
||||
function listConfiguredAccountIdsForChannel(config: AgentConfigDocument, channelType: string): string[] {
|
||||
const channelSection = config.channels?.[channelType];
|
||||
if (!channelSection || channelSection.enabled === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const accounts = channelSection.accounts;
|
||||
if (!accounts || typeof accounts !== 'object' || Object.keys(accounts).length === 0) {
|
||||
return [DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
|
||||
return Object.keys(accounts)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => {
|
||||
if (a === DEFAULT_ACCOUNT_ID) return -1;
|
||||
if (b === DEFAULT_ACCOUNT_ID) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<AgentsSnapshot> {
|
||||
const { entries, defaultAgentId } = normalizeAgentsConfig(config);
|
||||
const configuredChannels = await listConfiguredChannels();
|
||||
const { channelToAgent, accountToAgent } = getChannelBindingMap(config.bindings);
|
||||
const defaultAgentIdNorm = normalizeAgentIdForBinding(defaultAgentId);
|
||||
const channelOwners: Record<string, string> = {};
|
||||
|
||||
// Build per-agent channel lists from account-scoped bindings
|
||||
const agentChannelSets = new Map<string, Set<string>>();
|
||||
|
||||
for (const channelType of configuredChannels) {
|
||||
const accountIds = listConfiguredAccountIdsForChannel(config, channelType);
|
||||
let primaryOwner: string | undefined;
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
const owner =
|
||||
accountToAgent.get(`${channelType}:${accountId}`)
|
||||
|| (accountId === DEFAULT_ACCOUNT_ID ? (channelToAgent.get(channelType) || defaultAgentIdNorm) : undefined);
|
||||
|
||||
if (!owner) {
|
||||
continue;
|
||||
}
|
||||
|
||||
primaryOwner ??= owner;
|
||||
const existing = agentChannelSets.get(owner) ?? new Set();
|
||||
existing.add(channelType);
|
||||
agentChannelSets.set(owner, existing);
|
||||
}
|
||||
|
||||
if (!primaryOwner) {
|
||||
primaryOwner = channelToAgent.get(channelType) || defaultAgentIdNorm;
|
||||
const existing = agentChannelSets.get(primaryOwner) ?? new Set();
|
||||
existing.add(channelType);
|
||||
agentChannelSets.set(primaryOwner, existing);
|
||||
}
|
||||
|
||||
channelOwners[channelType] = primaryOwner;
|
||||
}
|
||||
|
||||
const defaultModelLabel = formatModelLabel((config.agents as AgentsConfig | undefined)?.defaults?.model);
|
||||
const agents: AgentSummary[] = entries.map((entry) => {
|
||||
const modelLabel = formatModelLabel(entry.model) || defaultModelLabel || 'Not configured';
|
||||
const inheritedModel = !formatModelLabel(entry.model) && Boolean(defaultModelLabel);
|
||||
const entryIdNorm = normalizeAgentIdForBinding(entry.id);
|
||||
const ownedChannels = agentChannelSets.get(entryIdNorm) ?? new Set<string>();
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name || (entry.id === MAIN_AGENT_ID ? MAIN_AGENT_NAME : entry.id),
|
||||
isDefault: entry.id === defaultAgentId,
|
||||
modelDisplay: modelLabel,
|
||||
inheritedModel,
|
||||
workspace: entry.workspace || (entry.id === MAIN_AGENT_ID ? getDefaultWorkspacePath(config) : `~/.openclaw/workspace-${entry.id}`),
|
||||
agentDir: entry.agentDir || getDefaultAgentDirPath(entry.id),
|
||||
mainSessionKey: buildAgentMainSessionKey(config, entry.id),
|
||||
channelTypes: configuredChannels.filter((ct) => ownedChannels.has(ct)),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
agents,
|
||||
defaultAgentId,
|
||||
configuredChannelTypes: configuredChannels,
|
||||
channelOwners,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listAgentsSnapshot(): Promise<AgentsSnapshot> {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
return buildSnapshotFromConfig(config);
|
||||
}
|
||||
|
||||
export async function listConfiguredAgentIds(): Promise<string[]> {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
const { entries } = normalizeAgentsConfig(config);
|
||||
const ids = [...new Set(entries.map((entry) => entry.id.trim()).filter(Boolean))];
|
||||
return ids.length > 0 ? ids : [MAIN_AGENT_ID];
|
||||
}
|
||||
|
||||
export async function createAgent(name: string): Promise<AgentsSnapshot> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
const { agentsConfig, entries, syntheticMain } = normalizeAgentsConfig(config);
|
||||
const normalizedName = normalizeAgentName(name);
|
||||
const existingIds = new Set(entries.map((entry) => entry.id));
|
||||
const diskIds = await listExistingAgentIdsOnDisk();
|
||||
let nextId = slugifyAgentId(normalizedName);
|
||||
let suffix = 2;
|
||||
|
||||
while (existingIds.has(nextId) || diskIds.has(nextId)) {
|
||||
nextId = `${slugifyAgentId(normalizedName)}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
const nextEntries = syntheticMain ? [createImplicitMainEntry(config), ...entries.filter((_, index) => index > 0)] : [...entries];
|
||||
const newAgent: AgentListEntry = {
|
||||
id: nextId,
|
||||
name: normalizedName,
|
||||
workspace: `~/.openclaw/workspace-${nextId}`,
|
||||
agentDir: getDefaultAgentDirPath(nextId),
|
||||
};
|
||||
|
||||
if (!nextEntries.some((entry) => entry.id === MAIN_AGENT_ID) && syntheticMain) {
|
||||
nextEntries.unshift(createImplicitMainEntry(config));
|
||||
}
|
||||
nextEntries.push(newAgent);
|
||||
|
||||
config.agents = {
|
||||
...agentsConfig,
|
||||
list: nextEntries,
|
||||
};
|
||||
|
||||
await provisionAgentFilesystem(config, newAgent);
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Created agent config entry', { agentId: nextId });
|
||||
return buildSnapshotFromConfig(config);
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAgentName(agentId: string, name: string): Promise<AgentsSnapshot> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
const { agentsConfig, entries } = normalizeAgentsConfig(config);
|
||||
const normalizedName = normalizeAgentName(name);
|
||||
const index = entries.findIndex((entry) => entry.id === agentId);
|
||||
if (index === -1) {
|
||||
throw new Error(`Agent "${agentId}" not found`);
|
||||
}
|
||||
|
||||
entries[index] = {
|
||||
...entries[index],
|
||||
name: normalizedName,
|
||||
};
|
||||
|
||||
config.agents = {
|
||||
...agentsConfig,
|
||||
list: entries,
|
||||
};
|
||||
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Updated agent name', { agentId, name: normalizedName });
|
||||
return buildSnapshotFromConfig(config);
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAgentConfig(agentId: string): Promise<AgentsSnapshot> {
|
||||
return withConfigLock(async () => {
|
||||
if (agentId === MAIN_AGENT_ID) {
|
||||
throw new Error('The main agent cannot be deleted');
|
||||
}
|
||||
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
const { agentsConfig, entries, defaultAgentId } = normalizeAgentsConfig(config);
|
||||
const removedEntry = entries.find((entry) => entry.id === agentId);
|
||||
const nextEntries = entries.filter((entry) => entry.id !== agentId);
|
||||
if (!removedEntry || nextEntries.length === entries.length) {
|
||||
throw new Error(`Agent "${agentId}" not found`);
|
||||
}
|
||||
|
||||
config.agents = {
|
||||
...agentsConfig,
|
||||
list: nextEntries,
|
||||
};
|
||||
config.bindings = Array.isArray(config.bindings)
|
||||
? config.bindings.filter((binding) => !(isChannelBinding(binding) && binding.agentId === agentId))
|
||||
: undefined;
|
||||
|
||||
if (defaultAgentId === agentId && nextEntries.length > 0) {
|
||||
nextEntries[0] = {
|
||||
...nextEntries[0],
|
||||
default: true,
|
||||
};
|
||||
}
|
||||
|
||||
await writeOpenClawConfig(config);
|
||||
await deleteAgentChannelAccounts(agentId);
|
||||
await removeAgentRuntimeDirectory(agentId);
|
||||
await removeAgentWorkspaceDirectory(removedEntry);
|
||||
logger.info('Deleted agent config entry', { agentId });
|
||||
return buildSnapshotFromConfig(config);
|
||||
});
|
||||
}
|
||||
|
||||
export async function assignChannelToAgent(agentId: string, channelType: string): Promise<AgentsSnapshot> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
const { entries } = normalizeAgentsConfig(config);
|
||||
if (!entries.some((entry) => entry.id === agentId)) {
|
||||
throw new Error(`Agent "${agentId}" not found`);
|
||||
}
|
||||
|
||||
const accountId = resolveAccountIdForAgent(agentId);
|
||||
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId);
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Assigned channel to agent', { agentId, channelType, accountId });
|
||||
return buildSnapshotFromConfig(config);
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearChannelBinding(channelType: string, accountId?: string): Promise<AgentsSnapshot> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
config.bindings = upsertBindingsForChannel(config.bindings, channelType, null, accountId);
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Cleared channel binding', { channelType, accountId });
|
||||
return buildSnapshotFromConfig(config);
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearAllBindingsForChannel(channelType: string): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
if (!Array.isArray(config.bindings)) return;
|
||||
|
||||
const nextBindings = config.bindings.filter((binding) => {
|
||||
if (!isChannelBinding(binding)) return true;
|
||||
return binding.match?.channel !== channelType;
|
||||
});
|
||||
|
||||
config.bindings = nextBindings.length > 0 ? nextBindings : undefined;
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Cleared all bindings for channel', { channelType });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
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 { saveOAuthTokenToOpenClaw } from './openclaw-auth';
|
||||
|
||||
export type BrowserOAuthProviderType = 'google' | 'openai';
|
||||
|
||||
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.3-codex';
|
||||
|
||||
class BrowserOAuthManager extends EventEmitter {
|
||||
private activeProvider: BrowserOAuthProviderType | null = null;
|
||||
private activeAccountId: string | null = null;
|
||||
private activeLabel: string | null = null;
|
||||
private active = false;
|
||||
private mainWindow: BrowserWindow | null = null;
|
||||
private pendingManualCodeResolve: ((value: string) => void) | null = null;
|
||||
private pendingManualCodeReject: ((reason?: unknown) => void) | null = null;
|
||||
|
||||
setWindow(window: BrowserWindow) {
|
||||
this.mainWindow = window;
|
||||
}
|
||||
|
||||
async startFlow(
|
||||
provider: BrowserOAuthProviderType,
|
||||
options?: { accountId?: string; label?: string },
|
||||
): Promise<boolean> {
|
||||
if (this.active) {
|
||||
await this.stopFlow();
|
||||
}
|
||||
|
||||
this.active = true;
|
||||
this.activeProvider = provider;
|
||||
this.activeAccountId = options?.accountId || provider;
|
||||
this.activeLabel = options?.label || null;
|
||||
this.emit('oauth:start', { provider, accountId: this.activeAccountId });
|
||||
|
||||
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 = 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) {
|
||||
if (!this.active) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
this.pendingManualCodeReject = null;
|
||||
}
|
||||
}
|
||||
|
||||
async stopFlow(): Promise<void> {
|
||||
this.active = false;
|
||||
this.activeProvider = null;
|
||||
this.activeAccountId = null;
|
||||
this.activeLabel = null;
|
||||
if (this.pendingManualCodeReject) {
|
||||
this.pendingManualCodeReject(new Error('OAuth flow cancelled'));
|
||||
}
|
||||
this.pendingManualCodeResolve = null;
|
||||
this.pendingManualCodeReject = null;
|
||||
logger.info('[BrowserOAuth] Flow explicitly stopped');
|
||||
}
|
||||
|
||||
submitManualCode(code: string): boolean {
|
||||
const value = code.trim();
|
||||
if (!value || !this.pendingManualCodeResolve) {
|
||||
return false;
|
||||
}
|
||||
this.pendingManualCodeResolve(value);
|
||||
this.pendingManualCodeResolve = null;
|
||||
this.pendingManualCodeReject = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async onSuccess(
|
||||
providerType: BrowserOAuthProviderType,
|
||||
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;
|
||||
this.pendingManualCodeReject = null;
|
||||
logger.info(`[BrowserOAuth] Successfully completed OAuth for ${providerType}`);
|
||||
|
||||
const providerService = getProviderService();
|
||||
const existing = await providerService.getAccount(accountId);
|
||||
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 (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;
|
||||
})();
|
||||
|
||||
const nextAccount = await providerService.createAccount({
|
||||
id: accountId,
|
||||
vendorId: providerType,
|
||||
label: accountLabel || existing?.label || accountLabelDefault,
|
||||
authMode: 'oauth_browser',
|
||||
baseUrl: existing?.baseUrl,
|
||||
apiProtocol: existing?.apiProtocol,
|
||||
model: normalizedExistingModel || defaultModel,
|
||||
fallbackModels: existing?.fallbackModels,
|
||||
fallbackAccountIds: existing?.fallbackAccountIds,
|
||||
enabled: existing?.enabled ?? true,
|
||||
isDefault: existing?.isDefault ?? false,
|
||||
metadata: {
|
||||
...existing?.metadata,
|
||||
email: oauthTokenEmail,
|
||||
resourceUrl: runtimeProviderId,
|
||||
},
|
||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await getSecretStore().set({
|
||||
type: 'oauth',
|
||||
accountId,
|
||||
accessToken: token.access,
|
||||
refreshToken: token.refresh,
|
||||
expiresAt: token.expires,
|
||||
email: oauthTokenEmail,
|
||||
subject: oauthTokenSubject,
|
||||
});
|
||||
|
||||
await saveOAuthTokenToOpenClaw(runtimeProviderId, {
|
||||
access: token.access,
|
||||
refresh: token.refresh,
|
||||
expires: token.expires,
|
||||
email: oauthTokenEmail,
|
||||
projectId: oauthTokenSubject,
|
||||
});
|
||||
|
||||
this.emit('oauth:success', { provider: providerType, accountId: nextAccount.id });
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('oauth:success', {
|
||||
provider: providerType,
|
||||
accountId: nextAccount.id,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private emitError(message: string) {
|
||||
this.emit('oauth:error', { message });
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('oauth:error', { message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const browserOAuthManager = new BrowserOAuthManager();
|
||||
+470
-292
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Async mutex for serializing read-modify-write operations on
|
||||
* ~/.openclaw/openclaw.json.
|
||||
*
|
||||
* Multiple code paths (channel-config, openclaw-auth, openclaw-proxy,
|
||||
* skill-config, agent-config) perform async read → modify → write against
|
||||
* the same JSON file. Without coordination, Node's event-loop can
|
||||
* interleave two I/O sequences so that the second writer reads stale data
|
||||
* and overwrites the first writer's changes (classic TOCTOU race).
|
||||
*
|
||||
* The mutex is **reentrant**: if a function already holding the lock calls
|
||||
* another function that also calls `withConfigLock`, the inner call will
|
||||
* pass through without blocking. This prevents deadlocks when e.g.
|
||||
* `deleteAgentConfig` (locked) calls `deleteAgentChannelAccounts` (also locked).
|
||||
*
|
||||
* Usage:
|
||||
* import { withConfigLock } from './config-mutex';
|
||||
*
|
||||
* await withConfigLock(async () => {
|
||||
* const cfg = await readConfig();
|
||||
* cfg.foo = 'bar';
|
||||
* await writeConfig(cfg);
|
||||
* });
|
||||
*/
|
||||
|
||||
import { AsyncLocalStorage } from 'async_hooks';
|
||||
|
||||
/** Tracks whether the current async context already holds the config lock. */
|
||||
const lockContext = new AsyncLocalStorage<boolean>();
|
||||
|
||||
class ConfigMutex {
|
||||
private queue: Array<() => void> = [];
|
||||
private locked = false;
|
||||
|
||||
async acquire(): Promise<() => void> {
|
||||
if (!this.locked) {
|
||||
this.locked = true;
|
||||
return this.createRelease();
|
||||
}
|
||||
return new Promise<() => void>((resolve) => {
|
||||
this.queue.push(() => resolve(this.createRelease()));
|
||||
});
|
||||
}
|
||||
|
||||
private createRelease(): () => void {
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
const next = this.queue.shift();
|
||||
if (next) {
|
||||
next();
|
||||
} else {
|
||||
this.locked = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton mutex shared across all openclaw.json writers. */
|
||||
const configMutex = new ConfigMutex();
|
||||
|
||||
/**
|
||||
* Execute `fn` while holding the config mutex.
|
||||
* Ensures only one read-modify-write cycle on openclaw.json runs at a time.
|
||||
*
|
||||
* **Reentrant**: if the current async context already holds the lock
|
||||
* (i.e. an outer `withConfigLock` is on the call stack), `fn` runs
|
||||
* immediately without re-acquiring the lock.
|
||||
*/
|
||||
export async function withConfigLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
// If we're already inside a withConfigLock call, skip re-acquiring
|
||||
if (lockContext.getStore()) {
|
||||
return fn();
|
||||
}
|
||||
|
||||
const release = await configMutex.acquire();
|
||||
try {
|
||||
return await lockContext.run(true, fn);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ export const PORTS = {
|
||||
|
||||
/** ClawX GUI production port (for reference) */
|
||||
CLAWX_GUI: 23333,
|
||||
|
||||
/** Local host API server port */
|
||||
CLAWX_HOST_API: 3210,
|
||||
|
||||
/** OpenClaw Gateway port */
|
||||
OPENCLAW_GATEWAY: 18789,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Device identity utilities for OpenClaw Gateway authentication.
|
||||
*
|
||||
* OpenClaw Gateway 2026.2.15+ requires a signed device identity in the
|
||||
* connect handshake to grant scopes (operator.read, operator.write, etc.).
|
||||
* Without a device, the gateway strips all requested scopes.
|
||||
*
|
||||
* All file I/O uses async fs/promises to avoid blocking the main thread.
|
||||
* Key generation (Ed25519) uses the async crypto.generateKeyPair API.
|
||||
*/
|
||||
import crypto from 'crypto';
|
||||
import { access, readFile, writeFile, mkdir, chmod } from 'fs/promises';
|
||||
import { constants } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export interface DeviceIdentity {
|
||||
deviceId: string;
|
||||
publicKeyPem: string;
|
||||
privateKeyPem: string;
|
||||
}
|
||||
|
||||
export interface DeviceAuthPayloadParams {
|
||||
deviceId: string;
|
||||
clientId: string;
|
||||
clientMode: string;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
signedAtMs: number;
|
||||
token?: string | null;
|
||||
nonce?: string | null;
|
||||
version?: 'v1' | 'v2';
|
||||
}
|
||||
|
||||
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
||||
|
||||
function base64UrlEncode(buf: Buffer): string {
|
||||
return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function derivePublicKeyRaw(publicKeyPem: string): Buffer {
|
||||
const spki = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' }) as Buffer;
|
||||
if (
|
||||
spki.length === ED25519_SPKI_PREFIX.length + 32 &&
|
||||
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
|
||||
) {
|
||||
return spki.subarray(ED25519_SPKI_PREFIX.length);
|
||||
}
|
||||
return spki;
|
||||
}
|
||||
|
||||
function fingerprintPublicKey(publicKeyPem: string): string {
|
||||
const raw = derivePublicKeyRaw(publicKeyPem);
|
||||
return crypto.createHash('sha256').update(raw).digest('hex');
|
||||
}
|
||||
|
||||
/** Non-throwing async existence check. */
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try { await access(p, constants.F_OK); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
/** Generate a new Ed25519 identity (async key generation). */
|
||||
async function generateIdentity(): Promise<DeviceIdentity> {
|
||||
const { publicKey, privateKey } = await new Promise<crypto.KeyPairKeyObjectResult>(
|
||||
(resolve, reject) => {
|
||||
crypto.generateKeyPair('ed25519', (err, publicKey, privateKey) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ publicKey, privateKey });
|
||||
});
|
||||
},
|
||||
);
|
||||
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,
|
||||
privateKeyPem,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load device identity from disk, or create and persist a new one.
|
||||
* The identity file is stored at `filePath` with mode 0o600.
|
||||
*
|
||||
* Fully async — no synchronous file I/O or crypto.
|
||||
*/
|
||||
export async function loadOrCreateDeviceIdentity(filePath: string): Promise<DeviceIdentity> {
|
||||
try {
|
||||
if (await fileExists(filePath)) {
|
||||
const raw = await readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (
|
||||
parsed?.version === 1 &&
|
||||
typeof parsed.deviceId === 'string' &&
|
||||
typeof parsed.publicKeyPem === 'string' &&
|
||||
typeof parsed.privateKeyPem === 'string'
|
||||
) {
|
||||
const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
|
||||
if (derivedId && derivedId !== parsed.deviceId) {
|
||||
const updated = { ...parsed, deviceId: derivedId };
|
||||
await writeFile(filePath, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
|
||||
return { deviceId: derivedId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
|
||||
}
|
||||
return { deviceId: parsed.deviceId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through to create a new identity
|
||||
}
|
||||
|
||||
const identity = await generateIdentity();
|
||||
const dir = path.dirname(filePath);
|
||||
if (!(await fileExists(dir))) await mkdir(dir, { recursive: true });
|
||||
const stored = { version: 1, ...identity, createdAtMs: Date.now() };
|
||||
await writeFile(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
|
||||
try { await chmod(filePath, 0o600); } catch { /* ignore */ }
|
||||
return identity;
|
||||
}
|
||||
|
||||
/** Sign a string payload with the Ed25519 private key, returns base64url signature. */
|
||||
export function signDevicePayload(privateKeyPem: string, payload: string): string {
|
||||
const key = crypto.createPrivateKey(privateKeyPem);
|
||||
return base64UrlEncode(crypto.sign(null, Buffer.from(payload, 'utf8'), key));
|
||||
}
|
||||
|
||||
/** Encode the raw Ed25519 public key bytes (from PEM) as base64url. */
|
||||
export function publicKeyRawBase64UrlFromPem(publicKeyPem: string): string {
|
||||
return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
|
||||
}
|
||||
|
||||
/** Build the canonical payload string that must be signed for device auth. */
|
||||
export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string {
|
||||
const version = params.version ?? (params.nonce ? 'v2' : 'v1');
|
||||
const scopes = params.scopes.join(',');
|
||||
const token = params.token ?? '';
|
||||
const base = [
|
||||
version,
|
||||
params.deviceId,
|
||||
params.clientId,
|
||||
params.clientMode,
|
||||
params.role,
|
||||
scopes,
|
||||
String(params.signedAtMs),
|
||||
token,
|
||||
];
|
||||
if (version === 'v2') base.push(params.nonce ?? '');
|
||||
return base.join('|');
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Device OAuth Manager
|
||||
*
|
||||
* Delegates MiniMax and Qwen OAuth to the OpenClaw extension oauth.ts functions
|
||||
* imported directly from the bundled openclaw package at build time.
|
||||
*
|
||||
* This approach:
|
||||
* - Avoids hardcoding client_id (lives in openclaw extension)
|
||||
* - Avoids duplicating HTTP OAuth logic
|
||||
* - Avoids spawning CLI process (which requires interactive TTY)
|
||||
* - Works identically on macOS, Windows, and Linux
|
||||
*
|
||||
* The extension oauth.ts files only use `node:crypto` and global `fetch` —
|
||||
* they are pure Node.js HTTP functions, no TTY, no prompter needed.
|
||||
*
|
||||
* We provide our own callbacks (openUrl/note/progress) that hook into
|
||||
* the Electron IPC system to display UI in the ClawX frontend.
|
||||
*/
|
||||
import { EventEmitter } from 'events';
|
||||
import { BrowserWindow, shell } from 'electron';
|
||||
import { logger } from './logger';
|
||||
import { saveProvider, getProvider, ProviderConfig } from './secure-storage';
|
||||
import { getProviderDefaultModel } from './provider-registry';
|
||||
import { isOpenClawPresent } from './paths';
|
||||
import {
|
||||
loginMiniMaxPortalOAuth,
|
||||
type MiniMaxOAuthToken,
|
||||
type MiniMaxRegion,
|
||||
} from '../../node_modules/openclaw/extensions/minimax-portal-auth/oauth';
|
||||
import {
|
||||
loginQwenPortalOAuth,
|
||||
type QwenOAuthToken,
|
||||
} from '../../node_modules/openclaw/extensions/qwen-portal-auth/oauth';
|
||||
import { saveOAuthTokenToOpenClaw, setOpenClawDefaultModelWithOverride } from './openclaw-auth';
|
||||
|
||||
export type OAuthProviderType = 'minimax-portal' | 'minimax-portal-cn' | 'qwen-portal';
|
||||
export type { MiniMaxRegion };
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DeviceOAuthManager
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
class DeviceOAuthManager extends EventEmitter {
|
||||
private activeProvider: OAuthProviderType | null = null;
|
||||
private activeAccountId: string | null = null;
|
||||
private activeLabel: string | null = null;
|
||||
private active: boolean = false;
|
||||
private mainWindow: BrowserWindow | null = null;
|
||||
|
||||
setWindow(window: BrowserWindow) {
|
||||
this.mainWindow = window;
|
||||
}
|
||||
|
||||
async startFlow(
|
||||
provider: OAuthProviderType,
|
||||
region: MiniMaxRegion = 'global',
|
||||
options?: { accountId?: string; label?: string },
|
||||
): Promise<boolean> {
|
||||
if (this.active) {
|
||||
await this.stopFlow();
|
||||
}
|
||||
|
||||
this.active = true;
|
||||
this.emit('oauth:start', { provider, accountId: options?.accountId || provider });
|
||||
this.activeProvider = provider;
|
||||
this.activeAccountId = options?.accountId || provider;
|
||||
this.activeLabel = options?.label || null;
|
||||
|
||||
try {
|
||||
if (provider === 'minimax-portal' || provider === 'minimax-portal-cn') {
|
||||
const actualRegion = provider === 'minimax-portal-cn' ? 'cn' : (region || 'global');
|
||||
await this.runMiniMaxFlow(actualRegion, provider);
|
||||
} else if (provider === 'qwen-portal') {
|
||||
await this.runQwenFlow();
|
||||
} else {
|
||||
throw new Error(`Unsupported OAuth provider type: ${provider}`);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!this.active) {
|
||||
// Flow was cancelled — not an error
|
||||
return false;
|
||||
}
|
||||
logger.error(`[DeviceOAuth] 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;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async stopFlow(): Promise<void> {
|
||||
this.active = false;
|
||||
this.activeProvider = null;
|
||||
this.activeAccountId = null;
|
||||
this.activeLabel = null;
|
||||
logger.info('[DeviceOAuth] Flow explicitly stopped');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// MiniMax flow
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
private async runMiniMaxFlow(region?: MiniMaxRegion, providerType: OAuthProviderType = 'minimax-portal'): Promise<void> {
|
||||
if (!isOpenClawPresent()) {
|
||||
throw new Error('OpenClaw package not found');
|
||||
}
|
||||
const provider = this.activeProvider!;
|
||||
|
||||
const token: MiniMaxOAuthToken = await loginMiniMaxPortalOAuth({
|
||||
region,
|
||||
openUrl: async (url) => {
|
||||
logger.info(`[DeviceOAuth] MiniMax opening browser: ${url}`);
|
||||
// Open the authorization URL in the system browser
|
||||
shell.openExternal(url).catch((err) =>
|
||||
logger.warn(`[DeviceOAuth] Failed to open browser:`, err)
|
||||
);
|
||||
},
|
||||
note: async (message, _title) => {
|
||||
if (!this.active) return;
|
||||
// The extension calls note() with a message containing
|
||||
// the user_code and verification_uri — parse them for the UI
|
||||
const { verificationUri, userCode } = this.parseNote(message);
|
||||
if (verificationUri && userCode) {
|
||||
this.emitCode({ provider, verificationUri, userCode, expiresIn: 300 });
|
||||
} else {
|
||||
logger.info(`[DeviceOAuth] MiniMax note: ${message}`);
|
||||
}
|
||||
},
|
||||
progress: {
|
||||
update: (msg) => logger.info(`[DeviceOAuth] MiniMax progress: ${msg}`),
|
||||
stop: (msg) => logger.info(`[DeviceOAuth] MiniMax progress done: ${msg ?? ''}`),
|
||||
},
|
||||
});
|
||||
|
||||
if (!this.active) return;
|
||||
|
||||
await this.onSuccess(providerType, {
|
||||
access: token.access,
|
||||
refresh: token.refresh,
|
||||
expires: token.expires,
|
||||
// MiniMax returns a per-account resourceUrl as the API base URL
|
||||
resourceUrl: token.resourceUrl,
|
||||
// Revert back to anthropic-messages
|
||||
api: 'anthropic-messages',
|
||||
region,
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Qwen flow
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
private async runQwenFlow(): Promise<void> {
|
||||
if (!isOpenClawPresent()) {
|
||||
throw new Error('OpenClaw package not found');
|
||||
}
|
||||
const provider = this.activeProvider!;
|
||||
|
||||
const token: QwenOAuthToken = await loginQwenPortalOAuth({
|
||||
openUrl: async (url) => {
|
||||
logger.info(`[DeviceOAuth] Qwen opening browser: ${url}`);
|
||||
shell.openExternal(url).catch((err) =>
|
||||
logger.warn(`[DeviceOAuth] Failed to open browser:`, err)
|
||||
);
|
||||
},
|
||||
note: async (message, _title) => {
|
||||
if (!this.active) return;
|
||||
const { verificationUri, userCode } = this.parseNote(message);
|
||||
if (verificationUri && userCode) {
|
||||
this.emitCode({ provider, verificationUri, userCode, expiresIn: 300 });
|
||||
} else {
|
||||
logger.info(`[DeviceOAuth] Qwen note: ${message}`);
|
||||
}
|
||||
},
|
||||
progress: {
|
||||
update: (msg) => logger.info(`[DeviceOAuth] Qwen progress: ${msg}`),
|
||||
stop: (msg) => logger.info(`[DeviceOAuth] Qwen progress done: ${msg ?? ''}`),
|
||||
},
|
||||
});
|
||||
|
||||
if (!this.active) return;
|
||||
|
||||
await this.onSuccess('qwen-portal', {
|
||||
access: token.access,
|
||||
refresh: token.refresh,
|
||||
expires: token.expires,
|
||||
// Qwen returns a per-account resourceUrl as the API base URL
|
||||
resourceUrl: token.resourceUrl,
|
||||
// Qwen uses OpenAI Completions API format
|
||||
api: 'openai-completions',
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Success handler
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
private async onSuccess(providerType: OAuthProviderType, token: {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
resourceUrl?: string;
|
||||
api: 'anthropic-messages' | 'openai-completions';
|
||||
region?: MiniMaxRegion;
|
||||
}) {
|
||||
const accountId = this.activeAccountId || providerType;
|
||||
const accountLabel = this.activeLabel;
|
||||
this.active = false;
|
||||
this.activeProvider = null;
|
||||
this.activeAccountId = null;
|
||||
this.activeLabel = null;
|
||||
logger.info(`[DeviceOAuth] Successfully completed OAuth for ${providerType}`);
|
||||
|
||||
// 1. Write OAuth token to OpenClaw's auth-profiles.json in native OAuth format.
|
||||
// (matches what `openclaw models auth login` → upsertAuthProfile writes).
|
||||
// We save both MiniMax providers to the generic "minimax-portal" profile
|
||||
// so OpenClaw's gateway auto-refresher knows how to find it.
|
||||
try {
|
||||
const tokenProviderId = providerType.startsWith('minimax-portal') ? 'minimax-portal' : providerType;
|
||||
await saveOAuthTokenToOpenClaw(tokenProviderId, {
|
||||
access: token.access,
|
||||
refresh: token.refresh,
|
||||
expires: token.expires,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(`[DeviceOAuth] Failed to save OAuth token to OpenClaw:`, err);
|
||||
}
|
||||
|
||||
// 2. Write openclaw.json: set default model + provider config (baseUrl/api/models)
|
||||
// This mirrors what the OpenClaw plugin's configPatch does after CLI login.
|
||||
// The baseUrl comes from token.resourceUrl (per-account URL from the OAuth server)
|
||||
// or falls back to the provider's default public endpoint.
|
||||
const defaultBaseUrl = providerType === 'minimax-portal'
|
||||
? 'https://api.minimax.io/anthropic'
|
||||
: (providerType === 'minimax-portal-cn' ? 'https://api.minimaxi.com/anthropic' : 'https://portal.qwen.ai/v1');
|
||||
|
||||
let baseUrl = token.resourceUrl || defaultBaseUrl;
|
||||
|
||||
// Ensure baseUrl has a protocol prefix
|
||||
if (baseUrl && !baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||
baseUrl = 'https://' + baseUrl;
|
||||
}
|
||||
|
||||
// Ensure the base URL ends with /anthropic
|
||||
if (providerType.startsWith('minimax-portal') && baseUrl) {
|
||||
baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
|
||||
} else if (providerType === 'qwen-portal' && baseUrl) {
|
||||
// Ensure Qwen API gets /v1 at the end
|
||||
if (!baseUrl.endsWith('/v1')) {
|
||||
baseUrl = baseUrl.replace(/\/$/, '') + '/v1';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenProviderId = providerType.startsWith('minimax-portal') ? 'minimax-portal' : providerType;
|
||||
await setOpenClawDefaultModelWithOverride(tokenProviderId, undefined, {
|
||||
baseUrl,
|
||||
api: token.api,
|
||||
// Tells OpenClaw's anthropic adapter to use `Authorization: Bearer` instead of `x-api-key`
|
||||
authHeader: providerType.startsWith('minimax-portal') ? true : undefined,
|
||||
// OAuth placeholder — tells Gateway to resolve credentials
|
||||
// from auth-profiles.json (type: 'oauth') instead of a static API key.
|
||||
apiKeyEnv: tokenProviderId === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(`[DeviceOAuth] Failed to configure openclaw models:`, err);
|
||||
}
|
||||
|
||||
// 3. Save provider record in ClawX's own store so UI shows it as configured
|
||||
const existing = await getProvider(accountId);
|
||||
const nameMap: Record<OAuthProviderType, string> = {
|
||||
'minimax-portal': 'MiniMax (Global)',
|
||||
'minimax-portal-cn': 'MiniMax (CN)',
|
||||
'qwen-portal': 'Qwen',
|
||||
};
|
||||
const providerConfig: ProviderConfig = {
|
||||
id: accountId,
|
||||
name: accountLabel || nameMap[providerType as OAuthProviderType] || providerType,
|
||||
type: providerType,
|
||||
enabled: existing?.enabled ?? true,
|
||||
baseUrl, // Save the dynamically resolved URL (Global vs CN)
|
||||
|
||||
model: existing?.model || getProviderDefaultModel(providerType),
|
||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await saveProvider(providerConfig);
|
||||
|
||||
// 4. Emit success internally so the main process can restart the Gateway
|
||||
this.emit('oauth:success', { provider: providerType, accountId });
|
||||
|
||||
// 5. Emit success to frontend
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('oauth:success', { provider: providerType, accountId, success: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse user_code and verification_uri from the note message sent by
|
||||
* the OpenClaw extension's loginXxxPortalOAuth function.
|
||||
*
|
||||
* Note format (minimax-portal-auth/oauth.ts):
|
||||
* "Open https://platform.minimax.io/oauth-authorize?user_code=dyMj_wOhpK&client=... to approve access.\n"
|
||||
* "If prompted, enter the code dyMj_wOhpK.\n"
|
||||
* ...
|
||||
*
|
||||
* user_code format: mixed-case alphanumeric with underscore, e.g. "dyMj_wOhpK"
|
||||
*/
|
||||
private parseNote(message: string): { verificationUri?: string; userCode?: string } {
|
||||
// Primary: extract URL (everything between "Open " and " to")
|
||||
const urlMatch = message.match(/Open\s+(https?:\/\/\S+?)\s+to/i);
|
||||
const verificationUri = urlMatch?.[1];
|
||||
|
||||
let userCode: string | undefined;
|
||||
|
||||
// Method 1: extract user_code from URL query param (most reliable)
|
||||
if (verificationUri) {
|
||||
try {
|
||||
const parsed = new URL(verificationUri);
|
||||
const qp = parsed.searchParams.get('user_code');
|
||||
if (qp) userCode = qp;
|
||||
} catch {
|
||||
// fall through to text-based extraction
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: text-based extraction — matches mixed-case alnum + underscore/hyphen codes
|
||||
if (!userCode) {
|
||||
const codeMatch = message.match(/enter.*?code\s+([A-Za-z0-9][A-Za-z0-9_-]{3,})/i);
|
||||
if (codeMatch?.[1]) userCode = codeMatch[1].replace(/\.$/, ''); // strip trailing period
|
||||
}
|
||||
|
||||
return { verificationUri, userCode };
|
||||
}
|
||||
|
||||
private emitCode(data: {
|
||||
provider: string;
|
||||
verificationUri: string;
|
||||
userCode: string;
|
||||
expiresIn: number;
|
||||
}) {
|
||||
this.emit('oauth:code', data);
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('oauth:code', data);
|
||||
}
|
||||
}
|
||||
|
||||
private emitError(message: string) {
|
||||
this.emit('oauth:error', { message });
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('oauth:error', { message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const deviceOAuthManager = new DeviceOAuthManager();
|
||||
@@ -0,0 +1,59 @@
|
||||
type EnvMap = Record<string, string | undefined>;
|
||||
|
||||
function isPathKey(key: string): boolean {
|
||||
return key.toLowerCase() === 'path';
|
||||
}
|
||||
|
||||
function preferredPathKey(): string {
|
||||
return process.platform === 'win32' ? 'Path' : 'PATH';
|
||||
}
|
||||
|
||||
function pathDelimiter(): string {
|
||||
return process.platform === 'win32' ? ';' : ':';
|
||||
}
|
||||
|
||||
export function getPathEnvKey(env: EnvMap): string {
|
||||
const keys = Object.keys(env).filter(isPathKey);
|
||||
if (keys.length === 0) return preferredPathKey();
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
if (keys.includes('Path')) return 'Path';
|
||||
if (keys.includes('PATH')) return 'PATH';
|
||||
return keys[0];
|
||||
}
|
||||
|
||||
if (keys.includes('PATH')) return 'PATH';
|
||||
return keys[0];
|
||||
}
|
||||
|
||||
export function getPathEnvValue(env: EnvMap): string {
|
||||
const key = getPathEnvKey(env);
|
||||
return env[key] ?? '';
|
||||
}
|
||||
|
||||
export function setPathEnvValue(
|
||||
env: EnvMap,
|
||||
nextPath: string,
|
||||
): EnvMap {
|
||||
const nextEnv: EnvMap = { ...env };
|
||||
for (const key of Object.keys(nextEnv)) {
|
||||
if (isPathKey(key)) {
|
||||
delete nextEnv[key];
|
||||
}
|
||||
}
|
||||
|
||||
nextEnv[getPathEnvKey(env)] = nextPath;
|
||||
return nextEnv;
|
||||
}
|
||||
|
||||
export function prependPathEntry(
|
||||
env: EnvMap,
|
||||
entry: string,
|
||||
): { env: EnvMap; path: string } {
|
||||
const current = getPathEnvValue(env);
|
||||
const nextPath = current ? `${entry}${pathDelimiter()}${current}` : entry;
|
||||
return {
|
||||
env: setPathEnvValue(env, nextPath),
|
||||
path: nextPath,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
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';
|
||||
|
||||
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 fetch('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 fetch(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 fetch(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 fetch(`${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 fetch(`${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 fetch(`${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 fetch(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;
|
||||
}
|
||||
}
|
||||
+107
-62
@@ -1,10 +1,16 @@
|
||||
/**
|
||||
* Logger Utility
|
||||
* Centralized logging with levels, file output, and log retrieval for UI
|
||||
* Centralized logging with levels, file output, and log retrieval for UI.
|
||||
*
|
||||
* File writes use an async buffered writer so that high-frequency logging
|
||||
* (e.g. during gateway startup) never blocks the Electron main thread.
|
||||
* Only the final `process.on('exit')` handler uses synchronous I/O to
|
||||
* guarantee the last few messages are flushed before the process exits.
|
||||
*/
|
||||
import { app } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { existsSync, mkdirSync, appendFileSync, readFileSync, readdirSync, statSync } from 'fs';
|
||||
import { existsSync, mkdirSync, appendFileSync } from 'fs';
|
||||
import { appendFile, readFile, readdir, stat } from 'fs/promises';
|
||||
|
||||
/**
|
||||
* Log levels
|
||||
@@ -19,7 +25,11 @@ export enum LogLevel {
|
||||
/**
|
||||
* Current log level (can be changed at runtime)
|
||||
*/
|
||||
let currentLevel = LogLevel.DEBUG; // Default to DEBUG for better diagnostics
|
||||
// Default to INFO in packaged builds to reduce sync-like overhead from
|
||||
// high-volume DEBUG logging. In dev mode, keep DEBUG for diagnostics.
|
||||
// Note: app.isPackaged may not be available before app.isReady(), but the
|
||||
// logger is initialised after that point so this is safe.
|
||||
let currentLevel = LogLevel.DEBUG;
|
||||
|
||||
/**
|
||||
* Log file path
|
||||
@@ -33,11 +43,58 @@ let logDir: string | null = null;
|
||||
const RING_BUFFER_SIZE = 500;
|
||||
const recentLogs: string[] = [];
|
||||
|
||||
// ── Async write buffer ───────────────────────────────────────────
|
||||
|
||||
/** Pending log lines waiting to be flushed to disk. */
|
||||
let writeBuffer: string[] = [];
|
||||
/** Timer for the next scheduled flush. */
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
/** Whether a flush is currently in progress. */
|
||||
let flushing = false;
|
||||
|
||||
const FLUSH_INTERVAL_MS = 500;
|
||||
const FLUSH_SIZE_THRESHOLD = 20;
|
||||
|
||||
async function flushBuffer(): Promise<void> {
|
||||
if (flushing || writeBuffer.length === 0 || !logFilePath) return;
|
||||
flushing = true;
|
||||
const batch = writeBuffer.join('');
|
||||
writeBuffer = [];
|
||||
try {
|
||||
await appendFile(logFilePath, batch);
|
||||
} catch {
|
||||
// Silently fail if we can't write to file
|
||||
} finally {
|
||||
flushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Synchronous flush for the `exit` handler — guaranteed to write. */
|
||||
function flushBufferSync(): void {
|
||||
if (writeBuffer.length === 0 || !logFilePath) return;
|
||||
try {
|
||||
appendFileSync(logFilePath, writeBuffer.join(''));
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
writeBuffer = [];
|
||||
}
|
||||
|
||||
// Ensure all buffered data reaches disk before the process exits.
|
||||
process.on('exit', flushBufferSync);
|
||||
|
||||
// ── Initialisation ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initialize logger — safe to call before app.isReady()
|
||||
*/
|
||||
export function initLogger(): void {
|
||||
try {
|
||||
// In production, default to INFO to reduce log volume and overhead.
|
||||
if (app.isPackaged && currentLevel < LogLevel.INFO) {
|
||||
currentLevel = LogLevel.INFO;
|
||||
}
|
||||
|
||||
logDir = join(app.getPath('userData'), 'logs');
|
||||
|
||||
if (!existsSync(logDir)) {
|
||||
@@ -47,7 +104,7 @@ export function initLogger(): void {
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
logFilePath = join(logDir, `clawx-${timestamp}.log`);
|
||||
|
||||
// Write a separator for new session
|
||||
// Write a separator for new session (sync is OK — happens once at startup)
|
||||
const sessionHeader = `\n${'='.repeat(80)}\n[${new Date().toISOString()}] === ClawX Session Start (v${app.getVersion()}) ===\n${'='.repeat(80)}\n`;
|
||||
appendFileSync(logFilePath, sessionHeader);
|
||||
} catch (error) {
|
||||
@@ -55,30 +112,22 @@ export function initLogger(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set log level
|
||||
*/
|
||||
// ── Level / path accessors ───────────────────────────────────────
|
||||
|
||||
export function setLogLevel(level: LogLevel): void {
|
||||
currentLevel = level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get log file directory path
|
||||
*/
|
||||
export function getLogDir(): string | null {
|
||||
return logDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current log file path
|
||||
*/
|
||||
export function getLogFilePath(): string | null {
|
||||
return logFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format log message
|
||||
*/
|
||||
// ── Formatting ───────────────────────────────────────────────────
|
||||
|
||||
function formatMessage(level: string, message: string, ...args: unknown[]): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
const formattedArgs = args.length > 0 ? ' ' + args.map(arg => {
|
||||
@@ -98,29 +147,36 @@ function formatMessage(level: string, message: string, ...args: unknown[]): stri
|
||||
return `[${timestamp}] [${level.padEnd(5)}] ${message}${formattedArgs}`;
|
||||
}
|
||||
|
||||
// ── Core write ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write to log file and ring buffer
|
||||
* Write to ring buffer + schedule an async flush to disk.
|
||||
*/
|
||||
function writeLog(formatted: string): void {
|
||||
// Ring buffer
|
||||
// Ring buffer (always synchronous — in-memory only)
|
||||
recentLogs.push(formatted);
|
||||
if (recentLogs.length > RING_BUFFER_SIZE) {
|
||||
recentLogs.shift();
|
||||
}
|
||||
|
||||
// File
|
||||
// Async file write via buffer
|
||||
if (logFilePath) {
|
||||
try {
|
||||
appendFileSync(logFilePath, formatted + '\n');
|
||||
} catch {
|
||||
// Silently fail if we can't write to file
|
||||
writeBuffer.push(formatted + '\n');
|
||||
if (writeBuffer.length >= FLUSH_SIZE_THRESHOLD) {
|
||||
// Buffer is large enough — flush immediately (non-blocking)
|
||||
void flushBuffer();
|
||||
} else if (!flushTimer) {
|
||||
// Schedule a flush after a short delay
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
void flushBuffer();
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug message
|
||||
*/
|
||||
// ── Public log methods ───────────────────────────────────────────
|
||||
|
||||
export function debug(message: string, ...args: unknown[]): void {
|
||||
if (currentLevel <= LogLevel.DEBUG) {
|
||||
const formatted = formatMessage('DEBUG', message, ...args);
|
||||
@@ -129,9 +185,6 @@ export function debug(message: string, ...args: unknown[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message
|
||||
*/
|
||||
export function info(message: string, ...args: unknown[]): void {
|
||||
if (currentLevel <= LogLevel.INFO) {
|
||||
const formatted = formatMessage('INFO', message, ...args);
|
||||
@@ -140,9 +193,6 @@ export function info(message: string, ...args: unknown[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warning message
|
||||
*/
|
||||
export function warn(message: string, ...args: unknown[]): void {
|
||||
if (currentLevel <= LogLevel.WARN) {
|
||||
const formatted = formatMessage('WARN', message, ...args);
|
||||
@@ -151,9 +201,6 @@ export function warn(message: string, ...args: unknown[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error message
|
||||
*/
|
||||
export function error(message: string, ...args: unknown[]): void {
|
||||
if (currentLevel <= LogLevel.ERROR) {
|
||||
const formatted = formatMessage('ERROR', message, ...args);
|
||||
@@ -162,11 +209,8 @@ export function error(message: string, ...args: unknown[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent logs from ring buffer (for UI display)
|
||||
* @param count Number of recent log lines to return (default: all)
|
||||
* @param minLevel Minimum log level to include (default: DEBUG)
|
||||
*/
|
||||
// ── Log retrieval (for UI / diagnostics) ─────────────────────────
|
||||
|
||||
export function getRecentLogs(count?: number, minLevel?: LogLevel): string[] {
|
||||
const filtered = minLevel != null
|
||||
? recentLogs.filter(line => {
|
||||
@@ -181,14 +225,13 @@ export function getRecentLogs(count?: number, minLevel?: LogLevel): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current day's log file content (last N lines)
|
||||
* Read the current day's log file content (last N lines).
|
||||
* Uses async I/O to avoid blocking.
|
||||
*/
|
||||
export function readLogFile(tailLines = 200): string {
|
||||
if (!logFilePath || !existsSync(logFilePath)) {
|
||||
return '(No log file found)';
|
||||
}
|
||||
export async function readLogFile(tailLines = 200): Promise<string> {
|
||||
if (!logFilePath) return '(No log file found)';
|
||||
try {
|
||||
const content = readFileSync(logFilePath, 'utf-8');
|
||||
const content = await readFile(logFilePath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
if (lines.length <= tailLines) return content;
|
||||
return lines.slice(-tailLines).join('\n');
|
||||
@@ -198,24 +241,26 @@ export function readLogFile(tailLines = 200): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* List available log files
|
||||
* List available log files.
|
||||
* Uses async I/O to avoid blocking.
|
||||
*/
|
||||
export function listLogFiles(): Array<{ name: string; path: string; size: number; modified: string }> {
|
||||
if (!logDir || !existsSync(logDir)) return [];
|
||||
export async function listLogFiles(): Promise<Array<{ name: string; path: string; size: number; modified: string }>> {
|
||||
if (!logDir) return [];
|
||||
try {
|
||||
return readdirSync(logDir)
|
||||
.filter(f => f.endsWith('.log'))
|
||||
.map(f => {
|
||||
const fullPath = join(logDir!, f);
|
||||
const stat = statSync(fullPath);
|
||||
return {
|
||||
name: f,
|
||||
path: fullPath,
|
||||
size: stat.size,
|
||||
modified: stat.mtime.toISOString(),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.modified.localeCompare(a.modified));
|
||||
const files = await readdir(logDir);
|
||||
const results: Array<{ name: string; path: string; size: number; modified: string }> = [];
|
||||
for (const f of files) {
|
||||
if (!f.endsWith('.log')) continue;
|
||||
const fullPath = join(logDir, f);
|
||||
const s = await stat(fullPath);
|
||||
results.push({
|
||||
name: f,
|
||||
path: fullPath,
|
||||
size: s.size,
|
||||
modified: s.mtime.toISOString(),
|
||||
});
|
||||
}
|
||||
return results.sort((a, b) => b.modified.localeCompare(a.modified));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { createServer } from 'node:http';
|
||||
|
||||
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
||||
const AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize';
|
||||
const TOKEN_URL = 'https://auth.openai.com/oauth/token';
|
||||
// Must match the redirect URI expected by OpenAI Codex OAuth client.
|
||||
const REDIRECT_URI = 'http://localhost:1455/auth/callback';
|
||||
const SCOPE = 'openid profile email offline_access';
|
||||
const JWT_CLAIM_PATH = 'https://api.openai.com/auth';
|
||||
const ORIGINATOR = 'codex_cli_rs';
|
||||
|
||||
const SUCCESS_HTML = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Authentication successful</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Authentication successful. Return to ClawX to continue.</p>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
export interface OpenAICodexOAuthCredentials {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
interface OpenAICodexAuthorizationFlow {
|
||||
verifier: string;
|
||||
state: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface OpenAICodexLocalServer {
|
||||
close: () => void;
|
||||
waitForCode: () => Promise<{ code: string } | null>;
|
||||
}
|
||||
|
||||
function toBase64Url(buffer: Buffer): string {
|
||||
return buffer
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function createPkce(): { verifier: string; challenge: string } {
|
||||
const verifier = toBase64Url(randomBytes(32));
|
||||
const challenge = toBase64Url(createHash('sha256').update(verifier).digest());
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
function createState(): string {
|
||||
return toBase64Url(randomBytes(32));
|
||||
}
|
||||
|
||||
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
|
||||
const value = input.trim();
|
||||
if (!value) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return {
|
||||
code: url.searchParams.get('code') ?? undefined,
|
||||
state: url.searchParams.get('state') ?? undefined,
|
||||
};
|
||||
} catch {
|
||||
// not a URL
|
||||
}
|
||||
|
||||
if (value.includes('#')) {
|
||||
const [code, state] = value.split('#', 2);
|
||||
return { code, state };
|
||||
}
|
||||
|
||||
if (value.includes('code=')) {
|
||||
const params = new URLSearchParams(value);
|
||||
return {
|
||||
code: params.get('code') ?? undefined,
|
||||
state: params.get('state') ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { code: value };
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = parts[1];
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
|
||||
const decoded = Buffer.from(padded, 'base64').toString('utf8');
|
||||
return JSON.parse(decoded) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getAccountIdFromAccessToken(accessToken: string): string | null {
|
||||
const payload = decodeJwtPayload(accessToken);
|
||||
const authClaims = payload?.[JWT_CLAIM_PATH];
|
||||
if (!authClaims || typeof authClaims !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const accountId = (authClaims as Record<string, unknown>).chatgpt_account_id;
|
||||
if (typeof accountId !== 'string' || !accountId.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return accountId;
|
||||
}
|
||||
|
||||
async function createAuthorizationFlow(): Promise<OpenAICodexAuthorizationFlow> {
|
||||
const { verifier, challenge } = createPkce();
|
||||
const state = createState();
|
||||
const url = new URL(AUTHORIZE_URL);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('client_id', CLIENT_ID);
|
||||
url.searchParams.set('redirect_uri', REDIRECT_URI);
|
||||
url.searchParams.set('scope', SCOPE);
|
||||
url.searchParams.set('code_challenge', challenge);
|
||||
url.searchParams.set('code_challenge_method', 'S256');
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('id_token_add_organizations', 'true');
|
||||
url.searchParams.set('codex_cli_simplified_flow', 'true');
|
||||
url.searchParams.set('originator', ORIGINATOR);
|
||||
|
||||
return { verifier, state, url: url.toString() };
|
||||
}
|
||||
|
||||
function startLocalOAuthServer(state: string): Promise<OpenAICodexLocalServer | null> {
|
||||
let lastCode: string | null = null;
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || '', 'http://localhost');
|
||||
if (url.pathname !== '/auth/callback') {
|
||||
res.statusCode = 404;
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.searchParams.get('state') !== state) {
|
||||
res.statusCode = 400;
|
||||
res.end('State mismatch');
|
||||
return;
|
||||
}
|
||||
|
||||
const code = url.searchParams.get('code');
|
||||
if (!code) {
|
||||
res.statusCode = 400;
|
||||
res.end('Missing authorization code');
|
||||
return;
|
||||
}
|
||||
|
||||
lastCode = code;
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.end(SUCCESS_HTML);
|
||||
} catch {
|
||||
res.statusCode = 500;
|
||||
res.end('Internal error');
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server
|
||||
.listen(1455, 'localhost', () => {
|
||||
resolve({
|
||||
close: () => server.close(),
|
||||
waitForCode: async () => {
|
||||
const sleep = () => new Promise((r) => setTimeout(r, 100));
|
||||
for (let i = 0; i < 600; i += 1) {
|
||||
if (lastCode) {
|
||||
return { code: lastCode };
|
||||
}
|
||||
await sleep();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
})
|
||||
.on('error', () => {
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function exchangeAuthorizationCode(
|
||||
code: string,
|
||||
verifier: string,
|
||||
): Promise<{ access: string; refresh: string; expires: number }> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`OpenAI token exchange failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const json = await response.json() as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== 'number') {
|
||||
throw new Error('OpenAI token response missing fields');
|
||||
}
|
||||
|
||||
return {
|
||||
access: json.access_token,
|
||||
refresh: json.refresh_token,
|
||||
expires: Date.now() + json.expires_in * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loginOpenAICodexOAuth(options: {
|
||||
openUrl: (url: string) => Promise<void>;
|
||||
onProgress?: (message: string) => void;
|
||||
onManualCodeRequired?: (payload: { authorizationUrl: string; reason: 'port_in_use' | 'callback_timeout' }) => void;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
}): Promise<OpenAICodexOAuthCredentials> {
|
||||
const { verifier, state, url } = await createAuthorizationFlow();
|
||||
options.onProgress?.('Opening OpenAI sign-in page…');
|
||||
|
||||
const server = await startLocalOAuthServer(state);
|
||||
|
||||
try {
|
||||
await options.openUrl(url);
|
||||
options.onProgress?.(
|
||||
server ? 'Waiting for OpenAI OAuth callback…' : 'Callback port unavailable, waiting for manual authorization code…',
|
||||
);
|
||||
|
||||
let code: string | undefined;
|
||||
if (server) {
|
||||
const result = await server.waitForCode();
|
||||
code = result?.code ?? undefined;
|
||||
if (!code && options.onManualCodeInput) {
|
||||
options.onManualCodeRequired?.({ authorizationUrl: url, reason: 'callback_timeout' });
|
||||
code = await options.onManualCodeInput();
|
||||
}
|
||||
} else {
|
||||
if (!options.onManualCodeInput) {
|
||||
throw new Error('Cannot start OpenAI OAuth callback server on localhost:1455');
|
||||
}
|
||||
options.onManualCodeRequired?.({ authorizationUrl: url, reason: 'port_in_use' });
|
||||
code = await options.onManualCodeInput();
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw new Error('Missing OpenAI authorization code');
|
||||
}
|
||||
|
||||
const parsed = parseAuthorizationInput(code);
|
||||
if (parsed.state && parsed.state !== state) {
|
||||
throw new Error('OpenAI OAuth state mismatch');
|
||||
}
|
||||
code = parsed.code;
|
||||
|
||||
if (!code) {
|
||||
throw new Error('Missing OpenAI authorization code');
|
||||
}
|
||||
|
||||
const token = await exchangeAuthorizationCode(code, verifier);
|
||||
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,
|
||||
};
|
||||
} finally {
|
||||
server?.close();
|
||||
}
|
||||
}
|
||||
+950
-249
File diff suppressed because it is too large
Load Diff
+332
-21
@@ -1,13 +1,24 @@
|
||||
/**
|
||||
* OpenClaw CLI utilities
|
||||
* OpenClaw CLI utilities — cross-platform auto-install
|
||||
*/
|
||||
import { app } from 'electron';
|
||||
import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import {
|
||||
appendFileSync,
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
symlinkSync,
|
||||
unlinkSync,
|
||||
} from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { getOpenClawDir, getOpenClawEntryPath } from './paths';
|
||||
import { logger } from './logger';
|
||||
|
||||
// ── Quoting helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function escapeForDoubleQuotes(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
@@ -20,17 +31,25 @@ function quoteForPowerShell(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
// ── CLI command string (for display / copy) ──────────────────────────────────
|
||||
|
||||
export function getOpenClawCliCommand(): string {
|
||||
const entryPath = getOpenClawEntryPath();
|
||||
const platform = process.platform;
|
||||
|
||||
if (platform === 'darwin') {
|
||||
if (platform === 'darwin' || platform === 'linux') {
|
||||
const localBinPath = join(homedir(), '.local', 'bin', 'openclaw');
|
||||
if (existsSync(localBinPath)) {
|
||||
return quoteForPosix(localBinPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
if (existsSync('/usr/local/bin/openclaw')) {
|
||||
return '/usr/local/bin/openclaw';
|
||||
}
|
||||
}
|
||||
|
||||
if (!app.isPackaged) {
|
||||
const openclawDir = getOpenClawDir();
|
||||
const nodeModulesDir = dirname(openclawDir);
|
||||
@@ -46,6 +65,14 @@ export function getOpenClawCliCommand(): string {
|
||||
}
|
||||
|
||||
if (app.isPackaged) {
|
||||
if (platform === 'win32') {
|
||||
const cliDir = join(process.resourcesPath, 'cli');
|
||||
const cmdPath = join(cliDir, 'openclaw.cmd');
|
||||
if (existsSync(cmdPath)) {
|
||||
return quoteForPowerShell(cmdPath);
|
||||
}
|
||||
}
|
||||
|
||||
const execPath = process.execPath;
|
||||
if (platform === 'win32') {
|
||||
return `$env:ELECTRON_RUN_AS_NODE=1; & ${quoteForPowerShell(execPath)} ${quoteForPowerShell(entryPath)}`;
|
||||
@@ -60,34 +87,318 @@ export function getOpenClawCliCommand(): string {
|
||||
return `node ${quoteForPosix(entryPath)}`;
|
||||
}
|
||||
|
||||
export async function installOpenClawCliMac(): Promise<{ success: boolean; path?: string; error?: string }>
|
||||
{
|
||||
if (process.platform !== 'darwin') {
|
||||
return { success: false, error: 'Install is only supported on macOS.' };
|
||||
// ── Packaged CLI wrapper path ────────────────────────────────────────────────
|
||||
|
||||
function getPackagedCliWrapperPath(): string | null {
|
||||
if (!app.isPackaged) return null;
|
||||
const platform = process.platform;
|
||||
|
||||
if (platform === 'darwin' || platform === 'linux') {
|
||||
const wrapper = join(process.resourcesPath, 'cli', 'openclaw');
|
||||
return existsSync(wrapper) ? wrapper : null;
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
const wrapper = join(process.resourcesPath, 'cli', 'openclaw.cmd');
|
||||
return existsSync(wrapper) ? wrapper : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getWindowsPowerShellPath(): string {
|
||||
const systemRoot = process.env.SystemRoot || 'C:\\Windows';
|
||||
return join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
||||
}
|
||||
|
||||
// ── macOS / Linux install ────────────────────────────────────────────────────
|
||||
|
||||
function getCliTargetPath(): string {
|
||||
return join(homedir(), '.local', 'bin', 'openclaw');
|
||||
}
|
||||
|
||||
export async function installOpenClawCli(): Promise<{
|
||||
success: boolean; path?: string; error?: string;
|
||||
}> {
|
||||
const platform = process.platform;
|
||||
|
||||
if (platform === 'win32') {
|
||||
return { success: false, error: 'Windows CLI is configured by the installer.' };
|
||||
}
|
||||
|
||||
const entryPath = getOpenClawEntryPath();
|
||||
if (!existsSync(entryPath)) {
|
||||
return { success: false, error: `OpenClaw entry not found at: ${entryPath}` };
|
||||
if (!app.isPackaged) {
|
||||
return { success: false, error: 'CLI install is only available in packaged builds.' };
|
||||
}
|
||||
|
||||
const wrapperSrc = getPackagedCliWrapperPath();
|
||||
if (!wrapperSrc) {
|
||||
return { success: false, error: 'CLI wrapper not found in app resources.' };
|
||||
}
|
||||
|
||||
const execPath = process.execPath;
|
||||
const targetDir = join(homedir(), '.local', 'bin');
|
||||
const target = join(targetDir, 'openclaw');
|
||||
const target = getCliTargetPath();
|
||||
|
||||
try {
|
||||
const script = [
|
||||
'#!/bin/sh',
|
||||
`ELECTRON_RUN_AS_NODE=1 "${escapeForDoubleQuotes(execPath)}" "${escapeForDoubleQuotes(entryPath)}" "$@"`,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
writeFileSync(target, script, { mode: 0o755 });
|
||||
chmodSync(target, 0o755);
|
||||
|
||||
// Remove existing file/symlink to avoid EEXIST
|
||||
if (existsSync(target)) {
|
||||
unlinkSync(target);
|
||||
}
|
||||
|
||||
symlinkSync(wrapperSrc, target);
|
||||
chmodSync(wrapperSrc, 0o755);
|
||||
logger.info(`OpenClaw CLI symlink created: ${target} -> ${wrapperSrc}`);
|
||||
return { success: true, path: target };
|
||||
} catch (error) {
|
||||
logger.error('Failed to install OpenClaw CLI:', error);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-install on first launch ─────────────────────────────────────────────
|
||||
|
||||
function isCliInstalled(): boolean {
|
||||
const platform = process.platform;
|
||||
|
||||
if (platform === 'win32') return true; // handled by NSIS installer
|
||||
|
||||
const target = getCliTargetPath();
|
||||
if (!existsSync(target)) return false;
|
||||
|
||||
// Also check /usr/local/bin/openclaw for deb installs
|
||||
if (platform === 'linux' && existsSync('/usr/local/bin/openclaw')) return true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureWindowsCliOnPath(): Promise<'updated' | 'already-present'> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cliWrapper = getPackagedCliWrapperPath();
|
||||
if (!cliWrapper) {
|
||||
reject(new Error('CLI wrapper not found in app resources.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const cliDir = dirname(cliWrapper);
|
||||
const helperPath = join(cliDir, 'update-user-path.ps1');
|
||||
if (!existsSync(helperPath)) {
|
||||
reject(new Error(`PATH helper not found at ${helperPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(
|
||||
getWindowsPowerShellPath(),
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-File',
|
||||
helperPath,
|
||||
'-Action',
|
||||
'add',
|
||||
'-CliDir',
|
||||
cliDir,
|
||||
],
|
||||
{
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || `PowerShell exited with code ${code}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const status = stdout.trim();
|
||||
if (status === 'updated' || status === 'already-present') {
|
||||
resolve(status);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`Unexpected PowerShell output: ${status || '(empty)'}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ensureLocalBinInPath(): void {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const localBin = join(homedir(), '.local', 'bin');
|
||||
const pathEnv = process.env.PATH || '';
|
||||
if (pathEnv.split(':').includes(localBin)) return;
|
||||
|
||||
const shell = process.env.SHELL || '/bin/zsh';
|
||||
const profileFile = shell.includes('zsh')
|
||||
? join(homedir(), '.zshrc')
|
||||
: shell.includes('fish')
|
||||
? join(homedir(), '.config', 'fish', 'config.fish')
|
||||
: join(homedir(), '.bashrc');
|
||||
|
||||
try {
|
||||
const marker = '.local/bin';
|
||||
let content = '';
|
||||
try {
|
||||
content = readFileSync(profileFile, 'utf-8');
|
||||
} catch {
|
||||
// file doesn't exist yet
|
||||
}
|
||||
|
||||
if (content.includes(marker)) return;
|
||||
|
||||
const line = shell.includes('fish')
|
||||
? '\n# Added by ClawX\nfish_add_path "$HOME/.local/bin"\n'
|
||||
: '\n# Added by ClawX\nexport PATH="$HOME/.local/bin:$PATH"\n';
|
||||
|
||||
appendFileSync(profileFile, line);
|
||||
logger.info(`Added ~/.local/bin to PATH in ${profileFile}`);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to add ~/.local/bin to PATH:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function autoInstallCliIfNeeded(
|
||||
notify?: (path: string) => void,
|
||||
): Promise<void> {
|
||||
if (!app.isPackaged) return;
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
const result = await ensureWindowsCliOnPath();
|
||||
if (result === 'updated') {
|
||||
logger.info('Added Windows CLI directory to user PATH.');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to ensure Windows CLI is on PATH:', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const target = getCliTargetPath();
|
||||
const wrapperSrc = getPackagedCliWrapperPath();
|
||||
|
||||
if (isCliInstalled()) {
|
||||
if (target && wrapperSrc && existsSync(target)) {
|
||||
try {
|
||||
unlinkSync(target);
|
||||
symlinkSync(wrapperSrc, target);
|
||||
logger.debug(`Refreshed CLI symlink: ${target} -> ${wrapperSrc}`);
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Auto-installing openclaw CLI...');
|
||||
const result = await installOpenClawCli();
|
||||
if (result.success) {
|
||||
logger.info(`CLI auto-installed at ${result.path}`);
|
||||
ensureLocalBinInPath();
|
||||
if (result.path) notify?.(result.path);
|
||||
} else {
|
||||
logger.warn(`CLI auto-install failed: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Completion helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function getNodeExecForCli(): string {
|
||||
if (process.platform === 'darwin' && app.isPackaged) {
|
||||
const appName = app.getName();
|
||||
const helperName = `${appName} Helper`;
|
||||
const helperPath = join(
|
||||
dirname(process.execPath),
|
||||
'../Frameworks',
|
||||
`${helperName}.app`,
|
||||
'Contents/MacOS',
|
||||
helperName,
|
||||
);
|
||||
if (existsSync(helperPath)) return helperPath;
|
||||
}
|
||||
return process.execPath;
|
||||
}
|
||||
|
||||
export function generateCompletionCache(): void {
|
||||
if (!app.isPackaged) return;
|
||||
|
||||
const entryPath = getOpenClawEntryPath();
|
||||
if (!existsSync(entryPath)) return;
|
||||
|
||||
const execPath = getNodeExecForCli();
|
||||
|
||||
const child = spawn(execPath, [entryPath, 'completion', '--write-state'], {
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
OPENCLAW_NO_RESPAWN: '1',
|
||||
OPENCLAW_EMBEDDED_IN: 'ClawX',
|
||||
},
|
||||
stdio: 'ignore',
|
||||
detached: false,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
logger.info('OpenClaw completion cache generated');
|
||||
} else {
|
||||
logger.warn(`OpenClaw completion cache generation exited with code ${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
logger.warn('Failed to generate completion cache:', err);
|
||||
});
|
||||
}
|
||||
|
||||
export function installCompletionToProfile(): void {
|
||||
if (!app.isPackaged) return;
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const entryPath = getOpenClawEntryPath();
|
||||
if (!existsSync(entryPath)) return;
|
||||
|
||||
const execPath = getNodeExecForCli();
|
||||
|
||||
const child = spawn(
|
||||
execPath,
|
||||
[entryPath, 'completion', '--install', '-y'],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
OPENCLAW_NO_RESPAWN: '1',
|
||||
OPENCLAW_EMBEDDED_IN: 'ClawX',
|
||||
},
|
||||
stdio: 'ignore',
|
||||
detached: false,
|
||||
windowsHide: true,
|
||||
}
|
||||
);
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
logger.info('OpenClaw completion installed to shell profile');
|
||||
} else {
|
||||
logger.warn(`OpenClaw completion install exited with code ${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
logger.warn('Failed to install completion to shell profile:', err);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { app, utilityProcess } from 'electron';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getOpenClawDir, getOpenClawEntryPath } from './paths';
|
||||
import { logger } from './logger';
|
||||
import { getUvMirrorEnv } from './uv-env';
|
||||
|
||||
const OPENCLAW_DOCTOR_TIMEOUT_MS = 60_000;
|
||||
const OPENCLAW_DOCTOR_ARGS = ['doctor', '--json'];
|
||||
const OPENCLAW_DOCTOR_FIX_ARGS = ['doctor', '--fix', '--yes', '--non-interactive'];
|
||||
|
||||
export type OpenClawDoctorMode = 'diagnose' | 'fix';
|
||||
|
||||
export interface OpenClawDoctorResult {
|
||||
mode: OpenClawDoctorMode;
|
||||
success: boolean;
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
command: string;
|
||||
cwd: string;
|
||||
durationMs: number;
|
||||
timedOut?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function getBundledBinPath(): string {
|
||||
const target = `${process.platform}-${process.arch}`;
|
||||
return app.isPackaged
|
||||
? path.join(process.resourcesPath, 'bin')
|
||||
: path.join(process.cwd(), 'resources', 'bin', target);
|
||||
}
|
||||
|
||||
async function runDoctorCommand(mode: OpenClawDoctorMode): Promise<OpenClawDoctorResult> {
|
||||
const openclawDir = getOpenClawDir();
|
||||
const entryScript = getOpenClawEntryPath();
|
||||
const args = mode === 'fix' ? OPENCLAW_DOCTOR_FIX_ARGS : OPENCLAW_DOCTOR_ARGS;
|
||||
const command = `openclaw ${args.join(' ')}`;
|
||||
const startedAt = Date.now();
|
||||
|
||||
if (!existsSync(entryScript)) {
|
||||
const error = `OpenClaw entry script not found at ${entryScript}`;
|
||||
logger.error(`Cannot run OpenClaw doctor: ${error}`);
|
||||
return {
|
||||
mode,
|
||||
success: false,
|
||||
exitCode: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
command,
|
||||
cwd: openclawDir,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
const binPath = getBundledBinPath();
|
||||
const binPathExists = existsSync(binPath);
|
||||
const finalPath = binPathExists
|
||||
? `${binPath}${path.delimiter}${process.env.PATH || ''}`
|
||||
: process.env.PATH || '';
|
||||
const uvEnv = await getUvMirrorEnv();
|
||||
|
||||
logger.info(
|
||||
`Running OpenClaw doctor (mode=${mode}, entry="${entryScript}", args="${args.join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'})`,
|
||||
);
|
||||
|
||||
return await new Promise<OpenClawDoctorResult>((resolve) => {
|
||||
const child = utilityProcess.fork(entryScript, args, {
|
||||
cwd: openclawDir,
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
...uvEnv,
|
||||
PATH: finalPath,
|
||||
OPENCLAW_NO_RESPAWN: '1',
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
|
||||
const finish = (result: Omit<OpenClawDoctorResult, 'durationMs'>) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({
|
||||
...result,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
logger.error(`OpenClaw doctor timed out after ${OPENCLAW_DOCTOR_TIMEOUT_MS}ms`);
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
finish({
|
||||
mode,
|
||||
success: false,
|
||||
exitCode: null,
|
||||
stdout,
|
||||
stderr,
|
||||
command,
|
||||
cwd: openclawDir,
|
||||
timedOut: true,
|
||||
error: `Timed out after ${OPENCLAW_DOCTOR_TIMEOUT_MS}ms`,
|
||||
});
|
||||
}, OPENCLAW_DOCTOR_TIMEOUT_MS);
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
logger.error('Failed to spawn OpenClaw doctor process:', error);
|
||||
finish({
|
||||
mode,
|
||||
success: false,
|
||||
exitCode: null,
|
||||
stdout,
|
||||
stderr,
|
||||
command,
|
||||
cwd: openclawDir,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
clearTimeout(timeout);
|
||||
logger.info(`OpenClaw doctor exited with code ${code ?? 'null'}`);
|
||||
finish({
|
||||
mode,
|
||||
success: code === 0,
|
||||
exitCode: code,
|
||||
stdout,
|
||||
stderr,
|
||||
command,
|
||||
cwd: openclawDir,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function runOpenClawDoctor(): Promise<OpenClawDoctorResult> {
|
||||
return await runDoctorCommand('diagnose');
|
||||
}
|
||||
|
||||
export async function runOpenClawDoctorFix(): Promise<OpenClawDoctorResult> {
|
||||
return await runDoctorCommand('fix');
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { readOpenClawConfig, writeOpenClawConfig } from './channel-config';
|
||||
import { resolveProxySettings, type ProxySettings } from './proxy';
|
||||
import { logger } from './logger';
|
||||
import { withConfigLock } from './config-mutex';
|
||||
|
||||
/**
|
||||
* Sync ClawX global proxy settings into OpenClaw channel config where the
|
||||
* upstream runtime expects an explicit per-channel proxy knob.
|
||||
*/
|
||||
export async function syncProxyConfigToOpenClaw(settings: ProxySettings): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig();
|
||||
const telegramConfig = config.channels?.telegram;
|
||||
|
||||
if (!telegramConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = resolveProxySettings(settings);
|
||||
const nextProxy = settings.proxyEnabled
|
||||
? (resolved.allProxy || resolved.httpsProxy || resolved.httpProxy)
|
||||
: '';
|
||||
const currentProxy = typeof telegramConfig.proxy === 'string' ? telegramConfig.proxy : '';
|
||||
|
||||
if (!nextProxy && !currentProxy) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.channels) {
|
||||
config.channels = {};
|
||||
}
|
||||
|
||||
config.channels.telegram = {
|
||||
...telegramConfig,
|
||||
};
|
||||
|
||||
if (nextProxy) {
|
||||
config.channels.telegram.proxy = nextProxy;
|
||||
} else {
|
||||
delete config.channels.telegram.proxy;
|
||||
}
|
||||
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info(`Synced Telegram proxy to OpenClaw config (${nextProxy || 'disabled'})`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* OpenClaw workspace context utilities.
|
||||
*
|
||||
* All file I/O is async (fs/promises) to avoid blocking the Electron
|
||||
* main thread.
|
||||
*/
|
||||
import { access, readFile, writeFile, readdir, mkdir, unlink } from 'fs/promises';
|
||||
import { constants, Dirent } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { logger } from './logger';
|
||||
import { getResourcesDir } from './paths';
|
||||
|
||||
const CLAWX_BEGIN = '<!-- clawx:begin -->';
|
||||
const CLAWX_END = '<!-- clawx:end -->';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try { await access(p, constants.F_OK); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
async function ensureDir(dir: string): Promise<void> {
|
||||
if (!(await fileExists(dir))) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pure helpers (no I/O) ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Merge a ClawX context section into an existing file's content.
|
||||
* If markers already exist, replaces the section in-place.
|
||||
* Otherwise appends it at the end.
|
||||
*/
|
||||
export function mergeClawXSection(existing: string, section: string): string {
|
||||
const wrapped = `${CLAWX_BEGIN}\n${section.trim()}\n${CLAWX_END}`;
|
||||
const beginIdx = existing.indexOf(CLAWX_BEGIN);
|
||||
const endIdx = existing.indexOf(CLAWX_END);
|
||||
if (beginIdx !== -1 && endIdx !== -1) {
|
||||
return existing.slice(0, beginIdx) + wrapped + existing.slice(endIdx + CLAWX_END.length);
|
||||
}
|
||||
return existing.trimEnd() + '\n\n' + wrapped + '\n';
|
||||
}
|
||||
|
||||
// ── Workspace directory resolution ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Collect all unique workspace directories from the openclaw config:
|
||||
* the defaults workspace, each agent's workspace, and any workspace-*
|
||||
* directories that already exist under ~/.openclaw/.
|
||||
*/
|
||||
async function resolveAllWorkspaceDirs(): Promise<string[]> {
|
||||
const openclawDir = join(homedir(), '.openclaw');
|
||||
const dirs = new Set<string>();
|
||||
|
||||
const configPath = join(openclawDir, 'openclaw.json');
|
||||
try {
|
||||
if (await fileExists(configPath)) {
|
||||
const config = JSON.parse(await readFile(configPath, 'utf-8'));
|
||||
|
||||
const defaultWs = config?.agents?.defaults?.workspace;
|
||||
if (typeof defaultWs === 'string' && defaultWs.trim()) {
|
||||
dirs.add(defaultWs.replace(/^~/, homedir()));
|
||||
}
|
||||
|
||||
const agents = config?.agents?.list;
|
||||
if (Array.isArray(agents)) {
|
||||
for (const agent of agents) {
|
||||
const ws = agent?.workspace;
|
||||
if (typeof ws === 'string' && ws.trim()) {
|
||||
dirs.add(ws.replace(/^~/, homedir()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore config parse errors
|
||||
}
|
||||
|
||||
try {
|
||||
const entries: Dirent[] = await readdir(openclawDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name.startsWith('workspace')) {
|
||||
dirs.add(join(openclawDir, entry.name));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore read errors
|
||||
}
|
||||
|
||||
if (dirs.size === 0) {
|
||||
dirs.add(join(openclawDir, 'workspace'));
|
||||
}
|
||||
|
||||
return [...dirs];
|
||||
}
|
||||
|
||||
// ── Bootstrap file repair ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detect and remove bootstrap .md files that contain only ClawX markers
|
||||
* with no meaningful OpenClaw content outside them.
|
||||
*/
|
||||
export async function repairClawXOnlyBootstrapFiles(): Promise<void> {
|
||||
const workspaceDirs = await resolveAllWorkspaceDirs();
|
||||
for (const workspaceDir of workspaceDirs) {
|
||||
if (!(await fileExists(workspaceDir))) continue;
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = (await readdir(workspaceDir)).filter((f) => f.endsWith('.md'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const file of entries) {
|
||||
const filePath = join(workspaceDir, file);
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(filePath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const beginIdx = content.indexOf(CLAWX_BEGIN);
|
||||
const endIdx = content.indexOf(CLAWX_END);
|
||||
if (beginIdx === -1 || endIdx === -1) continue;
|
||||
|
||||
const before = content.slice(0, beginIdx).trim();
|
||||
const after = content.slice(endIdx + CLAWX_END.length).trim();
|
||||
if (before === '' && after === '') {
|
||||
try {
|
||||
await unlink(filePath);
|
||||
logger.info(`Removed ClawX-only bootstrap file for re-seeding: ${file} (${workspaceDir})`);
|
||||
} catch {
|
||||
logger.warn(`Failed to remove ClawX-only bootstrap file: ${filePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Context merging ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Merge ClawX context snippets into workspace bootstrap files that
|
||||
* already exist on disk. Returns the number of target files that were
|
||||
* skipped because they don't exist yet.
|
||||
*/
|
||||
async function mergeClawXContextOnce(): Promise<number> {
|
||||
const contextDir = join(getResourcesDir(), 'context');
|
||||
if (!(await fileExists(contextDir))) {
|
||||
logger.debug('ClawX context directory not found, skipping context merge');
|
||||
return 0;
|
||||
}
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = (await readdir(contextDir)).filter((f) => f.endsWith('.clawx.md'));
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const workspaceDirs = await resolveAllWorkspaceDirs();
|
||||
let skipped = 0;
|
||||
|
||||
for (const workspaceDir of workspaceDirs) {
|
||||
await ensureDir(workspaceDir);
|
||||
|
||||
for (const file of files) {
|
||||
const targetName = file.replace('.clawx.md', '.md');
|
||||
const targetPath = join(workspaceDir, targetName);
|
||||
|
||||
if (!(await fileExists(targetPath))) {
|
||||
logger.debug(`Skipping ${targetName} in ${workspaceDir} (file does not exist yet, will be seeded by gateway)`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const section = await readFile(join(contextDir, file), 'utf-8');
|
||||
const existing = await readFile(targetPath, 'utf-8');
|
||||
|
||||
const merged = mergeClawXSection(existing, section);
|
||||
if (merged !== existing) {
|
||||
await writeFile(targetPath, merged, 'utf-8');
|
||||
logger.info(`Merged ClawX context into ${targetName} (${workspaceDir})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return skipped;
|
||||
}
|
||||
|
||||
const RETRY_INTERVAL_MS = 2000;
|
||||
const MAX_RETRIES = 15;
|
||||
|
||||
/**
|
||||
* Ensure ClawX context snippets are merged into the openclaw workspace
|
||||
* bootstrap files.
|
||||
*/
|
||||
export async function ensureClawXContext(): Promise<void> {
|
||||
let skipped = await mergeClawXContextOnce();
|
||||
if (skipped === 0) return;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
await new Promise((r) => setTimeout(r, RETRY_INTERVAL_MS));
|
||||
skipped = await mergeClawXContextOnce();
|
||||
if (skipped === 0) {
|
||||
logger.info(`ClawX context merge completed after ${attempt} retry(ies)`);
|
||||
return;
|
||||
}
|
||||
logger.debug(`ClawX context merge: ${skipped} file(s) still missing (retry ${attempt}/${MAX_RETRIES})`);
|
||||
}
|
||||
|
||||
logger.warn(`ClawX context merge: ${skipped} file(s) still missing after ${MAX_RETRIES} retries`);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Gateway fetch preload — loaded via NODE_OPTIONS --require before
|
||||
* the OpenClaw Gateway starts.
|
||||
*
|
||||
* Patches globalThis.fetch so that every request whose URL contains
|
||||
* "openrouter.ai" carries the ClawX app-attribution headers.
|
||||
*
|
||||
* The OpenAI SDK (used by OpenClaw) captures globalThis.fetch in its
|
||||
* constructor, so patching here guarantees all SDK requests go through
|
||||
* the interceptor.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
var _f = globalThis.fetch;
|
||||
if (typeof _f !== 'function') return;
|
||||
if (globalThis.__clawxFetchPatched) return;
|
||||
globalThis.__clawxFetchPatched = true;
|
||||
|
||||
globalThis.fetch = function clawxFetch(input, init) {
|
||||
var url =
|
||||
typeof input === 'string' ? input
|
||||
: input && typeof input === 'object' && typeof input.url === 'string'
|
||||
? input.url : '';
|
||||
|
||||
if (url.indexOf('openrouter.ai') !== -1) {
|
||||
init = init ? Object.assign({}, init) : {};
|
||||
var prev = init.headers;
|
||||
var flat = {};
|
||||
if (prev && typeof prev.forEach === 'function') {
|
||||
prev.forEach(function (v, k) { flat[k] = v; });
|
||||
} else if (prev && typeof prev === 'object') {
|
||||
Object.assign(flat, prev);
|
||||
}
|
||||
delete flat['http-referer'];
|
||||
delete flat['HTTP-Referer'];
|
||||
delete flat['x-title'];
|
||||
delete flat['X-Title'];
|
||||
flat['HTTP-Referer'] = 'https://claw-x.com';
|
||||
flat['X-Title'] = 'ClawX';
|
||||
init.headers = flat;
|
||||
}
|
||||
return _f.call(globalThis, input, init);
|
||||
};
|
||||
})();
|
||||
@@ -8,6 +8,14 @@ import { homedir } from 'os';
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync } from 'fs';
|
||||
import { logger } from './logger';
|
||||
|
||||
export {
|
||||
quoteForCmd,
|
||||
needsWinShell,
|
||||
prepareWinSpawn,
|
||||
normalizeNodeRequirePathForNodeOptions,
|
||||
appendNodeRequireToNodeOptions,
|
||||
} from './win-shell';
|
||||
|
||||
/**
|
||||
* Expand ~ to home directory
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
const MULTI_INSTANCE_PROVIDER_TYPES = new Set(['custom', 'ollama']);
|
||||
|
||||
export const OPENCLAW_PROVIDER_KEY_MINIMAX = 'minimax-portal';
|
||||
export const OPENCLAW_PROVIDER_KEY_QWEN = 'qwen-portal';
|
||||
export const OPENCLAW_PROVIDER_KEY_MOONSHOT = 'moonshot';
|
||||
export const OAUTH_PROVIDER_TYPES = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'] as const;
|
||||
export const OPENCLAW_OAUTH_PLUGIN_PROVIDER_KEYS = [
|
||||
OPENCLAW_PROVIDER_KEY_MINIMAX,
|
||||
OPENCLAW_PROVIDER_KEY_QWEN,
|
||||
] as const;
|
||||
|
||||
const OAUTH_PROVIDER_TYPE_SET = new Set<string>(OAUTH_PROVIDER_TYPES);
|
||||
const OPENCLAW_OAUTH_PLUGIN_PROVIDER_KEY_SET = new Set<string>(OPENCLAW_OAUTH_PLUGIN_PROVIDER_KEYS);
|
||||
|
||||
const PROVIDER_KEY_ALIASES: Record<string, string> = {
|
||||
'minimax-portal-cn': OPENCLAW_PROVIDER_KEY_MINIMAX,
|
||||
};
|
||||
|
||||
export function getOpenClawProviderKeyForType(type: string, providerId: string): string {
|
||||
if (MULTI_INSTANCE_PROVIDER_TYPES.has(type)) {
|
||||
const suffix = providerId.replace(/-/g, '').slice(0, 8);
|
||||
return `${type}-${suffix}`;
|
||||
}
|
||||
|
||||
return PROVIDER_KEY_ALIASES[type] ?? type;
|
||||
}
|
||||
|
||||
export function isOAuthProviderType(type: string): boolean {
|
||||
return OAUTH_PROVIDER_TYPE_SET.has(type);
|
||||
}
|
||||
|
||||
export function isMiniMaxProviderType(type: string): boolean {
|
||||
return type === OPENCLAW_PROVIDER_KEY_MINIMAX || type === 'minimax-portal-cn';
|
||||
}
|
||||
|
||||
export function getOAuthProviderTargetKey(type: string): string | undefined {
|
||||
if (!isOAuthProviderType(type)) return undefined;
|
||||
return isMiniMaxProviderType(type) ? OPENCLAW_PROVIDER_KEY_MINIMAX : OPENCLAW_PROVIDER_KEY_QWEN;
|
||||
}
|
||||
|
||||
export function getOAuthProviderApi(type: string): 'anthropic-messages' | 'openai-completions' | undefined {
|
||||
if (!isOAuthProviderType(type)) return undefined;
|
||||
return isMiniMaxProviderType(type) ? 'anthropic-messages' : 'openai-completions';
|
||||
}
|
||||
|
||||
export function getOAuthProviderDefaultBaseUrl(type: string): string | undefined {
|
||||
if (!isOAuthProviderType(type)) return undefined;
|
||||
if (type === OPENCLAW_PROVIDER_KEY_MINIMAX) return 'https://api.minimax.io/anthropic';
|
||||
if (type === 'minimax-portal-cn') return 'https://api.minimaxi.com/anthropic';
|
||||
return 'https://portal.qwen.ai/v1';
|
||||
}
|
||||
|
||||
export function normalizeOAuthBaseUrl(type: string, baseUrl?: string): string | undefined {
|
||||
if (!baseUrl) return undefined;
|
||||
if (isMiniMaxProviderType(type)) {
|
||||
return baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
|
||||
}
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
export function usesOAuthAuthHeader(providerKey: string): boolean {
|
||||
return providerKey === OPENCLAW_PROVIDER_KEY_MINIMAX;
|
||||
}
|
||||
|
||||
export function getOAuthApiKeyEnv(providerKey: string): string | undefined {
|
||||
if (providerKey === OPENCLAW_PROVIDER_KEY_MINIMAX) return 'minimax-oauth';
|
||||
if (providerKey === OPENCLAW_PROVIDER_KEY_QWEN) return 'qwen-oauth';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isOpenClawOAuthPluginProviderKey(provider: string): boolean {
|
||||
return OPENCLAW_OAUTH_PLUGIN_PROVIDER_KEY_SET.has(provider);
|
||||
}
|
||||
@@ -1,103 +1,25 @@
|
||||
/**
|
||||
* Provider Registry — single source of truth for backend provider metadata.
|
||||
* Centralizes env var mappings, default models, and OpenClaw provider configs.
|
||||
*
|
||||
* NOTE: When adding a new provider type, also update src/lib/providers.ts
|
||||
* Backend compatibility layer around the shared provider registry.
|
||||
*/
|
||||
|
||||
export const BUILTIN_PROVIDER_TYPES = [
|
||||
'anthropic',
|
||||
'openai',
|
||||
'google',
|
||||
'openrouter',
|
||||
'moonshot',
|
||||
'siliconflow',
|
||||
'ollama',
|
||||
] as const;
|
||||
export type BuiltinProviderType = (typeof BUILTIN_PROVIDER_TYPES)[number];
|
||||
export type ProviderType = BuiltinProviderType | 'custom';
|
||||
export {
|
||||
BUILTIN_PROVIDER_TYPES,
|
||||
type BuiltinProviderType,
|
||||
type ProviderType,
|
||||
} from '../shared/providers/types';
|
||||
import {
|
||||
type ProviderBackendConfig,
|
||||
type ProviderModelEntry,
|
||||
} from '../shared/providers/types';
|
||||
import {
|
||||
getKeyableProviderTypes as getSharedKeyableProviderTypes,
|
||||
getProviderBackendConfig,
|
||||
getProviderDefaultModel as getSharedProviderDefaultModel,
|
||||
getProviderEnvVar as getSharedProviderEnvVar,
|
||||
} from '../shared/providers/registry';
|
||||
|
||||
interface ProviderModelEntry extends Record<string, unknown> {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
|
||||
interface ProviderBackendMeta {
|
||||
envVar?: string;
|
||||
defaultModel?: string;
|
||||
/** OpenClaw models.providers config (omit for built-in providers like anthropic) */
|
||||
providerConfig?: {
|
||||
baseUrl: string;
|
||||
api: string;
|
||||
apiKeyEnv: string;
|
||||
models?: ProviderModelEntry[];
|
||||
};
|
||||
}
|
||||
|
||||
const REGISTRY: Record<string, ProviderBackendMeta> = {
|
||||
anthropic: {
|
||||
envVar: 'ANTHROPIC_API_KEY',
|
||||
defaultModel: 'anthropic/claude-opus-4-6',
|
||||
// anthropic is built-in to OpenClaw's model registry, no provider config needed
|
||||
},
|
||||
openai: {
|
||||
envVar: 'OPENAI_API_KEY',
|
||||
defaultModel: 'openai/gpt-5.2',
|
||||
providerConfig: {
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
api: 'openai-responses',
|
||||
apiKeyEnv: 'OPENAI_API_KEY',
|
||||
},
|
||||
},
|
||||
google: {
|
||||
envVar: 'GEMINI_API_KEY',
|
||||
defaultModel: 'google/gemini-3-pro-preview',
|
||||
providerConfig: {
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
api: 'google',
|
||||
apiKeyEnv: 'GEMINI_API_KEY',
|
||||
},
|
||||
},
|
||||
openrouter: {
|
||||
envVar: 'OPENROUTER_API_KEY',
|
||||
defaultModel: 'openrouter/anthropic/claude-opus-4.6',
|
||||
providerConfig: {
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
api: 'openai-completions',
|
||||
apiKeyEnv: 'OPENROUTER_API_KEY',
|
||||
},
|
||||
},
|
||||
moonshot: {
|
||||
envVar: 'MOONSHOT_API_KEY',
|
||||
defaultModel: 'moonshot/kimi-k2.5',
|
||||
providerConfig: {
|
||||
baseUrl: 'https://api.moonshot.cn/v1',
|
||||
api: 'openai-completions',
|
||||
apiKeyEnv: 'MOONSHOT_API_KEY',
|
||||
models: [
|
||||
{
|
||||
id: 'kimi-k2.5',
|
||||
name: 'Kimi K2.5',
|
||||
reasoning: false,
|
||||
input: ['text'],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 256000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
siliconflow: {
|
||||
envVar: 'SILICONFLOW_API_KEY',
|
||||
defaultModel: 'siliconflow/deepseek-ai/DeepSeek-V3',
|
||||
providerConfig: {
|
||||
baseUrl: 'https://api.siliconflow.cn/v1',
|
||||
api: 'openai-completions',
|
||||
apiKeyEnv: 'SILICONFLOW_API_KEY',
|
||||
},
|
||||
},
|
||||
// Additional providers with env var mappings but no default model
|
||||
// Additional env-backed providers that are not yet exposed in the UI.
|
||||
const EXTRA_ENV_ONLY_PROVIDERS: Record<string, { envVar: string }> = {
|
||||
groq: { envVar: 'GROQ_API_KEY' },
|
||||
deepgram: { envVar: 'DEEPGRAM_API_KEY' },
|
||||
cerebras: { envVar: 'CEREBRAS_API_KEY' },
|
||||
@@ -107,19 +29,25 @@ const REGISTRY: Record<string, ProviderBackendMeta> = {
|
||||
|
||||
/** Get the environment variable name for a provider type */
|
||||
export function getProviderEnvVar(type: string): string | undefined {
|
||||
return REGISTRY[type]?.envVar;
|
||||
return getSharedProviderEnvVar(type) ?? EXTRA_ENV_ONLY_PROVIDERS[type]?.envVar;
|
||||
}
|
||||
|
||||
/** Get all environment variable names for a provider type (primary first). */
|
||||
export function getProviderEnvVars(type: string): string[] {
|
||||
const envVar = getProviderEnvVar(type);
|
||||
return envVar ? [envVar] : [];
|
||||
}
|
||||
|
||||
/** Get the default model string for a provider type */
|
||||
export function getProviderDefaultModel(type: string): string | undefined {
|
||||
return REGISTRY[type]?.defaultModel;
|
||||
return getSharedProviderDefaultModel(type);
|
||||
}
|
||||
|
||||
/** Get the OpenClaw provider config (baseUrl, api, apiKeyEnv, models) */
|
||||
/** Get the OpenClaw provider config (baseUrl, api, apiKeyEnv, models, headers) */
|
||||
export function getProviderConfig(
|
||||
type: string
|
||||
): { baseUrl: string; api: string; apiKeyEnv: string; models?: ProviderModelEntry[] } | undefined {
|
||||
return REGISTRY[type]?.providerConfig;
|
||||
): { baseUrl: string; api: string; apiKeyEnv: string; models?: ProviderModelEntry[]; headers?: Record<string, string> } | undefined {
|
||||
return getProviderBackendConfig(type) as ProviderBackendConfig | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,7 +55,5 @@ export function getProviderConfig(
|
||||
* Used by GatewayManager to inject API keys as env vars.
|
||||
*/
|
||||
export function getKeyableProviderTypes(): string[] {
|
||||
return Object.entries(REGISTRY)
|
||||
.filter(([, meta]) => meta.envVar)
|
||||
.map(([type]) => type);
|
||||
return [...getSharedKeyableProviderTypes(), ...Object.keys(EXTRA_ENV_ONLY_PROVIDERS)];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Use Electron's network stack when available so requests honor
|
||||
* session.defaultSession.setProxy(...). Fall back to the Node global fetch
|
||||
* for non-Electron test environments.
|
||||
*/
|
||||
|
||||
export async function proxyAwareFetch(
|
||||
input: string | URL,
|
||||
init?: RequestInit
|
||||
): Promise<Response> {
|
||||
if (process.versions.electron) {
|
||||
try {
|
||||
const { net } = await import('electron');
|
||||
return await net.fetch(input, init);
|
||||
} catch {
|
||||
// Fall through to the global fetch.
|
||||
}
|
||||
}
|
||||
|
||||
return await fetch(input, init);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Proxy helpers shared by the Electron main process and Gateway launcher.
|
||||
*/
|
||||
|
||||
export interface ProxySettings {
|
||||
proxyEnabled: boolean;
|
||||
proxyServer: string;
|
||||
proxyHttpServer: string;
|
||||
proxyHttpsServer: string;
|
||||
proxyAllServer: string;
|
||||
proxyBypassRules: string;
|
||||
}
|
||||
|
||||
export interface ResolvedProxySettings {
|
||||
httpProxy: string;
|
||||
httpsProxy: string;
|
||||
allProxy: string;
|
||||
bypassRules: string;
|
||||
}
|
||||
|
||||
export interface ElectronProxyConfig {
|
||||
mode: 'direct' | 'fixed_servers';
|
||||
proxyRules?: string;
|
||||
proxyBypassRules?: string;
|
||||
}
|
||||
|
||||
function trimValue(value: string | undefined | null): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept bare host:port values from users and normalize them to a valid URL.
|
||||
* Electron accepts scheme-less proxy rules in some cases, but child-process
|
||||
* env vars are more reliable when they are full URLs.
|
||||
*/
|
||||
export function normalizeProxyServer(proxyServer: string): string {
|
||||
const value = trimValue(proxyServer);
|
||||
if (!value) return '';
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return value;
|
||||
return `http://${value}`;
|
||||
}
|
||||
|
||||
export function resolveProxySettings(settings: ProxySettings): ResolvedProxySettings {
|
||||
const legacyProxy = normalizeProxyServer(settings.proxyServer);
|
||||
const allProxy = normalizeProxyServer(settings.proxyAllServer);
|
||||
const httpProxy = normalizeProxyServer(settings.proxyHttpServer) || legacyProxy || allProxy;
|
||||
const httpsProxy = normalizeProxyServer(settings.proxyHttpsServer) || legacyProxy || allProxy;
|
||||
|
||||
return {
|
||||
httpProxy,
|
||||
httpsProxy,
|
||||
allProxy: allProxy || legacyProxy,
|
||||
bypassRules: trimValue(settings.proxyBypassRules),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildElectronProxyConfig(settings: ProxySettings): ElectronProxyConfig {
|
||||
if (!settings.proxyEnabled) {
|
||||
return { mode: 'direct' };
|
||||
}
|
||||
|
||||
const resolved = resolveProxySettings(settings);
|
||||
const rules: string[] = [];
|
||||
|
||||
if (resolved.httpProxy) {
|
||||
rules.push(`http=${resolved.httpProxy}`);
|
||||
}
|
||||
if (resolved.httpsProxy) {
|
||||
rules.push(`https=${resolved.httpsProxy}`);
|
||||
}
|
||||
|
||||
// Fallback rule for protocols like ws/wss or when users only configured ALL_PROXY.
|
||||
const fallbackProxy = resolved.allProxy || resolved.httpsProxy || resolved.httpProxy;
|
||||
if (fallbackProxy) {
|
||||
rules.push(fallbackProxy);
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
return { mode: 'direct' };
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'fixed_servers',
|
||||
proxyRules: rules.join(';'),
|
||||
...(resolved.bypassRules ? { proxyBypassRules: resolved.bypassRules } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProxyEnv(settings: ProxySettings): Record<string, string> {
|
||||
const blank = {
|
||||
HTTP_PROXY: '',
|
||||
HTTPS_PROXY: '',
|
||||
ALL_PROXY: '',
|
||||
http_proxy: '',
|
||||
https_proxy: '',
|
||||
all_proxy: '',
|
||||
NO_PROXY: '',
|
||||
no_proxy: '',
|
||||
};
|
||||
|
||||
if (!settings.proxyEnabled) {
|
||||
return blank;
|
||||
}
|
||||
|
||||
const resolved = resolveProxySettings(settings);
|
||||
const noProxy = resolved.bypassRules
|
||||
.split(/[,\n;]/)
|
||||
.map((rule) => rule.trim())
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
|
||||
return {
|
||||
HTTP_PROXY: resolved.httpProxy,
|
||||
HTTPS_PROXY: resolved.httpsProxy,
|
||||
ALL_PROXY: resolved.allProxy,
|
||||
http_proxy: resolved.httpProxy,
|
||||
https_proxy: resolved.httpsProxy,
|
||||
all_proxy: resolved.allProxy,
|
||||
NO_PROXY: noProxy,
|
||||
no_proxy: noProxy,
|
||||
};
|
||||
}
|
||||
@@ -1,27 +1,29 @@
|
||||
/**
|
||||
* Provider Storage
|
||||
* Manages provider configurations and API keys.
|
||||
* Keys are stored in plain text alongside provider configs in a single electron-store.
|
||||
* This file remains the legacy compatibility layer while the app migrates to
|
||||
* account-based provider storage and a dedicated secret-store abstraction.
|
||||
*/
|
||||
|
||||
// Lazy-load electron-store (ESM module)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let providerStore: any = null;
|
||||
|
||||
async function getProviderStore() {
|
||||
if (!providerStore) {
|
||||
const Store = (await import('electron-store')).default;
|
||||
providerStore = new Store({
|
||||
name: 'clawx-providers',
|
||||
defaults: {
|
||||
providers: {} as Record<string, ProviderConfig>,
|
||||
apiKeys: {} as Record<string, string>,
|
||||
defaultProvider: null as string | null,
|
||||
},
|
||||
});
|
||||
}
|
||||
return providerStore;
|
||||
}
|
||||
import { BUILTIN_PROVIDER_TYPES, type ProviderType } from './provider-registry';
|
||||
import { getActiveOpenClawProviders } from './openclaw-auth';
|
||||
import {
|
||||
deleteProviderAccount,
|
||||
getProviderAccount,
|
||||
listProviderAccounts,
|
||||
providerAccountToConfig,
|
||||
providerConfigToAccount,
|
||||
saveProviderAccount,
|
||||
setDefaultProviderAccount,
|
||||
} from '../services/providers/provider-store';
|
||||
import { ensureProviderStoreMigrated } from '../services/providers/provider-migration';
|
||||
import { getClawXProviderStore } from '../services/providers/store-instance';
|
||||
import {
|
||||
deleteProviderSecret,
|
||||
getProviderSecret,
|
||||
setProviderSecret,
|
||||
} from '../services/secrets/secret-store';
|
||||
import { getOpenClawProviderKeyForType } from './provider-keys';
|
||||
|
||||
/**
|
||||
* Provider configuration
|
||||
@@ -29,9 +31,12 @@ async function getProviderStore() {
|
||||
export interface ProviderConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'anthropic' | 'openai' | 'google' | 'openrouter' | 'moonshot' | 'siliconflow' | 'ollama' | 'custom';
|
||||
type: ProviderType;
|
||||
baseUrl?: string;
|
||||
apiProtocol?: 'openai-completions' | 'openai-responses' | 'anthropic-messages';
|
||||
model?: string;
|
||||
fallbackModels?: string[];
|
||||
fallbackProviderIds?: string[];
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -44,10 +49,16 @@ export interface ProviderConfig {
|
||||
*/
|
||||
export async function storeApiKey(providerId: string, apiKey: string): Promise<boolean> {
|
||||
try {
|
||||
const s = await getProviderStore();
|
||||
await ensureProviderStoreMigrated();
|
||||
const s = await getClawXProviderStore();
|
||||
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
|
||||
keys[providerId] = apiKey;
|
||||
s.set('apiKeys', keys);
|
||||
await setProviderSecret({
|
||||
type: 'api_key',
|
||||
accountId: providerId,
|
||||
apiKey,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to store API key:', error);
|
||||
@@ -60,7 +71,16 @@ export async function storeApiKey(providerId: string, apiKey: string): Promise<b
|
||||
*/
|
||||
export async function getApiKey(providerId: string): Promise<string | null> {
|
||||
try {
|
||||
const s = await getProviderStore();
|
||||
await ensureProviderStoreMigrated();
|
||||
const secret = await getProviderSecret(providerId);
|
||||
if (secret?.type === 'api_key') {
|
||||
return secret.apiKey;
|
||||
}
|
||||
if (secret?.type === 'local') {
|
||||
return secret.apiKey ?? null;
|
||||
}
|
||||
|
||||
const s = await getClawXProviderStore();
|
||||
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
|
||||
return keys[providerId] || null;
|
||||
} catch (error) {
|
||||
@@ -74,10 +94,12 @@ export async function getApiKey(providerId: string): Promise<string | null> {
|
||||
*/
|
||||
export async function deleteApiKey(providerId: string): Promise<boolean> {
|
||||
try {
|
||||
const s = await getProviderStore();
|
||||
await ensureProviderStoreMigrated();
|
||||
const s = await getClawXProviderStore();
|
||||
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
|
||||
delete keys[providerId];
|
||||
s.set('apiKeys', keys);
|
||||
await deleteProviderSecret(providerId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete API key:', error);
|
||||
@@ -89,7 +111,13 @@ export async function deleteApiKey(providerId: string): Promise<boolean> {
|
||||
* Check if an API key exists for a provider
|
||||
*/
|
||||
export async function hasApiKey(providerId: string): Promise<boolean> {
|
||||
const s = await getProviderStore();
|
||||
await ensureProviderStoreMigrated();
|
||||
const secret = await getProviderSecret(providerId);
|
||||
if (secret?.type === 'api_key') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const s = await getClawXProviderStore();
|
||||
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
|
||||
return providerId in keys;
|
||||
}
|
||||
@@ -98,7 +126,8 @@ export async function hasApiKey(providerId: string): Promise<boolean> {
|
||||
* List all provider IDs that have stored keys
|
||||
*/
|
||||
export async function listStoredKeyIds(): Promise<string[]> {
|
||||
const s = await getProviderStore();
|
||||
await ensureProviderStoreMigrated();
|
||||
const s = await getClawXProviderStore();
|
||||
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
|
||||
return Object.keys(keys);
|
||||
}
|
||||
@@ -109,28 +138,47 @@ export async function listStoredKeyIds(): Promise<string[]> {
|
||||
* Save a provider configuration
|
||||
*/
|
||||
export async function saveProvider(config: ProviderConfig): Promise<void> {
|
||||
const s = await getProviderStore();
|
||||
await ensureProviderStoreMigrated();
|
||||
const s = await getClawXProviderStore();
|
||||
const providers = s.get('providers') as Record<string, ProviderConfig>;
|
||||
providers[config.id] = config;
|
||||
s.set('providers', providers);
|
||||
|
||||
const defaultProviderId = (s.get('defaultProvider') ?? null) as string | null;
|
||||
await saveProviderAccount(
|
||||
providerConfigToAccount(config, { isDefault: defaultProviderId === config.id }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a provider configuration
|
||||
*/
|
||||
export async function getProvider(providerId: string): Promise<ProviderConfig | null> {
|
||||
const s = await getProviderStore();
|
||||
await ensureProviderStoreMigrated();
|
||||
const s = await getClawXProviderStore();
|
||||
const providers = s.get('providers') as Record<string, ProviderConfig>;
|
||||
return providers[providerId] || null;
|
||||
if (providers[providerId]) {
|
||||
return providers[providerId];
|
||||
}
|
||||
|
||||
const account = await getProviderAccount(providerId);
|
||||
return account ? providerAccountToConfig(account) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all provider configurations
|
||||
*/
|
||||
export async function getAllProviders(): Promise<ProviderConfig[]> {
|
||||
const s = await getProviderStore();
|
||||
await ensureProviderStoreMigrated();
|
||||
const s = await getClawXProviderStore();
|
||||
const providers = s.get('providers') as Record<string, ProviderConfig>;
|
||||
return Object.values(providers);
|
||||
const legacyProviders = Object.values(providers);
|
||||
if (legacyProviders.length > 0) {
|
||||
return legacyProviders;
|
||||
}
|
||||
|
||||
const accounts = await listProviderAccounts();
|
||||
return accounts.map(providerAccountToConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,18 +186,21 @@ export async function getAllProviders(): Promise<ProviderConfig[]> {
|
||||
*/
|
||||
export async function deleteProvider(providerId: string): Promise<boolean> {
|
||||
try {
|
||||
await ensureProviderStoreMigrated();
|
||||
// Delete the API key
|
||||
await deleteApiKey(providerId);
|
||||
|
||||
// Delete the provider config
|
||||
const s = await getProviderStore();
|
||||
const s = await getClawXProviderStore();
|
||||
const providers = s.get('providers') as Record<string, ProviderConfig>;
|
||||
delete providers[providerId];
|
||||
s.set('providers', providers);
|
||||
await deleteProviderAccount(providerId);
|
||||
|
||||
// Clear default if this was the default
|
||||
if (s.get('defaultProvider') === providerId) {
|
||||
s.delete('defaultProvider');
|
||||
s.delete('defaultProviderAccountId');
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -163,16 +214,20 @@ export async function deleteProvider(providerId: string): Promise<boolean> {
|
||||
* Set the default provider
|
||||
*/
|
||||
export async function setDefaultProvider(providerId: string): Promise<void> {
|
||||
const s = await getProviderStore();
|
||||
await ensureProviderStoreMigrated();
|
||||
const s = await getClawXProviderStore();
|
||||
s.set('defaultProvider', providerId);
|
||||
await setDefaultProviderAccount(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default provider
|
||||
*/
|
||||
export async function getDefaultProvider(): Promise<string | undefined> {
|
||||
const s = await getProviderStore();
|
||||
return s.get('defaultProvider') as string | undefined;
|
||||
await ensureProviderStoreMigrated();
|
||||
const s = await getClawXProviderStore();
|
||||
return (s.get('defaultProvider') as string | undefined)
|
||||
?? (s.get('defaultProviderAccountId') as string | undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,14 +259,32 @@ export async function getProviderWithKeyInfo(
|
||||
|
||||
/**
|
||||
* Get all providers with key info (for UI display)
|
||||
* Also synchronizes ClawX local provider list with OpenClaw's actual config.
|
||||
*/
|
||||
export async function getAllProvidersWithKeyInfo(): Promise<
|
||||
Array<ProviderConfig & { hasKey: boolean; keyMasked: string | null }>
|
||||
> {
|
||||
const providers = await getAllProviders();
|
||||
const results: Array<ProviderConfig & { hasKey: boolean; keyMasked: string | null }> = [];
|
||||
const activeOpenClawProviders = await getActiveOpenClawProviders();
|
||||
|
||||
for (const provider of providers) {
|
||||
// Sync check: If it's a custom/OAuth provider and it no longer exists in OpenClaw config
|
||||
// (e.g. wiped by Gateway due to missing plugin, or manually deleted by user)
|
||||
// we should remove it from ClawX UI to stay consistent.
|
||||
const isBuiltin = BUILTIN_PROVIDER_TYPES.includes(provider.type);
|
||||
// For custom/ollama providers, the OpenClaw config key is derived as
|
||||
// "<type>-<suffix>" where suffix = first 8 chars of providerId with hyphens stripped.
|
||||
// e.g. provider.id "custom-a1b2c3d4-..." → strip hyphens → "customa1b2c3d4..." → slice(0,8) → "customa1"
|
||||
// → openClawKey = "custom-customa1"
|
||||
// This must match getOpenClawProviderKey() in ipc-handlers.ts exactly.
|
||||
const openClawKey = getOpenClawProviderKeyForType(provider.type, provider.id);
|
||||
if (!isBuiltin && !activeOpenClawProviders.has(provider.type) && !activeOpenClawProviders.has(provider.id) && !activeOpenClawProviders.has(openClawKey)) {
|
||||
console.log(`[Sync] Provider ${provider.id} (${provider.type}) missing from OpenClaw, dropping from ClawX UI`);
|
||||
await deleteProvider(provider.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const apiKey = await getApiKey(provider.id);
|
||||
let keyMasked: string | null = null;
|
||||
|
||||
|
||||
+320
-61
@@ -1,11 +1,18 @@
|
||||
/**
|
||||
* Skill Config Utilities
|
||||
* Direct read/write access to skill configuration in ~/.openclaw/openclaw.json
|
||||
* This bypasses the Gateway RPC for faster and more reliable config updates
|
||||
* This bypasses the Gateway RPC for faster and more reliable config updates.
|
||||
*
|
||||
* All file I/O uses async fs/promises to avoid blocking the main thread.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { readFile, writeFile, access, cp, mkdir } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { constants } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { getOpenClawDir, getResourcesDir } from './paths';
|
||||
import { logger } from './logger';
|
||||
import { withConfigLock } from './config-mutex';
|
||||
|
||||
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
|
||||
@@ -23,15 +30,45 @@ interface OpenClawConfig {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface PreinstalledSkillSpec {
|
||||
slug: string;
|
||||
version?: string;
|
||||
autoEnable?: boolean;
|
||||
}
|
||||
|
||||
interface PreinstalledManifest {
|
||||
skills?: PreinstalledSkillSpec[];
|
||||
}
|
||||
|
||||
interface PreinstalledLockEntry {
|
||||
slug: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
interface PreinstalledLockFile {
|
||||
skills?: PreinstalledLockEntry[];
|
||||
}
|
||||
|
||||
interface PreinstalledMarker {
|
||||
source: 'clawx-preinstalled';
|
||||
slug: string;
|
||||
version: string;
|
||||
installedAt: string;
|
||||
}
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try { await access(p, constants.F_OK); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current OpenClaw config
|
||||
*/
|
||||
function readConfig(): OpenClawConfig {
|
||||
if (!existsSync(OPENCLAW_CONFIG_PATH)) {
|
||||
async function readConfig(): Promise<OpenClawConfig> {
|
||||
if (!(await fileExists(OPENCLAW_CONFIG_PATH))) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const raw = readFileSync(OPENCLAW_CONFIG_PATH, 'utf-8');
|
||||
const raw = await readFile(OPENCLAW_CONFIG_PATH, 'utf-8');
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
console.error('Failed to read openclaw config:', err);
|
||||
@@ -42,79 +79,99 @@ function readConfig(): OpenClawConfig {
|
||||
/**
|
||||
* Write the OpenClaw config
|
||||
*/
|
||||
function writeConfig(config: OpenClawConfig): void {
|
||||
async function writeConfig(config: OpenClawConfig): Promise<void> {
|
||||
const json = JSON.stringify(config, null, 2);
|
||||
writeFileSync(OPENCLAW_CONFIG_PATH, json, 'utf-8');
|
||||
await writeFile(OPENCLAW_CONFIG_PATH, json, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skill config
|
||||
*/
|
||||
export function getSkillConfig(skillKey: string): SkillEntry | undefined {
|
||||
const config = readConfig();
|
||||
return config.skills?.entries?.[skillKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update skill config (apiKey and env)
|
||||
*/
|
||||
export function updateSkillConfig(
|
||||
skillKey: string,
|
||||
updates: { apiKey?: string; env?: Record<string, string> }
|
||||
): { success: boolean; error?: string } {
|
||||
try {
|
||||
const config = readConfig();
|
||||
|
||||
// Ensure skills.entries exists
|
||||
async function setSkillsEnabled(skillKeys: string[], enabled: boolean): Promise<void> {
|
||||
if (skillKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
return withConfigLock(async () => {
|
||||
const config = await readConfig();
|
||||
if (!config.skills) {
|
||||
config.skills = {};
|
||||
}
|
||||
if (!config.skills.entries) {
|
||||
config.skills.entries = {};
|
||||
}
|
||||
|
||||
// Get or create skill entry
|
||||
const entry = config.skills.entries[skillKey] || {};
|
||||
|
||||
// Update apiKey
|
||||
if (updates.apiKey !== undefined) {
|
||||
const trimmed = updates.apiKey.trim();
|
||||
if (trimmed) {
|
||||
entry.apiKey = trimmed;
|
||||
} else {
|
||||
delete entry.apiKey;
|
||||
}
|
||||
for (const skillKey of skillKeys) {
|
||||
const entry = config.skills.entries[skillKey] || {};
|
||||
entry.enabled = enabled;
|
||||
config.skills.entries[skillKey] = entry;
|
||||
}
|
||||
await writeConfig(config);
|
||||
});
|
||||
}
|
||||
|
||||
// Update env
|
||||
if (updates.env !== undefined) {
|
||||
const newEnv: Record<string, string> = {};
|
||||
/**
|
||||
* Get skill config
|
||||
*/
|
||||
export async function getSkillConfig(skillKey: string): Promise<SkillEntry | undefined> {
|
||||
const config = await readConfig();
|
||||
return config.skills?.entries?.[skillKey];
|
||||
}
|
||||
|
||||
// Process all keys from the update
|
||||
for (const [key, value] of Object.entries(updates.env)) {
|
||||
const trimmedKey = key.trim();
|
||||
if (!trimmedKey) continue;
|
||||
/**
|
||||
* Update skill config (apiKey and env)
|
||||
*/
|
||||
export async function updateSkillConfig(
|
||||
skillKey: string,
|
||||
updates: { apiKey?: string; env?: Record<string, string> }
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
return await withConfigLock(async () => {
|
||||
const config = await readConfig();
|
||||
|
||||
const trimmedVal = value.trim();
|
||||
if (trimmedVal) {
|
||||
newEnv[trimmedKey] = trimmedVal;
|
||||
// Ensure skills.entries exists
|
||||
if (!config.skills) {
|
||||
config.skills = {};
|
||||
}
|
||||
if (!config.skills.entries) {
|
||||
config.skills.entries = {};
|
||||
}
|
||||
|
||||
// Get or create skill entry
|
||||
const entry = config.skills.entries[skillKey] || {};
|
||||
|
||||
// Update apiKey
|
||||
if (updates.apiKey !== undefined) {
|
||||
const trimmed = updates.apiKey.trim();
|
||||
if (trimmed) {
|
||||
entry.apiKey = trimmed;
|
||||
} else {
|
||||
delete entry.apiKey;
|
||||
}
|
||||
// Empty value = don't include (delete)
|
||||
}
|
||||
|
||||
// Only set env if there are values, otherwise delete
|
||||
if (Object.keys(newEnv).length > 0) {
|
||||
entry.env = newEnv;
|
||||
} else {
|
||||
delete entry.env;
|
||||
// Update env
|
||||
if (updates.env !== undefined) {
|
||||
const newEnv: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(updates.env)) {
|
||||
const trimmedKey = key.trim();
|
||||
if (!trimmedKey) continue;
|
||||
|
||||
const trimmedVal = value.trim();
|
||||
if (trimmedVal) {
|
||||
newEnv[trimmedKey] = trimmedVal;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(newEnv).length > 0) {
|
||||
entry.env = newEnv;
|
||||
} else {
|
||||
delete entry.env;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save entry back
|
||||
config.skills.entries[skillKey] = entry;
|
||||
// Save entry back
|
||||
config.skills.entries[skillKey] = entry;
|
||||
|
||||
writeConfig(config);
|
||||
return { success: true };
|
||||
await writeConfig(config);
|
||||
return { success: true };
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to update skill config:', err);
|
||||
return { success: false, error: String(err) };
|
||||
@@ -124,7 +181,209 @@ export function updateSkillConfig(
|
||||
/**
|
||||
* Get all skill configs (for syncing to frontend)
|
||||
*/
|
||||
export function getAllSkillConfigs(): Record<string, SkillEntry> {
|
||||
const config = readConfig();
|
||||
export async function getAllSkillConfigs(): Promise<Record<string, SkillEntry>> {
|
||||
const config = await readConfig();
|
||||
return config.skills?.entries || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in skills bundled with ClawX that should be pre-deployed to
|
||||
* ~/.openclaw/skills/ on first launch. These come from the openclaw package's
|
||||
* extensions directory and are available in both dev and packaged builds.
|
||||
*/
|
||||
const BUILTIN_SKILLS = [] as const;
|
||||
|
||||
/**
|
||||
* Ensure built-in skills are deployed to ~/.openclaw/skills/<slug>/.
|
||||
* Skips any skill that already has a SKILL.md present (idempotent).
|
||||
* Runs at app startup; all errors are logged and swallowed so they never
|
||||
* block the normal startup flow.
|
||||
*/
|
||||
export async function ensureBuiltinSkillsInstalled(): Promise<void> {
|
||||
const skillsRoot = join(homedir(), '.openclaw', 'skills');
|
||||
|
||||
for (const { slug, sourceExtension } of BUILTIN_SKILLS) {
|
||||
const targetDir = join(skillsRoot, slug);
|
||||
const targetManifest = join(targetDir, 'SKILL.md');
|
||||
|
||||
if (existsSync(targetManifest)) {
|
||||
continue; // already installed
|
||||
}
|
||||
|
||||
const openclawDir = getOpenClawDir();
|
||||
const sourceDir = join(openclawDir, 'extensions', sourceExtension, 'skills', slug);
|
||||
|
||||
if (!existsSync(join(sourceDir, 'SKILL.md'))) {
|
||||
logger.warn(`Built-in skill source not found, skipping: ${sourceDir}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
await cp(sourceDir, targetDir, { recursive: true });
|
||||
logger.info(`Installed built-in skill: ${slug} -> ${targetDir}`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to install built-in skill ${slug}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PREINSTALLED_MANIFEST_NAME = 'preinstalled-manifest.json';
|
||||
const PREINSTALLED_MARKER_NAME = '.clawx-preinstalled.json';
|
||||
|
||||
async function readPreinstalledManifest(): Promise<PreinstalledSkillSpec[]> {
|
||||
const candidates = [
|
||||
join(getResourcesDir(), 'skills', PREINSTALLED_MANIFEST_NAME),
|
||||
join(process.cwd(), 'resources', 'skills', PREINSTALLED_MANIFEST_NAME),
|
||||
];
|
||||
|
||||
const manifestPath = candidates.find((p) => existsSync(p));
|
||||
if (!manifestPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await readFile(manifestPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as PreinstalledManifest;
|
||||
if (!Array.isArray(parsed.skills)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.skills.filter((s): s is PreinstalledSkillSpec => Boolean(s?.slug));
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read preinstalled-skills manifest:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePreinstalledSkillsSourceRoot(): string | null {
|
||||
const candidates = [
|
||||
join(getResourcesDir(), 'preinstalled-skills'),
|
||||
join(process.cwd(), 'build', 'preinstalled-skills'),
|
||||
join(__dirname, '../../build/preinstalled-skills'),
|
||||
];
|
||||
|
||||
const root = candidates.find((dir) => existsSync(dir));
|
||||
return root || null;
|
||||
}
|
||||
|
||||
async function readPreinstalledLockVersions(sourceRoot: string): Promise<Map<string, string>> {
|
||||
const lockPath = join(sourceRoot, '.preinstalled-lock.json');
|
||||
if (!existsSync(lockPath)) {
|
||||
return new Map();
|
||||
}
|
||||
try {
|
||||
const raw = await readFile(lockPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as PreinstalledLockFile;
|
||||
const versions = new Map<string, string>();
|
||||
for (const entry of parsed.skills || []) {
|
||||
const slug = entry.slug?.trim();
|
||||
const version = entry.version?.trim();
|
||||
if (slug && version) {
|
||||
versions.set(slug, version);
|
||||
}
|
||||
}
|
||||
return versions;
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read preinstalled-skills lock file:', error);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
async function tryReadMarker(markerPath: string): Promise<PreinstalledMarker | null> {
|
||||
if (!existsSync(markerPath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = await readFile(markerPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as PreinstalledMarker;
|
||||
if (!parsed?.slug || !parsed?.version) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure third-party preinstalled skills (bundled in app resources) are
|
||||
* deployed to ~/.openclaw/skills/<slug>/ as full directories.
|
||||
*
|
||||
* Policy:
|
||||
* - If skill is missing locally, install it.
|
||||
* - If local skill exists without our marker, treat as user-managed and never overwrite.
|
||||
* - If marker exists with same version, skip.
|
||||
* - If marker exists with a different version, skip by default to avoid overwriting edits.
|
||||
*/
|
||||
export async function ensurePreinstalledSkillsInstalled(): Promise<void> {
|
||||
const skills = await readPreinstalledManifest();
|
||||
if (skills.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceRoot = resolvePreinstalledSkillsSourceRoot();
|
||||
if (!sourceRoot) {
|
||||
logger.warn('Preinstalled skills source root not found; skipping preinstall.');
|
||||
return;
|
||||
}
|
||||
const lockVersions = await readPreinstalledLockVersions(sourceRoot);
|
||||
|
||||
const targetRoot = join(homedir(), '.openclaw', 'skills');
|
||||
await mkdir(targetRoot, { recursive: true });
|
||||
const toEnable: string[] = [];
|
||||
|
||||
for (const spec of skills) {
|
||||
const sourceDir = join(sourceRoot, spec.slug);
|
||||
const sourceManifest = join(sourceDir, 'SKILL.md');
|
||||
if (!existsSync(sourceManifest)) {
|
||||
logger.warn(`Preinstalled skill source missing SKILL.md, skipping: ${sourceDir}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetDir = join(targetRoot, spec.slug);
|
||||
const targetManifest = join(targetDir, 'SKILL.md');
|
||||
const markerPath = join(targetDir, PREINSTALLED_MARKER_NAME);
|
||||
const desiredVersion = lockVersions.get(spec.slug)
|
||||
|| (spec.version || 'unknown').trim()
|
||||
|| 'unknown';
|
||||
const marker = await tryReadMarker(markerPath);
|
||||
|
||||
if (existsSync(targetManifest)) {
|
||||
if (!marker) {
|
||||
logger.info(`Skipping user-managed skill: ${spec.slug}`);
|
||||
continue;
|
||||
}
|
||||
if (marker.version === desiredVersion) {
|
||||
continue;
|
||||
}
|
||||
logger.info(`Skipping preinstalled skill update for ${spec.slug} (local marker version=${marker.version}, desired=${desiredVersion})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
await cp(sourceDir, targetDir, { recursive: true, force: true });
|
||||
const markerPayload: PreinstalledMarker = {
|
||||
source: 'clawx-preinstalled',
|
||||
slug: spec.slug,
|
||||
version: desiredVersion,
|
||||
installedAt: new Date().toISOString(),
|
||||
};
|
||||
await writeFile(markerPath, `${JSON.stringify(markerPayload, null, 2)}\n`, 'utf-8');
|
||||
if (spec.autoEnable) {
|
||||
toEnable.push(spec.slug);
|
||||
}
|
||||
logger.info(`Installed preinstalled skill: ${spec.slug} -> ${targetDir}`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to install preinstalled skill ${spec.slug}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (toEnable.length > 0) {
|
||||
try {
|
||||
await setSkillsEnabled(toEnable, true);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to auto-enable preinstalled skills:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-8
@@ -25,22 +25,31 @@ export interface AppSettings {
|
||||
language: string;
|
||||
startMinimized: boolean;
|
||||
launchAtStartup: boolean;
|
||||
|
||||
telemetryEnabled: boolean;
|
||||
machineId: string;
|
||||
hasReportedInstall: boolean;
|
||||
|
||||
// Gateway
|
||||
gatewayAutoStart: boolean;
|
||||
gatewayPort: number;
|
||||
gatewayToken: string;
|
||||
|
||||
proxyEnabled: boolean;
|
||||
proxyServer: string;
|
||||
proxyHttpServer: string;
|
||||
proxyHttpsServer: string;
|
||||
proxyAllServer: string;
|
||||
proxyBypassRules: string;
|
||||
|
||||
// Update
|
||||
updateChannel: 'stable' | 'beta' | 'dev';
|
||||
autoCheckUpdate: boolean;
|
||||
autoDownloadUpdate: boolean;
|
||||
skippedVersions: string[];
|
||||
|
||||
|
||||
// UI State
|
||||
sidebarCollapsed: boolean;
|
||||
devModeUnlocked: boolean;
|
||||
|
||||
|
||||
// Presets
|
||||
selectedBundles: string[];
|
||||
enabledSkills: string[];
|
||||
@@ -56,22 +65,31 @@ const defaults: AppSettings = {
|
||||
language: 'en',
|
||||
startMinimized: false,
|
||||
launchAtStartup: false,
|
||||
|
||||
telemetryEnabled: true,
|
||||
machineId: '',
|
||||
hasReportedInstall: false,
|
||||
|
||||
// Gateway
|
||||
gatewayAutoStart: true,
|
||||
gatewayPort: 18789,
|
||||
gatewayToken: generateToken(),
|
||||
|
||||
proxyEnabled: false,
|
||||
proxyServer: '',
|
||||
proxyHttpServer: '',
|
||||
proxyHttpsServer: '',
|
||||
proxyAllServer: '',
|
||||
proxyBypassRules: '<local>;localhost;127.0.0.1;::1',
|
||||
|
||||
// Update
|
||||
updateChannel: 'stable',
|
||||
autoCheckUpdate: true,
|
||||
autoDownloadUpdate: false,
|
||||
skippedVersions: [],
|
||||
|
||||
|
||||
// UI State
|
||||
sidebarCollapsed: false,
|
||||
devModeUnlocked: false,
|
||||
|
||||
|
||||
// Presets
|
||||
selectedBundles: ['productivity', 'developer'],
|
||||
enabledSkills: [],
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { PostHog } from 'posthog-node';
|
||||
import { machineIdSync } from 'node-machine-id';
|
||||
import { app } from 'electron';
|
||||
import { getSetting, setSetting } from './store';
|
||||
import { logger } from './logger';
|
||||
|
||||
const POSTHOG_API_KEY = 'phc_aGNegeJQP5FzNiF2rEoKqQbkuCpiiETMttplibXpB0n';
|
||||
const POSTHOG_HOST = 'https://us.i.posthog.com';
|
||||
const TELEMETRY_SHUTDOWN_TIMEOUT_MS = 1500;
|
||||
|
||||
let posthogClient: PostHog | null = null;
|
||||
let distinctId: string = '';
|
||||
|
||||
function isIgnorablePostHogShutdownError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = `${error.name} ${error.message}`.toLowerCase();
|
||||
if (
|
||||
message.includes('posthogfetchnetworkerror') ||
|
||||
message.includes('network error while fetching posthog') ||
|
||||
message.includes('timeouterror') ||
|
||||
message.includes('aborted due to timeout') ||
|
||||
message.includes('fetch failed')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'cause' in error && error.cause !== error
|
||||
? isIgnorablePostHogShutdownError(error.cause)
|
||||
: false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize PostHog telemetry
|
||||
*/
|
||||
export async function initTelemetry(): Promise<void> {
|
||||
try {
|
||||
const telemetryEnabled = await getSetting('telemetryEnabled');
|
||||
if (!telemetryEnabled) {
|
||||
logger.info('Telemetry is disabled in settings');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize PostHog client
|
||||
posthogClient = new PostHog(POSTHOG_API_KEY, { host: POSTHOG_HOST });
|
||||
|
||||
// Get or generate machine ID
|
||||
distinctId = await getSetting('machineId');
|
||||
if (!distinctId) {
|
||||
distinctId = machineIdSync();
|
||||
await setSetting('machineId', distinctId);
|
||||
logger.debug(`Generated new machine ID for telemetry: ${distinctId}`);
|
||||
}
|
||||
|
||||
// Common properties for all events
|
||||
const properties = {
|
||||
$app_version: app.getVersion(),
|
||||
$os: process.platform,
|
||||
arch: process.arch,
|
||||
};
|
||||
|
||||
// Check if this is a new installation
|
||||
const hasReportedInstall = await getSetting('hasReportedInstall');
|
||||
if (!hasReportedInstall) {
|
||||
posthogClient.capture({
|
||||
distinctId,
|
||||
event: 'app_installed',
|
||||
properties,
|
||||
});
|
||||
await setSetting('hasReportedInstall', true);
|
||||
logger.info('Reported app_installed event');
|
||||
}
|
||||
|
||||
// Always report app opened
|
||||
posthogClient.capture({
|
||||
distinctId,
|
||||
event: 'app_opened',
|
||||
properties,
|
||||
});
|
||||
logger.debug('Reported app_opened event');
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize telemetry:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort telemetry shutdown that never blocks app exit on network issues.
|
||||
*/
|
||||
export async function shutdownTelemetry(): Promise<void> {
|
||||
const client = posthogClient;
|
||||
posthogClient = null;
|
||||
distinctId = '';
|
||||
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
let didTimeout = false;
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
const shutdownPromise = client.shutdown().catch((error) => {
|
||||
if (isIgnorablePostHogShutdownError(error)) {
|
||||
logger.debug('Ignored telemetry shutdown network error:', error);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
shutdownPromise,
|
||||
new Promise<void>((resolve) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
didTimeout = true;
|
||||
resolve();
|
||||
}, TELEMETRY_SHUTDOWN_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
|
||||
if (didTimeout) {
|
||||
logger.debug(`Skipped waiting for telemetry shutdown after ${TELEMETRY_SHUTDOWN_TIMEOUT_MS}ms`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('Flushed telemetry events on shutdown');
|
||||
} catch (error) {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
logger.error('Error shutting down telemetry:', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
export interface TokenUsageHistoryEntry {
|
||||
timestamp: string;
|
||||
sessionId: string;
|
||||
agentId: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
content?: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
totalTokens: number;
|
||||
costUsd?: number;
|
||||
}
|
||||
|
||||
export function extractSessionIdFromTranscriptFileName(fileName: string): string | undefined {
|
||||
if (!fileName.endsWith('.jsonl') && !fileName.includes('.jsonl.reset.')) return undefined;
|
||||
return fileName
|
||||
.replace(/\.reset\..+$/, '')
|
||||
.replace(/\.deleted\.jsonl$/, '')
|
||||
.replace(/\.jsonl$/, '');
|
||||
}
|
||||
|
||||
interface TranscriptUsageShape {
|
||||
input?: number;
|
||||
output?: number;
|
||||
total?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
cost?: {
|
||||
total?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface TranscriptLineShape {
|
||||
type?: string;
|
||||
timestamp?: string;
|
||||
message?: {
|
||||
role?: string;
|
||||
model?: string;
|
||||
modelRef?: string;
|
||||
provider?: string;
|
||||
usage?: TranscriptUsageShape;
|
||||
details?: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
usage?: TranscriptUsageShape;
|
||||
content?: unknown;
|
||||
externalContent?: {
|
||||
provider?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUsageContent(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const chunks = value
|
||||
.map((item) => normalizeUsageContent(item))
|
||||
.filter((item): item is string => Boolean(item));
|
||||
if (chunks.length === 0) return undefined;
|
||||
return chunks.join('\n\n');
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.text === 'string') {
|
||||
const trimmed = record.text.trim();
|
||||
if (trimmed.length > 0) return trimmed;
|
||||
}
|
||||
if (typeof record.content === 'string') {
|
||||
const trimmed = record.content.trim();
|
||||
if (trimmed.length > 0) return trimmed;
|
||||
}
|
||||
if (Array.isArray(record.content)) {
|
||||
return normalizeUsageContent(record.content);
|
||||
}
|
||||
if (typeof record.thinking === 'string') {
|
||||
const trimmed = record.thinking.trim();
|
||||
if (trimmed.length > 0) return trimmed;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(record, null, 2);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseUsageEntriesFromJsonl(
|
||||
content: string,
|
||||
context: { sessionId: string; agentId: string },
|
||||
limit?: number,
|
||||
): TokenUsageHistoryEntry[] {
|
||||
const entries: TokenUsageHistoryEntry[] = [];
|
||||
const lines = content.split(/\r?\n/).filter(Boolean);
|
||||
const maxEntries = typeof limit === 'number' && Number.isFinite(limit)
|
||||
? Math.max(Math.floor(limit), 0)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
|
||||
for (let i = lines.length - 1; i >= 0 && entries.length < maxEntries; i -= 1) {
|
||||
let parsed: TranscriptLineShape;
|
||||
try {
|
||||
parsed = JSON.parse(lines[i]) as TranscriptLineShape;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const message = parsed.message;
|
||||
if (!message || !parsed.timestamp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === 'assistant' && message.usage) {
|
||||
const usage = message.usage;
|
||||
const inputTokens = usage.input ?? usage.promptTokens ?? 0;
|
||||
const outputTokens = usage.output ?? usage.completionTokens ?? 0;
|
||||
const cacheReadTokens = usage.cacheRead ?? 0;
|
||||
const cacheWriteTokens = usage.cacheWrite ?? 0;
|
||||
const totalTokens = usage.total ?? usage.totalTokens ?? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
|
||||
|
||||
if (totalTokens <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentText = normalizeUsageContent((message as Record<string, unknown>).content);
|
||||
entries.push({
|
||||
timestamp: parsed.timestamp,
|
||||
sessionId: context.sessionId,
|
||||
agentId: context.agentId,
|
||||
model: message.model ?? message.modelRef,
|
||||
provider: message.provider,
|
||||
...(contentText ? { content: contentText } : {}),
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens,
|
||||
totalTokens,
|
||||
costUsd: usage.cost?.total,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role !== 'toolResult') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const details = message.details;
|
||||
if (!details) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const usage = details.usage;
|
||||
const inputTokens = usage?.input ?? usage?.promptTokens ?? 0;
|
||||
const outputTokens = usage?.output ?? usage?.completionTokens ?? 0;
|
||||
const cacheReadTokens = usage?.cacheRead ?? 0;
|
||||
const cacheWriteTokens = usage?.cacheWrite ?? 0;
|
||||
const totalTokens = usage?.total ?? usage?.totalTokens ?? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
|
||||
|
||||
const provider = details.provider ?? details.externalContent?.provider ?? message.provider;
|
||||
const model = details.model ?? message.model ?? message.modelRef;
|
||||
const contentText = normalizeUsageContent(details.content)
|
||||
?? normalizeUsageContent((message as Record<string, unknown>).content);
|
||||
|
||||
if (!provider && !model) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (totalTokens <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push({
|
||||
timestamp: parsed.timestamp,
|
||||
sessionId: context.sessionId,
|
||||
agentId: context.agentId,
|
||||
model,
|
||||
provider,
|
||||
...(contentText ? { content: contentText } : {}),
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens,
|
||||
totalTokens,
|
||||
costUsd: usage?.cost?.total,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { readdir, readFile, stat } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { getOpenClawConfigDir } from './paths';
|
||||
import { logger } from './logger';
|
||||
import {
|
||||
extractSessionIdFromTranscriptFileName,
|
||||
parseUsageEntriesFromJsonl,
|
||||
type TokenUsageHistoryEntry,
|
||||
} from './token-usage-core';
|
||||
import { listConfiguredAgentIds } from './agent-config';
|
||||
|
||||
export {
|
||||
extractSessionIdFromTranscriptFileName,
|
||||
parseUsageEntriesFromJsonl,
|
||||
type TokenUsageHistoryEntry,
|
||||
} from './token-usage-core';
|
||||
|
||||
async function listAgentIdsWithSessionDirs(): Promise<string[]> {
|
||||
const openclawDir = getOpenClawConfigDir();
|
||||
const agentsDir = join(openclawDir, 'agents');
|
||||
const agentIds = new Set<string>();
|
||||
|
||||
try {
|
||||
for (const agentId of await listConfiguredAgentIds()) {
|
||||
const normalized = agentId.trim();
|
||||
if (normalized) {
|
||||
agentIds.add(normalized);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore config discovery failures and fall back to disk scan.
|
||||
}
|
||||
|
||||
try {
|
||||
const agentEntries = await readdir(agentsDir, { withFileTypes: true });
|
||||
for (const entry of agentEntries) {
|
||||
if (entry.isDirectory()) {
|
||||
const normalized = entry.name.trim();
|
||||
if (normalized) {
|
||||
agentIds.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore disk discovery failures and return whatever we already found.
|
||||
}
|
||||
|
||||
return [...agentIds];
|
||||
}
|
||||
|
||||
async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }>> {
|
||||
const openclawDir = getOpenClawConfigDir();
|
||||
const agentsDir = join(openclawDir, 'agents');
|
||||
|
||||
try {
|
||||
const agentEntries = await listAgentIdsWithSessionDirs();
|
||||
const files: Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }> = [];
|
||||
|
||||
for (const agentId of agentEntries) {
|
||||
const sessionsDir = join(agentsDir, agentId, 'sessions');
|
||||
try {
|
||||
const sessionEntries = await readdir(sessionsDir);
|
||||
|
||||
for (const fileName of sessionEntries) {
|
||||
const sessionId = extractSessionIdFromTranscriptFileName(fileName);
|
||||
if (!sessionId) continue;
|
||||
const filePath = join(sessionsDir, fileName);
|
||||
try {
|
||||
const fileStat = await stat(filePath);
|
||||
files.push({
|
||||
filePath,
|
||||
sessionId,
|
||||
agentId,
|
||||
mtimeMs: fileStat.mtimeMs,
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
return files;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRecentTokenUsageHistory(limit?: number): Promise<TokenUsageHistoryEntry[]> {
|
||||
const files = await listRecentSessionFiles();
|
||||
const results: TokenUsageHistoryEntry[] = [];
|
||||
const maxEntries = typeof limit === 'number' && Number.isFinite(limit)
|
||||
? Math.max(Math.floor(limit), 0)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const file of files) {
|
||||
if (results.length >= maxEntries) break;
|
||||
try {
|
||||
const content = await readFile(file.filePath, 'utf8');
|
||||
const entries = parseUsageEntriesFromJsonl(content, {
|
||||
sessionId: file.sessionId,
|
||||
agentId: file.agentId,
|
||||
}, Number.isFinite(maxEntries) ? maxEntries - results.length : undefined);
|
||||
results.push(...entries);
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to read token usage transcript ${file.filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
||||
return Number.isFinite(maxEntries) ? results.slice(0, maxEntries) : results;
|
||||
}
|
||||
+147
-70
@@ -1,8 +1,10 @@
|
||||
import { app } from 'electron';
|
||||
import { spawn } from 'child_process';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { getUvMirrorEnv } from './uv-env';
|
||||
import { logger } from './logger';
|
||||
import { quoteForCmd, needsWinShell } from './paths';
|
||||
|
||||
/**
|
||||
* Get the path to the bundled uv binary
|
||||
@@ -12,33 +14,61 @@ function getBundledUvPath(): string {
|
||||
const arch = process.arch;
|
||||
const target = `${platform}-${arch}`;
|
||||
const binName = platform === 'win32' ? 'uv.exe' : 'uv';
|
||||
|
||||
|
||||
if (app.isPackaged) {
|
||||
// In production, we flattened the structure to 'bin/'
|
||||
return join(process.resourcesPath, 'bin', binName);
|
||||
} else {
|
||||
// In dev, resources are at project root/resources/bin/<platform>-<arch>
|
||||
return join(process.cwd(), 'resources', 'bin', target, binName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if uv is available (either in system PATH or bundled)
|
||||
* Resolve the best uv binary to use.
|
||||
*
|
||||
* In packaged mode we always prefer the bundled binary so we never accidentally
|
||||
* pick up a system-wide uv that may be a different (possibly broken) version.
|
||||
* In dev we fall through to the system PATH for convenience.
|
||||
*/
|
||||
function resolveUvBin(): { bin: string; source: 'bundled' | 'path' | 'bundled-fallback' } {
|
||||
const bundled = getBundledUvPath();
|
||||
|
||||
if (app.isPackaged) {
|
||||
if (existsSync(bundled)) {
|
||||
return { bin: bundled, source: 'bundled' };
|
||||
}
|
||||
logger.warn(`Bundled uv binary not found at ${bundled}, falling back to system PATH`);
|
||||
}
|
||||
|
||||
// Dev mode or missing bundled binary — check system PATH
|
||||
const found = findUvInPathSync();
|
||||
if (found) return { bin: 'uv', source: 'path' };
|
||||
|
||||
if (existsSync(bundled)) {
|
||||
return { bin: bundled, source: 'bundled-fallback' };
|
||||
}
|
||||
|
||||
return { bin: 'uv', source: 'path' };
|
||||
}
|
||||
|
||||
function findUvInPathSync(): boolean {
|
||||
try {
|
||||
const cmd = process.platform === 'win32' ? 'where.exe uv' : 'which uv';
|
||||
execSync(cmd, { stdio: 'ignore', timeout: 5000, windowsHide: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if uv is available (either bundled or in system PATH)
|
||||
*/
|
||||
export async function checkUvInstalled(): Promise<boolean> {
|
||||
// 1. Check system PATH first
|
||||
const inPath = await new Promise<boolean>((resolve) => {
|
||||
const cmd = process.platform === 'win32' ? 'where.exe' : 'which';
|
||||
const child = spawn(cmd, ['uv']);
|
||||
child.on('close', (code) => resolve(code === 0));
|
||||
child.on('error', () => resolve(false));
|
||||
});
|
||||
|
||||
if (inPath) return true;
|
||||
|
||||
// 2. Check bundled path
|
||||
const bin = getBundledUvPath();
|
||||
return existsSync(bin);
|
||||
const { bin, source } = resolveUvBin();
|
||||
if (source === 'bundled' || source === 'bundled-fallback') {
|
||||
return existsSync(bin);
|
||||
}
|
||||
return findUvInPathSync();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,27 +81,21 @@ export async function installUv(): Promise<void> {
|
||||
const bin = getBundledUvPath();
|
||||
throw new Error(`uv not found in system PATH and bundled binary missing at ${bin}`);
|
||||
}
|
||||
console.log('uv is available and ready to use');
|
||||
logger.info('uv is available and ready to use');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a managed Python 3.12 is ready and accessible
|
||||
*/
|
||||
export async function isPythonReady(): Promise<boolean> {
|
||||
// Use 'uv' if in PATH, otherwise use full bundled path
|
||||
const inPath = await new Promise<boolean>((resolve) => {
|
||||
const cmd = process.platform === 'win32' ? 'where.exe' : 'which';
|
||||
const child = spawn(cmd, ['uv']);
|
||||
child.on('close', (code) => resolve(code === 0));
|
||||
child.on('error', () => resolve(false));
|
||||
});
|
||||
|
||||
const uvBin = inPath ? 'uv' : getBundledUvPath();
|
||||
const { bin: uvBin } = resolveUvBin();
|
||||
const useShell = needsWinShell(uvBin);
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
try {
|
||||
const child = spawn(uvBin, ['python', 'find', '3.12'], {
|
||||
shell: process.platform === 'win32',
|
||||
const child = spawn(useShell ? quoteForCmd(uvBin) : uvBin, ['python', 'find', '3.12'], {
|
||||
shell: useShell,
|
||||
windowsHide: true,
|
||||
});
|
||||
child.on('close', (code) => resolve(code === 0));
|
||||
child.on('error', () => resolve(false));
|
||||
@@ -82,70 +106,123 @@ export async function isPythonReady(): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Use bundled uv to install a managed Python version (default 3.12)
|
||||
* Automatically picks the best available uv binary
|
||||
* Run `uv python install 3.12` once with the given environment.
|
||||
* Returns on success, throws with captured stderr on failure.
|
||||
*/
|
||||
export async function setupManagedPython(): Promise<void> {
|
||||
// Use 'uv' if in PATH, otherwise use full bundled path
|
||||
const inPath = await new Promise<boolean>((resolve) => {
|
||||
const cmd = process.platform === 'win32' ? 'where.exe' : 'which';
|
||||
const child = spawn(cmd, ['uv']);
|
||||
child.on('close', (code) => resolve(code === 0));
|
||||
child.on('error', () => resolve(false));
|
||||
});
|
||||
async function runPythonInstall(
|
||||
uvBin: string,
|
||||
env: Record<string, string | undefined>,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
const useShell = needsWinShell(uvBin);
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const stderrChunks: string[] = [];
|
||||
const stdoutChunks: string[] = [];
|
||||
|
||||
const uvBin = inPath ? 'uv' : getBundledUvPath();
|
||||
|
||||
console.log(`Setting up python with: ${uvBin}`);
|
||||
const uvEnv = await getUvMirrorEnv();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(uvBin, ['python', 'install', '3.12'], {
|
||||
shell: process.platform === 'win32',
|
||||
env: {
|
||||
...process.env,
|
||||
...uvEnv,
|
||||
},
|
||||
const child = spawn(useShell ? quoteForCmd(uvBin) : uvBin, ['python', 'install', '3.12'], {
|
||||
shell: useShell,
|
||||
env,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
console.log(`python setup stdout: ${data}`);
|
||||
const line = data.toString().trim();
|
||||
if (line) {
|
||||
stdoutChunks.push(line);
|
||||
logger.debug(`[python-setup:${label}] stdout: ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
// uv prints progress to stderr, so we log it as info
|
||||
console.log(`python setup info: ${data.toString().trim()}`);
|
||||
const line = data.toString().trim();
|
||||
if (line) {
|
||||
stderrChunks.push(line);
|
||||
logger.info(`[python-setup:${label}] stderr: ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Python installation failed with code ${code}`));
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
const stderr = stderrChunks.join('\n');
|
||||
const stdout = stdoutChunks.join('\n');
|
||||
const detail = stderr || stdout || '(no output captured)';
|
||||
reject(new Error(
|
||||
`Python installation failed with code ${code} [${label}]\n` +
|
||||
` uv binary: ${uvBin}\n` +
|
||||
` platform: ${process.platform}/${process.arch}\n` +
|
||||
` output: ${detail}`
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => reject(err));
|
||||
child.on('error', (err) => {
|
||||
reject(new Error(
|
||||
`Python installation spawn error [${label}]: ${err.message}\n` +
|
||||
` uv binary: ${uvBin}\n` +
|
||||
` platform: ${process.platform}/${process.arch}`
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// After installation, find and print where the Python executable is
|
||||
/**
|
||||
* Use bundled uv to install a managed Python version (default 3.12).
|
||||
*
|
||||
* Tries with mirror env first (for CN region), then retries without mirror
|
||||
* if the first attempt fails, to rule out mirror-specific issues.
|
||||
*/
|
||||
export async function setupManagedPython(): Promise<void> {
|
||||
const { bin: uvBin, source } = resolveUvBin();
|
||||
const uvEnv = await getUvMirrorEnv();
|
||||
const hasMirror = Object.keys(uvEnv).length > 0;
|
||||
|
||||
logger.info(
|
||||
`Setting up managed Python 3.12 ` +
|
||||
`(uv=${uvBin}, source=${source}, arch=${process.arch}, mirror=${hasMirror})`
|
||||
);
|
||||
|
||||
const baseEnv: Record<string, string | undefined> = { ...process.env };
|
||||
|
||||
// Attempt 1: with mirror (if applicable)
|
||||
try {
|
||||
await runPythonInstall(uvBin, { ...baseEnv, ...uvEnv }, hasMirror ? 'mirror' : 'default');
|
||||
} catch (firstError) {
|
||||
logger.warn('Python install attempt 1 failed:', firstError);
|
||||
|
||||
if (hasMirror) {
|
||||
// Attempt 2: retry without mirror to rule out mirror issues
|
||||
logger.info('Retrying Python install without mirror...');
|
||||
try {
|
||||
await runPythonInstall(uvBin, baseEnv, 'no-mirror');
|
||||
} catch (secondError) {
|
||||
logger.error('Python install attempt 2 (no mirror) also failed:', secondError);
|
||||
throw secondError;
|
||||
}
|
||||
} else {
|
||||
throw firstError;
|
||||
}
|
||||
}
|
||||
|
||||
// After installation, verify and log the Python path
|
||||
const verifyShell = needsWinShell(uvBin);
|
||||
try {
|
||||
const findPath = await new Promise<string>((resolve) => {
|
||||
const child = spawn(uvBin, ['python', 'find', '3.12'], {
|
||||
shell: process.platform === 'win32',
|
||||
env: {
|
||||
...process.env,
|
||||
...uvEnv,
|
||||
},
|
||||
const child = spawn(verifyShell ? quoteForCmd(uvBin) : uvBin, ['python', 'find', '3.12'], {
|
||||
shell: verifyShell,
|
||||
env: { ...process.env, ...uvEnv },
|
||||
windowsHide: true,
|
||||
});
|
||||
let output = '';
|
||||
child.stdout?.on('data', (data) => { output += data; });
|
||||
child.on('close', () => resolve(output.trim()));
|
||||
});
|
||||
|
||||
|
||||
if (findPath) {
|
||||
console.log(`✅ Managed Python 3.12 path: ${findPath}`);
|
||||
// Note: uv stores environments in a central cache,
|
||||
// Individual skills will create their own venvs in ~/.cache/uv or similar.
|
||||
logger.info(`Managed Python 3.12 installed at: ${findPath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Could not determine Python path:', err);
|
||||
logger.warn('Could not determine Python path after install:', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Windows shell quoting utilities for child_process.spawn().
|
||||
*
|
||||
* When spawn() is called with `shell: true` on Windows, the command and
|
||||
* arguments are concatenated and passed to cmd.exe. Paths containing spaces
|
||||
* must be wrapped in double-quotes to prevent cmd.exe from splitting them
|
||||
* into separate tokens.
|
||||
*
|
||||
* This module is intentionally dependency-free so it can be unit-tested
|
||||
* without mocking Electron.
|
||||
*/
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Quote a path/value for safe use with Windows cmd.exe (shell: true in spawn).
|
||||
*
|
||||
* When Node.js spawn is called with `shell: true` on Windows, cmd.exe
|
||||
* interprets spaces as argument separators. Wrapping the value in double
|
||||
* quotes prevents this. On non-Windows platforms the value is returned
|
||||
* unchanged so this function can be called unconditionally.
|
||||
*/
|
||||
export function quoteForCmd(value: string): string {
|
||||
if (process.platform !== 'win32') return value;
|
||||
if (!value.includes(' ')) return value;
|
||||
if (value.startsWith('"') && value.endsWith('"')) return value;
|
||||
return `"${value}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a spawn call needs `shell: true` on Windows.
|
||||
*
|
||||
* Full (absolute) paths can be executed directly by the OS via
|
||||
* CreateProcessW, which handles spaces correctly without a shell.
|
||||
* Simple command names (e.g. 'uv', 'node') need shell for PATH/PATHEXT
|
||||
* resolution on Windows.
|
||||
*/
|
||||
export function needsWinShell(bin: string): boolean {
|
||||
if (process.platform !== 'win32') return false;
|
||||
return !path.win32.isAbsolute(bin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare command and args for spawn(), handling Windows paths with spaces.
|
||||
*
|
||||
* Returns the shell option, the (possibly quoted) command, and the
|
||||
* (possibly quoted) args array ready for child_process.spawn().
|
||||
*/
|
||||
export function prepareWinSpawn(
|
||||
command: string,
|
||||
args: string[],
|
||||
forceShell?: boolean,
|
||||
): { shell: boolean; command: string; args: string[] } {
|
||||
const isWin = process.platform === 'win32';
|
||||
const useShell = forceShell ?? (isWin && !path.win32.isAbsolute(command));
|
||||
|
||||
if (!useShell || !isWin) {
|
||||
return { shell: useShell, command, args };
|
||||
}
|
||||
|
||||
return {
|
||||
shell: true,
|
||||
command: quoteForCmd(command),
|
||||
args: args.map(a => quoteForCmd(a)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a module path for NODE_OPTIONS `--require` usage.
|
||||
*
|
||||
* Node parses NODE_OPTIONS using shell-like escaping rules. On Windows,
|
||||
* a quoted path with backslashes (e.g. "C:\Users\...") loses separators
|
||||
* because backslashes are interpreted as escapes. Using forward slashes
|
||||
* keeps the absolute path intact while still being valid on Windows.
|
||||
*/
|
||||
export function normalizeNodeRequirePathForNodeOptions(modulePath: string): string {
|
||||
if (process.platform !== 'win32') return modulePath;
|
||||
return modulePath.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a `--require` preload module path to NODE_OPTIONS safely.
|
||||
*/
|
||||
export function appendNodeRequireToNodeOptions(
|
||||
nodeOptions: string | undefined,
|
||||
modulePath: string,
|
||||
): string {
|
||||
const normalized = normalizeNodeRequirePathForNodeOptions(modulePath);
|
||||
return `${nodeOptions ?? ''} --require "${normalized}"`.trim();
|
||||
}
|
||||
@@ -40,4 +40,26 @@ export default [
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: "CallExpression[callee.type='MemberExpression'][callee.property.name='invoke'][callee.object.type='MemberExpression'][callee.object.property.name='ipcRenderer'][callee.object.object.type='MemberExpression'][callee.object.object.property.name='electron'][callee.object.object.object.name='window']",
|
||||
message: 'Use invokeIpc from @/lib/api-client instead of window.electron.ipcRenderer.invoke.',
|
||||
},
|
||||
{
|
||||
selector: "CallExpression[callee.name='fetch'] Literal[value=/^https?:\\/\\/(127\\.0\\.0\\.1|localhost)(:\\d+)?\\//]",
|
||||
message: 'Do not call local endpoints directly from renderer. Route through host-api/api-client proxies.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/lib/api-client.ts'],
|
||||
rules: {
|
||||
'no-restricted-syntax': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
+71
-58
@@ -1,15 +1,20 @@
|
||||
{
|
||||
"name": "clawx",
|
||||
"version": "0.1.13",
|
||||
"version": "0.2.2",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@discordjs/opus",
|
||||
"@whiskeysockets/baileys",
|
||||
"electron",
|
||||
"esbuild",
|
||||
"koffi",
|
||||
"node-llama-cpp",
|
||||
"protobufjs",
|
||||
"sharp"
|
||||
]
|
||||
],
|
||||
"overrides": {
|
||||
"isbinaryfile": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"description": "ClawX - Graphical AI Assistant based on OpenClaw",
|
||||
"main": "dist-electron/main/index.js",
|
||||
@@ -19,92 +24,100 @@
|
||||
"scripts": {
|
||||
"init": "pnpm install && pnpm run uv:download",
|
||||
"dev": "vite",
|
||||
"build": "vite build && zx scripts/bundle-openclaw.mjs && electron-builder",
|
||||
"build": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder",
|
||||
"build:vite": "vite build",
|
||||
"bundle:openclaw-plugins": "zx scripts/bundle-openclaw-plugins.mjs",
|
||||
"bundle:preinstalled-skills": "zx scripts/bundle-preinstalled-skills.mjs",
|
||||
"lint": "eslint . --fix",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"uv:download": "zx scripts/download-bundled-uv.mjs",
|
||||
"uv:download:mac": "zx scripts/download-bundled-uv.mjs --platform=mac",
|
||||
"uv:download:win": "zx scripts/download-bundled-uv.mjs --platform=win",
|
||||
"uv:download:linux": "zx scripts/download-bundled-uv.mjs --platform=linux",
|
||||
"uv:download:all": "zx scripts/download-bundled-uv.mjs --all",
|
||||
"icons": "zx scripts/generate-icons.mjs",
|
||||
"package": "electron-builder",
|
||||
"package:mac": "vite build && zx scripts/bundle-openclaw.mjs && electron-builder --mac",
|
||||
"package:win": "vite build && zx scripts/bundle-openclaw.mjs && electron-builder --win",
|
||||
"package:linux": "vite build && zx scripts/bundle-openclaw.mjs && electron-builder --linux",
|
||||
"release": "pnpm run uv:download && vite build && electron-builder --publish always",
|
||||
"package": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder",
|
||||
"package:mac": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder --mac",
|
||||
"package:win": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder --win",
|
||||
"package:linux": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder --linux",
|
||||
"release": "pnpm run uv:download && vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder --publish always",
|
||||
"version:patch": "pnpm version patch",
|
||||
"version:minor": "pnpm version minor",
|
||||
"version:major": "pnpm version major",
|
||||
"postversion": "git push && git push --tags"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-progress": "^1.1.1",
|
||||
"@radix-ui/react-radio-group": "^1.2.2",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clawhub": "^0.5.0",
|
||||
"clsx": "^2.1.1",
|
||||
"electron-store": "^11.0.2",
|
||||
"electron-updater": "^6.8.2",
|
||||
"framer-motion": "^12.33.0",
|
||||
"i18next": "^25.8.4",
|
||||
"electron-updater": "^6.8.3",
|
||||
"ms": "^2.1.3",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"posthog-node": "^5.28.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@larksuite/openclaw-lark": "2026.3.10",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sliverp/qqbot": "^1.5.4",
|
||||
"@soimy/dingtalk": "^3.1.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
||||
"@typescript-eslint/parser": "^8.56.0",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@wecom/wecom-openclaw-plugin": "^1.0.6",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"electron": "^40.6.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.0",
|
||||
"framer-motion": "^12.34.2",
|
||||
"globals": "^17.3.0",
|
||||
"i18next": "^25.8.11",
|
||||
"jsdom": "^28.1.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"openclaw": "2026.2.6-3",
|
||||
"openclaw": "2026.3.8",
|
||||
"png2icons": "^2.0.1",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"ws": "^8.19.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/node": "^25.2.1",
|
||||
"@types/react": "^19.2.13",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||
"@typescript-eslint/parser": "^8.54.0",
|
||||
"@vitejs/plugin-react": "^5.1.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"electron": "^40.2.1",
|
||||
"electron-builder": "^26.7.0",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.0",
|
||||
"globals": "^17.3.0",
|
||||
"jsdom": "^28.0.0",
|
||||
"png2icons": "^2.0.1",
|
||||
"postcss": "^8.4.49",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.9.3",
|
||||
"use-stick-to-bottom": "^1.1.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-electron": "^0.29.0",
|
||||
"vite-plugin-electron-renderer": "^0.14.6",
|
||||
"vitest": "^4.0.18",
|
||||
"zustand": "^5.0.11",
|
||||
"zx": "^8.8.5"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
|
||||
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268"
|
||||
}
|
||||
|
||||
Generated
+2732
-2259
File diff suppressed because it is too large
Load Diff
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/sh
|
||||
# OpenClaw CLI — managed by ClawX
|
||||
# Do not edit manually. Regenerated on ClawX updates.
|
||||
|
||||
# Resolve the real path of this script (follow symlinks)
|
||||
SCRIPT="$0"
|
||||
while [ -L "$SCRIPT" ]; do
|
||||
SCRIPT_DIR="$(cd -P "$(dirname "$SCRIPT")" && pwd)"
|
||||
SCRIPT="$(readlink "$SCRIPT")"
|
||||
[ "${SCRIPT#/}" = "$SCRIPT" ] && SCRIPT="$SCRIPT_DIR/$SCRIPT"
|
||||
done
|
||||
SCRIPT_DIR="$(cd -P "$(dirname "$SCRIPT")" && pwd)"
|
||||
|
||||
if [ "$(uname)" = "Darwin" ]; then
|
||||
# macOS: <App>.app/Contents/Resources/cli/openclaw
|
||||
# SCRIPT_DIR = .../Contents/Resources/cli
|
||||
CONTENTS_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
ELECTRON="$CONTENTS_DIR/MacOS/ClawX"
|
||||
CLI="$CONTENTS_DIR/Resources/openclaw/openclaw.mjs"
|
||||
else
|
||||
# Linux: /opt/ClawX/resources/cli/openclaw
|
||||
# SCRIPT_DIR = .../resources/cli
|
||||
INSTALL_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
ELECTRON="$INSTALL_DIR/clawx"
|
||||
CLI="$INSTALL_DIR/resources/openclaw/openclaw.mjs"
|
||||
fi
|
||||
|
||||
if [ ! -f "$ELECTRON" ]; then
|
||||
echo "Error: ClawX executable not found at $ELECTRON" >&2
|
||||
echo "Please reinstall ClawX or remove this script: $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
update)
|
||||
echo "openclaw is managed by ClawX (bundled version)."
|
||||
echo ""
|
||||
echo "To update openclaw, update ClawX:"
|
||||
echo " Open ClawX > Settings > Check for Updates"
|
||||
echo " Or download the latest version from https://claw-x.com"
|
||||
echo ""
|
||||
ELECTRON_RUN_AS_NODE=1 "$ELECTRON" "$CLI" --version 2>/dev/null || true
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
export OPENCLAW_EMBEDDED_IN="ClawX"
|
||||
ELECTRON_RUN_AS_NODE=1 exec "$ELECTRON" "$CLI" "$@"
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
# OpenClaw CLI wrapper for Git Bash / MSYS2 on Windows
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
INSTALL_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
case "$1" in
|
||||
update)
|
||||
echo "openclaw is managed by ClawX (bundled version)."
|
||||
echo ""
|
||||
echo "To update openclaw, update ClawX:"
|
||||
echo " Open ClawX > Settings > Check for Updates"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
export OPENCLAW_EMBEDDED_IN="ClawX"
|
||||
ELECTRON_RUN_AS_NODE=1 exec "$INSTALL_DIR/ClawX.exe" "$INSTALL_DIR/resources/openclaw/openclaw.mjs" "$@"
|
||||
@@ -0,0 +1,25 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
if /i "%1"=="update" (
|
||||
echo openclaw is managed by ClawX ^(bundled version^).
|
||||
echo.
|
||||
echo To update openclaw, update ClawX:
|
||||
echo Open ClawX ^> Settings ^> Check for Updates
|
||||
echo Or download the latest version from https://claw-x.com
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
rem Switch console to UTF-8 so Unicode box-drawing and CJK text render correctly
|
||||
rem on non-English Windows (e.g. Chinese CP936). Save the previous codepage to restore later.
|
||||
for /f "tokens=2 delims=:." %%a in ('chcp') do set /a "_CP=%%a" 2>nul
|
||||
chcp 65001 >nul 2>&1
|
||||
|
||||
set ELECTRON_RUN_AS_NODE=1
|
||||
set OPENCLAW_EMBEDDED_IN=ClawX
|
||||
"%~dp0..\..\ClawX.exe" "%~dp0..\openclaw\openclaw.mjs" %*
|
||||
set _EXIT=%ERRORLEVEL%
|
||||
|
||||
if defined _CP chcp %_CP% >nul 2>&1
|
||||
|
||||
endlocal & exit /b %_EXIT%
|
||||
@@ -0,0 +1,150 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet('add', 'remove')]
|
||||
[string]$Action,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CliDir
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Get-UserPathRegistryValue {
|
||||
$raw = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
$kind = [Microsoft.Win32.RegistryValueKind]::ExpandString
|
||||
|
||||
try {
|
||||
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $false)
|
||||
if ($null -ne $key) {
|
||||
try {
|
||||
$stored = $key.GetValue('Path', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||
if ($null -ne $stored) {
|
||||
$raw = [string]$stored
|
||||
}
|
||||
} catch {
|
||||
# Fallback to Environment API value
|
||||
}
|
||||
|
||||
try {
|
||||
$kind = $key.GetValueKind('Path')
|
||||
} catch {
|
||||
# Keep default ExpandString
|
||||
}
|
||||
$key.Close()
|
||||
}
|
||||
} catch {
|
||||
# Fallback to Environment API value
|
||||
}
|
||||
|
||||
return @{
|
||||
Raw = $raw
|
||||
Kind = $kind
|
||||
}
|
||||
}
|
||||
|
||||
function Normalize-PathEntry {
|
||||
param([string]$Value)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return $Value.Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
|
||||
}
|
||||
|
||||
$pathMeta = Get-UserPathRegistryValue
|
||||
$current = $pathMeta.Raw
|
||||
$entries = @()
|
||||
if (-not [string]::IsNullOrWhiteSpace($current)) {
|
||||
$entries = $current -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
|
||||
}
|
||||
|
||||
$target = Normalize-PathEntry $CliDir
|
||||
$seen = [System.Collections.Generic.HashSet[string]]::new()
|
||||
$nextEntries = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
foreach ($entry in $entries) {
|
||||
$normalized = Normalize-PathEntry $entry
|
||||
if ([string]::IsNullOrWhiteSpace($normalized)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ($normalized -eq $target) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ($seen.Add($normalized)) {
|
||||
$nextEntries.Add($entry.Trim().Trim('"'))
|
||||
}
|
||||
}
|
||||
|
||||
$status = 'already-present'
|
||||
if ($Action -eq 'add') {
|
||||
if ($seen.Add($target)) {
|
||||
$nextEntries.Add($CliDir)
|
||||
$status = 'updated'
|
||||
}
|
||||
} elseif ($entries.Count -ne $nextEntries.Count) {
|
||||
$status = 'updated'
|
||||
}
|
||||
|
||||
$isLikelyCorruptedWrite = (
|
||||
$Action -eq 'add' -and
|
||||
$entries.Count -gt 1 -and
|
||||
$nextEntries.Count -le 1
|
||||
)
|
||||
if ($isLikelyCorruptedWrite) {
|
||||
throw "Refusing to rewrite user PATH: input had $($entries.Count) entries but output has $($nextEntries.Count)."
|
||||
}
|
||||
|
||||
$newPath = if ($nextEntries.Count -eq 0) { $null } else { $nextEntries -join ';' }
|
||||
try {
|
||||
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
|
||||
if ($null -eq $key) {
|
||||
throw 'Unable to open HKCU\Environment for write.'
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($newPath)) {
|
||||
$key.DeleteValue('Path', $false)
|
||||
} else {
|
||||
$kind = if ($pathMeta.Kind -eq [Microsoft.Win32.RegistryValueKind]::String) {
|
||||
[Microsoft.Win32.RegistryValueKind]::String
|
||||
} else {
|
||||
[Microsoft.Win32.RegistryValueKind]::ExpandString
|
||||
}
|
||||
$key.SetValue('Path', $newPath, $kind)
|
||||
}
|
||||
$key.Close()
|
||||
} catch {
|
||||
throw "Failed to write HKCU\\Environment\\Path: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
try {
|
||||
Add-Type -Namespace OpenClaw -Name NativeMethods -MemberDefinition @"
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Auto)]
|
||||
public static extern System.IntPtr SendMessageTimeout(
|
||||
System.IntPtr hWnd,
|
||||
int Msg,
|
||||
System.IntPtr wParam,
|
||||
string lParam,
|
||||
int fuFlags,
|
||||
int uTimeout,
|
||||
out System.IntPtr lpdwResult
|
||||
);
|
||||
"@
|
||||
|
||||
$result = [IntPtr]::Zero
|
||||
[OpenClaw.NativeMethods]::SendMessageTimeout(
|
||||
[IntPtr]0xffff,
|
||||
0x001A,
|
||||
[IntPtr]::Zero,
|
||||
'Environment',
|
||||
0x0002,
|
||||
5000,
|
||||
[ref]$result
|
||||
) | Out-Null
|
||||
} catch {
|
||||
Write-Warning "PATH updated but failed to broadcast environment change: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
Write-Output $status
|
||||
@@ -0,0 +1,3 @@
|
||||
## ClawX Environment
|
||||
|
||||
You are ClawX, a desktop AI assistant application based on OpenClaw. See TOOLS.md for ClawX-specific tool notes (uv, browser automation, etc.).
|
||||
@@ -0,0 +1,13 @@
|
||||
## ClawX Tool Notes
|
||||
|
||||
### uv (Python)
|
||||
|
||||
- `uv` is bundled with ClawX and on PATH. Do NOT use bare `python` or `pip`.
|
||||
- Run scripts: `uv run python <script>` | Install packages: `uv pip install <package>`
|
||||
|
||||
### Browser
|
||||
|
||||
- `browser` tool provides full automation (scraping, form filling, testing) via an isolated managed browser.
|
||||
- Flow: `action="start"` → `action="snapshot"` (see page + get element refs like `e12`) → `action="act"` (click/type using refs).
|
||||
- Open new tabs: `action="open"` with `targetUrl`.
|
||||
- To just open a URL for the user to view, use `shell:openExternal` instead.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 221 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user