mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 08:53:09 +00:00
Compare commits
310
Commits
v0.3.1
...
faster-e2e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9c2e8eb6f | ||
|
|
46ff98c219 | ||
|
|
813746f5cc | ||
|
|
c4346a4b02 | ||
|
|
a9088496f5 | ||
|
|
1875dac040 | ||
|
|
3f2cd9345f | ||
|
|
bbbf6d5bb8 | ||
|
|
6dd39e8f7c | ||
|
|
d5ac6ca5e5 | ||
|
|
cdf75da7ce | ||
|
|
8508c0cd8c | ||
|
|
164489da97 | ||
|
|
f7e025cb20 | ||
|
|
b564d4a57a | ||
|
|
0f152e022b | ||
|
|
8335ca329c | ||
|
|
d0dfe84463 | ||
|
|
2679dc7aee | ||
|
|
76f22a0e8e | ||
|
|
9591deba7e | ||
|
|
04f57b286e | ||
|
|
2a4426d4ff | ||
|
|
82b7f445af | ||
|
|
230ac15cbf | ||
|
|
3a241cf09f | ||
|
|
863f9caf0f | ||
|
|
017749b169 | ||
|
|
4318fdc3f1 | ||
|
|
960f6b298d | ||
|
|
8034c5ad31 | ||
|
|
1f26cd205d | ||
|
|
9cdd501a80 | ||
|
|
26d20fdb35 | ||
|
|
378e01ee1e | ||
|
|
7cb6eb240c | ||
|
|
cd0d95ad69 | ||
|
|
d501bad05d | ||
|
|
5d0099abaa | ||
|
|
951ca13db8 | ||
|
|
c96b2336c6 | ||
|
|
a42a7e4256 | ||
|
|
ff61f5dd72 | ||
|
|
91836cd6bc | ||
|
|
fb19cc61c5 | ||
|
|
0fa869f1cc | ||
|
|
33bcf654eb | ||
|
|
7774d89c66 | ||
|
|
d741364c74 | ||
|
|
502d9d5367 | ||
|
|
1e52b0048a | ||
|
|
fc87e00323 | ||
|
|
a9d9376fe0 | ||
|
|
1a46e1f1d5 | ||
|
|
51655b91e1 | ||
|
|
702f03e055 | ||
|
|
c7d4345b6e | ||
|
|
b598e2482d | ||
|
|
3d134a563a | ||
|
|
ff9024fb5d | ||
|
|
4a74b22796 | ||
|
|
bcec47f600 | ||
|
|
d7671a42e6 | ||
|
|
f44c2250fb | ||
|
|
d6c770e4a5 | ||
|
|
e84f13c876 | ||
|
|
a0509b9b54 | ||
|
|
e09dd289db | ||
|
|
2a1e2adde3 | ||
|
|
6ea53bdeb1 | ||
|
|
f3689894bf | ||
|
|
33694efe84 | ||
|
|
22af7468d7 | ||
|
|
cb8c3cfd02 | ||
|
|
e42307cbd3 | ||
|
|
ffe50ff8a5 | ||
|
|
d223af5747 | ||
|
|
b0db926874 | ||
|
|
05ea50a834 | ||
|
|
808f4a840d | ||
|
|
d9ffbca856 | ||
|
|
fcfb72b103 | ||
|
|
ae91ba3c05 | ||
|
|
dd92cf400b | ||
|
|
d246860ef1 | ||
|
|
66692f1777 | ||
|
|
1fd3c50c3e | ||
|
|
bf038f5ca4 | ||
|
|
07c5c4cfcc | ||
|
|
71ea96a881 | ||
|
|
1e2fc7e404 | ||
|
|
65a8a906a6 | ||
|
|
581981f203 | ||
|
|
48d80bf660 | ||
|
|
277c21c91a | ||
|
|
580b213030 | ||
|
|
9e531d1229 | ||
|
|
8ea306d91c | ||
|
|
840fe62182 | ||
|
|
7435e9ce29 | ||
|
|
e47fce4ae4 | ||
|
|
d27faa5a00 | ||
|
|
83317210f6 | ||
|
|
e91b6774d4 | ||
|
|
aa7f1f72fc | ||
|
|
c25c7fc432 | ||
|
|
73e68f769d | ||
|
|
55ab4d5a7c | ||
|
|
c16b428651 | ||
|
|
6e305e6dba | ||
|
|
97dca873a1 | ||
|
|
5b0c1c9909 | ||
|
|
a87a86df50 | ||
|
|
1830bbe787 | ||
|
|
800932507b | ||
|
|
29de7e15c2 | ||
|
|
e97af800b3 | ||
|
|
1e64cf2230 | ||
|
|
be7f8fc768 | ||
|
|
39c8400421 | ||
|
|
b7d0ca0c53 | ||
|
|
8635f0f5eb | ||
|
|
e79a4f923e | ||
|
|
d15e2330d5 | ||
|
|
219db5ff78 | ||
|
|
21c1ba2e33 | ||
|
|
d596ed5b50 | ||
|
|
0da6a24f55 | ||
|
|
3659179fc9 | ||
|
|
94de6fb08e | ||
|
|
3178f96c55 | ||
|
|
b4588c5aba | ||
|
|
f474bd41ac | ||
|
|
fb2d9e312a | ||
|
|
0fb460efca | ||
|
|
d9f42930d7 | ||
|
|
2f7466e2dd | ||
|
|
dbe9d3c9bb | ||
|
|
1aa4776ea5 | ||
|
|
0d5323360c | ||
|
|
3de1fed446 | ||
|
|
035ee8e32c | ||
|
|
4136b04c42 | ||
|
|
566a2d752d | ||
|
|
a417d4ec6b | ||
|
|
8af070a7a0 | ||
|
|
8df7eee0c1 | ||
|
|
f4d6478f1c | ||
|
|
fdc0b0d746 | ||
|
|
dea38c0c44 | ||
|
|
0ee8f5e202 | ||
|
|
60b5209cf6 | ||
|
|
3edeb8cdfa | ||
|
|
34bfae2851 | ||
|
|
360b6649ba | ||
|
|
75343d58d1 | ||
|
|
160160ecde | ||
|
|
80251a5825 | ||
|
|
b0e55692f5 | ||
|
|
ed1525e25b | ||
|
|
4865afc6e6 | ||
|
|
42d62f5002 | ||
|
|
62c11d2441 | ||
|
|
58aa88b3ae | ||
|
|
2327f0ba6a | ||
|
|
7dc18bb6e7 | ||
|
|
5c1ed5e6ee | ||
|
|
abce8a8b96 | ||
|
|
087b38a39a | ||
|
|
cbf70fb299 | ||
|
|
8a8cd73919 | ||
|
|
a7f4552be6 | ||
|
|
c1ba38c71b | ||
|
|
294c88f739 | ||
|
|
51b86bdded | ||
|
|
15e8fe8e05 | ||
|
|
c6adf36b57 | ||
|
|
05eb54edb6 | ||
|
|
50e88f6789 | ||
|
|
8ba82e23f9 | ||
|
|
4e4ebd1092 | ||
|
|
35ae535646 | ||
|
|
fc13237023 | ||
|
|
9ada49afb3 | ||
|
|
0c8dc8cb72 | ||
|
|
59579e0852 | ||
|
|
654468b26c | ||
|
|
e516394580 | ||
|
|
5bc994ada7 | ||
|
|
5c2e689cb4 | ||
|
|
0c10c729ee | ||
|
|
0f0ec50e91 | ||
|
|
8c9b4ea670 | ||
|
|
d47101ab4c | ||
|
|
cc7992e160 | ||
|
|
62d83b401f | ||
|
|
110b473080 | ||
|
|
e6c18022a5 | ||
|
|
e545fa2194 | ||
|
|
d18985712c | ||
|
|
f88d6bc17b | ||
|
|
1fc1e20ec4 | ||
|
|
54b75ded19 | ||
|
|
a74db571d6 | ||
|
|
5c97044865 | ||
|
|
9d3a59dc0e | ||
|
|
91c6a26115 | ||
|
|
ebeb2893a1 | ||
|
|
9c930b2198 | ||
|
|
e95c72f79a | ||
|
|
f9361e686a | ||
|
|
ceb537f7bd | ||
|
|
04e234bbde | ||
|
|
9a360eb43f | ||
|
|
89dd765fd6 | ||
|
|
4271419abb | ||
|
|
01fd010a0e | ||
|
|
f893f2b21b | ||
|
|
ae9af725b2 | ||
|
|
42a26c41a2 | ||
|
|
31d4531327 | ||
|
|
430d1d0603 | ||
|
|
5137e706c9 | ||
|
|
4b14f977fa | ||
|
|
1b75fec71c | ||
|
|
956e943072 | ||
|
|
d0c79f6a0a | ||
|
|
eda34ad9ce | ||
|
|
c29ff4dd33 | ||
|
|
36282fce35 | ||
|
|
285f8202c7 | ||
|
|
1ed9f77a29 | ||
|
|
92144ab639 | ||
|
|
9a1575114d | ||
|
|
7fa4852c1d | ||
|
|
1b2dccee6e | ||
|
|
2f03aa1fad | ||
|
|
3a424ef692 | ||
|
|
24b43335f8 | ||
|
|
6d67a77633 | ||
|
|
4ac4e12aad | ||
|
|
48415db990 | ||
|
|
396e9e0b03 | ||
|
|
b884db629e | ||
|
|
2fefbf3aba | ||
|
|
1f39d1a8a7 | ||
|
|
6acd8acf5a | ||
|
|
a20a97ad83 | ||
|
|
30bd8c08f9 | ||
|
|
758a8f8c94 | ||
|
|
54ec784545 | ||
|
|
03c40985e1 | ||
|
|
5482acd43d | ||
|
|
b2c478d554 | ||
|
|
4ff6861042 | ||
|
|
87ab12849c | ||
|
|
49518300dc | ||
|
|
66b2ddb2dc | ||
|
|
fc9e37c4a3 | ||
|
|
467fcf7e92 | ||
|
|
96c9f6fe5b | ||
|
|
d03902dd4d | ||
|
|
c1e165d48d | ||
|
|
19b3ea974b | ||
|
|
3b629e8db4 | ||
|
|
0b3152f7f2 | ||
|
|
25b13ab912 | ||
|
|
32d14b8cf9 | ||
|
|
3021ad5089 | ||
|
|
97d29ab23c | ||
|
|
c3a735a49c | ||
|
|
d8750e135b | ||
|
|
413244522e | ||
|
|
0cdd12cd40 | ||
|
|
c866205eac | ||
|
|
91c735c9f4 | ||
|
|
e640316382 | ||
|
|
83f67e1ed3 | ||
|
|
1d2cbf8f26 | ||
|
|
42d6cec1e0 | ||
|
|
28508bc643 | ||
|
|
560ae95611 | ||
|
|
d3112f28d8 | ||
|
|
b9fd5f6a78 | ||
|
|
fa2131ab13 | ||
|
|
c14bdfa204 | ||
|
|
7e2c4d3835 | ||
|
|
06266cb4d2 | ||
|
|
5a3da41562 | ||
|
|
34bbb039d3 | ||
|
|
ca92d7fa2c | ||
|
|
d34a88e629 | ||
|
|
bf5b089158 | ||
|
|
eb948d5820 | ||
|
|
ec8db0be75 | ||
|
|
aa2e4eae14 | ||
|
|
e988258c59 | ||
|
|
ef3cf64484 | ||
|
|
47c9560b9e | ||
|
|
91a86a4a76 | ||
|
|
870abb99c4 | ||
|
|
2668082809 | ||
|
|
514a6c4112 | ||
|
|
9b56d80d22 | ||
|
|
07f3c310b5 | ||
|
|
15a3faa996 | ||
|
|
77d6879b16 | ||
|
|
50bcfeccfc | ||
|
|
1292e9f120 | ||
|
|
aa98e59317 |
@@ -25,9 +25,52 @@ jobs:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Prefer HTTPS for public GitHub git dependencies
|
||||
run: |
|
||||
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
|
||||
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# The runtime compatibility test executes Electron to assert its embedded
|
||||
# Node and SQLite versions, so this job cannot rely on the package alone.
|
||||
# Use the same extraction path as Electron E2E because install.js can
|
||||
# leave a partially extracted dist directory on GitHub-hosted runners.
|
||||
- name: Install Electron binary for runtime compatibility test
|
||||
shell: bash
|
||||
env:
|
||||
force_no_cache: 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
unset ELECTRON_SKIP_BINARY_DOWNLOAD
|
||||
ELECTRON_DIR="$(node -p "require('path').dirname(require.resolve('electron/package.json'))")"
|
||||
echo "Electron package dir: $ELECTRON_DIR"
|
||||
rm -rf "$ELECTRON_DIR/dist" "$ELECTRON_DIR/path.txt"
|
||||
mkdir -p "$ELECTRON_DIR/dist"
|
||||
|
||||
ZIP="$(cd "$ELECTRON_DIR" && node -e "
|
||||
const { downloadArtifact } = require('@electron/get');
|
||||
const { version } = require('./package.json');
|
||||
downloadArtifact({ version, artifactName: 'electron', force: true })
|
||||
.then((z) => { process.stdout.write(z); process.exit(0); })
|
||||
.catch((e) => { console.error(e); process.exit(1); });
|
||||
")"
|
||||
ZIP_SIZE="$(stat -c%s "$ZIP")"
|
||||
echo "Downloaded zip: $ZIP ($ZIP_SIZE bytes)"
|
||||
|
||||
unzip -oq "$ZIP" -d "$ELECTRON_DIR/dist"
|
||||
echo "Extracted top-level entries: $(ls -1 "$ELECTRON_DIR/dist" | wc -l | tr -d ' ')"
|
||||
if [ -f "$ELECTRON_DIR/dist/electron.d.ts" ]; then
|
||||
mv "$ELECTRON_DIR/dist/electron.d.ts" "$ELECTRON_DIR/electron.d.ts"
|
||||
fi
|
||||
test -f "$ELECTRON_DIR/dist/electron"
|
||||
chmod +x "$ELECTRON_DIR/dist/electron"
|
||||
printf '%s' 'electron' > "$ELECTRON_DIR/path.txt"
|
||||
|
||||
- name: Generate extension bridge
|
||||
run: pnpm run ext:bridge
|
||||
|
||||
- name: Run linter
|
||||
run: pnpm run lint
|
||||
|
||||
@@ -37,6 +80,18 @@ jobs:
|
||||
- name: Run tests
|
||||
run: pnpm run test
|
||||
|
||||
- name: Run harness checks
|
||||
run: pnpm run harness:ci
|
||||
|
||||
- name: Upload harness artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: harness-artifacts
|
||||
path: artifacts/harness
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
@@ -54,8 +109,19 @@ jobs:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Prefer HTTPS for public GitHub git dependencies
|
||||
run: |
|
||||
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
|
||||
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test Windows attachment open-with bridge
|
||||
run: pnpm exec vitest run tests/unit/attachment-open-with.test.ts tests/unit/attachment-open-with-native.test.ts tests/unit/safe-fs.test.ts
|
||||
|
||||
- name: Generate extension bridge
|
||||
run: pnpm run ext:bridge
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build:vite
|
||||
|
||||
@@ -35,6 +35,11 @@ jobs:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Prefer HTTPS for public GitHub git dependencies
|
||||
run: |
|
||||
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
|
||||
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
name: Electron E2E
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
electron-e2e:
|
||||
name: Electron E2E (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
env:
|
||||
CI: 'true'
|
||||
CLAWX_E2E_WORKERS: '2'
|
||||
# Linux runners cannot use Electron's setuid chrome-sandbox; harmless on macOS/Windows.
|
||||
ELECTRON_DISABLE_SANDBOX: '1'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Prefer HTTPS for public GitHub git dependencies
|
||||
run: |
|
||||
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
|
||||
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
echo "side-effects-cache=false" >> .npmrc
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# electron/install.js and extract-zip both leave dist half-extracted on CI
|
||||
# (114MB zip downloads OK; only LICENSE + LICENSES.chromium.html land in dist).
|
||||
# Download with @electron/get, extract with the OS unzip tool instead.
|
||||
- name: Install Electron binary (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
force_no_cache: 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
unset ELECTRON_SKIP_BINARY_DOWNLOAD
|
||||
ELECTRON_DIR="$(node -p "require('path').dirname(require.resolve('electron/package.json'))")"
|
||||
echo "Electron package dir: $ELECTRON_DIR"
|
||||
rm -rf "$ELECTRON_DIR/dist" "$ELECTRON_DIR/path.txt"
|
||||
mkdir -p "$ELECTRON_DIR/dist"
|
||||
|
||||
ZIP="$(cd "$ELECTRON_DIR" && node -e "
|
||||
const { downloadArtifact } = require('@electron/get');
|
||||
const { version } = require('./package.json');
|
||||
downloadArtifact({ version, artifactName: 'electron', force: true })
|
||||
.then((z) => { process.stdout.write(z); process.exit(0); })
|
||||
.catch((e) => { console.error(e); process.exit(1); });
|
||||
")"
|
||||
ZIP_SIZE="$(stat -c%s "$ZIP" 2>/dev/null || stat -f%z "$ZIP")"
|
||||
echo "Downloaded zip: $ZIP ($ZIP_SIZE bytes)"
|
||||
|
||||
unzip -oq "$ZIP" -d "$ELECTRON_DIR/dist"
|
||||
echo "Extracted top-level entries: $(ls -1 "$ELECTRON_DIR/dist" | wc -l | tr -d ' ')"
|
||||
|
||||
if [ -f "$ELECTRON_DIR/dist/electron.d.ts" ]; then
|
||||
mv "$ELECTRON_DIR/dist/electron.d.ts" "$ELECTRON_DIR/electron.d.ts"
|
||||
fi
|
||||
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
PLATFORM_PATH='Electron.app/Contents/MacOS/Electron'
|
||||
test -f "$ELECTRON_DIR/dist/Electron.app/Contents/MacOS/Electron"
|
||||
else
|
||||
PLATFORM_PATH='electron'
|
||||
test -f "$ELECTRON_DIR/dist/electron"
|
||||
chmod +x "$ELECTRON_DIR/dist/electron"
|
||||
fi
|
||||
printf '%s' "$PLATFORM_PATH" > "$ELECTRON_DIR/path.txt"
|
||||
echo "path.txt: $(cat "$ELECTRON_DIR/path.txt")"
|
||||
|
||||
- name: Install Electron binary (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
force_no_cache: 'true'
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Remove-Item Env:ELECTRON_SKIP_BINARY_DOWNLOAD -ErrorAction SilentlyContinue
|
||||
$electronDir = node -p "require('path').dirname(require.resolve('electron/package.json'))"
|
||||
Write-Host "Electron package dir: $electronDir"
|
||||
Remove-Item -Recurse -Force "$electronDir\dist", "$electronDir\path.txt" -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path "$electronDir\dist" | Out-Null
|
||||
|
||||
Push-Location $electronDir
|
||||
try {
|
||||
$zip = node -e "const { downloadArtifact } = require('@electron/get'); const { version } = require('./package.json'); downloadArtifact({ version, artifactName: 'electron', force: true }).then((z) => { process.stdout.write(z); process.exit(0); }).catch((e) => { console.error(e); process.exit(1); });"
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
$zipSize = (Get-Item -LiteralPath $zip).Length
|
||||
Write-Host "Downloaded zip: $zip ($zipSize bytes)"
|
||||
|
||||
Expand-Archive -LiteralPath $zip -DestinationPath "$electronDir\dist" -Force
|
||||
$entryCount = (Get-ChildItem -LiteralPath "$electronDir\dist").Count
|
||||
Write-Host "Extracted top-level entries: $entryCount"
|
||||
|
||||
$typeDef = Join-Path $electronDir 'dist\electron.d.ts'
|
||||
if (Test-Path -LiteralPath $typeDef) {
|
||||
Move-Item -LiteralPath $typeDef -Destination (Join-Path $electronDir 'electron.d.ts') -Force
|
||||
}
|
||||
|
||||
$exe = Join-Path $electronDir 'dist\electron.exe'
|
||||
if (-not (Test-Path -LiteralPath $exe)) {
|
||||
throw "electron.exe missing after extract: $exe"
|
||||
}
|
||||
Set-Content -LiteralPath (Join-Path $electronDir 'path.txt') -Value 'electron.exe' -NoNewline
|
||||
Write-Host "path.txt: electron.exe"
|
||||
|
||||
- name: Verify Electron binary
|
||||
run: pnpm exec electron --version
|
||||
|
||||
- name: Generate extension bridge
|
||||
run: pnpm run ext:bridge
|
||||
|
||||
- name: Run Electron E2E on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: xvfb-run -a pnpm run test:e2e
|
||||
|
||||
- name: Run Electron E2E on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: pnpm run test:e2e
|
||||
|
||||
- name: Run Electron E2E on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: pnpm run test:e2e
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-artifacts-${{ matrix.os }}
|
||||
path: |
|
||||
playwright-report
|
||||
test-results
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Harness
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'harness/**'
|
||||
- 'tests/unit/harness-specs.test.ts'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- '.github/workflows/harness.yml'
|
||||
|
||||
jobs:
|
||||
harness:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: '1'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Prefer HTTPS for public GitHub git dependencies
|
||||
run: |
|
||||
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
|
||||
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run harness CI checks
|
||||
run: pnpm run harness:ci
|
||||
|
||||
- name: Upload harness artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: harness-artifacts
|
||||
path: artifacts/harness
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
@@ -43,12 +43,20 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Prefer HTTPS for public GitHub git dependencies
|
||||
run: |
|
||||
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
|
||||
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Download uv binaries for Windows
|
||||
run: pnpm run uv:download:win
|
||||
|
||||
- name: Download agent-browser binaries for Windows
|
||||
run: pnpm run agent-browser:download:win
|
||||
|
||||
- name: Build Windows package (no publish)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -18,7 +18,18 @@ permissions:
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
# Fails fast on tag pushes if package.json "version" does not match the tag.
|
||||
validate-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Assert tag matches package.json
|
||||
run: node scripts/assert-tag-matches-package.mjs
|
||||
|
||||
release:
|
||||
needs: validate-release
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
@@ -46,6 +57,11 @@ jobs:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Prefer HTTPS for public GitHub git dependencies
|
||||
run: |
|
||||
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
|
||||
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -61,6 +77,18 @@ jobs:
|
||||
if: matrix.platform == 'linux'
|
||||
run: pnpm run uv:download:linux
|
||||
|
||||
- name: Download agent-browser binaries for macOS
|
||||
if: matrix.platform == 'mac'
|
||||
run: pnpm run agent-browser:download:mac
|
||||
|
||||
- name: Download agent-browser binaries for Windows
|
||||
if: matrix.platform == 'win'
|
||||
run: pnpm run agent-browser:download:win
|
||||
|
||||
- name: Download agent-browser binaries for Linux
|
||||
if: matrix.platform == 'linux'
|
||||
run: pnpm run agent-browser:download:linux
|
||||
|
||||
|
||||
# macOS specific steps
|
||||
- name: Free disk space (macOS)
|
||||
|
||||
@@ -31,12 +31,20 @@ jobs:
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Prefer HTTPS for public GitHub git dependencies
|
||||
run: |
|
||||
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
|
||||
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Download uv binaries for Windows
|
||||
run: pnpm run uv:download:win
|
||||
|
||||
- name: Download agent-browser binaries for Windows
|
||||
run: pnpm run agent-browser:download:win
|
||||
|
||||
- name: Build Windows
|
||||
run: pnpm run package:win
|
||||
|
||||
|
||||
+22
@@ -39,6 +39,11 @@ desktop.ini
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# Local session transcript fixtures (may contain private conversation data)
|
||||
tests/fixtures/transcripts/
|
||||
|
||||
# Cache
|
||||
.cache/
|
||||
@@ -61,7 +66,24 @@ resources/bin
|
||||
|
||||
build/
|
||||
artifacts/
|
||||
.delivery/
|
||||
docs/pr-session-notes-*.md
|
||||
|
||||
.cursor/
|
||||
.claude/
|
||||
.pnpm-store/
|
||||
package-lock.json
|
||||
|
||||
# Generated extension bridges (created by scripts/generate-ext-bridge.mjs)
|
||||
electron/extensions/_ext-bridge.generated.ts
|
||||
src/extensions/_ext-bridge.generated.ts
|
||||
|
||||
# Local playground artifacts
|
||||
playground/
|
||||
|
||||
# ClawX-biz bridge workspace artifacts
|
||||
resources/enterprise-skills/
|
||||
resources/openclaw-plugins/skillshub/
|
||||
|
||||
.opencode
|
||||
.superpowers
|
||||
|
||||
+3
-3
@@ -2,6 +2,6 @@
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100
|
||||
}
|
||||
"trailingComma": "all",
|
||||
"printWidth": 120
|
||||
}
|
||||
@@ -21,21 +21,27 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
|
||||
| Comms baseline refresh | `pnpm run comms:baseline` |
|
||||
| Comms regression compare | `pnpm run comms:compare` |
|
||||
| E2E tests (Playwright) | `pnpm run test:e2e` |
|
||||
| Chat performance profiles | `pnpm run perf:chat` |
|
||||
| Electron Main inspector | `pnpm run profile:main` |
|
||||
| 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).
|
||||
- **Performance profiling**: `pnpm run perf:chat` writes synthetic Renderer/Main CPU profiles and versioned metrics under ignored Playwright `test-results/`. For live Renderer CDP use `CLAWX_REMOTE_DEBUGGING_PORT=9223 pnpm dev`; for live Main inspection use `pnpm run profile:main` and port 9229.
|
||||
- **E2E parallel isolation**: Functional Electron specs run concurrently with `CLAWX_E2E_WORKERS=2` by default. Keep tests parallel-safe and test-scoped; apply `E2E_EXCLUSIVE_TAG` from `tests/e2e/parallel-policy.ts` to tests that use the real clipboard or other OS-global state, and `E2E_PERFORMANCE_TAG` to host performance profiles. Extend `tests/unit/e2e-parallel-policy.test.ts` for recognizable new global APIs.
|
||||
- **`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.
|
||||
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, scans both configured agents and any runtime agent directories found on disk, and treats normal, `.deleted.jsonl`, and `.jsonl.reset.*` transcripts as valid history sources. It extracts assistant/tool usage records with `message.usage` and aggregates fields such as input/output/cache/total tokens and cost from those structured records. Note: "Delete conversation" in the sidebar is a hard delete — the Main process unlinks `<id>.jsonl` plus any leftover `<id>.deleted.jsonl` and `<id>.jsonl.reset.*` siblings, *and* OpenClaw's trajectory artefacts (`<id>.trajectory.jsonl` flight recorder + `<id>.trajectory-path.json` pointer); when the pointer references a runtime file outside the agent's `sessions/` folder (the `OPENCLAW_TRAJECTORY_DIR` override), that off-disk file is unlinked too. Deleted conversations stop contributing to this chart — use a fresh session if you want history retained.
|
||||
- **Models page aggregation**: The 7-day/30-day filters are relative rolling windows, not calendar-month buckets. When grouped by time, the chart should keep all day buckets in the selected window; only model grouping is intentionally capped to the top entries.
|
||||
- **OpenClaw Doctor in UI**: In Settings > Advanced > Developer, the app exposes both `Run Doctor` (`openclaw doctor --json`) and `Run Doctor Fix` (`openclaw doctor --fix --yes --non-interactive`) through the host-api. Renderer code should call the host route, not spawn CLI processes directly.
|
||||
- **UI change validation**: Any user-visible UI change should include or update an Electron E2E spec in the same PR so the interaction is covered by Playwright.
|
||||
- **i18n & styling conventions**: New user-facing features must (1) route all text through `react-i18next` with full locale coverage (`en` / `zh` / `ja` / `ru` under `shared/i18n/locales/<lang>/<ns>.json`) — never hardcode display strings, and (2) use the design tokens and substitution rules documented in `src/styles/globals.css` (surfaces `bg-surface-modal` / `bg-surface-input`, selected state `bg-black/5 dark:bg-white/10`, status colours `text-X-700 dark:text-X-400`, page H1/H2 `font-serif font-normal tracking-tight`, etc.) — see the *Component conventions* block in `globals.css` for the full substitution table.
|
||||
- **Renderer/Main API boundary (important)**:
|
||||
- Renderer must use `src/lib/host-api.ts` and `src/lib/api-client.ts` as the single entry for backend calls.
|
||||
- Do not add new direct `window.electron.ipcRenderer.invoke(...)` calls in pages/components; expose them through host-api/api-client instead.
|
||||
@@ -43,3 +49,7 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
|
||||
- Transport policy is Main-owned and fixed as `WS -> HTTP -> IPC fallback`; renderer should not implement protocol switching UI/business logic.
|
||||
- **Comms-change checklist**: If your change touches communication paths (gateway events, runtime send/receive, delivery, or fallback), run `pnpm run comms:replay` and `pnpm run comms:compare` before pushing.
|
||||
- **Doc sync rule**: After any functional or architecture change, review `README.md`, `README.zh-CN.md`, and `README.ja-JP.md` for required updates; if behavior/flows/interfaces changed, update docs in the same PR/commit.
|
||||
- **Spec-driven harness rule**: AI Coding tasks that touch backend communication must start from a task spec under `harness/specs/tasks/` and reference `gateway-backend-communication` when the change involves renderer/Main/host-api/api-client/Gateway/OpenClaw runtime paths. Run `pnpm harness validate --spec <task-spec>` before implementation review, and `pnpm harness run --spec <task-spec>` or `--dry-run` when checking the selected validation flow.
|
||||
- **Spec/rule growth rule**: When adding a new feature, user-visible OpenClaw scenario, or recurring AI Coding constraint, add or update the relevant harness scenario spec and rule spec in the same PR so future AI work can validate the behavior instead of relying on tribal knowledge.
|
||||
- **Harness CI/local parity**: Run `pnpm run harness:ci` to exercise the same baseline harness checks used by GitHub Actions. Real task specs should be validated without `--no-diff`; `--no-diff` is only for structural checks of checked-in examples.
|
||||
- **Harness reference docs**: Keep durable, non-executable architecture and compatibility details under `harness/reference/`. Link them from the relevant scenario, rule, and task specs, but do not pass reference documents to `harness validate` or `harness run`.
|
||||
|
||||
+120
-54
@@ -30,7 +30,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | 日本語
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | 日本語 | <a href="README.ru-RU.md">Русский</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -43,6 +43,8 @@
|
||||
|
||||
ClawXはベストプラクティスのモデルプロバイダーが事前設定されており、Windowsおよび多言語設定をネイティブにサポートしています。もちろん、**設定 → 詳細設定 → 開発者モード**から高度な設定を微調整することもできます。
|
||||
|
||||
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">完全なエンタープライズ版、専用のサービスサポート、または御社のビジネスシナリオに合わせた導入支援が必要な場合は、<a href="mailto:public@valuecell.ai">public@valuecell.ai</a> までお問い合わせください。</strong></p>
|
||||
|
||||
---
|
||||
## スクリーンショット
|
||||
|
||||
@@ -81,6 +83,7 @@ AIエージェントの構築にコマンドラインの習得は不要である
|
||||
| 複雑なCLIセットアップ | ワンクリックインストールとガイド付きセットアップウィザード |
|
||||
| 設定ファイル | リアルタイムバリデーション付きのビジュアル設定 |
|
||||
| プロセス管理 | ゲートウェイライフサイクルの自動管理 |
|
||||
| アプリ更新 | 起動時に更新を確認し、ダウンロードやインストール前に通知 |
|
||||
| 複数のAIプロバイダー | 統合プロバイダー設定パネル |
|
||||
| スキル/プラグインのインストール | 組み込みのスキルマーケットプレイスと管理機能 |
|
||||
|
||||
@@ -98,32 +101,43 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
|
||||
インストールから最初のAIインタラクションまで、すべてのセットアップを直感的なグラフィカルインターフェースで完了できます。ターミナルコマンド不要、YAMLファイル不要、環境変数の探索も不要です。
|
||||
|
||||
### 💬 インテリジェントチャットインターフェース
|
||||
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングに加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
|
||||
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴に加え、シンタックスハイライト付きのフェンスコード、CJK 対応の解析、GitHub 風テーブル、KaTeX による LaTeX 数式(`$インライン$`、`$$ブロック$$`、`\(インライン\)`、`\[ブロック\]`)を含む、エージェント応答のストリーミング Markdown レンダリングをサポートします。ユーザー入力は常にプレーンテキストとして表示します。さらに、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。フェンスコードはソースの改行を保持し、長い行はソフトラップされ、ストリーミング完了後にローカライズされたコピー操作を利用できます。
|
||||
コンポーザーから挿入した Skill は `/skill-name` 形式のチップとして表示され、チップをクリックすると右側のプレビュー側欄でその Skill の `SKILL.md` を開けます。
|
||||
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
|
||||
セッション側欄はワークスペース優先で整理され、既定ワークスペースを先頭に固定し、その他のワークスペースは自然順に並べます。各ワークスペースは折りたたみや追加読み込みができます。AI の返信中は行にスピナーが表示され、未確認の返信が完了すると青い点に変わり、会話を開くと相対アクティビティ時刻に戻ります。ホバーすると引き続き操作ボタンが表示されます。インポートしたワークスペースは側欄の見出しから名前を変更でき、新しい名前はチャット入力欄の下にも反映されます。見出しにホバーすると引き続きファイルシステムのパスを確認できます。選択中の会話に有効なワークスペースがある場合、新しいチャットはそれを引き継ぎ、最初の送信までは変更できます。編集可能な新規または未バインドのチャットでは、コンポーザーのワークスペースチップから最近使用したワークスペースと既存セッションのワークスペースの一覧を開き、既定ワークスペースへ戻すか別フォルダーを選べます。保存済みのワークスペースフォルダーが移動または削除されている場合、Chat はセッション作成を一時停止し、無効なパスを繰り返し再試行せずに既存のフォルダーを選ぶよう案内します。利用できない既定以外のグループには側欄で印が付き、確認後に削除できます。この操作ではグループ内の全セッションが完全に削除されます。セッション行の削除と画面遷移は完全削除が成功した後にのみ行われ、失敗した場合は会話と確認ダイアログが保持されるため再試行できます。OpenClaw が生成する UUID と日付のフォールバックタイトルは、そのセッション ID と一致する場合に限って欠落タイトルとして扱い、セッション名として保存せず、会話の最初のユーザーメッセージに置き換えて表示します。
|
||||
各 Agent は `provider/model` の実行時設定を個別に上書きできます。上書きしていない Agent は引き続きグローバルの既定モデルを継承します。
|
||||
|
||||
Chat の右パネルにあるワークスペースとプレビューの各タブでは、Markdown、`.docx`、`.pptx` ファイルを読み取り専用でプレビューできます。Markdown ファイルのプレビューでは、同じシンタックスハイライト、ソフトラップ、コピー操作付きのフェンスコード、CJK 対応の解析、KaTeX 数式を静的レンダリングモードでサポートします。プレビューのヘッダーから選択中のファイルを ClawX の表示領域全体に拡大でき、同じボタンまたは Esc で右パネルへ戻れます。従来形式の `.doc` と `.ppt` はアプリ内ではプレビューせず、引き続き OS 経由で開きます。DOCX のページ区切りは Microsoft Word と異なる場合があり、PPTX プレビューではアニメーション、画面切り替え、メディア再生をサポートしません。20 MB を超える Office ファイルはアプリ内でプレビューされません。
|
||||
|
||||
### ローカル HTML プレビュー
|
||||
Chat の右パネルにはワークスペース、プレビュー、変更だけがあり、汎用ウェブブラウザ、ホーム画面、アドレスバーはありません。許可済みのローカル `.html` / `.htm` 添付ファイル、ファイルアクティビティ、ワークスペースファイルは既定でプレビューに開きます。ファイル操作では ClawX 内蔵プレビューまたはシステムアプリを選択でき、プレビューのヘッダーから現在の HTML ファイルをシステムブラウザで開くこともできます。
|
||||
|
||||
すべてのリンクはクリックできません。ClawX が描画するリンクは通常のテキストとして表示され、HTML プレビュー内のリンクからもリンク装飾とポインター操作が除去されます。フォーム、スクリプトによる移動、リダイレクト、ページ内移動、ポップアップ、ダウンロード、ネットワーク要求、デバイス権限もブロックされます。自己完結したローカル HTML は表示できますが、選択中の文書から移動することはできません。
|
||||
|
||||
### 📡 マルチチャネル管理
|
||||
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。
|
||||
現在は各チャンネルで複数アカウントを扱え、Channels ページでアカウントの Agent 紐付けやデフォルトアカウント切替を直接管理できます。
|
||||
カスタムのチャンネルアカウント ID には、ルーティング不一致を防ぐため OpenClaw 互換の正規形式(`[a-z0-9_-]`、英小文字、最大 64 文字、先頭は英小文字または数字)を必須にしています。
|
||||
ClawX には Tencent 公式の個人 WeChat チャンネルプラグインも同梱されており、Channels ページからアプリ内 QR フローで直接 WeChat を連携できます。
|
||||
|
||||
### ⏰ Cronベースの自動化
|
||||
AIタスクを自動的に実行するようスケジュール設定できます。トリガーを定義し、間隔を設定することで、手動介入なしにAIエージェントを24時間稼働させることができます。
|
||||
定期タスク画面では外部配信を「送信アカウント」と「受信先ターゲット」の 2 段階セレクターで設定できるようになりました。対応チャネルでは、受信先候補をチャネルのディレクトリ機能や既知セッション履歴から自動検出するため、`jobs.json` を手で編集する必要はありません。
|
||||
既知の制限: WeChat は現在、定期タスク配信の対応チャネルから意図的に除外しています。`openclaw-weixin` プラグインの送信処理が、リアルタイム会話で得られる `contextToken` を必要とするため、cron のような能動配信をプラグイン自体がサポートしていません。
|
||||
定期タスク画面では外部配信を「送信アカウント」と「受信先ターゲット」の 2 段階セレクターで設定できるようになりました。対応チャネルでは、受信先候補をチャネルのディレクトリ機能や既知セッション履歴から自動検出するため、`jobs.json` を手で編集する必要はありません。タスクのメッセージ入力欄でも、メインのチャット入力と同じインライン `/skill` トークン記法でスキルを挿入できるようになりました(選択中のエージェントに応じて読み込み)。スケジュールされたプロンプトから直接スキルを起動できます。スケジュール選択は**繰り返し**と**1回のみ**のタブに分かれました。繰り返しは毎時・毎日・平日・毎週・カスタム(生の cron)の頻度を時刻/曜日コントロール付きで選べ、1回のみは選択した日付(曜日を表示)と時刻に一度だけ実行します。1回のみのタスクは未来の時刻を指定する必要があり、実行後はランタイムにより自動的に削除されます。
|
||||
|
||||
|
||||
### 🧩 拡張可能なスキルシステム
|
||||
事前構築されたスキルでAIエージェントを拡張できます。統合スキルパネルからスキルの閲覧、インストール、管理が可能です。パッケージマネージャーは不要です。
|
||||
ClawX はドキュメント処理スキル(`pdf`、`xlsx`、`docx`、`pptx`)もフル内容で同梱し、起動時に管理スキルディレクトリ(既定 `~/.openclaw/skills`)へ自動配備し、初回インストール時に既定で有効化します。追加の同梱スキル(`find-skills`、`self-improving-agent`、`tavily-search`、`brave-web-search`)も既定で有効化されますが、必要な API キーが未設定の場合は OpenClaw が実行時に設定エラーを表示します。
|
||||
Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、workspace、追加スキルディレクトリ)から検出されたスキルを表示でき、各スキルの実際のパスを確認して実フォルダを直接開けます。
|
||||
|
||||
主な検索スキルで必要な環境変数:
|
||||
- `BRAVE_SEARCH_API_KEY`: `brave-web-search` 用
|
||||
- `TAVILY_API_KEY`: `tavily-search` 用(上流ランタイムで OAuth 対応の場合あり)
|
||||
事前構築されたスキルでAIエージェントを拡張できます。統合 Skills ページはローカル優先で、管理ディレクトリや workspace のスキルをスキャンし、Gateway に依存せず有効/無効を切り替えられます。エンタープライズ拡張がある場合は、その拡張が提供する marketplace も表示できます。
|
||||
ClawX はドキュメント処理スキル(`pdf`、`xlsx`、`docx`、`pptx`)もフル内容で同梱し、起動時に管理スキルディレクトリ(既定 `~/.openclaw/skills`)へ自動配備し、初回インストール時に既定で有効化します。
|
||||
Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、workspace、追加スキルディレクトリ)から検出されたスキルを表示でき、各スキルの実際のパスを確認して実フォルダを直接開けます。OpenClaw 同梱の bundled skill については、コミュニティ版ではパッケージにも表示にも `skill-creator` のみを残し、dev 起動時と packaged 起動時の両方で他の bundled skill を物理的に削除します。さらに、削除済み bundled skill の古い `openclaw.json` エントリも一緒に掃除します。
|
||||
|
||||
### 🔐 セキュアなプロバイダー統合
|
||||
複数のAIプロバイダー(OpenAI、Anthropicなど)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuth(Codex サブスクリプション)の両方に対応しています。
|
||||
複数のAIプロバイダー(OpenAI、Anthropic、Z.AI / GLMなど)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuth(Codex サブスクリプション)の両方に対応しています。
|
||||
開発者モードでは、専用の Image Generation ページで、独立した OpenAI 互換の画像生成エンドポイント(Base URL、API キー、`gpt-image-2` などのモデル名)を設定でき、画像生成だけ専用の `/v1/images/generations` サービスを使い、チャットは通常の OpenAI Provider のまま継続できます。
|
||||
OpenAI-compatible ゲートウェイを **Custom プロバイダー** で使う場合、**設定 → AI Providers → Provider 編集** でカスタム `User-Agent` を設定でき、互換性が必要なエンドポイントで有効です。
|
||||
プロバイダーの編集や切り替え時、ClawX は `input: ["text", "image"]` など既存のモデル単位の能力メタデータを保持します。新しく選択した Custom プロバイダーのモデルには OpenClaw onboarding と同等の画像入力推論を適用し、不明なモデルはテキスト専用として扱います。
|
||||
Custom プロバイダーのモデル行には明示的な `contextWindow` も書き込まれ(モデルファミリーから推定、例:`gpt-5.x` → 272k)、旧バージョンで保存された行は起動時に自動補完されます。これにより OpenClaw は長いセッションを "Context overflow" エラーになる前に圧縮できます。compaction 未設定の場合は `agents.defaults.compaction.mode = "safeguard"` と `reserveTokensFloor = 50000` が既定値として設定されますが、ユーザーが自分で設定したモデル行や圧縮設定が変更されることはありません(`reserveTokensFloor` が未設定の場合のみ補完されることがあります)。
|
||||
Z.AI(CN / Global)は OpenClaw 組み込みの `zai` プロバイダー(`ZAI_API_KEY`)に対応し、既定モデルは `glm-5.2` です。Code Plan プリセットで Coding Plan エンドポイント(`…/api/coding/paas/v4`)へ切り替え、通常 API(`…/api/paas/v4`)も利用できます。CN と Global は同じ OpenClaw ランタイムキーを共有するため同時追加できません。
|
||||
互換ゲートウェイで `/models` が認証以外の理由で使えない場合、ClawX は API キー検証時に設定済みモデルを使った軽量な `/chat/completions` または `/responses` プローブへ自動フォールバックします。
|
||||
|
||||
### 🌙 アダプティブテーマ
|
||||
ライトモード、ダークモード、またはシステム同期テーマ。ClawXはあなたの好みに自動的に適応します。
|
||||
@@ -131,6 +145,9 @@ OpenAI-compatible ゲートウェイを **Custom プロバイダー** で使う
|
||||
### 🚀 自動起動設定
|
||||
**設定 → 通用** から **システム起動時に自動起動** を有効化すると、ログイン後に ClawX が自動的に起動します。
|
||||
|
||||
### 🔔 更新通知
|
||||
ClawX は起動時に新しいバージョンを自動確認できます。更新が見つかるとアプリ内通知を表示し、ダウンロードやインストールはユーザーが選択した後にのみ実行されます。
|
||||
|
||||
---
|
||||
|
||||
## はじめに
|
||||
@@ -171,6 +188,9 @@ ClawXを初めて起動すると、**セットアップウィザード**が以
|
||||
|
||||
サポート対象のシステム言語がある場合、ウィザードはその言語を初期選択し、未対応の場合は英語にフォールバックします。
|
||||
|
||||
> Web searchについて:ClawXは、AgentとGatewayの両方のポリシーレイヤーでOpenClawの汎用`web_search`ツールを無効にします。
|
||||
> Moonshot(Kimi)検索も対象です。管理対象のブラウザ自動化と`web_fetch`は引き続き利用できます。
|
||||
|
||||
### プロキシ設定
|
||||
|
||||
ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネルがローカルプロキシクライアントを介してインターネットにアクセスする必要がある環境向けに、組み込みのプロキシ設定が含まれています。
|
||||
@@ -206,58 +226,81 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ
|
||||
|
||||
ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を採用しています。Renderer は単一クライアント抽象を呼び出し、プロトコル選択とライフサイクルは Main が管理します:
|
||||
|
||||
```┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ClawX デスクトップアプリ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Electron メインプロセス │ │
|
||||
│ │ • ウィンドウ&アプリケーションライフサイクル管理 │ │
|
||||
│ │ • ゲートウェイプロセスの監視 │ │
|
||||
│ │ • システム統合(トレイ、通知、キーチェーン) │ │
|
||||
│ │ • 自動アップデートオーケストレーション │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ IPC(権威ある制御プレーン) │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ React レンダラープロセス │ │
|
||||
│ │ • モダンなコンポーネントベースUI(React 19) │ │
|
||||
│ │ • Zustandによるステート管理 │ │
|
||||
│ │ • 統一 host-api/api-client 呼び出し │ │
|
||||
│ │ • リッチなMarkdownレンダリング │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────┬──────────────────────────────────┘
|
||||
OpenClaw の設定配信も Electron Main が一元管理します。Gateway の実行中は `config.get` の正規スナップショットを基準にし、変更を `config.set` でコミットします。Gateway が停止中または起動中の場合は、同じコーディネーターが解決済みの JSON5 設定ファイルだけを更新し、Gateway を起動しません。そのため、通常の Provider、Agent、Channel、バインディング、Skill、モデル変更では Gateway プロセスを置き換えません。完全な再起動は、プロキシなどのプロセス起動環境の変更とユーザーによる明示的な操作に限定されます。確認済みのプロセス終了と WebSocket 切断では、既存の自動再接続経路が引き続き使用されます。WebSocket のハートビート欠落は診断とヘルス状態だけを更新し、Gateway プロセスを置き換えないため、pong 処理の遅延によって長時間実行中の処理が中断されることはありません。認証プロファイルを SQLite に書き込んだ後は OpenClaw の `secrets.reload` を呼び出し、実行中の Agent がプロセス再起動なしで新しい認証情報を読み取れるようにします。
|
||||
|
||||
Chat は Electron Main が所有する ACP stdio bridge を使用します。Renderer は型付き host event を受け取り、メモリ上の ACP timeline を描画します。Gateway は providers、models、skills、workspace、settings、diagnostics、media configuration などの非 Chat 機能を引き続き担当します。
|
||||
|
||||
別の会話やページを開いても、未完了の ACP 応答はストリーミングを継続します。完了前に戻ると最新のメモリ内 timeline が復元され、ライブ応答の表示が続きます。完了後は通常の ACP 履歴リプレイが引き続き唯一の正となります。
|
||||
|
||||
ACP の assistant ターンにはターン全体の所要時間が表示されます。ライブ計時はクライアントが観測した prompt ライフサイクルに従い、アプリ内を移動しても継続します。履歴の所要時間は Electron Main が範囲を限定した OpenClaw transcript のタイムスタンプから算出し、ACP リプレイですでに復元されたターンだけに付与します。
|
||||
|
||||
ACP Chat は標準 ACP resource を添付ファイルとして表示します。ユーザーが選択した画像は、ホバー時のオーバーレイにファイル名を表示するサムネイルとして描画され、その他の利用可能な添付カードはファイル名に続いて、淡色で省略可能なソースパスを表示します。現在の OpenClaw ACP adapter が assistant のメディアを省略した場合も、OpenClaw が永続化した正規メディア情報と明示的な assistant の `MEDIA:` ディレクティブを、transcript 専用メタデータを表示せずに添付カードとして復元できます。現在の workspace 外を含む既存のローカルファイル参照は、プレビューまたはオープンのたびに Electron Main で正確な session と generation に対して再検証されます。AI が生成したプレビュー可能なローカル添付ファイル(20 MB 以下の `.docx` と `.pptx` を含む)は、読み取り専用のアプリ内プレビューを主要操作として維持し、対応アプリで開く操作と Finder、エクスプローラー、またはシステムのファイルマネージャーで表示する操作を副次メニューから利用できます。ローカル HTML 添付ファイルでは、そのメニューの先頭項目が右側のプレビューでファイルを開きます。ここでも Office プレビューには同じ制限があります。`.doc` と `.ppt` はシステムアプリで開く形式のままで、DOCX のページ区切りは Microsoft Word と異なる場合があり、PPTX のアニメーション、画面切り替え、メディア再生はサポートされません。対応アプリの検出は macOS と Windows のみで利用でき、Linux または検出失敗時には通知せず、ファイルの場所を表示する操作だけに切り替わります。それ以外のローカルファイル(20 MB を超える Office ファイルを含む)はユーザーのクリック後にシステムアプリで開かれます。ユーザーが選択したフォルダー添付も送信後に利用可能なまま保持され、クリックするとシステムのファイルマネージャーで開きます。ClawX はフォルダー内を読み取りまたはプレビューしません。リモートの HTTP/HTTPS 添付ファイルはクリック後に外部で開かれます。正規メディア情報がない通常の文章内の単独またはインラインパスは添付ファイルとして扱われません。
|
||||
|
||||
ACP Chat は、runtime が画像生成メディアを信頼できる構造化メディアとして配信した場合に、生成画像のプレビューも表示できます。信頼できる OpenClaw internal-UI 配信と画像生成タスクに関連付けられた最終返信では、テキストのみの失敗説明を含む元のユーザー向け完了テキストを保持し、汎用の画像キャプションへ置き換えません。OpenClaw の履歴リプレイ中は、同じセッションで画像生成タスク開始が記録されている場合に限り、assistant の画像 `MEDIA:` マーカーがインライン画像表示へ昇格されます。ClawX は Renderer から任意にファイルシステムへアクセスするのではなく、Electron Main のホストメディア処理を通じてプレビューを読み込みます。標準 ACP の画像と resource コンテンツは引き続き推奨パスであり、そのまま描画されます。
|
||||
|
||||
### ACP ファイルアクティビティのセマンティクス
|
||||
|
||||
- ファイルアクティビティは、成功して完了した OpenClaw の `write`、`edit`、`apply_patch` 呼び出しから投影されます。ツールの認識方法は公式 OpenClaw Chat UI に準拠し、完了した呼び出しだけに絞る処理は ClawX 固有です。
|
||||
- 作成・変更されたアクティビティ行は、プレビュー可能な assistant 添付ファイルと同じファイルカードと**アプリで開く**メニューを使い、状態表示と利用可能な `+/-` 集計も保持します。HTML ファイルでは、メニューの先頭項目が右側の**プレビュー**でファイルを開きます。削除された行には **Changes** 操作だけを残します。アプリ一覧、選択アプリで開く操作、ファイル位置の表示は、workspace ルートと相対パスから Electron Main が毎回個別に再検証します。ツール由来のパスが添付ファイルに変換されたり、Renderer に正規化済みのネイティブパスが渡されたりすることはありません。
|
||||
- `write` はツールが宣言したとおり、作成および全行追加の差分として表示されます。対象パスがすでに存在する可能性がある場合も同様です。
|
||||
- **Changes** は、ツールが宣言したアクティビティを時系列に並べたセッション単位の記録です。Git の出力でも、検証済みソースベースラインに対する差分でもありません。
|
||||
- 各ファイルについて、Changes はアシスタントの各ターンに最大 1 つの diff エディターを表示します。安全に連結できるフラグメントは合成し、独立したフラグメントは 1 つのエディターに連結しますが、完全なファイルベースラインとの差分であるとはみなしません。
|
||||
- シェルコマンド、スクリプト、ユーザー、IDE による副作用は検出されません。
|
||||
- 完全な ACP リプレイからは記録済みのファイルアクティビティを復元できます。リプレイが不完全な場合、ClawX はフォールバック推論で欠落したアクティビティを補いません。
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ ClawX デスクトップアプリ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Electron メインプロセス │ │
|
||||
│ │ • ウィンドウ&アプリケーションライフサイクル管理 │ │
|
||||
│ │ • ゲートウェイプロセスの監視 │ │
|
||||
│ │ • システム統合(トレイ、通知、キーチェーン) │ │
|
||||
│ │ • 自動アップデートオーケストレーション │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ IPC(権威ある制御プレーン) │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ React レンダラープロセス │ │
|
||||
│ │ • モダンなコンポーネントベースUI(React 19) │ │
|
||||
│ │ • Zustandによるステート管理 │ │
|
||||
│ │ • 統一 host-api/api-client 呼び出し │ │
|
||||
│ │ • 応答はMarkdown、ユーザー入力はプレーンテキスト │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
│ Main管理のトランスポート戦略
|
||||
│(WS優先、HTTP次点、IPCフォールバック)
|
||||
│ 型付き IPC リクエスト
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Host API と Main プロキシ層 │
|
||||
│ │
|
||||
│ • hostapi:fetch(Mainプロキシ、CORS回避) │
|
||||
│ • gateway:httpProxy(RendererはGateway HTTPに直アクセスしない) │
|
||||
│ • 統一エラーマッピングとリトライ/バックオフ │
|
||||
│ Main Host Services と Gateway Manager │
|
||||
│ │
|
||||
│ • host:invoke 型付きサービスディスパッチ │
|
||||
│ • 設定、ファイル、セッション、スキル、プロバイダー、診断サービス │
|
||||
│ • Main が Gateway WebSocket とプロセス監視を所有 │
|
||||
└──────────────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
│ WS / HTTP / IPC フォールバック
|
||||
│ Main 所有 WebSocket
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OpenClaw ゲートウェイ │
|
||||
│ │
|
||||
│ • AIエージェントランタイムとオーケストレーション │
|
||||
│ • メッセージチャネル管理 │
|
||||
│ • スキル/プラグイン実行環境 │
|
||||
│ • プロバイダー抽象化レイヤー │
|
||||
│ │
|
||||
│ • AIエージェントランタイムとオーケストレーション │
|
||||
│ • メッセージチャネル管理 │
|
||||
│ • スキル/プラグイン実行環境 │
|
||||
│ • プロバイダー抽象化レイヤー │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
### 設計原則
|
||||
|
||||
- **プロセス分離**: AIランタイムは別プロセスで動作し、重い計算処理中でもUIの応答性を確保します
|
||||
- **フロントエンド呼び出しの単一入口**: Renderer は host-api/api-client を通じて呼び出し、下位プロトコルに依存しません
|
||||
- **Mainによるトランスポート制御**: WS/HTTP の選択と IPC フォールバックを Main で一元管理します
|
||||
- **Mainによるトランスポート制御**: ACP Chat stdio bridge と Gateway トランスポートは Electron Main が所有し、Renderer は型付き IPC で Main と通信します
|
||||
- **拡張 IPC コントリビューション**: Main プロセス拡張は HTTP route ではなく、型付き IPC レジストリを通じて host-api action を提供します
|
||||
- **グレースフルリカバリ**: 再接続・タイムアウト・バックオフで一時的障害を自動処理します
|
||||
- **セキュアストレージ**: APIキーや機密データは、OSのネイティブセキュアストレージ機構を活用します
|
||||
- **CORSセーフ設計**: ローカルHTTPはMainプロキシ経由とし、Renderer側CORS問題を回避します
|
||||
- **CORSセーフ設計**: Renderer はローカル Gateway や Host API HTTP エンドポイントを直接呼び出しません
|
||||
|
||||
### プロセスモデルと Gateway トラブルシューティング
|
||||
|
||||
@@ -265,6 +308,7 @@ ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を
|
||||
- 単一起動保護は Electron のロックに加え、ローカルのプロセスロックファイルも併用し、デスクトップ IPC / セッションバスが不安定な環境でも重複起動を防ぎます。
|
||||
- ローリングアップグレード中に旧版/新版が混在すると、単一起動保護の挙動が非対称になる場合があります。安定運用のため、デスクトップクライアントは可能な限り同一バージョンへ揃えてください。
|
||||
- ただし OpenClaw Gateway の待受は常に**単一**であるべきです。`127.0.0.1:18789` を Listen しているプロセスは1つだけです。
|
||||
- Gateway の readiness は `system-presence`、`health`、`status` などの OpenClaw コア信号を基準にし、memory またはチャネルの失敗はグローバルな Gateway 障害ではなく capability degradation として表示します。
|
||||
- Listen プロセスの確認例:
|
||||
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
|
||||
- Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen`
|
||||
@@ -292,16 +336,19 @@ AI を開発ワークフローに統合できます。エージェントを使
|
||||
|
||||
### 前提条件
|
||||
|
||||
- **Node.js**: 22以上(LTS推奨)
|
||||
- **Node.js**: 対応するメジャー系列の 22.22.3以上、24.15.0以上、または25.9.0以上(Node 24 LTS推奨)
|
||||
- **パッケージマネージャー**: pnpm 9以上(推奨)またはnpm
|
||||
- **Linux(Ubuntu/Debian)**: Electron を実行する前に、必要なシステムライブラリをインストールしてください:
|
||||
```bash
|
||||
sudo apt-get install -y libnss3 libgtk-3-0 libxss1 libxtst6 libatspi2.0-0 libnotify4 xdg-utils
|
||||
```
|
||||
Ubuntu 24.04以降では、一部のパッケージに `t64` サフィックスが付いています。上記コマンドを実行すると `apt` が自動的に適切なバリアントを選択します。
|
||||
|
||||
### プロジェクト構成
|
||||
|
||||
```ClawX/
|
||||
├── electron/ # Electron メインプロセス
|
||||
│ ├── api/ # メイン側 API ルーターとハンドラー
|
||||
│ │ └── routes/ # RPC/HTTP プロキシのルートモジュール
|
||||
│ ├── services/ # Provider/Secrets/ランタイムサービス
|
||||
│ ├── services/ # 型付き Host API、Provider/Secrets/ランタイムサービス
|
||||
│ │ ├── providers/ # provider/account モデル同期ロジック
|
||||
│ │ └── secrets/ # OS キーチェーンと秘密情報管理
|
||||
│ ├── shared/ # 共通 Provider スキーマ/定数
|
||||
@@ -318,6 +365,7 @@ AI を開発ワークフローに統合できます。エージェントを使
|
||||
│ ├── i18n/ # ローカライズリソース
|
||||
│ └── types/ # TypeScript 型定義
|
||||
├── tests/
|
||||
│ ├── e2e/ # Playwright による Electron E2E スモークテスト
|
||||
│ └── unit/ # Vitest ユニット/統合寄りテスト
|
||||
├── resources/ # 静的アセット(アイコン、画像)
|
||||
└── scripts/ # ビルド/ユーティリティスクリプト
|
||||
@@ -326,7 +374,7 @@ AI を開発ワークフローに統合できます。エージェントを使
|
||||
|
||||
```bash
|
||||
# 開発
|
||||
pnpm run init # 依存関係のインストール + uvのダウンロード
|
||||
pnpm run init # 依存関係のインストール + バンドルバイナリ(uv、agent-browser)のダウンロード
|
||||
pnpm dev # ホットリロードで起動(不足時は同梱スキルを自動準備)
|
||||
|
||||
# コード品質
|
||||
@@ -335,6 +383,10 @@ pnpm typecheck # TypeScriptの型チェック
|
||||
|
||||
# テスト
|
||||
pnpm test # ユニットテストを実行
|
||||
pnpm run test:e2e # Electron E2E スモークテストを実行
|
||||
pnpm run test:e2e:headed # 表示付きウィンドウで Electron E2E を実行
|
||||
pnpm run perf:chat # 合成 Chat の Renderer/Main CPU プロファイルを取得
|
||||
pnpm run profile:main # ビルド済みアプリを Main inspector の 9229 番ポートで起動
|
||||
pnpm run comms:replay # 通信リプレイ指標を算出
|
||||
pnpm run comms:baseline # 通信ベースラインを更新
|
||||
pnpm run comms:compare # リプレイ指標をベースライン閾値と比較
|
||||
@@ -348,9 +400,23 @@ pnpm package:win # Windows向けにパッケージ化
|
||||
pnpm package:linux # Linux向けにパッケージ化
|
||||
```
|
||||
|
||||
ヘッドレス Linux では Electron テストに表示サーバーが必要です。`xvfb-run -a pnpm run test:e2e` を利用してください。
|
||||
|
||||
Electron E2E の機能テストはローカルと CI の両方で既定で 2 つの Playwright worker を使用します。通常の並列レーンは `CLAWX_E2E_WORKERS=<正の整数>` でマシンに合わせて調整できます。OS 全体の状態を扱うテストは 1 worker の `exclusive` project に入り、ホストのパフォーマンスプロファイルは機能テスト後に単独で実行されます。新しい E2E テストは既定で並列です。実クリップボードなどのマシン全体で共有されるリソースを使う場合は、`tests/e2e/parallel-policy.ts` の `E2E_EXCLUSIVE_TAG` を適用してください。
|
||||
|
||||
独占前提を必要としない通常の spec だけを実行する場合は、`pnpm exec playwright test <spec> --project=parallel --no-deps` を使用します。
|
||||
|
||||
### Electron パフォーマンス診断
|
||||
|
||||
`pnpm run perf:chat` は隔離された合成 ACP 負荷を実行し、ストリーミング応答と、リッチな静的 Markdown 会話でのサイドバーおよびスクロール操作を測定します。Playwright の `test-results/` には、バージョン付きメトリクスと Renderer/Main CPU プロファイルが出力されます。Renderer プロファイルは本番の store/render 経路とフレームペーシングを対象とします。ストリーミング Main プロファイルは Main から Renderer への IPC fanout を測定し、操作時の Main プロファイルは Renderer 操作中に Main がアイドルのままかを確認します。どちらも上流の OpenClaw/ACP サブプロセスや GPU プロセスの経路は含みません。CPU プロファイルは Chrome DevTools で開けます。アーティファクトには生成されたテスト文字列だけが含まれ、製品テレメトリーには送信されません。測定値はハードウェアに依存するため、共通の絶対閾値ではなく同じマシン上の複数回の結果を比較してください。
|
||||
|
||||
実際の Renderer を記録する場合は `CLAWX_REMOTE_DEBUGGING_PORT=9223 pnpm dev` で開発環境を起動し、Playwright または Chrome DevTools を `localhost:9223` に接続します。Electron Main を記録する場合は `pnpm run profile:main` を実行し、`chrome://inspect` で `localhost:9229` を設定して Electron Main target を選択します。WebSocket trace 自体を測定する場合を除き、`CLAWX_GATEWAY_WS_TRACE` は設定しないでください。
|
||||
|
||||
ClawX は Chromium のハードウェアアクセラレーションを既定で有効なままにし、長い文書、スクロール、レイアウトアニメーションで GPU コンポジットとラスタライズを利用します。グラフィックスドライバーに問題があるマシンでは、Chromium 標準の `--disable-gpu` コマンドラインスイッチをトラブルシューティング用のフォールバックとして利用できます。
|
||||
|
||||
### 通信回帰チェック
|
||||
|
||||
PR が通信経路(Gateway イベント、Chat 送受信フロー、Channel 配信、トランスポートのフォールバック)に触れる場合は、次を実行してください。
|
||||
PR が通信経路(Gateway イベント、ACP Chat bridge の送受信フロー、Channel 配信、トランスポートのフォールバック)に触れる場合は、次を実行してください。
|
||||
|
||||
```bash
|
||||
pnpm run comms:replay
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
English | <a href="README.zh-CN.md">简体中文</a> | <a href="README.ja-JP.md">日本語</a>
|
||||
English | <a href="README.zh-CN.md">简体中文</a> | <a href="README.ja-JP.md">日本語</a> | <a href="README.ru-RU.md">Русский</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -43,6 +43,8 @@ Whether you're automating workflows, managing AI-powered channels, or scheduling
|
||||
|
||||
ClawX comes pre-configured with best-practice model providers and natively supports Windows as well as multi-language settings. Of course, you can also fine-tune advanced configurations via **Settings → Advanced → Developer Mode**.
|
||||
|
||||
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">For a full enterprise edition, dedicated service support, or tailored deployment guidance for your business scenario, contact us at <a href="mailto:public@valuecell.ai">public@valuecell.ai</a>.</strong></p>
|
||||
|
||||
---
|
||||
## Screenshot
|
||||
|
||||
@@ -81,8 +83,9 @@ Building AI agents shouldn't require mastering the command line. ClawX was desig
|
||||
| Complex CLI setup | One-click installation with guided setup wizard |
|
||||
| Configuration files | Visual settings with real-time validation |
|
||||
| Process management | Automatic gateway lifecycle management |
|
||||
| App updates | Startup update checks with a prompt before downloading or installing |
|
||||
| Multiple AI providers | Unified provider configuration panel |
|
||||
| Skill/plugin installation | Built-in skill marketplace and management |
|
||||
| Skill/plugin installation | Local-first skill management with optional extension-provided marketplace |
|
||||
|
||||
### OpenClaw Inside
|
||||
|
||||
@@ -98,33 +101,43 @@ 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, rich content rendering with Markdown, and direct `@agent` routing in the main composer for multi-agent setups.
|
||||
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, assistant replies rendered as streaming Markdown with syntax-highlighted fenced code, CJK-aware parsing, GitHub-flavored tables, and KaTeX-powered LaTeX math (`$inline$`, `$$block$$`, `\(inline\)`, and `\[block\]`) while user input remains literal text, and direct `@agent` routing in the main composer for multi-agent setups. Fenced code preserves source line breaks, soft-wraps long lines, and provides a localized copy action after streaming completes.
|
||||
Skills you insert from the composer appear as `/skill-name` chips; click a chip to open the preview sidebar and read that skill's `SKILL.md`.
|
||||
When you target another agent with `@agent`, ClawX switches into that agent's own conversation context directly instead of relaying through the default agent. Agent workspaces stay separate by default, and stronger isolation depends on OpenClaw sandbox settings.
|
||||
The session sidebar is workspace-first: the default workspace stays at the top, other workspaces sort naturally, and each workspace can collapse or load more sessions. A row shows a spinner while the AI is replying, a blue dot when an unseen reply finishes, and its relative activity time after the conversation is opened; hovering still reveals row actions. Imported workspaces can be renamed from their sidebar header; the custom name is reflected in the chat composer while hovering the header still reveals the filesystem path. When available, a new chat inherits the selected conversation's workspace while remaining editable until first send. Editable new or unbound chats expose the composer workspace chip as a small menu that lists recent and known-session workspaces, returns to the default workspace, or chooses another folder. If a saved workspace folder was moved or deleted, Chat pauses session creation and prompts you to choose an existing folder instead of repeatedly retrying the missing path. Unavailable non-default groups are marked in the sidebar and can be removed after confirmation; this permanently deletes every session in that group. A session row is removed and navigation changes only after permanent deletion succeeds; failed deletions leave the conversation and confirmation open for retry. Synthetic OpenClaw UUID-date fallback titles are treated as missing only when they match the session ID, then replaced with the conversation's first user prompt instead of being persisted as the session name.
|
||||
Each agent can also override its own `provider/model` runtime setting; agents without overrides continue inheriting the global default model.
|
||||
|
||||
The Workspace and Preview tabs in Chat's right panel provide read-only previews for Markdown, `.docx`, and `.pptx` files. Markdown file previews use the same syntax-highlighted, soft-wrapped, copyable fenced code, CJK-aware parsing, and KaTeX math support in static rendering mode. The Preview header can expand the selected file to the full ClawX viewport; use the same control or Escape to return to the panel. Legacy `.doc` and `.ppt` files continue to open through the operating system instead of inline. DOCX pagination may differ from Microsoft Word, and PPTX previews do not support animations, transitions, or media playback. Office files larger than 20 MB are not previewed inline.
|
||||
|
||||
### Local HTML Preview
|
||||
The Chat right panel has Workspace, Preview, and Changes tabs; it no longer includes a general Web Browser, Home page, or address bar. Authorized local `.html` and `.htm` attachments, file activities, and Workspace files open in Preview by default. Their file actions let you choose the built-in Preview or a system application, and the Preview header can open the current HTML file in the system browser.
|
||||
|
||||
All links are non-clickable. Links rendered by ClawX appear as ordinary text, and links inside HTML Preview have their styling and pointer interaction removed. HTML Preview also blocks forms, script navigation, redirects, hash navigation, popups, downloads, network requests, and device permissions. It can render self-contained local HTML but cannot leave the selected document.
|
||||
|
||||
### 📡 Multi-Channel Management
|
||||
Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks.
|
||||
Each channel now supports multiple accounts, per-account agent binding, and switching the channel default account directly from the Channels page.
|
||||
For custom channel account IDs, ClawX enforces OpenClaw-compatible canonical IDs (`[a-z0-9_-]`, lowercase, max 64 chars, must start with a letter/number) to prevent routing mismatches.
|
||||
ClawX now also bundles Tencent's official personal WeChat channel plugin, so you can link WeChat directly from the Channels page with an in-app QR flow.
|
||||
|
||||
### ⏰ Cron-Based Automation
|
||||
Schedule AI tasks to run automatically. Define triggers, set intervals, and let your AI agents work around the clock without manual intervention.
|
||||
The Cron page now lets you configure external delivery directly in the task form with separate sender-account and recipient-target selectors. For supported channels, recipient targets are discovered automatically from channel directories or known session history, so you no longer need to edit `jobs.json` by hand.
|
||||
Known limitation: WeChat is intentionally excluded from supported cron delivery channels for now. The current `openclaw-weixin` plugin requires a live conversation `contextToken` for outbound sends, so cron-style proactive delivery is not supported by the plugin itself.
|
||||
The Cron page now lets you configure external delivery directly in the task form with separate sender-account and recipient-target selectors. For supported channels, recipient targets are discovered automatically from channel directories or known session history, so you no longer need to edit `jobs.json` by hand. The task message field also supports inserting skills with the same inline `/skill` token syntax as the main chat composer (scoped to the selected agent), so scheduled prompts can trigger skills directly. The schedule picker is split into **Recurring** and **Once** tabs: Recurring offers Hourly, Daily, Weekdays, Weekly, and Custom (raw cron) frequencies with inline time/weekday controls, while Once runs the task a single time at a chosen date (with weekday shown) and time. One-time tasks must be scheduled for a future moment and are automatically removed by the runtime once they finish.
|
||||
|
||||
|
||||
### 🧩 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`) 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)
|
||||
- `find-skills` and `self-improving-agent` do not require API keys
|
||||
Extend your AI agents with pre-built skills. The integrated Skills page is local-first: it scans managed/workspace skill directories, lets you enable or disable skills without depending on the Gateway, and can optionally expose an extension-provided marketplace in enterprise builds.
|
||||
ClawX also pre-bundles full document-processing skills (`pdf`, `xlsx`, `docx`, `pptx`), deploys them automatically to the managed skills directory (default `~/.openclaw/skills`) on startup, and enables them by default on first install.
|
||||
The Skills page can display skills discovered from multiple OpenClaw sources (managed dir, workspace, and extra skill dirs), and now shows each skill's actual location so you can open the real folder directly. For bundled OpenClaw skills, community builds now ship and expose only `skill-creator`; non-allowlisted bundled skills are physically trimmed in both dev and packaged startup, and any stale `openclaw.json` entries left behind for those removed bundled skills are pruned.
|
||||
|
||||
### 🔐 Secure Provider Integration
|
||||
Connect to multiple AI providers (OpenAI, Anthropic, and more) with credentials stored securely in your system's native keychain. OpenAI supports both API key and browser OAuth (Codex subscription) sign-in.
|
||||
Connect to multiple AI providers (OpenAI, Anthropic, Z.AI / GLM, and more) with credentials stored securely in your system's native keychain. OpenAI supports both API key and browser OAuth (Codex subscription) sign-in.
|
||||
In developer mode, the dedicated Image Generation page supports an independent OpenAI-compatible image-generation endpoint (Base URL, API key, and model name such as `gpt-image-2`) so image generation can use a dedicated `/v1/images/generations` service while chat continues using the normal OpenAI provider.
|
||||
For **Custom** providers used with OpenAI-compatible gateways, you can set a custom `User-Agent` in **Settings → AI Providers → Edit Provider** for compatibility-sensitive endpoints.
|
||||
When you edit or switch providers, ClawX preserves existing per-model capability metadata such as `input: ["text", "image"]`. Newly selected Custom-provider models use OpenClaw onboarding-compatible image-input inference, with unknown models defaulting to text-only.
|
||||
Custom-provider model rows also receive an explicit `contextWindow` (inferred from the model family, e.g. `gpt-5.x` → 272k), and rows saved by older versions are backfilled on startup, so OpenClaw can compact long sessions before they fail with "Context overflow" errors. When you have no compaction config, ClawX seeds `agents.defaults.compaction.mode = "safeguard"` and `reserveTokensFloor = 50000`; rows or configs you authored yourself are never modified (except a missing `reserveTokensFloor` may be backfilled).
|
||||
Z.AI (CN / Global) maps to OpenClaw's built-in `zai` provider (`ZAI_API_KEY`). Default model is `glm-5.2`. Use the Code Plan preset for Coding Plan endpoints (`…/api/coding/paas/v4`) or the normal API endpoints (`…/api/paas/v4`); CN and Global are mutually exclusive because they share one OpenClaw runtime key.
|
||||
When a compatible gateway rejects `/models` for non-auth reasons, ClawX automatically falls back to a lightweight `/chat/completions` or `/responses` probe using the configured model during API key validation.
|
||||
|
||||
### 🌙 Adaptive Theming
|
||||
Light mode, dark mode, or system-synchronized themes. ClawX adapts to your preferences automatically.
|
||||
@@ -132,6 +145,9 @@ Light mode, dark mode, or system-synchronized themes. ClawX adapts to your prefe
|
||||
### 🚀 Startup Launch Control
|
||||
In **Settings → General**, you can enable **Launch at system startup** so ClawX starts automatically after login.
|
||||
|
||||
### 🔔 Update Prompts
|
||||
ClawX can automatically check for new versions on startup. When an update is available, it shows an in-app prompt; downloading and installing only happen after you choose the action.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
@@ -172,8 +188,8 @@ When you launch ClawX for the first time, the **Setup Wizard** will guide you th
|
||||
|
||||
The wizard preselects your system language when it is supported, and falls back to English otherwise.
|
||||
|
||||
> 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.
|
||||
> Web search note: ClawX disables OpenClaw's general-purpose `web_search` tool at both the agent and Gateway policy layers.
|
||||
> This includes Moonshot (Kimi) search; managed browser automation and `web_fetch` remain available.
|
||||
|
||||
### Proxy Settings
|
||||
|
||||
@@ -210,58 +226,81 @@ Notes:
|
||||
|
||||
ClawX employs a **dual-process architecture** with a unified host API layer. The renderer talks to a single client abstraction, while Electron Main owns protocol selection and process lifecycle:
|
||||
|
||||
```┌─────────────────────────────────────────────────────────────────┐
|
||||
Electron Main also owns OpenClaw configuration delivery. While the Gateway is running, ClawX reads the authoritative `config.get` snapshot and commits changes with `config.set`; while it is stopped or starting, the same coordinator updates the resolved JSON5 config file without starting the Gateway. Ordinary provider, agent, channel, binding, skill, and model changes therefore do not replace the Gateway process. Full restarts remain for process-launch environment changes such as proxy settings and explicit user actions. Confirmed process exits and WebSocket closes retain their existing automatic reconnect paths. WebSocket heartbeat misses update diagnostics and health state but do not replace the Gateway process, so delayed pong handling cannot interrupt long-running work. Auth-profile SQLite updates use OpenClaw's `secrets.reload` RPC so running agents see new credentials without a process restart.
|
||||
|
||||
Chat uses an ACP stdio bridge owned by Electron Main. Renderer receives typed host events and renders an in-memory ACP timeline. Gateway remains responsible for non-Chat capabilities such as providers, models, skills, workspace, settings, diagnostics, and media configuration.
|
||||
|
||||
An unfinished ACP response keeps streaming when you open another conversation or page. Returning before it finishes restores the latest in-memory timeline and continues the live response; once it finishes, normal ACP history replay remains the source of truth.
|
||||
|
||||
ACP assistant turns show whole-turn duration. Live timing follows the client-observed prompt lifecycle and survives in-app navigation; historical timing is derived in Electron Main from bounded OpenClaw transcript timestamps and only annotates a turn already restored by ACP replay.
|
||||
|
||||
ACP Chat renders standard ACP resources as attachments. User-selected images appear as thumbnails with a filename hover overlay, while other available attachment cards show the filename and a muted, truncating source path. When the current OpenClaw ACP adapter omits assistant media, canonical persisted OpenClaw media facts and explicit assistant `MEDIA:` directives can also be recovered as attachment cards without displaying transcript-only metadata. Existing local file references, including paths outside the active workspace, are revalidated in Electron Main for the exact session and generation before every preview or open. Previewable local attachments produced by the AI, including `.docx` and `.pptx` files within the 20 MB inline-preview limit, keep their primary read-only in-app preview action and provide a secondary menu for opening with compatible applications or revealing the file in Finder, File Explorer, or the system file manager. For local HTML attachments, that menu starts with an action that opens the file in the right-side Preview tab. The same Office limitations apply here: `.doc` and `.ppt` remain system-open formats, DOCX pagination may differ from Microsoft Word, and PPTX animations, transitions, and media playback are unsupported. Compatible-application discovery is available only on macOS and Windows and silently degrades to reveal-only behavior on Linux or when discovery fails. Other local files, including Office files larger than 20 MB, open in the system application after a user click. User-selected folder attachments also remain available after send and open in the system file manager; ClawX does not read or preview their contents. Remote HTTP and HTTPS attachments open externally after a user click. Bare or inline prose paths without canonical media facts are not treated as attachments.
|
||||
|
||||
ACP Chat can also display generated image previews when image-generation media is delivered by the runtime as trusted structured media. Trusted OpenClaw internal-UI deliveries and task-correlated final replies preserve the original user-facing completion text, including text-only failure explanations, rather than replacing it with a generic image caption. During historical OpenClaw replay, assistant image `MEDIA:` markers are promoted to the inline image experience only when they follow a recorded image-generation task start for that session. ClawX loads previews through host media handling in Electron Main, not arbitrary Renderer filesystem access. Standard ACP image and resource content remains the preferred path and renders directly.
|
||||
|
||||
### ACP File Activity Semantics
|
||||
|
||||
- File activity is projected from successful, completed OpenClaw `write`, `edit`, and `apply_patch` calls. Tool recognition follows the official OpenClaw Chat UI; filtering to completed calls is specific to ClawX.
|
||||
- Created and modified activity rows use the same file-card shell and **Open with** menu as previewable assistant attachments while retaining their status and optional `+/-` summary. For HTML files, the first menu item opens the file in the right-side Preview tab. Deleted rows keep only the **Changes** action. Every application-list, selected-application, and reveal request is independently revalidated in Electron Main from the workspace root and relative path; tool-derived paths never become attachments or expose canonical native paths to Renderer.
|
||||
- A `write` is shown as the tool declares it: a creation with an all-added diff, even if the path may already exist.
|
||||
- **Changes** is a chronological, session-level record of tool-declared activity. It is not Git output or a verified diff against a source baseline.
|
||||
- For each file, Changes renders at most one diff editor per assistant turn. Sequential fragments are composed when safe; independent fragments share one concatenated editor without claiming a complete-file baseline.
|
||||
- Side effects made by shell commands, scripts, users, or IDEs are not detected.
|
||||
- A full ACP replay can restore recorded file activity. If replay is incomplete, ClawX does not infer missing activity through fallback behavior.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ ClawX Desktop App │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Electron Main Process │ │
|
||||
│ │ Electron Main Process │ │
|
||||
│ │ • Window & application lifecycle management │ │
|
||||
│ │ • Gateway process supervision │ │
|
||||
│ │ • System integration (tray, notifications, keychain) │ │
|
||||
│ │ • Auto-update orchestration │ │
|
||||
│ │ • Gateway process supervision │ │
|
||||
│ │ • System integration (tray, notifications, keychain) │ │
|
||||
│ │ • Auto-update orchestration │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ IPC (authoritative control plane) │
|
||||
│ ▼ │
|
||||
│ │ │
|
||||
│ │ IPC (authoritative control plane) │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ React Renderer Process │ │
|
||||
│ │ • Modern component-based UI (React 19) │ │
|
||||
│ │ • State management with Zustand │ │
|
||||
│ │ • Unified host-api/api-client calls │ │
|
||||
│ │ • Rich Markdown rendering │ │
|
||||
│ │ React Renderer Process │ │
|
||||
│ │ • Modern component-based UI (React 19) │ │
|
||||
│ │ • State management with Zustand │ │
|
||||
│ │ • Unified host-api/api-client calls │ │
|
||||
│ │ • Markdown assistant replies, literal user input │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────┬──────────────────────────────────┘
|
||||
└──────────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
│ Main-owned transport strategy
|
||||
│ (WS first, HTTP then IPC fallback)
|
||||
│ Typed IPC requests
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Host API & Main Process Proxies │
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Main Host Services & Gateway Manager │
|
||||
│ │
|
||||
│ • hostapi:fetch (Main proxy, avoids CORS in dev/prod) │
|
||||
│ • gateway:httpProxy (Renderer never calls Gateway HTTP direct) │
|
||||
│ • Unified error mapping & retry/backoff │
|
||||
└──────────────────────────────┬──────────────────────────────────┘
|
||||
│ • host:invoke typed service dispatcher │
|
||||
│ • Settings, files, sessions, skills, providers, diagnostics │
|
||||
│ • Main-owned Gateway WebSocket and process supervision │
|
||||
└──────────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
│ WS / HTTP / IPC fallback
|
||||
│ Main-owned WebSocket
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ OpenClaw Gateway │
|
||||
│ │
|
||||
│ • AI agent runtime and orchestration │
|
||||
│ • AI agent runtime and orchestration │
|
||||
│ • Message channel management │
|
||||
│ • Skill/plugin execution environment │
|
||||
│ • Skill/plugin execution environment │
|
||||
│ • Provider abstraction layer │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
### Design Principles
|
||||
|
||||
- **Process Isolation**: The AI runtime operates in a separate process, ensuring UI responsiveness even during heavy computation
|
||||
- **Single Entry for Frontend Calls**: Renderer requests go through host-api/api-client; protocol details are hidden behind a stable interface
|
||||
- **Main-Process Transport Ownership**: Electron Main controls WS/HTTP usage and fallback to IPC for reliability
|
||||
- **Main-Process Transport Ownership**: Electron Main owns the ACP Chat stdio bridge and Gateway transports; the renderer talks to Main over typed IPC
|
||||
- **Extension IPC Contributions**: Main-process extensions contribute host-api actions through the typed IPC registry instead of HTTP routes
|
||||
- **Graceful Recovery**: Built-in reconnect, timeout, and backoff logic handles transient failures automatically
|
||||
- **Secure Storage**: API keys and sensitive data leverage the operating system's native secure storage mechanisms
|
||||
- **CORS-Safe by Design**: Local HTTP access is proxied by Main, preventing renderer-side CORS issues
|
||||
- **CORS-Safe by Design**: The renderer does not call local Gateway or Host API HTTP endpoints directly
|
||||
|
||||
### Process Model & Gateway Troubleshooting
|
||||
|
||||
@@ -269,6 +308,7 @@ ClawX employs a **dual-process architecture** with a unified host API layer. The
|
||||
- Single-instance protection uses Electron's lock plus a local process-file lock fallback, preventing duplicate app launch in environments where desktop IPC/session bus is unstable.
|
||||
- During rolling upgrades, mixed old/new app versions can still have asymmetric protection behavior. For best reliability, upgrade all desktop clients to the same version.
|
||||
- The OpenClaw Gateway listener should still be **single-owner**: only one process should listen on `127.0.0.1:18789`.
|
||||
- Gateway readiness is based on OpenClaw core signals such as `system-presence`, `health`, and `status`; memory or channel failures are shown as capability degradation instead of global Gateway failure.
|
||||
- To verify the active listener:
|
||||
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
|
||||
- Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen`
|
||||
@@ -296,16 +336,19 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js**: 22+ (LTS recommended)
|
||||
- **Node.js**: 22.22.3+, 24.15.0+, or 25.9.0+ within the corresponding supported major line (Node 24 LTS recommended)
|
||||
- **Package Manager**: pnpm 9+ (recommended) or npm
|
||||
- **Linux (Ubuntu/Debian)**: Install required system libraries before running Electron:
|
||||
```bash
|
||||
sudo apt-get install -y libnss3 libgtk-3-0 libxss1 libxtst6 libatspi2.0-0 libnotify4 xdg-utils
|
||||
```
|
||||
On Ubuntu 24.04+, some packages use a `t64` suffix; run the above command and `apt` will automatically select the correct variant.
|
||||
|
||||
### Project Structure
|
||||
|
||||
```ClawX/
|
||||
├── electron/ # Electron Main Process
|
||||
│ ├── api/ # Main-side API router and handlers
|
||||
│ │ └── routes/ # RPC/HTTP proxy route modules
|
||||
│ ├── services/ # Provider, secrets and runtime services
|
||||
│ ├── services/ # Typed host APIs, provider, secrets and runtime services
|
||||
│ │ ├── providers/ # Provider/account model sync logic
|
||||
│ │ └── secrets/ # OS keychain and secret storage
|
||||
│ ├── shared/ # Shared provider schemas/constants
|
||||
@@ -322,6 +365,7 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro
|
||||
│ ├── i18n/ # Localization resources
|
||||
│ └── types/ # TypeScript type definitions
|
||||
├── tests/
|
||||
│ ├── e2e/ # Playwright Electron end-to-end smoke tests
|
||||
│ └── unit/ # Vitest unit/integration-like tests
|
||||
├── resources/ # Static assets (icons/images)
|
||||
└── scripts/ # Build and utility scripts
|
||||
@@ -330,7 +374,7 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm run init # Install dependencies + download uv
|
||||
pnpm run init # Install dependencies + download bundled binaries (uv, agent-browser)
|
||||
pnpm dev # Start with hot reload (auto-prepares bundled skills if missing)
|
||||
|
||||
# Quality
|
||||
@@ -339,6 +383,10 @@ pnpm typecheck # TypeScript validation
|
||||
|
||||
# Testing
|
||||
pnpm test # Run unit tests
|
||||
pnpm run test:e2e # Run Electron E2E smoke tests with Playwright
|
||||
pnpm run test:e2e:headed # Run Electron E2E tests with a visible window
|
||||
pnpm run perf:chat # Capture synthetic Chat Renderer/Main CPU profiles
|
||||
pnpm run profile:main # Launch the built app with Main inspector on port 9229
|
||||
pnpm run comms:replay # Compute communication replay metrics
|
||||
pnpm run comms:baseline # Refresh communication baseline snapshot
|
||||
pnpm run comms:compare # Compare replay metrics against baseline thresholds
|
||||
@@ -352,9 +400,23 @@ pnpm package:win # Package for Windows
|
||||
pnpm package:linux # Package for Linux
|
||||
```
|
||||
|
||||
On headless Linux, run Electron tests under a display server such as `xvfb-run -a pnpm run test:e2e`.
|
||||
|
||||
Electron E2E functional specs use two Playwright workers by default both locally and in CI; set `CLAWX_E2E_WORKERS=<positive integer>` to tune the ordinary parallel lane for the machine. Tests that touch OS-global state use the one-worker `exclusive` project, and host performance profiles run alone afterward. New E2E tests are parallel by default; apply `E2E_EXCLUSIVE_TAG` from `tests/e2e/parallel-policy.ts` when a test uses the real clipboard or another machine-global resource.
|
||||
|
||||
For a focused ordinary spec that does not need the exclusive prerequisite, run `pnpm exec playwright test <spec> --project=parallel --no-deps`.
|
||||
|
||||
### Electron Performance Diagnostics
|
||||
|
||||
`pnpm run perf:chat` runs isolated synthetic ACP workloads for streaming and for rich static Markdown sidebar/scroll interaction. It writes versioned metrics plus Renderer and Main CPU profiles under the Playwright `test-results/` directory. The Renderer profiles cover the production store/render path and frame pacing. The streaming Main profile measures Main-to-Renderer IPC fanout; the interaction Main profile shows whether Main remains idle while Renderer interactions run. Neither includes the upstream OpenClaw/ACP subprocess or GPU-process paths. Open a CPU profile in Chrome DevTools; the artifacts contain generated fixture text only and are not product telemetry. Results are hardware-dependent, so compare repeated runs on the same machine instead of applying one cross-platform absolute threshold.
|
||||
|
||||
For a live Renderer recording, start development with `CLAWX_REMOTE_DEBUGGING_PORT=9223 pnpm dev` and attach Playwright or Chrome DevTools to `localhost:9223`. For a live Electron Main recording, run `pnpm run profile:main`, open `chrome://inspect`, configure `localhost:9229`, and select the Electron Main target. Leave `CLAWX_GATEWAY_WS_TRACE` unset unless WebSocket tracing itself is being measured.
|
||||
|
||||
ClawX leaves Chromium hardware acceleration enabled by default so long documents, scrolling, and layout animations can use GPU compositing and rasterization. Chromium still honors the native `--disable-gpu` command-line switch as a troubleshooting fallback for a machine with a broken graphics driver.
|
||||
|
||||
### Communication Regression Checks
|
||||
|
||||
When a PR changes communication paths (gateway events, chat runtime send/receive flow, channel delivery, or transport fallback), run:
|
||||
When a PR changes communication paths (gateway events, ACP Chat bridge send/receive flow, channel delivery, or transport fallback), run:
|
||||
|
||||
```bash
|
||||
pnpm run comms:replay
|
||||
@@ -362,6 +424,29 @@ pnpm run comms:compare
|
||||
```
|
||||
|
||||
`comms-regression` in CI enforces required scenarios and threshold checks.
|
||||
|
||||
### Electron E2E Tests
|
||||
|
||||
The Playwright Electron suite launches the packaged renderer and main process
|
||||
from `dist/` and `dist-electron/`, so it does not require manually running
|
||||
`pnpm dev` first.
|
||||
|
||||
`pnpm run test:e2e` automatically:
|
||||
|
||||
- builds the renderer and Electron bundles with `pnpm run build:vite`
|
||||
- starts Electron in an isolated E2E mode with a temporary `HOME`
|
||||
- uses a temporary ClawX `userData` directory
|
||||
- runs ordinary spec files concurrently while fencing OS-global and performance tests
|
||||
- skips heavy startup side effects such as gateway auto-start, bundled skill
|
||||
installation, tray creation, and CLI auto-install
|
||||
|
||||
The first two baseline specs cover:
|
||||
|
||||
- first-launch setup wizard visibility on a fresh profile
|
||||
- skipping setup and navigating to the Models page inside the Electron app
|
||||
|
||||
Add future Electron flows under `tests/e2e/` and reuse the shared fixture in
|
||||
`tests/e2e/fixtures/electron.ts`. Keep tests parallel-safe by avoiding fixed writable paths, ports, native keychains, and other external shared state; use `E2E_EXCLUSIVE_TAG` when isolation is not possible.
|
||||
### Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|
||||
+486
@@ -0,0 +1,486 @@
|
||||
|
||||
<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>Десктоп-интерфейс для AI-агентов OpenClaw</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> | <a href="README.ja-JP.md">日本語</a> | Русский
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Обзор
|
||||
|
||||
**ClawX** — это мост между мощными AI-агентами и повседневными пользователями. Построенный на базе [OpenClaw](https://github.com/OpenClaw), он превращает управление AI через командную строку в доступный и красивый десктоп-опыт — терминал не нужен.
|
||||
|
||||
Автоматизация рабочих процессов, управление AI-каналами или планирование интеллектуальных задач — ClawX предоставляет интерфейс для эффективного использования AI-агентов.
|
||||
|
||||
ClawX поставляется с предустановленными лучшими практиками для провайдеров моделей и нативно поддерживает Windows, а также многоязычные настройки. Вы можете тонко настроить расширенные параметры через **Настройки → Дополнительно → Режим разработчика**.
|
||||
|
||||
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">Для получения полной корпоративной версии, специализированной поддержки или индивидуального сопровождения внедрения под ваш бизнес-сценарий, свяжитесь с нами по адресу <a href="mailto:public@valuecell.ai">public@valuecell.ai</a>.</strong></p>
|
||||
|
||||
---
|
||||
|
||||
## Скриншоты
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/ru/chat.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/ru/cron.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/ru/skills.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/ru/channels.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/ru/models.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/ru/settings.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Почему ClawX
|
||||
|
||||
Создание AI-агентов не должно требовать владения командной строкой. Философия ClawX проста: **мощные технологии заслуживают интерфейса, который уважает ваше время.**
|
||||
|
||||
| Проблема | Решение ClawX |
|
||||
|----------|---------------|
|
||||
| Сложная настройка через CLI | Установка в один клик с мастером настройки |
|
||||
| Редактирование конфигурационных файлов | Визуальные настройки с проверкой в реальном времени |
|
||||
| Управление процессами | Автоматическое управление жизненным циклом шлюза |
|
||||
| Несколько AI-провайдеров | Единая панель настройки провайдеров |
|
||||
| Установка навыков/плагинов | Встроенный маркетплейс и управление навыками |
|
||||
|
||||
### OpenClaw внутри
|
||||
|
||||
ClawX построен непосредственно на официальном ядре **OpenClaw**. Вместо отдельной установки мы встраиваем среду выполнения в приложение для бесшовного опыта "всё включено".
|
||||
|
||||
Мы стремимся поддерживать строгое соответствие с проектом OpenClaw, чтобы вы всегда имели доступ к новейшим возможностям, улучшениям стабильности и совместимости с экосистемой.
|
||||
|
||||
---
|
||||
|
||||
## Возможности
|
||||
|
||||
### 🎯 Нулевой порог настройки
|
||||
Весь процесс — от установки до первого взаимодействия с AI — выполняется через интуитивный графический интерфейс. Без терминальных команд, без YAML-файлов, без поиска переменных окружения.
|
||||
|
||||
### 💬 Интеллектуальный интерфейс чата
|
||||
Общайтесь с AI-агентами через современный чат. Поддержка нескольких контекстов разговора, истории сообщений и рендеринга ответов агента в Markdown (включая таблицы GitHub-flavored и математические формулы LaTeX через KaTeX: `$строчные$`, `$$блочные$$`, `\(строчные\)` и `\[блочные\]`), при этом пользовательский ввод всегда отображается как обычный текст. Для мультиагентных конфигураций также доступна прямая маршрутизация через `@agent` в главном поле ввода.
|
||||
Навыки, вставляемые из поля ввода, отображаются как чипы `/skill-name`; нажмите на чип, чтобы открыть боковую панель предпросмотра и прочитать `SKILL.md` соответствующего навыка.
|
||||
При выборе другого агента через `@agent` ClawX переключается непосредственно в контекст этого агента вместо ретрансляции через агента по умолчанию. Рабочие пространства агентов по умолчанию разделены, но более строгая изоляция зависит от настроек песочницы OpenClaw.
|
||||
Каждый агент может переопределить свои настройки `provider/model`; агенты без переопределения продолжают наследовать глобальную модель по умолчанию.
|
||||
|
||||
### Предпросмотр локального HTML
|
||||
На правой панели Chat остаются только вкладки «Рабочая область», «Просмотр» и «Изменения»; универсального веб-браузера, домашней страницы и адресной строки больше нет. Разрешённые локальные вложения `.html` / `.htm`, файловые операции и файлы рабочей области по умолчанию открываются в «Просмотре». В действиях файла можно выбрать встроенный просмотр ClawX или системное приложение, а кнопка в заголовке просмотра открывает текущий HTML-файл в системном браузере.
|
||||
|
||||
Все ссылки некликабельны. Ссылки, отображаемые ClawX, выглядят как обычный текст; в HTML-просмотре также удаляются оформление ссылок и взаимодействие указателем. Формы, переходы из скриптов, перенаправления, переходы внутри страницы, всплывающие окна, загрузки, сетевые запросы и разрешения устройств блокируются. Самодостаточный локальный HTML отображается, но не может покинуть выбранный документ.
|
||||
|
||||
### 📡 Управление несколькими каналами
|
||||
Настраивайте и отслеживайте несколько AI-каналов одновременно. Каждый канал работает независимо, позволяя запускать специализированных агентов для разных задач.
|
||||
Каждый канал теперь поддерживает несколько учётных записей, привязку агента к учётной записи и переключение канала по умолчанию прямо на странице Каналы.
|
||||
Для пользовательских идентификаторов учётных записей каналов ClawX требует совместимый с OpenClaw канонический формат (`[a-z0-9_-]`, строчные буквы, максимум 64 символа, должен начинаться с буквы или цифры) для предотвращения ошибок маршрутизации.
|
||||
ClawX также включает официальный плагин личного WeChat от Tencent, позволяя подключить WeChat напрямую со страницы Каналы через встроенный QR-код.
|
||||
|
||||
### ⏰ Автоматизация по расписанию
|
||||
Планируйте автоматический запуск AI-задач. Определяйте триггеры, устанавливайте интервалы и позволяйте AI-агентам работать круглосуточно без ручного вмешательства.
|
||||
На странице Cron теперь можно настроить внешнюю доставку непосредственно в форме задачи с отдельными селекторами учётной записи отправителя и цели получателя. Для поддерживаемых каналов цели получателей автоматически обнаруживаются из каталогов каналов или известной истории сессий, поэтому больше не нужно редактировать `jobs.json` вручную. Поле сообщения задачи также поддерживает вставку навыков с помощью того же синтаксиса встроенных токенов `/skill`, что и в основном окне чата (с учётом выбранного агента), поэтому запланированные подсказки могут запускать навыки напрямую. Выбор расписания разделён на вкладки **Повтор** и **Однократно**: повтор предлагает частоты «Ежечасно», «Ежедневно», «По будням», «Еженедельно» и «Свой» (произвольный cron) со встроенными элементами выбора времени/дня недели, а однократно запускает задачу один раз в выбранную дату (с показом дня недели) и время. Однократные задачи должны быть запланированы на будущее и автоматически удаляются средой выполнения после завершения.
|
||||
|
||||
### 🧩 Расширяемая система навыков
|
||||
Расширяйте возможности AI-агентов готовыми навыками. Просматривайте, устанавливайте и управляйте навыками через встроенную панель — менеджеры пакетов не нужны.
|
||||
ClawX также предварительно упаковывает полные навыки обработки документов (`pdf`, `xlsx`, `docx`, `pptx`), автоматически развёртывает их в управляемый каталог навыков (по умолчанию `~/.openclaw/skills`) при запуске и включает по умолчанию при первой установке.
|
||||
На странице Навыки отображаются навыки из нескольких источников OpenClaw (управляемый каталог, workspace и дополнительные каталоги навыков), а также показывается фактическое расположение каждого навыка для прямого открытия папки.
|
||||
|
||||
### 🔐 Безопасная интеграция провайдеров
|
||||
Подключайтесь к нескольким AI-провайдерам (OpenAI, Anthropic, Z.AI / GLM и др.) с учётными данными, безопасно хранящимися в системной связке ключей. OpenAI поддерживает как API-ключи, так и OAuth через браузер (подписка Codex).
|
||||
Для провайдеров **Custom**, используемых с OpenAI-совместимыми шлюзами, вы можете установить пользовательский `User-Agent` в **Настройки → AI Провайдеры → Редактировать провайдера** для совместимости с чувствительными эндпоинтами.
|
||||
Z.AI (CN / Global) соответствует встроенному провайдеру OpenClaw `zai` (`ZAI_API_KEY`). Модель по умолчанию — `glm-5.2`. Пресет Code Plan переключает на эндпоинты Coding Plan (`…/api/coding/paas/v4`); также доступны обычные API (`…/api/paas/v4`). CN и Global взаимоисключающие, так как используют один и тот же runtime-ключ OpenClaw.
|
||||
Когда совместимый шлюз отклоняет `/models` по причинам, не связанным с аутентификацией, ClawX автоматически переключается на легковесный зонд `/chat/completions` или `/responses` при проверке API-ключа.
|
||||
|
||||
### 🌙 Адаптивные темы
|
||||
Светлая тема, тёмная тема или синхронизация с системой. ClawX автоматически адаптируется к вашим предпочтениям.
|
||||
|
||||
### 🚀 Управление автозапуском
|
||||
В **Настройки → Общие** вы можете включить **Запускать при старте системы**, чтобы ClawX автоматически запускался после входа в систему.
|
||||
|
||||
---
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Системные требования
|
||||
|
||||
- **Операционная система**: macOS 11+, Windows 10+ или Linux (Ubuntu 20.04+)
|
||||
- **Память**: минимум 4 ГБ RAM (рекомендуется 8 ГБ)
|
||||
- **Хранилище**: 1 ГБ свободного места на диске
|
||||
|
||||
### Установка
|
||||
|
||||
#### Готовые релизы (рекомендуется)
|
||||
|
||||
Скачайте последний релиз для вашей платформы со страницы [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 или каналы вроде Telegram должны выходить в интернет через локальный прокси-клиент.
|
||||
|
||||
Откройте **Настройки → Шлюз → Прокси** и настройте:
|
||||
|
||||
- **Прокси-сервер**: прокси по умолчанию для всех запросов
|
||||
- **Правила обхода**: хосты, которые должны подключаться напрямую, разделённые точкой с запятой, запятыми или новыми строками
|
||||
- В **Режиме разработчика** можно дополнительно переопределить:
|
||||
- **HTTP Прокси**
|
||||
- **HTTPS Прокси**
|
||||
- **ALL_PROXY / SOCKS**
|
||||
|
||||
Рекомендуемые примеры локальных настроек:
|
||||
|
||||
```text
|
||||
Прокси-сервер: http://127.0.0.1:7890
|
||||
```
|
||||
Примечания:
|
||||
|
||||
- Значение `host:port` рассматривается как HTTP.
|
||||
- Если расширенные поля прокси пусты, ClawX использует `Прокси-сервер`.
|
||||
- Сохранение настроек прокси немедленно повторно применяет сеть Electron и автоматически перезапускает шлюз.
|
||||
- ClawX также синхронизирует прокси с конфигурацией канала Telegram в OpenClaw, когда Telegram включён.
|
||||
- При перезапуске шлюза существующий прокси канала Telegram сохраняется, если прокси ClawX отключен.
|
||||
- Чтобы явно очистить прокси Telegram из конфигурации OpenClaw, сохраните настройки прокси с отключенным прокси.
|
||||
- В **Настройки → Дополнительно → Разработчик** можно запустить **OpenClaw Doctor** для выполнения `openclaw doctor --json` и просмотра диагностического вывода, не покидая приложение.
|
||||
- В упакованных сборках Windows встроенный `openclaw` CLI/TUI запускается через поставляемый `node.exe` для стабильного поведения ввода в терминале.
|
||||
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
ClawX использует **двухпроцессную архитектуру с унифицированным уровнем Host API**. Рендерер обращается к единой абстракции клиента, а Electron 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 в dev/prod) │
|
||||
│ • gateway:httpProxy (Рендерер не вызывает Gateway HTTP напрямую)│
|
||||
│ • Унифицированное отображение ошибок и повторные попытки │
|
||||
└──────────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
Резерв WS / HTTP / IPC
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Шлюз OpenClaw │
|
||||
│ │
|
||||
│ • Среда выполнения AI-агентов и оркестрация │
|
||||
│ • Управление каналами сообщений │
|
||||
│ • Среда выполнения навыков/плагинов │
|
||||
│ • Уровень абстракции провайдеров │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Принципы проектирования
|
||||
|
||||
- **Изоляция процессов**: Среда выполнения AI работает в отдельном процессе, обеспечивая отзывчивость UI даже при тяжёлых вычислениях
|
||||
- **Единая точка входа для фронтенда**: Запросы рендерера проходят через host-api/api-client; детали протокола скрыты за стабильным интерфейсом
|
||||
- **Владение транспортом в Main**: Electron Main управляет использованием WS/HTTP и откатом к IPC для надёжности
|
||||
- **Корректное восстановление**: Встроенная логика переподключения, таймаутов и отката автоматически обрабатывает временные сбои
|
||||
- **Безопасное хранение**: API-ключи и конфиденциальные данные используют нативные механизмы безопасного хранения ОС
|
||||
- **Безопасность CORS**: Локальный HTTP-доступ проксируется через Main, предотвращая CORS-проблемы на стороне рендерера
|
||||
|
||||
### Модель процессов и устранение неполадок шлюза
|
||||
|
||||
- ClawX — это приложение Electron, поэтому **один экземпляр приложения обычно отображается как несколько процессов ОС** (main/renderer/zygote/utility). Это нормально.
|
||||
- Защита единственного экземпляра использует блокировку Electron плюс локальный файл блокировки процессов, предотвращая дублирование запуска приложения в средах с нестабильным IPC/сессионной шиной.
|
||||
- При последовательных обновлениях смешанные старые/новые версии могут иметь асимметричное поведение защиты. Для лучшей надёжности обновите все десктоп-клиенты до одной версии.
|
||||
- Слушатель шлюза OpenClaw должен быть **единственным владельцем**: только один процесс должен слушать `127.0.0.1:18789`.
|
||||
- Для проверки активного слушателя:
|
||||
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
|
||||
- Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen`
|
||||
- Нажатие кнопки закрытия окна (`X`) скрывает ClawX в трей; это **не** полностью закрывает приложение. Используйте меню трея **Quit ClawX** для полного завершения.
|
||||
|
||||
---
|
||||
|
||||
## Варианты использования
|
||||
|
||||
### 🤖 Персональный AI-ассистент
|
||||
Настройте универсального AI-агента, который может отвечать на вопросы, составлять письма, резюмировать документы и помогать с повседневными задачами — всё через чистый десктоп-интерфейс.
|
||||
|
||||
### 📊 Автоматизированный мониторинг
|
||||
Настройте запланированных агентов для отслеживания новостных лент, цен или определённых событий. Результаты доставляются в ваш предпочтительный канал уведомлений.
|
||||
|
||||
### 💻 Производительность разработчика
|
||||
Интегрируйте AI в рабочий процесс разработки. Используйте агентов для проверки кода, генерации документации или автоматизации повторяющихся задач кодирования.
|
||||
|
||||
### 🔄 Автоматизация рабочих процессов
|
||||
Связывайте несколько навыков для создания сложных конвейеров автоматизации. Обрабатывайте данные, преобразовывайте контент и запускайте действия — всё визуально оркестрируется.
|
||||
|
||||
---
|
||||
|
||||
## Разработка
|
||||
|
||||
### Требования
|
||||
|
||||
- **Node.js**: 22.22.3+, 24.15.0+ или 25.9.0+ в пределах соответствующей основной версии (рекомендуется Node 24 LTS)
|
||||
- **Менеджер пакетов**: pnpm 9+ (рекомендуется) или npm
|
||||
|
||||
### Структура проекта
|
||||
|
||||
```
|
||||
ClawX/
|
||||
├── electron/ # Главный процесс Electron
|
||||
│ ├── api/ # Маршрутизатор API и обработчики Main
|
||||
│ │ └── routes/ # Модули маршрутов RPC/HTTP прокси
|
||||
│ ├── services/ # Службы провайдеров, секретов и среды выполнения
|
||||
│ │ ├── providers/ # Логика синхронизации моделей provider/account
|
||||
│ │ └── secrets/ # Связка ключей ОС и хранилище секретов
|
||||
│ ├── shared/ # Общие схемы провайдеров и константы
|
||||
│ │ └── 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/
|
||||
│ ├── e2e/ # Сквозные дымовые тесты Playwright Electron
|
||||
│ └── unit/ # Модульные/интеграционные тесты Vitest
|
||||
├── resources/ # Статические ресуры (иконки, изображения)
|
||||
└── scripts/ # Скрипты сборки и утилит
|
||||
```
|
||||
|
||||
### Доступные команды
|
||||
|
||||
```bash
|
||||
# Разработка
|
||||
pnpm run init # Установить зависимости + скачать uv
|
||||
pnpm dev # Запуск с горячей перезагрузкой (автоподготовка упакованных навыков при отсутствии)
|
||||
|
||||
# Качество кода
|
||||
pnpm lint # Запустить ESLint
|
||||
pnpm typecheck # Проверка типов TypeScript
|
||||
|
||||
# Тестирование
|
||||
pnpm test # Запустить модульные тесты
|
||||
pnpm run test:e2e # Запустить E2E дымовые тесты Electron с Playwright
|
||||
pnpm run test:e2e:headed # Запустить E2E тесты Electron с видимым окном
|
||||
pnpm run comms:replay # Вычислить метрики повторного воспроизведения коммуникаций
|
||||
pnpm run comms:baseline # Обновить базовый снимок коммуникаций
|
||||
pnpm run comms:compare # Сравнить метрики воспроизведения с базовыми порогами
|
||||
|
||||
# Сборка и упаковка
|
||||
pnpm run build:vite # Собрать только фронтенд
|
||||
pnpm build # Полная production-сборка (с ресурсами упаковки)
|
||||
pnpm package # Упаковать для текущей платформы (включает предустановленные навыки)
|
||||
pnpm package:mac # Упаковать для macOS
|
||||
pnpm package:win # Упаковать для Windows
|
||||
pnpm package:linux # Упаковать для Linux
|
||||
```
|
||||
|
||||
На headless Linux запускайте тесты Electron под сервером отображения, например `xvfb-run -a pnpm run test:e2e`.
|
||||
|
||||
Функциональные Electron E2E-тесты локально и в CI по умолчанию используют два worker-процесса Playwright. Число worker-процессов обычной параллельной группы можно настроить через `CLAWX_E2E_WORKERS=<положительное целое>`. Тесты с глобальным состоянием ОС выполняются в однопоточном проекте `exclusive`, а профили производительности хоста запускаются отдельно после функциональных тестов. Новые E2E-тесты параллельны по умолчанию; при работе с реальным буфером обмена или другим общим ресурсом машины используйте `E2E_EXCLUSIVE_TAG` из `tests/e2e/parallel-policy.ts`.
|
||||
|
||||
Чтобы запустить только обычный spec без эксклюзивного предварительного этапа, используйте `pnpm exec playwright test <spec> --project=parallel --no-deps`.
|
||||
|
||||
### Проверка регрессии коммуникаций
|
||||
|
||||
Когда PR изменяет пути коммуникации (события шлюза, поток отправки/получения чата, доставка каналов или откат транспорта), запустите:
|
||||
|
||||
```bash
|
||||
pnpm run comms:replay
|
||||
pnpm run comms:compare
|
||||
```
|
||||
|
||||
`comms-regression` в CI проверяет обязательные сценарии и пороги.
|
||||
|
||||
### E2E-тесты Electron
|
||||
|
||||
Сьют Playwright Electron запускает упакованный рендерер и главный процесс из `dist/` и `dist-electron/`, поэтому не требует предварительного ручного запуска `pnpm dev`.
|
||||
|
||||
`pnpm run test:e2e` автоматически:
|
||||
|
||||
- собирает рендерер и пакеты Electron с `pnpm run build:vite`
|
||||
- запускает Electron в изолированном режиме E2E с временным `HOME`
|
||||
- использует временный каталог `userData` ClawX
|
||||
- параллельно запускает обычные spec-файлы, изолируя тесты глобальных ресурсов и производительности
|
||||
- пропускает тяжёлые побочные эффекты запуска, такие как автозапуск шлюза, установку упакованных навыков, создание трея и автоустановку CLI
|
||||
|
||||
Первые два базовых спецификации покрывают:
|
||||
|
||||
- видимость мастера настройки при первом запуске на чистом профиле
|
||||
- пропуск настройки и навигация на страницу Models внутри приложения Electron
|
||||
|
||||
Добавляйте будущие потоки Electron в `tests/e2e/` и переиспользуйте общий fixture в `tests/e2e/fixtures/electron.ts`.
|
||||
|
||||
### Технологический стек
|
||||
|
||||
| Уровень | Технология |
|
||||
|----------------|-------------------------------|
|
||||
| Среда выполнения | 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. **Откройте** Pull Request
|
||||
|
||||
### Руководящие принципы
|
||||
|
||||
- Следуйте существующему стилю кода (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) – Легковесное управление состоянием
|
||||
|
||||
---
|
||||
|
||||
## Сообщество
|
||||
|
||||
Присоединяйтесь к нашему сообществу, чтобы общаться с другими пользователями, получать поддержку и делиться опытом.
|
||||
|
||||
| WeChat Enterprise | Feishu Group | Discord |
|
||||
| :---: | :---: | :---: |
|
||||
| <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 🚀
|
||||
|
||||
Мы запускаем Партнёрскую программу ClawX и ищем партнёров, которые могут помочь представить ClawX большему числу клиентов, особенно тем, у кого есть потребности в кастомных AI-агентах или автоматизации.
|
||||
|
||||
Партнёры помогают связывать нас с потенциальными пользователями и проектами, а команда 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="Star History Chart" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Лицензия
|
||||
|
||||
ClawX выпускается под [лицензией MIT](LICENSE). Вы можете свободно использовать, модифицировать и распространять это программное обеспечение.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<sub>Создано с ❤️ командой ValueCell</sub>
|
||||
</p>
|
||||
+111
-48
@@ -30,7 +30,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | 简体中文 | <a href="README.ja-JP.md">日本語</a>
|
||||
<a href="README.md">English</a> | 简体中文 | <a href="README.ja-JP.md">日本語</a> | <a href="README.ru-RU.md">Русский</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -43,6 +43,8 @@
|
||||
|
||||
ClawX 预置了最佳实践的模型供应商配置,原生支持 Windows 平台以及多语言设置。当然,你也可以通过 **设置 → 高级 → 开发者模式** 来进行精细的高级配置。
|
||||
|
||||
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">如需完整的企业版、专属服务支持或面向您业务场景的定制化落地辅导,请联系 <a href="mailto:public@valuecell.ai">public@valuecell.ai</a>。</strong></p>
|
||||
|
||||
---
|
||||
|
||||
## 截图预览
|
||||
@@ -82,6 +84,7 @@ ClawX 预置了最佳实践的模型供应商配置,原生支持 Windows 平
|
||||
| 复杂的命令行配置 | 一键安装,配合引导式设置向导 |
|
||||
| 手动编辑配置文件 | 可视化设置界面,实时校验 |
|
||||
| 进程管理繁琐 | 自动管理网关生命周期 |
|
||||
| 应用更新 | 启动时检查新版本,并在下载或安装前提示确认 |
|
||||
| 多 AI 供应商切换 | 统一的供应商配置面板 |
|
||||
| 技能/插件安装复杂 | 内置技能市场与管理界面 |
|
||||
|
||||
@@ -99,32 +102,43 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
|
||||
从安装到第一次 AI 对话,全程通过直观的图形界面完成。无需终端命令,无需 YAML 文件,无需到处寻找环境变量。
|
||||
|
||||
### 💬 智能聊天界面
|
||||
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、Markdown 富文本渲染,以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。
|
||||
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录,并以流式 Markdown 渲染智能体回复,支持带语法高亮的围栏代码块、面向中日韩文本的解析、GitHub 风格表格,以及由 KaTeX 渲染的 LaTeX 数学公式(`$行内$`、`$$块级$$`、`\(行内\)` 和 `\[块级\]`);用户输入则始终按原始文本显示。同时支持在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。围栏代码会保留源码换行、自动软换行,并在流式输出结束后提供本地化的复制操作。
|
||||
从输入框插入的技能会以 `/技能名` 卡片形式显示;点击卡片可在右侧预览栏打开并阅读该技能的 `SKILL.md`。
|
||||
当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
|
||||
会话侧边栏现在以工作空间优先组织:默认工作空间固定在最上方,其它工作空间按自然顺序排列,每个工作空间都可折叠或继续加载更多会话。AI 回复期间,会话行显示加载指示器;未查看的回复完成后显示蓝点;打开会话后恢复显示相对活跃时间,悬停时仍会露出操作按钮。导入的工作空间可从侧边栏标题处重命名,新名称会同步显示在对话输入框下方,同时悬浮标题仍可查看文件系统路径。如果当前所选会话存在有效工作空间,新对话会继承该工作空间,并在首次发送前保持可编辑。对于可编辑的新对话或未绑定对话,输入框的工作空间卡片会打开一个小菜单,列出最近使用及现有会话中的工作空间,并可切回默认工作空间或选择其它目录。如果保存的工作空间文件夹已被移动或删除,Chat 会暂停创建会话并提示选择现有文件夹,而不会持续重试失效路径。不可用的非默认工作空间会在侧边栏显示标记,并可在确认后删除;该操作会永久删除分组中的全部会话。只有永久删除成功后,会话行才会移除且页面才会跳转;删除失败时会保留会话与确认框,方便重试。OpenClaw 生成的 UUID 加日期兜底标题只有在与该会话 ID 匹配时才会被视为缺失标题,随后改用会话的首条用户消息展示,而不会被持久化为会话名称。
|
||||
每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。
|
||||
|
||||
Chat 右侧面板的工作空间和预览选项卡支持以只读方式预览 Markdown、`.docx` 和 `.pptx` 文件。Markdown 文件预览以静态渲染模式提供相同的围栏代码语法高亮、软换行与复制操作、面向中日韩文本的解析和 KaTeX 数学公式支持。预览栏顶部可将当前文件展开至 ClawX 的整个可视区域;再次点击该按钮或按 Esc 即可返回侧栏。旧版 `.doc` 和 `.ppt` 文件不会在应用内预览,而是继续通过操作系统打开。DOCX 的分页效果可能与 Microsoft Word 不同;PPTX 预览不支持动画、切换效果或媒体播放。超过 20 MB 的 Office 文件不会在应用内预览。
|
||||
|
||||
### 本地 HTML 预览
|
||||
Chat 右侧面板只包含工作空间、预览和变更,不再提供通用网页浏览器、主页或地址栏。已授权的本地 `.html` 和 `.htm` 附件、文件活动及工作空间文件默认在预览中打开。文件操作可以选择 ClawX 内置预览或系统应用,预览标题栏也可将当前 HTML 文件交给系统浏览器打开。
|
||||
|
||||
所有链接都不可点击。ClawX 渲染的链接显示为普通文本,HTML 预览中的链接也会移除链接样式和指针交互。HTML 预览同时阻止表单、脚本跳转、重定向、页内跳转、弹窗、下载、网络请求和设备权限;它可以显示自包含的本地 HTML,但无法离开当前选中的文档。
|
||||
|
||||
### 📡 多频道管理
|
||||
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。
|
||||
现在每个频道支持多个账号,并可在 Channels 页面直接完成账号绑定到 Agent 与默认账号切换。
|
||||
对于自定义频道账号 ID,ClawX 现在会强制校验 OpenClaw 兼容的规范格式(`[a-z0-9_-]`、小写、最长 64 位、且必须以字母或数字开头),避免路由匹配异常。
|
||||
ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Channels 页面通过内置二维码流程完成微信连接。
|
||||
|
||||
### ⏰ 定时任务自动化
|
||||
调度 AI 任务自动执行。定义触发器、设置时间间隔,让 AI 智能体 7×24 小时不间断工作。
|
||||
现在定时任务页面已经可以直接配置外部投递,统一拆成“发送账号”和“接收目标”两个下拉选择。对于已支持的通道,接收目标会从通道目录能力或已知会话历史中自动发现,不需要再手动修改 `jobs.json`。
|
||||
已知限制:微信当前不在支持的定时任务投递通道列表内。原因是 `openclaw-weixin` 插件的出站发送依赖实时会话里的 `contextToken`,插件本身不支持 cron 这类主动推送场景。
|
||||
现在定时任务页面已经可以直接配置外部投递,统一拆成“发送账号”和“接收目标”两个下拉选择。对于已支持的通道,接收目标会从通道目录能力或已知会话历史中自动发现,不需要再手动修改 `jobs.json`。任务的消息输入框也支持像主对话框那样以内联 `/skill` 令牌的方式插入技能(按所选智能体范围加载),让定时提示词可以直接触发技能。调度选择器现在分为**周期**和**单次**两个选项卡:周期支持每小时、每天、工作日、每周、自定义(原始 cron)等频率,并内置时间/星期选择;单次则在所选日期(显示星期)和时间执行一次。单次任务必须设置为未来时间,并会在执行完成后由运行时自动清除。
|
||||
|
||||
|
||||
### 🧩 可扩展技能系统
|
||||
通过预构建的技能扩展 AI 智能体的能力。在集成的技能面板中浏览、安装和管理技能——无需包管理器。
|
||||
ClawX 还会内置预装完整的文档处理技能(`pdf`、`xlsx`、`docx`、`pptx`),在启动时自动部署到托管技能目录(默认 `~/.openclaw/skills`),并在首次安装时默认启用。额外预装技能(`find-skills`、`self-improving-agent`、`tavily-search`、`brave-web-search`)也会默认启用;若缺少必需的 API Key,OpenClaw 会在运行时给出配置错误提示。
|
||||
Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、workspace、额外技能目录),并显示每个技能的实际路径,便于直接打开真实安装位置。
|
||||
|
||||
重点搜索技能所需环境变量:
|
||||
- `BRAVE_SEARCH_API_KEY`:用于 `brave-web-search`
|
||||
- `TAVILY_API_KEY`:用于 `tavily-search`(上游运行时也可能支持 OAuth)
|
||||
通过预构建的技能扩展 AI 智能体的能力。集成的 Skills 页面采用“本地优先”方式:会扫描托管目录与 workspace 技能目录,并且无需依赖 Gateway 即可启用或停用技能;在企业扩展接管时,也可以显示扩展提供的 marketplace。
|
||||
ClawX 还会内置预装完整的文档处理技能(`pdf`、`xlsx`、`docx`、`pptx`),在启动时自动部署到托管技能目录(默认 `~/.openclaw/skills`),并在首次安装时默认启用。
|
||||
Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、workspace、额外技能目录),并显示每个技能的实际路径,便于直接打开真实安装位置。对于 OpenClaw 自带的 bundled skills,社区版现在在打包产物里只保留并展示 `skill-creator`;开发模式和打包版启动时都会直接清理其它 bundled skill,同时把这些已删除 bundled skill 在 `openclaw.json` 中残留的旧配置一并移除。
|
||||
|
||||
### 🔐 安全的供应商集成
|
||||
连接多个 AI 供应商(OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuth(Codex 订阅)登录。
|
||||
连接多个 AI 供应商(OpenAI、Anthropic、Z.AI / GLM 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuth(Codex 订阅)登录。
|
||||
在开发者模式下,独立的“图像生成”页面支持配置 OpenAI 兼容生图端点(Base URL、API Key 和模型名,例如 `gpt-image-2`),生图请求会走专用的 `/v1/images/generations` 服务,聊天仍继续使用正常的 OpenAI Provider。
|
||||
如果你通过 **自定义(Custom)Provider** 对接 OpenAI-compatible 网关,可以在 **设置 → AI Providers → 编辑 Provider** 中配置自定义 `User-Agent`,以提高兼容性。
|
||||
编辑或切换 Provider 时,ClawX 会保留已有的模型级能力元数据,例如 `input: ["text", "image"]`。新选择的自定义 Provider 模型会使用与 OpenClaw onboarding 一致的图片输入能力推断;未知模型默认按纯文本模型处理。
|
||||
自定义 Provider 的模型行还会写入显式的 `contextWindow`(按模型系列推断,例如 `gpt-5.x` → 272k),旧版本保存的模型行会在启动时自动回填,使 OpenClaw 能在长会话超限前主动压缩上下文,避免出现 "Context overflow" 报错。当你没有配置 compaction 时,ClawX 会默认写入 `agents.defaults.compaction.mode = "safeguard"` 和 `reserveTokensFloor = 50000`;你手动配置过的模型行或压缩配置永远不会被修改(仅可能回填缺失的 `reserveTokensFloor`)。
|
||||
Z.AI(国内站 / 国际站)会映射到 OpenClaw 内置的 `zai` 供应商(`ZAI_API_KEY`),默认模型为 `glm-5.2`。可通过 Code Plan 预设切换到编码套餐端点(`…/api/coding/paas/v4`),或使用普通 API 端点(`…/api/paas/v4`);国内站与国际站互斥,因为它们共享同一个 OpenClaw 运行时 key。
|
||||
如果兼容网关的 `/models` 因非鉴权原因不可用,ClawX 会在校验 API Key 时使用已配置的模型,自动降级为轻量的 `/chat/completions` 或 `/responses` 探测。
|
||||
|
||||
### 🌙 自适应主题
|
||||
支持浅色模式、深色模式或跟随系统主题。ClawX 自动适应你的偏好设置。
|
||||
@@ -132,6 +146,9 @@ Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、wor
|
||||
### 🚀 开机启动控制
|
||||
在 **设置 → 通用** 中,你可以开启 **开机自动启动**,让 ClawX 在系统登录后自动启动。
|
||||
|
||||
### 🔔 更新提示
|
||||
ClawX 可以在启动时自动检查新版本。发现更新后会显示应用内提示;只有在你选择操作后,才会下载或安装更新。
|
||||
|
||||
---
|
||||
|
||||
## 快速上手
|
||||
@@ -172,8 +189,8 @@ pnpm dev
|
||||
|
||||
如果系统语言在支持列表中,向导会默认选中该语言;否则回退到英文。
|
||||
|
||||
> Moonshot(Kimi)说明:ClawX 默认保持开启 Kimi 的 web search。
|
||||
> 当配置 Moonshot 后,ClawX 也会将 OpenClaw 配置中的 Kimi web search 同步到中国区端点(`https://api.moonshot.cn/v1`)。
|
||||
> Web search 说明:ClawX 会在 Agent 和 Gateway 两层策略中禁用 OpenClaw 的通用 `web_search` 工具。
|
||||
> 这也包括 Moonshot(Kimi)搜索;受管浏览器自动化和 `web_fetch` 仍然可用。
|
||||
|
||||
### 代理设置
|
||||
|
||||
@@ -210,47 +227,69 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问
|
||||
|
||||
ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用统一客户端抽象,协议选择与进程生命周期由 Electron 主进程统一管理:
|
||||
|
||||
```┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ClawX 桌面应用 │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
OpenClaw 配置交付也统一由 Electron Main 管理。Gateway 运行时,ClawX 以 `config.get` 返回的权威快照为基线,并通过 `config.set` 提交修改;Gateway 停止或启动中时,同一个协调器只更新解析后的 JSON5 配置文件,不会因此启动 Gateway。因此,普通的 Provider、Agent、Channel、绑定、Skill 和模型修改不会替换 Gateway 进程。完整重启仅保留给代理等进程启动环境变化和用户显式操作。已确认的进程退出与 WebSocket 关闭继续使用现有的自动重连路径。WebSocket 心跳缺失只更新诊断和健康状态,不会替换 Gateway 进程,因此延迟处理 pong 不会中断长时间运行的任务。认证配置写入 SQLite 后,ClawX 会调用 OpenClaw 的 `secrets.reload`,让运行中的 Agent 无需重启即可读取新凭据。
|
||||
|
||||
Chat 使用由 Electron Main 持有的 ACP stdio bridge。Renderer 接收类型化 host events,并渲染内存中的 ACP timeline。Gateway 仍负责 providers、models、skills、workspace、settings、diagnostics 和 media configuration 等非 Chat 能力。
|
||||
|
||||
打开其它会话或页面时,尚未完成的 ACP 回复仍会继续流式接收。若在回复完成前返回,ClawX 会恢复最新的内存 timeline 并继续显示实时输出;回复完成后,普通 ACP 历史回放仍是唯一事实来源。
|
||||
|
||||
ACP assistant 回合会显示整轮耗时。Live 计时跟随客户端观测到的 prompt 生命周期,并在应用内导航后保持连续;历史耗时由 Electron Main 根据有界的 OpenClaw transcript 时间戳计算,而且只能标注 ACP 回放已经恢复出的回合。
|
||||
|
||||
ACP Chat 会将标准 ACP resource 渲染为附件。用户选择的图片会显示为缩略图,并在悬停蒙层中显示文件名;其它可用的附件卡片会显示文件名,以及灰色、可截断的来源路径。当前 OpenClaw ACP adapter 遗漏 assistant 媒体时,OpenClaw 持久化的规范媒体事实和显式 assistant `MEDIA:` 指令也可恢复为附件卡片,且不会显示仅用于 transcript 的元数据。现有本地文件引用(包括当前 workspace 外的路径)在每次预览或打开前,都会由 Electron Main 按精确的 session 和 generation 重新验证。AI 生成且可预览的本地附件(包括不超过 20 MB 的 `.docx` 和 `.pptx` 文件)会保留主要的只读应用内预览操作,并提供次级菜单,可通过兼容应用打开,或在 Finder、文件资源管理器或系统文件管理器中显示。对于本地 HTML 附件,该菜单第一项会在右侧预览中打开文件。Office 预览在此处也有相同限制:`.doc` 和 `.ppt` 仍通过系统应用打开,DOCX 的分页效果可能与 Microsoft Word 不同,PPTX 的动画、切换效果和媒体播放不受支持。兼容应用发现仅在 macOS 和 Windows 上可用;在 Linux 上或发现失败时,会静默降级为仅显示文件位置。其它本地文件(包括超过 20 MB 的 Office 文件)会在用户点击后通过系统应用打开。用户选择的文件夹附件在发送后也会保持可用,点击后交给系统文件管理器打开;ClawX 不会读取或预览其中内容。远程 HTTP 和 HTTPS 附件会在用户点击后从外部打开。没有规范媒体事实佐证的普通文本裸路径或行内路径不会被当作附件。
|
||||
|
||||
ACP Chat 也可在 runtime 以可信结构化媒体投递图像生成结果时显示生成图片预览。对于可信的 OpenClaw internal-UI 投递和与生图任务关联的最终回复,ClawX 会保留原始的用户可见完成文案,包括只有文本的失败说明,而不会统一替换成通用图片文案。历史 OpenClaw 回放中,assistant 的图片 `MEDIA:` 标记只有在同一会话已记录图像生成任务启动后才会进入内联图片体验。ClawX 通过 Electron Main 的主机媒体处理加载预览,而不是让 Renderer 任意访问文件系统。标准 ACP 图片和 resource 内容仍是首选路径,并会直接渲染。
|
||||
|
||||
### ACP 文件活动语义
|
||||
|
||||
- 文件活动由成功且已完成的 OpenClaw `write`、`edit` 和 `apply_patch` 调用投影而来。工具识别方式与 OpenClaw 官方 Chat UI 保持一致;仅接收已完成调用的筛选规则是 ClawX 特有的。
|
||||
- 已创建和已修改的活动行与可预览的 assistant 附件共用同一种文件卡片外壳和**打开方式**菜单,同时保留状态文字及可用的 `+/-` 统计。对于 HTML 文件,菜单第一项会在右侧**预览**中打开文件;已删除的活动行只保留 **Changes** 操作。应用列表、指定应用打开和显示文件位置都会由 Electron Main 根据 workspace 根目录与相对路径分别重新验证;工具路径不会因此变成附件,Renderer 也不会获得规范化系统路径。
|
||||
- `write` 按工具声明的语义显示:视为创建,并展示为全部新增的差异,即使该路径可能已经存在。
|
||||
- **Changes** 是按时间顺序记录工具声明活动的会话级记录,不是 Git 输出,也不是相对于已验证源码基线的差异。
|
||||
- 对每个文件,Changes 在每轮助手回复中最多展示一个 diff 编辑器。可安全串联的片段会合并,独立片段会拼接到同一个编辑器中,但不会被描述为基于完整文件基线的差异。
|
||||
- Shell 命令、脚本、用户或 IDE 产生的副作用不会被检测。
|
||||
- 完整的 ACP 回放可以恢复已记录的文件活动;如果回放不完整,ClawX 不会通过回退推断来补造缺失活动。
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────┐
|
||||
│ ClawX 桌面应用 │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Electron 主进程 │ │
|
||||
│ │ • 窗口与应用生命周期管理 │ │
|
||||
│ │ • 窗口与应用生命周期管理 │ │
|
||||
│ │ • 网关进程监控 │ │
|
||||
│ │ • 系统集成(托盘、通知、密钥链) │ │
|
||||
│ │ • 自动更新编排 │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ IPC(权威控制面) │
|
||||
│ │ IPC (权威控制面) │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ React 渲染进程 │ │
|
||||
│ │ • 现代组件化 UI(React 19) │ │
|
||||
│ │ • Zustand 状态管理 │ │
|
||||
│ │ • 统一 host-api/api-client 调用 │ │
|
||||
│ │ • Markdown 富文本渲染 │ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ React 渲染进程 │ │
|
||||
│ │ • 现代组件化 UI(React 19) │ │
|
||||
│ │ • Zustand 状态管理 │ │
|
||||
│ │ • 统一 host-api/api-client 调用 │ │
|
||||
│ │ • 回复使用 Markdown,用户输入按原文显示 │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────┬──────────────────────────────────┘
|
||||
└──────────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
│ 主进程统一传输策略
|
||||
│(WS 优先,HTTP 次之,IPC 回退)
|
||||
│ 类型化 IPC 请求
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Host API 与主进程代理层 │
|
||||
│ │
|
||||
│ • hostapi:fetch(主进程代理,规避开发/生产 CORS) │
|
||||
│ • gateway:httpProxy(渲染进程不直连 Gateway HTTP) │
|
||||
│ • 统一错误映射与重试/退避策略 │
|
||||
│ 主进程 Host Services 与 Gateway Manager │
|
||||
│ │
|
||||
│ • host:invoke 类型化服务分发 │
|
||||
│ • 设置、文件、会话、技能、供应商、诊断服务 │
|
||||
│ • 主进程持有 Gateway WebSocket 并负责进程监控 │
|
||||
└──────────────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
│ WS / HTTP / IPC 回退
|
||||
│ 主进程持有 WebSocket
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OpenClaw 网关 │
|
||||
│ │
|
||||
│ • AI 智能体运行时与编排 │
|
||||
│ OpenClaw 网关 │
|
||||
│ │
|
||||
│ • AI 智能体运行时与编排 │
|
||||
│ • 消息频道管理 │
|
||||
│ • 技能/插件执行环境 │
|
||||
│ • 技能/插件执行环境 │
|
||||
│ • 供应商抽象层 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -258,10 +297,11 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
|
||||
|
||||
- **进程隔离**:AI 运行时在独立进程中运行,确保即使在高负载计算期间 UI 也能保持响应
|
||||
- **前端调用单一入口**:渲染层统一走 host-api/api-client,不感知底层协议细节
|
||||
- **主进程掌控传输策略**:WS/HTTP 选择与 IPC 回退在主进程集中处理,提升稳定性
|
||||
- **主进程掌控传输策略**:ACP Chat stdio bridge 与 Gateway 传输都由 Electron Main 持有,渲染进程通过类型化 IPC 调用 Main
|
||||
- **扩展 IPC 贡献点**:主进程扩展通过类型化 IPC 注册表贡献 host-api action,而不是挂载 HTTP route
|
||||
- **优雅恢复**:内置重连、超时、退避逻辑,自动处理瞬时故障
|
||||
- **安全存储**:API 密钥和敏感数据利用操作系统原生的安全存储机制
|
||||
- **CORS 安全**:本地 HTTP 请求由主进程代理,避免渲染进程跨域问题
|
||||
- **CORS 安全**:渲染进程不直接请求本地 Gateway 或 Host API HTTP 端点
|
||||
|
||||
### 进程模型与 Gateway 排障
|
||||
|
||||
@@ -269,6 +309,7 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
|
||||
- 单实例保护同时使用 Electron 自带锁与本地进程文件锁回退机制,可在桌面会话总线异常时避免重复启动。
|
||||
- 滚动升级期间若新旧版本混跑,单实例保护仍可能出现不对称行为。为保证稳定性,建议桌面客户端尽量统一升级到同一版本。
|
||||
- 但 OpenClaw Gateway 监听应始终保持**单实例**:`127.0.0.1:18789` 只能有一个监听者。
|
||||
- Gateway readiness 以 OpenClaw 的 `system-presence`、`health`、`status` 等核心信号为准;memory 或频道失败会显示为能力降级,而不是全局 Gateway 故障。
|
||||
- 可用以下命令确认监听进程:
|
||||
- macOS/Linux:`lsof -nP -iTCP:18789 -sTCP:LISTEN`
|
||||
- Windows(PowerShell):`Get-NetTCPConnection -LocalPort 18789 -State Listen`
|
||||
@@ -296,16 +337,19 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
|
||||
|
||||
### 前置要求
|
||||
|
||||
- **Node.js**:22+(推荐 LTS 版本)
|
||||
- **Node.js**:对应主版本范围内的 22.22.3+、24.15.0+ 或 25.9.0+(推荐 Node 24 LTS)
|
||||
- **包管理器**:pnpm 9+(推荐)或 npm
|
||||
- **Linux(Ubuntu/Debian)**:运行 Electron 前,请先安装所需系统库:
|
||||
```bash
|
||||
sudo apt-get install -y libnss3 libgtk-3-0 libxss1 libxtst6 libatspi2.0-0 libnotify4 xdg-utils
|
||||
```
|
||||
在 Ubuntu 24.04+ 上,部分软件包使用 `t64` 后缀,运行上述命令后 `apt` 会自动选择正确版本。
|
||||
|
||||
### 项目结构
|
||||
|
||||
```ClawX/
|
||||
├── electron/ # Electron 主进程
|
||||
│ ├── api/ # 主进程 API 路由与处理器
|
||||
│ │ └── routes/ # RPC/HTTP 代理路由模块
|
||||
│ ├── services/ # Provider、Secrets 与运行时服务
|
||||
│ ├── services/ # 类型化 Host API、Provider、Secrets 与运行时服务
|
||||
│ │ ├── providers/ # Provider/account 模型同步逻辑
|
||||
│ │ └── secrets/ # 系统钥匙串与密钥存储
|
||||
│ ├── shared/ # 共享 Provider schema/常量
|
||||
@@ -322,6 +366,7 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
|
||||
│ ├── i18n/ # 国际化资源
|
||||
│ └── types/ # TypeScript 类型定义
|
||||
├── tests/
|
||||
│ ├── e2e/ # Playwright Electron 端到端冒烟测试
|
||||
│ └── unit/ # Vitest 单元/集成型测试
|
||||
├── resources/ # 静态资源(图标、图片)
|
||||
└── scripts/ # 构建与工具脚本
|
||||
@@ -330,7 +375,7 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
|
||||
|
||||
```bash
|
||||
# 开发
|
||||
pnpm run init # 安装依赖并下载 uv
|
||||
pnpm run init # 安装依赖并下载捆绑二进制(uv、agent-browser)
|
||||
pnpm dev # 以热重载模式启动(若缺失会自动准备预装技能包)
|
||||
|
||||
# 代码质量
|
||||
@@ -339,6 +384,10 @@ pnpm typecheck # TypeScript 类型检查
|
||||
|
||||
# 测试
|
||||
pnpm test # 运行单元测试
|
||||
pnpm run test:e2e # 运行 Electron E2E 冒烟测试
|
||||
pnpm run test:e2e:headed # 以可见窗口运行 Electron E2E 测试
|
||||
pnpm run perf:chat # 采集合成 Chat 场景的 Renderer/Main CPU Profile
|
||||
pnpm run profile:main # 启动构建产物并在 9229 端口调试 Main
|
||||
pnpm run comms:replay # 计算通信回放指标
|
||||
pnpm run comms:baseline # 刷新通信基线快照
|
||||
pnpm run comms:compare # 将回放指标与基线阈值对比
|
||||
@@ -352,9 +401,23 @@ pnpm package:win # 为 Windows 打包
|
||||
pnpm package:linux # 为 Linux 打包
|
||||
```
|
||||
|
||||
在无头 Linux 环境下,Electron 测试需要显示服务;可使用 `xvfb-run -a pnpm run test:e2e`。
|
||||
|
||||
Electron E2E 功能测试在本地和 CI 中默认使用两个 Playwright worker;可通过 `CLAWX_E2E_WORKERS=<正整数>` 按机器能力调整普通并行通道。访问操作系统全局状态的测试进入单 worker 的 `exclusive` project,主机性能采样则在功能测试结束后独占运行。新增 E2E 测试默认并行;若测试使用真实剪贴板或其他机器级共享资源,请应用 `tests/e2e/parallel-policy.ts` 中的 `E2E_EXCLUSIVE_TAG`。
|
||||
|
||||
如果只需运行一个不依赖独占前置阶段的普通 spec,可使用 `pnpm exec playwright test <spec> --project=parallel --no-deps`。
|
||||
|
||||
### Electron 性能诊断
|
||||
|
||||
`pnpm run perf:chat` 会运行隔离的合成 ACP 负载,分别覆盖流式响应,以及富 Markdown 静态会话中的侧栏和滚动交互,并在 Playwright 的 `test-results/` 目录输出版本化指标与 Renderer/Main CPU Profile。Renderer Profile 覆盖生产 store/render 路径和帧节奏;流式 Main Profile 测量 Main 到 Renderer 的 IPC fanout,交互 Main Profile 用于确认 Renderer 交互期间 Main 是否保持空闲。两者都不包含上游 OpenClaw/ACP 子进程或 GPU 进程路径。CPU Profile 可直接用 Chrome DevTools 打开;其中只包含生成的测试文本,不会上报为产品遥测。性能数据依赖硬件,应在同一机器上多次运行后对比,不应使用统一的跨平台绝对阈值。
|
||||
|
||||
录制真实 Renderer 时,使用 `CLAWX_REMOTE_DEBUGGING_PORT=9223 pnpm dev` 启动开发环境,再让 Playwright 或 Chrome DevTools 连接 `localhost:9223`。录制真实 Electron Main 时,运行 `pnpm run profile:main`,在 `chrome://inspect` 中配置 `localhost:9229` 并选择 Electron Main target。除非正在测量 WebSocket trace 本身,否则不要设置 `CLAWX_GATEWAY_WS_TRACE`。
|
||||
|
||||
ClawX 默认保留 Chromium 硬件加速,使长文档、滚动和布局动画能够使用 GPU 合成与光栅化。若某台机器的显卡驱动存在问题,仍可使用 Chromium 原生的 `--disable-gpu` 命令行参数作为排障回退。
|
||||
|
||||
### 通信回归检查
|
||||
|
||||
当 PR 涉及通信链路(Gateway 事件、Chat 收发流程、Channel 投递、传输回退)时,建议执行:
|
||||
当 PR 涉及通信链路(Gateway 事件、ACP Chat bridge 收发流程、Channel 投递、传输回退)时,建议执行:
|
||||
|
||||
```bash
|
||||
pnpm run comms:replay
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extensions": {
|
||||
"main": [
|
||||
"builtin/diagnostics"
|
||||
],
|
||||
"renderer": []
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { PORTS } from '../utils/config';
|
||||
|
||||
/**
|
||||
* Allowed CORS origins — only the Electron renderer (Vite dev or production)
|
||||
* and the OpenClaw Gateway are permitted to make cross-origin requests.
|
||||
*/
|
||||
const ALLOWED_ORIGINS = new Set([
|
||||
`http://127.0.0.1:${PORTS.CLAWX_DEV}`,
|
||||
`http://localhost:${PORTS.CLAWX_DEV}`,
|
||||
`http://127.0.0.1:${PORTS.OPENCLAW_GATEWAY}`,
|
||||
`http://localhost:${PORTS.OPENCLAW_GATEWAY}`,
|
||||
]);
|
||||
|
||||
export async function parseJsonBody<T>(req: IncomingMessage): Promise<T> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
||||
if (!raw) {
|
||||
return {} as T;
|
||||
}
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that mutation requests (POST/PUT/DELETE) carry a JSON Content-Type.
|
||||
* This prevents "simple request" CSRF where the browser skips the preflight
|
||||
* when Content-Type is text/plain or application/x-www-form-urlencoded.
|
||||
*/
|
||||
export function requireJsonContentType(req: IncomingMessage): boolean {
|
||||
if (req.method === 'GET' || req.method === 'OPTIONS' || req.method === 'HEAD') {
|
||||
return true;
|
||||
}
|
||||
// Requests without a body (content-length 0 or absent) are safe — CSRF
|
||||
// "simple request" attacks rely on sending a crafted body.
|
||||
const contentLength = req.headers['content-length'];
|
||||
if (contentLength === '0' || contentLength === undefined) {
|
||||
return true;
|
||||
}
|
||||
const ct = req.headers['content-type'] || '';
|
||||
return ct.includes('application/json');
|
||||
}
|
||||
|
||||
export function setCorsHeaders(res: ServerResponse, origin?: string): void {
|
||||
// Only reflect the Origin header back if it is in the allow-list.
|
||||
// Omitting the header for unknown origins causes the browser to block
|
||||
// the response — this is the intended behavior for untrusted callers.
|
||||
if (origin && ALLOWED_ORIGINS.has(origin)) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
}
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
}
|
||||
|
||||
export function sendJson(res: ServerResponse, statusCode: number, payload: unknown): void {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function sendNoContent(res: ServerResponse): void {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
}
|
||||
|
||||
export function sendText(res: ServerResponse, statusCode: number, text: string): void {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.end(text);
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import {
|
||||
assignChannelToAgent,
|
||||
clearChannelBinding,
|
||||
createAgent,
|
||||
deleteAgentConfig,
|
||||
listAgentsSnapshot,
|
||||
removeAgentWorkspaceDirectory,
|
||||
resolveAccountIdForAgent,
|
||||
updateAgentModel,
|
||||
updateAgentName,
|
||||
} from '../../utils/agent-config';
|
||||
import { deleteChannelAccountConfig } from '../../utils/channel-config';
|
||||
import { syncAgentModelOverrideToRuntime, syncAllProviderAuthToRuntime } from '../../services/providers/provider-runtime-sync';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
function scheduleGatewayReload(ctx: HostApiContext, reason: string): void {
|
||||
if (ctx.gatewayManager.getStatus().state !== 'stopped') {
|
||||
ctx.gatewayManager.debouncedReload();
|
||||
return;
|
||||
}
|
||||
void reason;
|
||||
}
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Force a full Gateway process restart after agent deletion.
|
||||
*
|
||||
* A SIGUSR1 in-process reload is NOT sufficient here: channel plugins
|
||||
* (e.g. Feishu) maintain long-lived WebSocket connections to external
|
||||
* services and do not disconnect accounts that were removed from the
|
||||
* config during an in-process reload. The only reliable way to drop
|
||||
* stale bot connections is to kill the Gateway process entirely and
|
||||
* spawn a fresh one that reads the updated openclaw.json from scratch.
|
||||
*/
|
||||
export async function restartGatewayForAgentDeletion(ctx: HostApiContext): Promise<void> {
|
||||
try {
|
||||
// Capture the PID of the running Gateway BEFORE stop() clears it.
|
||||
const status = ctx.gatewayManager.getStatus();
|
||||
const pid = status.pid;
|
||||
const port = status.port;
|
||||
console.log('[agents] Triggering Gateway restart (kill+respawn) after agent deletion', { pid, port });
|
||||
|
||||
// Force-kill the Gateway process by PID. The manager's stop() only
|
||||
// kills "owned" processes; if the manager connected to an already-
|
||||
// running Gateway (ownsProcess=false), stop() simply closes the WS
|
||||
// and the old process stays alive with its stale channel connections.
|
||||
if (pid) {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
await execAsync(`taskkill /F /PID ${pid} /T`);
|
||||
} else {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
// Give it a moment to die
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
try { process.kill(pid, 0); process.kill(pid, 'SIGKILL'); } catch { /* already dead */ }
|
||||
}
|
||||
} catch {
|
||||
// process already gone – that's fine
|
||||
}
|
||||
} else if (port) {
|
||||
// If we don't know the PID (e.g. connected to an orphaned Gateway from
|
||||
// a previous pnpm dev run), forcefully kill whatever is on the port.
|
||||
try {
|
||||
if (process.platform === 'darwin' || process.platform === 'linux') {
|
||||
// MUST use -sTCP:LISTEN. Otherwise lsof returns the client process (ClawX itself)
|
||||
// that has an ESTABLISHED WebSocket connection to the port, causing us to kill ourselves.
|
||||
const { stdout } = await execAsync(`lsof -t -i :${port} -sTCP:LISTEN`);
|
||||
const pids = stdout.trim().split('\n').filter(Boolean);
|
||||
for (const p of pids) {
|
||||
try { process.kill(parseInt(p, 10), 'SIGTERM'); } catch { /* ignore */ }
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
for (const p of pids) {
|
||||
try { process.kill(parseInt(p, 10), 'SIGKILL'); } catch { /* ignore */ }
|
||||
}
|
||||
} else if (process.platform === 'win32') {
|
||||
// Find PID listening on the port
|
||||
const { stdout } = await execAsync(`netstat -ano | findstr :${port}`);
|
||||
const lines = stdout.trim().split('\n');
|
||||
const pids = new Set<string>();
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 5 && parts[1].endsWith(`:${port}`) && parts[3] === 'LISTENING') {
|
||||
pids.add(parts[4]);
|
||||
}
|
||||
}
|
||||
for (const p of pids) {
|
||||
try { await execAsync(`taskkill /F /PID ${p} /T`); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Port might not be bound or command failed; ignore
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.gatewayManager.restart();
|
||||
console.log('[agents] Gateway restart completed after agent deletion');
|
||||
} catch (err) {
|
||||
console.warn('[agents] Gateway restart after agent deletion failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleAgentRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/agents' && req.method === 'GET') {
|
||||
sendJson(res, 200, { success: true, ...(await listAgentsSnapshot()) });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/agents' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ name: string; inheritWorkspace?: boolean }>(req);
|
||||
const snapshot = await createAgent(body.name, { inheritWorkspace: body.inheritWorkspace });
|
||||
// Sync provider API keys to the new agent's auth-profiles.json so the
|
||||
// embedded runner can authenticate with LLM providers when messages
|
||||
// arrive via channel bots (e.g. Feishu). Without this, the copied
|
||||
// auth-profiles.json may contain a stale key → 401 from the LLM.
|
||||
syncAllProviderAuthToRuntime().catch((err) => {
|
||||
console.warn('[agents] Failed to sync provider auth after agent creation:', err);
|
||||
});
|
||||
scheduleGatewayReload(ctx, 'create-agent');
|
||||
sendJson(res, 200, { success: true, ...snapshot });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/agents/') && req.method === 'PUT') {
|
||||
const suffix = url.pathname.slice('/api/agents/'.length);
|
||||
const parts = suffix.split('/').filter(Boolean);
|
||||
|
||||
if (parts.length === 1) {
|
||||
try {
|
||||
const body = await parseJsonBody<{ name: string }>(req);
|
||||
const agentId = decodeURIComponent(parts[0]);
|
||||
const snapshot = await updateAgentName(agentId, body.name);
|
||||
scheduleGatewayReload(ctx, 'update-agent');
|
||||
sendJson(res, 200, { success: true, ...snapshot });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parts.length === 2 && parts[1] === 'model') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ modelRef?: string | null }>(req);
|
||||
const agentId = decodeURIComponent(parts[0]);
|
||||
const snapshot = await updateAgentModel(agentId, body.modelRef ?? null);
|
||||
try {
|
||||
await syncAllProviderAuthToRuntime();
|
||||
// Ensure this agent's runtime model registry reflects the new model override.
|
||||
await syncAgentModelOverrideToRuntime(agentId);
|
||||
} catch (syncError) {
|
||||
console.warn('[agents] Failed to sync runtime after updating agent model:', syncError);
|
||||
}
|
||||
scheduleGatewayReload(ctx, 'update-agent-model');
|
||||
sendJson(res, 200, { success: true, ...snapshot });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parts.length === 3 && parts[1] === 'channels') {
|
||||
try {
|
||||
const agentId = decodeURIComponent(parts[0]);
|
||||
const channelType = decodeURIComponent(parts[2]);
|
||||
const snapshot = await assignChannelToAgent(agentId, channelType);
|
||||
scheduleGatewayReload(ctx, 'assign-channel');
|
||||
sendJson(res, 200, { success: true, ...snapshot });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/agents/') && req.method === 'DELETE') {
|
||||
const suffix = url.pathname.slice('/api/agents/'.length);
|
||||
const parts = suffix.split('/').filter(Boolean);
|
||||
|
||||
if (parts.length === 1) {
|
||||
try {
|
||||
const agentId = decodeURIComponent(parts[0]);
|
||||
const { snapshot, removedEntry } = await deleteAgentConfig(agentId);
|
||||
// Await reload synchronously BEFORE responding to the client.
|
||||
// This ensures the Feishu plugin has disconnected the deleted bot
|
||||
// before the UI shows "delete success" and the user tries chatting.
|
||||
await restartGatewayForAgentDeletion(ctx);
|
||||
// Delete workspace after reload so the new config is already live.
|
||||
await removeAgentWorkspaceDirectory(removedEntry).catch((err) => {
|
||||
console.warn('[agents] Failed to remove workspace after agent deletion:', err);
|
||||
});
|
||||
sendJson(res, 200, { success: true, ...snapshot });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parts.length === 3 && parts[1] === 'channels') {
|
||||
try {
|
||||
const agentId = decodeURIComponent(parts[0]);
|
||||
const channelType = decodeURIComponent(parts[2]);
|
||||
const ownerId = agentId.trim().toLowerCase();
|
||||
const snapshotBefore = await listAgentsSnapshot();
|
||||
const ownedAccountIds = Object.entries(snapshotBefore.channelAccountOwners)
|
||||
.filter(([channelAccountKey, owner]) => {
|
||||
if (owner !== ownerId) return false;
|
||||
return channelAccountKey.startsWith(`${channelType}:`);
|
||||
})
|
||||
.map(([channelAccountKey]) => channelAccountKey.slice(channelAccountKey.indexOf(':') + 1));
|
||||
// Backward compatibility for legacy agentId->accountId mapping.
|
||||
if (ownedAccountIds.length === 0) {
|
||||
const legacyAccountId = resolveAccountIdForAgent(agentId);
|
||||
if (snapshotBefore.channelAccountOwners[`${channelType}:${legacyAccountId}`] === ownerId) {
|
||||
ownedAccountIds.push(legacyAccountId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const accountId of ownedAccountIds) {
|
||||
await deleteChannelAccountConfig(channelType, accountId);
|
||||
await clearChannelBinding(channelType, accountId);
|
||||
}
|
||||
const snapshot = await listAgentsSnapshot();
|
||||
scheduleGatewayReload(ctx, 'remove-agent-channel');
|
||||
sendJson(res, 200, { success: true, ...snapshot });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../../utils/openclaw-doctor';
|
||||
|
||||
export async function handleAppRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/events' && req.method === 'GET') {
|
||||
// CORS headers are already set by the server middleware.
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
res.write(': connected\n\n');
|
||||
ctx.eventBus.addSseClient(res);
|
||||
// Send a current-state snapshot immediately so renderer subscribers do not
|
||||
// miss lifecycle transitions that happened before the SSE connection opened.
|
||||
res.write(`event: gateway:status\ndata: ${JSON.stringify(ctx.gatewayManager.getStatus())}\n\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/app/openclaw-doctor' && req.method === 'POST') {
|
||||
const body = await parseJsonBody<{ mode?: 'diagnose' | 'fix' }>(req);
|
||||
const mode = body.mode === 'fix' ? 'fix' : 'diagnose';
|
||||
sendJson(res, 200, mode === 'fix' ? await runOpenClawDoctorFix() : await runOpenClawDoctor());
|
||||
return true;
|
||||
}
|
||||
|
||||
// OPTIONS is handled by the server middleware; no route-level handler needed.
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,568 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { join } from 'node:path';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
import { getOpenClawConfigDir } from '../../utils/paths';
|
||||
import { toOpenClawChannelType, toUiChannelType } from '../../utils/channel-alias';
|
||||
|
||||
interface GatewayCronJob {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
schedule: { kind: string; expr?: string; everyMs?: number; at?: string; tz?: string };
|
||||
payload: { kind: string; message?: string; text?: string };
|
||||
delivery?: { mode: string; channel?: string; to?: string; accountId?: string };
|
||||
sessionTarget?: string;
|
||||
state: {
|
||||
nextRunAtMs?: number;
|
||||
runningAtMs?: number;
|
||||
lastRunAtMs?: number;
|
||||
lastStatus?: string;
|
||||
lastError?: string;
|
||||
lastDurationMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface CronRunLogEntry {
|
||||
jobId?: string;
|
||||
action?: string;
|
||||
status?: string;
|
||||
error?: string;
|
||||
summary?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
ts?: number;
|
||||
runAtMs?: number;
|
||||
durationMs?: number;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
interface CronSessionKeyParts {
|
||||
agentId: string;
|
||||
jobId: string;
|
||||
runSessionId?: string;
|
||||
}
|
||||
|
||||
interface CronSessionFallbackMessage {
|
||||
id: string;
|
||||
role: 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
|
||||
if (!sessionKey.startsWith('agent:')) return null;
|
||||
const parts = sessionKey.split(':');
|
||||
if (parts.length < 4 || parts[2] !== 'cron') return null;
|
||||
|
||||
const agentId = parts[1] || 'main';
|
||||
const jobId = parts[3];
|
||||
if (!jobId) return null;
|
||||
|
||||
if (parts.length === 4) {
|
||||
return { agentId, jobId };
|
||||
}
|
||||
|
||||
if (parts.length === 6 && parts[4] === 'run' && parts[5]) {
|
||||
return { agentId, jobId, runSessionId: parts[5] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeTimestampMs(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value < 1e12 ? value * 1000 : value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Date.parse(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatDuration(durationMs: number | undefined): string | null {
|
||||
if (!durationMs || !Number.isFinite(durationMs)) return null;
|
||||
if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
|
||||
if (durationMs < 10_000) return `${(durationMs / 1000).toFixed(1)}s`;
|
||||
return `${Math.round(durationMs / 1000)}s`;
|
||||
}
|
||||
|
||||
function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSessionFallbackMessage | null {
|
||||
const timestamp = normalizeTimestampMs(entry.ts) ?? normalizeTimestampMs(entry.runAtMs);
|
||||
if (!timestamp) return null;
|
||||
|
||||
const status = typeof entry.status === 'string' ? entry.status.toLowerCase() : '';
|
||||
const summary = typeof entry.summary === 'string' ? entry.summary.trim() : '';
|
||||
const error = typeof entry.error === 'string' ? entry.error.trim() : '';
|
||||
let content = summary || error;
|
||||
|
||||
if (!content) {
|
||||
content = status === 'error'
|
||||
? 'Scheduled task failed.'
|
||||
: 'Scheduled task completed.';
|
||||
}
|
||||
|
||||
if (status === 'error' && !content.toLowerCase().startsWith('run failed:')) {
|
||||
content = `Run failed: ${content}`;
|
||||
}
|
||||
|
||||
const meta: string[] = [];
|
||||
const duration = formatDuration(entry.durationMs);
|
||||
if (duration) meta.push(`Duration: ${duration}`);
|
||||
if (entry.provider && entry.model) {
|
||||
meta.push(`Model: ${entry.provider}/${entry.model}`);
|
||||
} else if (entry.model) {
|
||||
meta.push(`Model: ${entry.model}`);
|
||||
}
|
||||
if (meta.length > 0) {
|
||||
content = `${content}\n\n${meta.join(' | ')}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `cron-run-${entry.sessionId ?? entry.ts ?? index}`,
|
||||
role: status === 'error' ? 'system' : 'assistant',
|
||||
content,
|
||||
timestamp,
|
||||
...(status === 'error' ? { isError: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function readCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
|
||||
const logPath = join(getOpenClawConfigDir(), 'cron', 'runs', `${jobId}.jsonl`);
|
||||
const raw = await readFile(logPath, 'utf8').catch(() => '');
|
||||
if (!raw.trim()) return [];
|
||||
|
||||
const entries: CronRunLogEntry[] = [];
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const entry = JSON.parse(trimmed) as CronRunLogEntry;
|
||||
if (!entry || entry.jobId !== jobId) continue;
|
||||
if (entry.action && entry.action !== 'finished') continue;
|
||||
entries.push(entry);
|
||||
} catch {
|
||||
// Ignore malformed log lines so one bad entry does not hide the rest.
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function readSessionStoreEntry(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
const storePath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json');
|
||||
const raw = await readFile(storePath, 'utf8').catch(() => '');
|
||||
if (!raw.trim()) return undefined;
|
||||
|
||||
try {
|
||||
const store = JSON.parse(raw) as Record<string, unknown>;
|
||||
const directEntry = store[sessionKey];
|
||||
if (directEntry && typeof directEntry === 'object') {
|
||||
return directEntry as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const sessions = (store as { sessions?: unknown }).sessions;
|
||||
if (Array.isArray(sessions)) {
|
||||
const arrayEntry = sessions.find((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return false;
|
||||
const record = entry as Record<string, unknown>;
|
||||
return record.key === sessionKey || record.sessionKey === sessionKey;
|
||||
});
|
||||
if (arrayEntry && typeof arrayEntry === 'object') {
|
||||
return arrayEntry as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildCronSessionFallbackMessages(params: {
|
||||
sessionKey: string;
|
||||
job?: Pick<GatewayCronJob, 'name' | 'payload' | 'state'>;
|
||||
runs: CronRunLogEntry[];
|
||||
sessionEntry?: { label?: string; updatedAt?: number };
|
||||
limit?: number;
|
||||
}): CronSessionFallbackMessage[] {
|
||||
const parsed = parseCronSessionKey(params.sessionKey);
|
||||
if (!parsed) return [];
|
||||
|
||||
const matchingRuns = params.runs
|
||||
.filter((entry) => {
|
||||
if (!parsed.runSessionId) return true;
|
||||
return entry.sessionId === parsed.runSessionId
|
||||
|| entry.sessionKey === `${params.sessionKey}`;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const left = normalizeTimestampMs(a.ts) ?? normalizeTimestampMs(a.runAtMs) ?? 0;
|
||||
const right = normalizeTimestampMs(b.ts) ?? normalizeTimestampMs(b.runAtMs) ?? 0;
|
||||
return left - right;
|
||||
});
|
||||
|
||||
const messages: CronSessionFallbackMessage[] = [];
|
||||
const prompt = params.job?.payload?.message || params.job?.payload?.text || '';
|
||||
const taskName = params.job?.name?.trim()
|
||||
|| params.sessionEntry?.label?.replace(/^Cron:\s*/, '').trim()
|
||||
|| '';
|
||||
const firstRelevantTimestamp = matchingRuns.length > 0
|
||||
? (normalizeTimestampMs(matchingRuns[0]?.runAtMs) ?? normalizeTimestampMs(matchingRuns[0]?.ts))
|
||||
: (normalizeTimestampMs(params.job?.state?.runningAtMs) ?? params.sessionEntry?.updatedAt);
|
||||
|
||||
if (taskName || prompt) {
|
||||
const lines = [taskName ? `Scheduled task: ${taskName}` : 'Scheduled task'];
|
||||
if (prompt) lines.push(`Prompt: ${prompt}`);
|
||||
messages.push({
|
||||
id: `cron-meta-${parsed.jobId}`,
|
||||
role: 'system',
|
||||
content: lines.join('\n'),
|
||||
timestamp: Math.max(0, (firstRelevantTimestamp ?? Date.now()) - 1),
|
||||
});
|
||||
}
|
||||
|
||||
matchingRuns.forEach((entry, index) => {
|
||||
const message = buildCronRunMessage(entry, index);
|
||||
if (message) messages.push(message);
|
||||
});
|
||||
|
||||
if (matchingRuns.length === 0) {
|
||||
const runningAt = normalizeTimestampMs(params.job?.state?.runningAtMs);
|
||||
if (runningAt) {
|
||||
messages.push({
|
||||
id: `cron-running-${parsed.jobId}`,
|
||||
role: 'system',
|
||||
content: 'This scheduled task is still running in OpenClaw, but no chat transcript is available yet.',
|
||||
timestamp: runningAt,
|
||||
});
|
||||
} else if (messages.length === 0) {
|
||||
messages.push({
|
||||
id: `cron-empty-${parsed.jobId}`,
|
||||
role: 'system',
|
||||
content: 'No chat transcript is available for this scheduled task yet.',
|
||||
timestamp: params.sessionEntry?.updatedAt ?? Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const limit = typeof params.limit === 'number' && Number.isFinite(params.limit)
|
||||
? Math.max(1, Math.floor(params.limit))
|
||||
: messages.length;
|
||||
return messages.slice(-limit);
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type GatewayCronDelivery = NonNullable<GatewayCronJob['delivery']>;
|
||||
|
||||
function getUnsupportedCronDeliveryError(channel: string | undefined): string | null {
|
||||
if (!channel) return null;
|
||||
return toUiChannelType(channel) === 'wechat'
|
||||
? 'WeChat scheduled delivery is not supported because the plugin requires a live conversation context token.'
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeCronDelivery(
|
||||
rawDelivery: unknown,
|
||||
fallbackMode: GatewayCronDelivery['mode'] = 'none',
|
||||
): GatewayCronDelivery {
|
||||
if (!rawDelivery || typeof rawDelivery !== 'object') {
|
||||
return { mode: fallbackMode };
|
||||
}
|
||||
|
||||
const delivery = rawDelivery as JsonRecord;
|
||||
const mode = typeof delivery.mode === 'string' && delivery.mode.trim()
|
||||
? delivery.mode.trim()
|
||||
: fallbackMode;
|
||||
const channel = typeof delivery.channel === 'string' && delivery.channel.trim()
|
||||
? toOpenClawChannelType(delivery.channel.trim())
|
||||
: undefined;
|
||||
const to = typeof delivery.to === 'string' && delivery.to.trim()
|
||||
? delivery.to.trim()
|
||||
: undefined;
|
||||
const accountId = typeof delivery.accountId === 'string' && delivery.accountId.trim()
|
||||
? delivery.accountId.trim()
|
||||
: undefined;
|
||||
|
||||
if (mode === 'announce' && !channel) {
|
||||
return { mode: 'none' };
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
...(channel ? { channel } : {}),
|
||||
...(to ? { to } : {}),
|
||||
...(accountId ? { accountId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCronDeliveryPatch(rawDelivery: unknown): Record<string, unknown> {
|
||||
if (!rawDelivery || typeof rawDelivery !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const delivery = rawDelivery as JsonRecord;
|
||||
const patch: Record<string, unknown> = {};
|
||||
if ('mode' in delivery) {
|
||||
patch.mode = typeof delivery.mode === 'string' && delivery.mode.trim()
|
||||
? delivery.mode.trim()
|
||||
: 'none';
|
||||
}
|
||||
if ('channel' in delivery) {
|
||||
patch.channel = typeof delivery.channel === 'string' && delivery.channel.trim()
|
||||
? toOpenClawChannelType(delivery.channel.trim())
|
||||
: '';
|
||||
}
|
||||
if ('to' in delivery) {
|
||||
patch.to = typeof delivery.to === 'string' ? delivery.to : '';
|
||||
}
|
||||
if ('accountId' in delivery) {
|
||||
patch.accountId = typeof delivery.accountId === 'string' ? delivery.accountId : '';
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
function buildCronUpdatePatch(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const patch = { ...input };
|
||||
|
||||
if (typeof patch.schedule === 'string') {
|
||||
patch.schedule = { kind: 'cron', expr: patch.schedule };
|
||||
}
|
||||
|
||||
if (typeof patch.message === 'string') {
|
||||
patch.payload = { kind: 'agentTurn', message: patch.message };
|
||||
delete patch.message;
|
||||
}
|
||||
|
||||
if ('delivery' in patch) {
|
||||
patch.delivery = normalizeCronDeliveryPatch(patch.delivery);
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
function transformCronJob(job: GatewayCronJob) {
|
||||
const message = job.payload?.message || job.payload?.text || '';
|
||||
const gatewayDelivery = normalizeCronDelivery(job.delivery);
|
||||
const channelType = gatewayDelivery.channel ? toUiChannelType(gatewayDelivery.channel) : undefined;
|
||||
const delivery = channelType
|
||||
? { ...gatewayDelivery, channel: channelType }
|
||||
: gatewayDelivery;
|
||||
const target = channelType
|
||||
? {
|
||||
channelType,
|
||||
channelId: delivery.accountId || gatewayDelivery.channel,
|
||||
channelName: channelType,
|
||||
recipient: delivery.to,
|
||||
}
|
||||
: undefined;
|
||||
const lastRun = job.state?.lastRunAtMs
|
||||
? {
|
||||
time: new Date(job.state.lastRunAtMs).toISOString(),
|
||||
success: job.state.lastStatus === 'ok',
|
||||
error: job.state.lastError,
|
||||
duration: job.state.lastDurationMs,
|
||||
}
|
||||
: undefined;
|
||||
const nextRun = job.state?.nextRunAtMs
|
||||
? new Date(job.state.nextRunAtMs).toISOString()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
message,
|
||||
schedule: job.schedule,
|
||||
delivery,
|
||||
target,
|
||||
enabled: job.enabled,
|
||||
createdAt: new Date(job.createdAtMs).toISOString(),
|
||||
updatedAt: new Date(job.updatedAtMs).toISOString(),
|
||||
lastRun,
|
||||
nextRun,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
delivery?: GatewayCronDelivery;
|
||||
enabled?: boolean;
|
||||
}>(req);
|
||||
const delivery = normalizeCronDelivery(input.delivery);
|
||||
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(delivery.channel);
|
||||
if (delivery.mode === 'announce' && unsupportedDeliveryError) {
|
||||
sendJson(res, 400, { success: false, error: unsupportedDeliveryError });
|
||||
return true;
|
||||
}
|
||||
const result = await ctx.gatewayManager.rpc('cron.add', {
|
||||
name: input.name,
|
||||
schedule: { kind: 'cron', expr: input.schedule },
|
||||
payload: { kind: 'agentTurn', message: input.message },
|
||||
enabled: input.enabled ?? true,
|
||||
wakeMode: 'next-heartbeat',
|
||||
sessionTarget: 'isolated',
|
||||
delivery,
|
||||
});
|
||||
sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result);
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/cron/jobs/') && req.method === 'PUT') {
|
||||
try {
|
||||
const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length));
|
||||
const input = await parseJsonBody<Record<string, unknown>>(req);
|
||||
const patch = buildCronUpdatePatch(input);
|
||||
const deliveryPatch = patch.delivery && typeof patch.delivery === 'object'
|
||||
? patch.delivery as Record<string, unknown>
|
||||
: undefined;
|
||||
const deliveryChannel = typeof deliveryPatch?.channel === 'string' && deliveryPatch.channel.trim()
|
||||
? deliveryPatch.channel.trim()
|
||||
: undefined;
|
||||
const deliveryMode = typeof deliveryPatch?.mode === 'string' && deliveryPatch.mode.trim()
|
||||
? deliveryPatch.mode.trim()
|
||||
: undefined;
|
||||
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(deliveryChannel);
|
||||
if (unsupportedDeliveryError && deliveryMode !== 'none') {
|
||||
sendJson(res, 400, { success: false, error: unsupportedDeliveryError });
|
||||
return true;
|
||||
}
|
||||
const result = await ctx.gatewayManager.rpc('cron.update', { id, patch });
|
||||
sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result);
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/cron/jobs/') && req.method === 'DELETE') {
|
||||
try {
|
||||
const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length));
|
||||
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.remove', { id }));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/cron/toggle' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ id: string; enabled: boolean }>(req);
|
||||
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.update', { id: body.id, patch: { enabled: body.enabled } }));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/cron/trigger' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ id: string }>(req);
|
||||
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.run', { id: body.id, mode: 'force' }));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { PORTS } from '../../utils/config';
|
||||
import { buildOpenClawControlUiUrl } from '../../utils/openclaw-control-ui';
|
||||
import { getSetting } from '../../utils/store';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
export async function handleGatewayRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/app/gateway-info' && req.method === 'GET') {
|
||||
const status = ctx.gatewayManager.getStatus();
|
||||
const token = await getSetting('gatewayToken');
|
||||
const port = status.port || PORTS.OPENCLAW_GATEWAY;
|
||||
sendJson(res, 200, {
|
||||
wsUrl: `ws://127.0.0.1:${port}/ws`,
|
||||
token,
|
||||
port,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/gateway/status' && req.method === 'GET') {
|
||||
sendJson(res, 200, ctx.gatewayManager.getStatus());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/gateway/health' && req.method === 'GET') {
|
||||
const health = await ctx.gatewayManager.checkHealth();
|
||||
sendJson(res, 200, health);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/gateway/start' && req.method === 'POST') {
|
||||
try {
|
||||
await ctx.gatewayManager.start();
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/gateway/stop' && req.method === 'POST') {
|
||||
try {
|
||||
await ctx.gatewayManager.stop();
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/gateway/restart' && req.method === 'POST') {
|
||||
try {
|
||||
await ctx.gatewayManager.restart();
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/gateway/control-ui' && req.method === 'GET') {
|
||||
try {
|
||||
const status = ctx.gatewayManager.getStatus();
|
||||
const token = await getSetting('gatewayToken');
|
||||
const port = status.port || PORTS.OPENCLAW_GATEWAY;
|
||||
const urlValue = buildOpenClawControlUiUrl(port, token);
|
||||
sendJson(res, 200, { success: true, url: urlValue, token, port });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/chat/send-with-media' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{
|
||||
sessionKey: string;
|
||||
message: string;
|
||||
deliver?: boolean;
|
||||
idempotencyKey: string;
|
||||
media?: Array<{ filePath: string; mimeType: string; fileName: string }>;
|
||||
}>(req);
|
||||
const VISION_MIME_TYPES = new Set([
|
||||
'image/png', 'image/jpeg', 'image/bmp', 'image/webp',
|
||||
]);
|
||||
const imageAttachments: Array<{ content: string; mimeType: string; fileName: string }> = [];
|
||||
const fileReferences: string[] = [];
|
||||
if (body.media && body.media.length > 0) {
|
||||
const fsP = await import('node:fs/promises');
|
||||
for (const m of body.media) {
|
||||
fileReferences.push(`[media attached: ${m.filePath} (${m.mimeType}) | ${m.filePath}]`);
|
||||
if (VISION_MIME_TYPES.has(m.mimeType)) {
|
||||
const fileBuffer = await fsP.readFile(m.filePath);
|
||||
imageAttachments.push({
|
||||
content: fileBuffer.toString('base64'),
|
||||
mimeType: m.mimeType,
|
||||
fileName: m.fileName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message = fileReferences.length > 0
|
||||
? [body.message, ...fileReferences].filter(Boolean).join('\n')
|
||||
: body.message;
|
||||
const rpcParams: Record<string, unknown> = {
|
||||
sessionKey: body.sessionKey,
|
||||
message,
|
||||
deliver: body.deliver ?? false,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
};
|
||||
if (imageAttachments.length > 0) {
|
||||
rpcParams.attachments = imageAttachments;
|
||||
}
|
||||
const result = await ctx.gatewayManager.rpc('chat.send', rpcParams, 120000);
|
||||
sendJson(res, 200, { success: true, result });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import {
|
||||
type ProviderConfig,
|
||||
} from '../../utils/secure-storage';
|
||||
import {
|
||||
getProviderConfig,
|
||||
} from '../../utils/provider-registry';
|
||||
import { deviceOAuthManager, type OAuthProviderType } from '../../utils/device-oauth';
|
||||
import { browserOAuthManager, type BrowserOAuthProviderType } from '../../utils/browser-oauth';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
import {
|
||||
syncDefaultProviderToRuntime,
|
||||
syncDeletedProviderApiKeyToRuntime,
|
||||
syncDeletedProviderToRuntime,
|
||||
syncProviderApiKeyToRuntime,
|
||||
syncSavedProviderToRuntime,
|
||||
syncUpdatedProviderToRuntime,
|
||||
} from '../../services/providers/provider-runtime-sync';
|
||||
import { validateApiKeyWithProvider } from '../../services/providers/provider-validation';
|
||||
import { getProviderService } from '../../services/providers/provider-service';
|
||||
import { providerAccountToConfig } from '../../services/providers/provider-store';
|
||||
import type { ProviderAccount } from '../../shared/providers/types';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const legacyProviderRoutesWarned = new Set<string>();
|
||||
|
||||
function hasObjectChanges<T extends Record<string, unknown>>(
|
||||
existing: T,
|
||||
patch: Partial<T> | undefined,
|
||||
): boolean {
|
||||
if (!patch) return false;
|
||||
const keys = Object.keys(patch) as Array<keyof T>;
|
||||
if (keys.length === 0) return false;
|
||||
return keys.some((key) => JSON.stringify(existing[key]) !== JSON.stringify(patch[key]));
|
||||
}
|
||||
|
||||
export async function handleProviderRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
const providerService = getProviderService();
|
||||
const logLegacyProviderRoute = (route: string): void => {
|
||||
if (legacyProviderRoutesWarned.has(route)) return;
|
||||
legacyProviderRoutesWarned.add(route);
|
||||
logger.warn(
|
||||
`[provider-migration] Legacy HTTP route "${route}" is deprecated. Prefer /api/provider-accounts endpoints.`,
|
||||
);
|
||||
};
|
||||
|
||||
if (url.pathname === '/api/provider-vendors' && req.method === 'GET') {
|
||||
sendJson(res, 200, await providerService.listVendors());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/provider-accounts' && req.method === 'GET') {
|
||||
sendJson(res, 200, await providerService.listAccounts());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/provider-accounts' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ account: ProviderAccount; apiKey?: string }>(req);
|
||||
const account = await providerService.createAccount(body.account, body.apiKey);
|
||||
await syncSavedProviderToRuntime(providerAccountToConfig(account), body.apiKey, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true, account });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/provider-accounts/default' && req.method === 'GET') {
|
||||
sendJson(res, 200, { accountId: await providerService.getDefaultAccountId() ?? null });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/provider-accounts/default' && req.method === 'PUT') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ accountId: string }>(req);
|
||||
const currentDefault = await providerService.getDefaultAccountId();
|
||||
if (currentDefault === body.accountId) {
|
||||
sendJson(res, 200, { success: true, noChange: true });
|
||||
return true;
|
||||
}
|
||||
await providerService.setDefaultAccount(body.accountId);
|
||||
await syncDefaultProviderToRuntime(body.accountId, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'GET') {
|
||||
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
|
||||
sendJson(res, 200, await providerService.getAccount(accountId));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'PUT') {
|
||||
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
|
||||
try {
|
||||
const body = await parseJsonBody<{ updates: Partial<ProviderAccount>; apiKey?: string }>(req);
|
||||
const existing = await providerService.getAccount(accountId);
|
||||
if (!existing) {
|
||||
sendJson(res, 404, { success: false, error: 'Provider account not found' });
|
||||
return true;
|
||||
}
|
||||
const hasPatchChanges = hasObjectChanges(existing as unknown as Record<string, unknown>, body.updates);
|
||||
if (!hasPatchChanges && body.apiKey === undefined) {
|
||||
sendJson(res, 200, { success: true, noChange: true, account: existing });
|
||||
return true;
|
||||
}
|
||||
const nextAccount = await providerService.updateAccount(accountId, body.updates, body.apiKey);
|
||||
await syncUpdatedProviderToRuntime(providerAccountToConfig(nextAccount), body.apiKey, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true, account: nextAccount });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'DELETE') {
|
||||
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
|
||||
try {
|
||||
const existing = await providerService.getAccount(accountId);
|
||||
const runtimeProviderKey = existing?.authMode === 'oauth_browser'
|
||||
? (existing.vendorId === 'google'
|
||||
? 'google-gemini-cli'
|
||||
: (existing.vendorId === 'openai' ? 'openai-codex' : undefined))
|
||||
: undefined;
|
||||
if (url.searchParams.get('apiKeyOnly') === '1') {
|
||||
await providerService.deleteLegacyProviderApiKey(accountId);
|
||||
await syncDeletedProviderApiKeyToRuntime(
|
||||
existing ? providerAccountToConfig(existing) : null,
|
||||
accountId,
|
||||
runtimeProviderKey,
|
||||
);
|
||||
sendJson(res, 200, { success: true });
|
||||
return true;
|
||||
}
|
||||
await providerService.deleteAccount(accountId);
|
||||
await syncDeletedProviderToRuntime(
|
||||
existing ? providerAccountToConfig(existing) : null,
|
||||
accountId,
|
||||
ctx.gatewayManager,
|
||||
runtimeProviderKey,
|
||||
);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers' && req.method === 'GET') {
|
||||
logLegacyProviderRoute('GET /api/providers');
|
||||
sendJson(res, 200, await providerService.listLegacyProvidersWithKeyInfo());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers/default' && req.method === 'GET') {
|
||||
logLegacyProviderRoute('GET /api/providers/default');
|
||||
sendJson(res, 200, { providerId: await providerService.getDefaultLegacyProvider() ?? null });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers/default' && req.method === 'PUT') {
|
||||
logLegacyProviderRoute('PUT /api/providers/default');
|
||||
try {
|
||||
const body = await parseJsonBody<{ providerId: string }>(req);
|
||||
const currentDefault = await providerService.getDefaultLegacyProvider();
|
||||
if (currentDefault === body.providerId) {
|
||||
sendJson(res, 200, { success: true, noChange: true });
|
||||
return true;
|
||||
}
|
||||
await providerService.setDefaultLegacyProvider(body.providerId);
|
||||
await syncDefaultProviderToRuntime(body.providerId, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers/validate' && req.method === 'POST') {
|
||||
logLegacyProviderRoute('POST /api/providers/validate');
|
||||
try {
|
||||
const body = await parseJsonBody<{ providerId: string; apiKey: string; options?: { baseUrl?: string; apiProtocol?: string } }>(req);
|
||||
const provider = await providerService.getLegacyProvider(body.providerId);
|
||||
const providerType = provider?.type || body.providerId;
|
||||
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
|
||||
const resolvedBaseUrl = body.options?.baseUrl || provider?.baseUrl || registryBaseUrl;
|
||||
const resolvedProtocol = body.options?.apiProtocol || provider?.apiProtocol;
|
||||
sendJson(res, 200, await validateApiKeyWithProvider(providerType, body.apiKey, { baseUrl: resolvedBaseUrl, apiProtocol: resolvedProtocol }));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { valid: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers/oauth/start' && req.method === 'POST') {
|
||||
logLegacyProviderRoute('POST /api/providers/oauth/start');
|
||||
try {
|
||||
const body = await parseJsonBody<{
|
||||
provider: OAuthProviderType | BrowserOAuthProviderType;
|
||||
region?: 'global' | 'cn';
|
||||
accountId?: string;
|
||||
label?: string;
|
||||
}>(req);
|
||||
if (body.provider === 'google' || body.provider === 'openai') {
|
||||
await browserOAuthManager.startFlow(body.provider, {
|
||||
accountId: body.accountId,
|
||||
label: body.label,
|
||||
});
|
||||
} else {
|
||||
await deviceOAuthManager.startFlow(body.provider, body.region, {
|
||||
accountId: body.accountId,
|
||||
label: body.label,
|
||||
});
|
||||
}
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers/oauth/cancel' && req.method === 'POST') {
|
||||
logLegacyProviderRoute('POST /api/providers/oauth/cancel');
|
||||
try {
|
||||
await deviceOAuthManager.stopFlow();
|
||||
await browserOAuthManager.stopFlow();
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers/oauth/submit' && req.method === 'POST') {
|
||||
logLegacyProviderRoute('POST /api/providers/oauth/submit');
|
||||
try {
|
||||
const body = await parseJsonBody<{ code: string }>(req);
|
||||
const accepted = browserOAuthManager.submitManualCode(body.code || '');
|
||||
if (!accepted) {
|
||||
sendJson(res, 400, { success: false, error: 'No active manual OAuth input pending' });
|
||||
return true;
|
||||
}
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers' && req.method === 'POST') {
|
||||
logLegacyProviderRoute('POST /api/providers');
|
||||
try {
|
||||
const body = await parseJsonBody<{ config: ProviderConfig; apiKey?: string }>(req);
|
||||
const config = body.config;
|
||||
await providerService.saveLegacyProvider(config);
|
||||
if (body.apiKey !== undefined) {
|
||||
const trimmedKey = body.apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await providerService.setLegacyProviderApiKey(config.id, trimmedKey);
|
||||
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
|
||||
}
|
||||
}
|
||||
await syncSavedProviderToRuntime(config, body.apiKey, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/providers/') && req.method === 'GET') {
|
||||
logLegacyProviderRoute('GET /api/providers/:id');
|
||||
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
||||
if (providerId.endsWith('/api-key')) {
|
||||
const actualId = providerId.slice(0, -('/api-key'.length));
|
||||
sendJson(res, 200, { apiKey: await providerService.getLegacyProviderApiKey(actualId) });
|
||||
return true;
|
||||
}
|
||||
if (providerId.endsWith('/has-api-key')) {
|
||||
const actualId = providerId.slice(0, -('/has-api-key'.length));
|
||||
sendJson(res, 200, { hasKey: await providerService.hasLegacyProviderApiKey(actualId) });
|
||||
return true;
|
||||
}
|
||||
sendJson(res, 200, await providerService.getLegacyProvider(providerId));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/providers/') && req.method === 'PUT') {
|
||||
logLegacyProviderRoute('PUT /api/providers/:id');
|
||||
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
||||
try {
|
||||
const body = await parseJsonBody<{ updates: Partial<ProviderConfig>; apiKey?: string }>(req);
|
||||
const existing = await providerService.getLegacyProvider(providerId);
|
||||
if (!existing) {
|
||||
sendJson(res, 404, { success: false, error: 'Provider not found' });
|
||||
return true;
|
||||
}
|
||||
const hasPatchChanges = hasObjectChanges(existing as unknown as Record<string, unknown>, body.updates);
|
||||
if (!hasPatchChanges && body.apiKey === undefined) {
|
||||
sendJson(res, 200, { success: true, noChange: true });
|
||||
return true;
|
||||
}
|
||||
const nextConfig: ProviderConfig = { ...existing, ...body.updates, updatedAt: new Date().toISOString() };
|
||||
await providerService.saveLegacyProvider(nextConfig);
|
||||
if (body.apiKey !== undefined) {
|
||||
const trimmedKey = body.apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await providerService.setLegacyProviderApiKey(providerId, trimmedKey);
|
||||
await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey);
|
||||
} else {
|
||||
await providerService.deleteLegacyProviderApiKey(providerId);
|
||||
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
|
||||
}
|
||||
}
|
||||
await syncUpdatedProviderToRuntime(nextConfig, body.apiKey, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/providers/') && req.method === 'DELETE') {
|
||||
logLegacyProviderRoute('DELETE /api/providers/:id');
|
||||
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
||||
try {
|
||||
const existing = await providerService.getLegacyProvider(providerId);
|
||||
if (url.searchParams.get('apiKeyOnly') === '1') {
|
||||
await providerService.deleteLegacyProviderApiKey(providerId);
|
||||
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
|
||||
sendJson(res, 200, { success: true });
|
||||
return true;
|
||||
}
|
||||
await providerService.deleteLegacyProvider(providerId);
|
||||
await syncDeletedProviderToRuntime(existing, providerId, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { applyProxySettings } from '../../main/proxy';
|
||||
import { syncLaunchAtStartupSettingFromStore } from '../../main/launch-at-startup';
|
||||
import { syncProxyConfigToOpenClaw } from '../../utils/openclaw-proxy';
|
||||
import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../../utils/store';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
async function handleProxySettingsChange(ctx: HostApiContext): Promise<void> {
|
||||
const settings = await getAllSettings();
|
||||
await syncProxyConfigToOpenClaw(settings, { preserveExistingWhenDisabled: false });
|
||||
await applyProxySettings(settings);
|
||||
if (ctx.gatewayManager.getStatus().state === 'running') {
|
||||
await ctx.gatewayManager.restart();
|
||||
}
|
||||
}
|
||||
|
||||
function patchTouchesProxy(patch: Partial<AppSettings>): boolean {
|
||||
return Object.keys(patch).some((key) => (
|
||||
key === 'proxyEnabled' ||
|
||||
key === 'proxyServer' ||
|
||||
key === 'proxyHttpServer' ||
|
||||
key === 'proxyHttpsServer' ||
|
||||
key === 'proxyAllServer' ||
|
||||
key === 'proxyBypassRules'
|
||||
));
|
||||
}
|
||||
|
||||
function patchTouchesLaunchAtStartup(patch: Partial<AppSettings>): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(patch, 'launchAtStartup');
|
||||
}
|
||||
|
||||
export async function handleSettingsRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/settings' && req.method === 'GET') {
|
||||
sendJson(res, 200, await getAllSettings());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/settings' && req.method === 'PUT') {
|
||||
try {
|
||||
const patch = await parseJsonBody<Partial<AppSettings>>(req);
|
||||
const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>;
|
||||
for (const [key, value] of entries) {
|
||||
await setSetting(key, value);
|
||||
}
|
||||
if (patchTouchesProxy(patch)) {
|
||||
await handleProxySettingsChange(ctx);
|
||||
}
|
||||
if (patchTouchesLaunchAtStartup(patch)) {
|
||||
await syncLaunchAtStartupSettingFromStore();
|
||||
}
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/settings/') && req.method === 'GET') {
|
||||
const key = url.pathname.slice('/api/settings/'.length) as keyof AppSettings;
|
||||
try {
|
||||
sendJson(res, 200, { value: await getSetting(key) });
|
||||
} catch (error) {
|
||||
sendJson(res, 404, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/settings/') && req.method === 'PUT') {
|
||||
const key = url.pathname.slice('/api/settings/'.length) as keyof AppSettings;
|
||||
try {
|
||||
const body = await parseJsonBody<{ value: AppSettings[keyof AppSettings] }>(req);
|
||||
await setSetting(key, body.value);
|
||||
if (
|
||||
key === 'proxyEnabled' ||
|
||||
key === 'proxyServer' ||
|
||||
key === 'proxyHttpServer' ||
|
||||
key === 'proxyHttpsServer' ||
|
||||
key === 'proxyAllServer' ||
|
||||
key === 'proxyBypassRules'
|
||||
) {
|
||||
await handleProxySettingsChange(ctx);
|
||||
}
|
||||
if (key === 'launchAtStartup') {
|
||||
await syncLaunchAtStartupSettingFromStore();
|
||||
}
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/settings/reset' && req.method === 'POST') {
|
||||
try {
|
||||
await resetSettings();
|
||||
await handleProxySettingsChange(ctx);
|
||||
await syncLaunchAtStartupSettingFromStore();
|
||||
sendJson(res, 200, { success: true, settings: await getAllSettings() });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
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, setCorsHeaders, requireJsonContentType } 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,
|
||||
];
|
||||
|
||||
/**
|
||||
* Per-session secret token used to authenticate Host API requests.
|
||||
* Generated once at server start and shared with the renderer via IPC.
|
||||
* This prevents cross-origin attackers from reading sensitive data even
|
||||
* if they can reach 127.0.0.1:3210 (the CORS wildcard alone is not
|
||||
* sufficient because browsers attach the Origin header but not a secret).
|
||||
*/
|
||||
let hostApiToken: string = '';
|
||||
|
||||
/** Retrieve the current Host API auth token (for use by IPC proxy). */
|
||||
export function getHostApiToken(): string {
|
||||
return hostApiToken;
|
||||
}
|
||||
|
||||
export function startHostApiServer(ctx: HostApiContext, port = PORTS.CLAWX_HOST_API): Server {
|
||||
// Generate a cryptographically random token for this session.
|
||||
hostApiToken = randomBytes(32).toString('hex');
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`);
|
||||
// ── CORS headers ─────────────────────────────────────────
|
||||
// Set origin-aware CORS headers early so every response
|
||||
// (including error responses) carries them consistently.
|
||||
const origin = req.headers.origin;
|
||||
setCorsHeaders(res, origin);
|
||||
|
||||
// CORS preflight — respond before auth so browsers can negotiate.
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Auth gate ──────────────────────────────────────────────
|
||||
// Every non-preflight request must carry a valid Bearer token.
|
||||
// Accept via Authorization header (preferred) or ?token= query
|
||||
// parameter (for EventSource which cannot set custom headers).
|
||||
const authHeader = req.headers.authorization || '';
|
||||
const bearerToken = authHeader.startsWith('Bearer ')
|
||||
? authHeader.slice(7)
|
||||
: (requestUrl.searchParams.get('token') || '');
|
||||
if (bearerToken !== hostApiToken) {
|
||||
sendJson(res, 401, { success: false, error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Content-Type gate (anti-CSRF) ──────────────────────────
|
||||
// Mutation requests must use application/json to force a CORS
|
||||
// preflight, preventing "simple request" CSRF attacks.
|
||||
if (!requireJsonContentType(req)) {
|
||||
sendJson(res, 415, { success: false, error: 'Content-Type must be application/json' });
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionContext,
|
||||
MarketplaceProviderExtension,
|
||||
MarketplaceCapability,
|
||||
} from '../types';
|
||||
import type {
|
||||
MarketplaceSearchParams,
|
||||
MarketplaceInstallParams,
|
||||
MarketplaceSkillResult,
|
||||
} from '../../gateway/clawhub';
|
||||
|
||||
class ClawHubMarketplaceExtension implements MarketplaceProviderExtension {
|
||||
readonly id = 'builtin/clawhub-marketplace';
|
||||
|
||||
setup(_ctx: ExtensionContext): void {
|
||||
// Built-in public ClawHub marketplace is disabled in community builds.
|
||||
}
|
||||
|
||||
async getCapability(): Promise<MarketplaceCapability> {
|
||||
return {
|
||||
mode: 'local-only',
|
||||
canSearch: false,
|
||||
canInstall: false,
|
||||
reason: 'marketplace-disabled',
|
||||
};
|
||||
}
|
||||
|
||||
async search(_params: MarketplaceSearchParams): Promise<MarketplaceSkillResult[]> {
|
||||
throw new Error('Marketplace search is disabled');
|
||||
}
|
||||
|
||||
async install(_params: MarketplaceInstallParams): Promise<void> {
|
||||
throw new Error('Marketplace install is disabled');
|
||||
}
|
||||
}
|
||||
|
||||
export function createClawHubMarketplaceExtension(): Extension {
|
||||
return new ClawHubMarketplaceExtension();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createDiagnosticsApi } from '../../services/diagnostics-api';
|
||||
import type { HostApiContribution, RuntimeHostAction } from '../../main/ipc/host-contract';
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionContext,
|
||||
HostApiProviderExtension,
|
||||
} from '../types';
|
||||
|
||||
class DiagnosticsExtension implements HostApiProviderExtension {
|
||||
readonly id = 'builtin/diagnostics';
|
||||
|
||||
setup(_ctx: ExtensionContext): void {
|
||||
// Diagnostics are exposed through host IPC contributions.
|
||||
}
|
||||
|
||||
getHostApiContributions(ctx: ExtensionContext): HostApiContribution[] {
|
||||
const diagnostics = createDiagnosticsApi({ gatewayManager: ctx.gatewayManager });
|
||||
const actions: Record<string, RuntimeHostAction> = {
|
||||
gatewaySnapshot: () => diagnostics.gatewaySnapshot(),
|
||||
acpTrace: () => diagnostics.acpTrace(),
|
||||
recordAcpTrace: (payload) => diagnostics.recordAcpTrace(
|
||||
payload as Parameters<typeof diagnostics.recordAcpTrace>[0],
|
||||
),
|
||||
};
|
||||
return [{
|
||||
module: 'diagnostics',
|
||||
actions,
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
export function createDiagnosticsExtension(): Extension {
|
||||
return new DiagnosticsExtension();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { registerBuiltinExtension } from '../loader';
|
||||
import { createClawHubMarketplaceExtension } from './clawhub-marketplace';
|
||||
import { createDiagnosticsExtension } from './diagnostics';
|
||||
|
||||
export function registerAllBuiltinExtensions(): void {
|
||||
registerBuiltinExtension('builtin/clawhub-marketplace', createClawHubMarketplaceExtension);
|
||||
registerBuiltinExtension('builtin/diagnostics', createDiagnosticsExtension);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export { extensionRegistry } from './registry';
|
||||
export { registerBuiltinExtension, loadExtensionsFromManifest } from './loader';
|
||||
export type {
|
||||
Extension,
|
||||
ExtensionContext,
|
||||
HostApiProviderExtension,
|
||||
MarketplaceProviderExtension,
|
||||
MarketplaceCapability,
|
||||
AuthProviderExtension,
|
||||
AuthStatus,
|
||||
} from './types';
|
||||
export {
|
||||
isHostApiProviderExtension,
|
||||
isMarketplaceProviderExtension,
|
||||
isAuthProviderExtension,
|
||||
} from './types';
|
||||
@@ -0,0 +1,76 @@
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { app } from 'electron';
|
||||
import { logger } from '../utils/logger';
|
||||
import { extensionRegistry } from './registry';
|
||||
import type { Extension } from './types';
|
||||
|
||||
interface ExtensionManifest {
|
||||
extensions?: {
|
||||
main?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const builtinModules = new Map<string, () => Extension>();
|
||||
|
||||
export function registerBuiltinExtension(id: string, factory: () => Extension): void {
|
||||
builtinModules.set(id, factory);
|
||||
}
|
||||
|
||||
function resolveManifestPath(): string {
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, 'clawx-extensions.json');
|
||||
}
|
||||
return join(app.getAppPath(), 'clawx-extensions.json');
|
||||
}
|
||||
|
||||
export async function loadExtensionsFromManifest(): Promise<void> {
|
||||
const manifestPath = resolveManifestPath();
|
||||
let manifest: ExtensionManifest = {};
|
||||
|
||||
if (existsSync(manifestPath)) {
|
||||
try {
|
||||
manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as ExtensionManifest;
|
||||
logger.info(`[extensions] Loaded manifest from ${manifestPath}`);
|
||||
} catch (err) {
|
||||
logger.warn(`[extensions] Failed to parse ${manifestPath}, using defaults:`, err);
|
||||
}
|
||||
} else {
|
||||
logger.debug('[extensions] No clawx-extensions.json found, loading all builtin extensions');
|
||||
}
|
||||
|
||||
const mainExtensions = manifest.extensions?.main;
|
||||
|
||||
if (!mainExtensions || mainExtensions.length === 0) {
|
||||
for (const [id, factory] of builtinModules) {
|
||||
extensionRegistry.register(factory());
|
||||
logger.debug(`[extensions] Auto-registered builtin extension "${id}"`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const extensionId of mainExtensions) {
|
||||
if (builtinModules.has(extensionId)) {
|
||||
extensionRegistry.register(builtinModules.get(extensionId)!());
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const mod = require(extensionId) as { default?: Extension; extension?: Extension };
|
||||
const ext = mod.default ?? mod.extension;
|
||||
if (ext && typeof ext.setup === 'function') {
|
||||
extensionRegistry.register(ext);
|
||||
} else {
|
||||
logger.warn(`[extensions] Module "${extensionId}" does not export a valid Extension`);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('Cannot find module')) {
|
||||
logger.debug(`[extensions] "${extensionId}" not loadable at runtime (expected when using ext-bridge)`);
|
||||
} else {
|
||||
logger.warn(`[extensions] Failed to load extension "${extensionId}": ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { logger } from '../utils/logger';
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionContext,
|
||||
MarketplaceProviderExtension,
|
||||
} from './types';
|
||||
import {
|
||||
isHostApiProviderExtension,
|
||||
isMarketplaceProviderExtension,
|
||||
} from './types';
|
||||
|
||||
class ExtensionRegistry {
|
||||
private extensions = new Map<string, Extension>();
|
||||
private ctx: ExtensionContext | null = null;
|
||||
private hostApiUnregisters = new Map<string, () => void>();
|
||||
|
||||
async initialize(ctx: ExtensionContext): Promise<void> {
|
||||
this.ctx = ctx;
|
||||
for (const ext of this.extensions.values()) {
|
||||
try {
|
||||
await ext.setup(ctx);
|
||||
this.registerHostApiContributions(ext, ctx);
|
||||
logger.info(`[extensions] Extension "${ext.id}" initialized`);
|
||||
} catch (err) {
|
||||
logger.error(`[extensions] Extension "${ext.id}" failed to initialize:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register(extension: Extension): void {
|
||||
if (this.extensions.has(extension.id)) {
|
||||
logger.warn(`[extensions] Extension "${extension.id}" is already registered; skipping duplicate`);
|
||||
return;
|
||||
}
|
||||
this.extensions.set(extension.id, extension);
|
||||
logger.debug(`[extensions] Registered extension "${extension.id}"`);
|
||||
|
||||
if (this.ctx) {
|
||||
void Promise.resolve(extension.setup(this.ctx))
|
||||
.then(() => {
|
||||
if (this.ctx) {
|
||||
this.registerHostApiContributions(extension, this.ctx);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error(`[extensions] Late-registered extension "${extension.id}" failed to initialize:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get(id: string): Extension | undefined {
|
||||
return this.extensions.get(id);
|
||||
}
|
||||
|
||||
getAll(): Extension[] {
|
||||
return [...this.extensions.values()];
|
||||
}
|
||||
|
||||
getMarketplaceProvider(): MarketplaceProviderExtension | undefined {
|
||||
return this.getAll().find(isMarketplaceProviderExtension) as MarketplaceProviderExtension | undefined;
|
||||
}
|
||||
|
||||
async teardownAll(): Promise<void> {
|
||||
for (const ext of this.extensions.values()) {
|
||||
try {
|
||||
this.hostApiUnregisters.get(ext.id)?.();
|
||||
this.hostApiUnregisters.delete(ext.id);
|
||||
await ext.teardown?.();
|
||||
} catch (err) {
|
||||
logger.warn(`[extensions] Extension "${ext.id}" teardown failed:`, err);
|
||||
}
|
||||
}
|
||||
this.extensions.clear();
|
||||
this.ctx = null;
|
||||
}
|
||||
|
||||
private registerHostApiContributions(ext: Extension, ctx: ExtensionContext): void {
|
||||
this.hostApiUnregisters.get(ext.id)?.();
|
||||
this.hostApiUnregisters.delete(ext.id);
|
||||
|
||||
if (!isHostApiProviderExtension(ext)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contributions = ext.getHostApiContributions(ctx);
|
||||
if (contributions.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.hostApiUnregisters.set(ext.id, ctx.hostApi.register(ext.id, contributions));
|
||||
}
|
||||
}
|
||||
|
||||
export const extensionRegistry = new ExtensionRegistry();
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { HostApiContribution, HostApiContributionRegistrar } from '../main/ipc/host-contract';
|
||||
import type {
|
||||
MarketplaceSearchParams,
|
||||
MarketplaceInstallParams,
|
||||
MarketplaceSkillResult,
|
||||
ClawHubSearchParams,
|
||||
ClawHubInstallParams,
|
||||
ClawHubSkillResult,
|
||||
} from '../gateway/clawhub';
|
||||
|
||||
export interface ExtensionContext {
|
||||
gatewayManager: GatewayManager;
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
hostApi: HostApiContributionRegistrar;
|
||||
}
|
||||
|
||||
export interface Extension {
|
||||
id: string;
|
||||
setup(ctx: ExtensionContext): void | Promise<void>;
|
||||
teardown?(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface MarketplaceCapability {
|
||||
mode: string;
|
||||
canSearch: boolean;
|
||||
canInstall: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface MarketplaceProviderExtension extends Extension {
|
||||
getCapability(): Promise<MarketplaceCapability>;
|
||||
search(params: MarketplaceSearchParams): Promise<MarketplaceSkillResult[]>;
|
||||
install(params: MarketplaceInstallParams): Promise<void>;
|
||||
}
|
||||
|
||||
export interface HostApiProviderExtension extends Extension {
|
||||
getHostApiContributions(ctx: ExtensionContext): HostApiContribution[];
|
||||
}
|
||||
|
||||
export type LegacyMarketplaceSearchParams = ClawHubSearchParams;
|
||||
export type LegacyMarketplaceInstallParams = ClawHubInstallParams;
|
||||
export type LegacyMarketplaceSkillResult = ClawHubSkillResult;
|
||||
|
||||
export interface AuthStatus {
|
||||
authenticated: boolean;
|
||||
expired: boolean;
|
||||
user: { username: string; displayName: string; email: string } | null;
|
||||
}
|
||||
|
||||
export interface AuthProviderExtension extends Extension {
|
||||
getAuthStatus(): Promise<AuthStatus>;
|
||||
onStartup?(mainWindow: BrowserWindow): Promise<void>;
|
||||
}
|
||||
|
||||
export function isMarketplaceProviderExtension(ext: Extension): ext is MarketplaceProviderExtension {
|
||||
return 'getCapability' in ext && 'search' in ext && 'install' in ext;
|
||||
}
|
||||
|
||||
export function isHostApiProviderExtension(ext: Extension): ext is HostApiProviderExtension {
|
||||
return 'getHostApiContributions' in ext
|
||||
&& typeof (ext as HostApiProviderExtension).getHostApiContributions === 'function';
|
||||
}
|
||||
|
||||
export function isAuthProviderExtension(ext: Extension): ext is AuthProviderExtension {
|
||||
return 'getAuthStatus' in ext && typeof (ext as AuthProviderExtension).getAuthStatus === 'function';
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type {
|
||||
GatewayDiagnosticsSnapshot,
|
||||
GatewayHealthSummary,
|
||||
GatewayStatus,
|
||||
} from './manager';
|
||||
import type { GatewayRuntimePayload } from '@shared/types/gateway';
|
||||
|
||||
export type GatewayCapabilityName = 'openclawHealth' | 'openclawStatus' | 'channels' | 'memory';
|
||||
|
||||
export interface GatewayCapabilityProbe {
|
||||
state: 'unknown' | 'healthy' | 'degraded';
|
||||
checkedAt?: number;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
payload?: GatewayRuntimePayload;
|
||||
}
|
||||
|
||||
export interface GatewayCoreProbe {
|
||||
ok: boolean;
|
||||
checkedAt: number;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface GatewayCapabilitySnapshot {
|
||||
core: {
|
||||
process: GatewayStatus['state'];
|
||||
transport: 'connected' | 'disconnected';
|
||||
rpcRouter: 'unknown' | 'ready' | 'blocked';
|
||||
lastProbe?: GatewayCoreProbe;
|
||||
};
|
||||
openclawHealth: GatewayCapabilityProbe;
|
||||
openclawStatus: GatewayCapabilityProbe;
|
||||
presence: GatewayCapabilityProbe;
|
||||
channels: GatewayCapabilityProbe;
|
||||
memory: GatewayCapabilityProbe;
|
||||
diagnostics: GatewayDiagnosticsSnapshot;
|
||||
summary?: GatewayHealthSummary;
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function capabilityFromPayload(payload: GatewayRuntimePayload, checkedAt = Date.now()): GatewayCapabilityProbe {
|
||||
return {
|
||||
state: 'healthy',
|
||||
checkedAt,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function capabilityFromError(error: unknown, checkedAt = Date.now()): GatewayCapabilityProbe {
|
||||
return {
|
||||
state: 'degraded',
|
||||
checkedAt,
|
||||
error: formatError(error),
|
||||
};
|
||||
}
|
||||
|
||||
const UNKNOWN_CAPABILITY: GatewayCapabilityProbe = { state: 'unknown' };
|
||||
|
||||
export class GatewayCapabilityMonitor {
|
||||
private openclawHealth: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
|
||||
private openclawStatus: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
|
||||
private presence: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
|
||||
private channels: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
|
||||
private memory: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
|
||||
private lastCoreProbe: GatewayCoreProbe | undefined;
|
||||
|
||||
recordOpenClawHealth(payload: GatewayRuntimePayload): void {
|
||||
this.openclawHealth = capabilityFromPayload(payload);
|
||||
}
|
||||
|
||||
recordOpenClawStatus(payload: GatewayRuntimePayload): void {
|
||||
this.openclawStatus = capabilityFromPayload(payload);
|
||||
}
|
||||
|
||||
recordPresence(payload: GatewayRuntimePayload): void {
|
||||
this.presence = capabilityFromPayload(payload);
|
||||
}
|
||||
|
||||
recordCoreProbe(probe: GatewayCoreProbe): void {
|
||||
this.lastCoreProbe = probe;
|
||||
}
|
||||
|
||||
recordCapabilitySuccess(name: GatewayCapabilityName, payload: GatewayRuntimePayload, durationMs?: number): void {
|
||||
const probe: GatewayCapabilityProbe = {
|
||||
state: 'healthy',
|
||||
checkedAt: Date.now(),
|
||||
durationMs,
|
||||
payload,
|
||||
};
|
||||
this.setCapability(name, probe);
|
||||
}
|
||||
|
||||
recordCapabilityFailure(name: GatewayCapabilityName, error: unknown, durationMs?: number): void {
|
||||
const probe = capabilityFromError(error);
|
||||
probe.durationMs = durationMs;
|
||||
this.setCapability(name, probe);
|
||||
}
|
||||
|
||||
buildSnapshot(params: {
|
||||
status: GatewayStatus;
|
||||
transportConnected: boolean;
|
||||
diagnostics: GatewayDiagnosticsSnapshot;
|
||||
summary?: GatewayHealthSummary;
|
||||
}): GatewayCapabilitySnapshot {
|
||||
return {
|
||||
core: {
|
||||
process: params.status.state,
|
||||
transport: params.transportConnected ? 'connected' : 'disconnected',
|
||||
rpcRouter: this.lastCoreProbe?.ok === false
|
||||
? 'blocked'
|
||||
: params.status.gatewayReady === true || this.lastCoreProbe?.ok === true
|
||||
? 'ready'
|
||||
: 'unknown',
|
||||
lastProbe: this.lastCoreProbe,
|
||||
},
|
||||
openclawHealth: this.openclawHealth,
|
||||
openclawStatus: this.openclawStatus,
|
||||
presence: this.presence,
|
||||
channels: this.channels,
|
||||
memory: this.memory,
|
||||
diagnostics: params.diagnostics,
|
||||
summary: params.summary,
|
||||
};
|
||||
}
|
||||
|
||||
private setCapability(name: GatewayCapabilityName, probe: GatewayCapabilityProbe): void {
|
||||
if (name === 'openclawHealth') {
|
||||
this.openclawHealth = probe;
|
||||
} else if (name === 'openclawStatus') {
|
||||
this.openclawStatus = probe;
|
||||
} else if (name === 'channels') {
|
||||
this.channels = probe;
|
||||
} else if (name === 'memory') {
|
||||
this.memory = probe;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { ChatRuntimeEvent } from '../../shared/chat-runtime-events';
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value : undefined;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
type ChatRuntimeEventType = ChatRuntimeEvent['type'];
|
||||
type ChatRuntimeEventFor<T extends ChatRuntimeEventType> = Extract<ChatRuntimeEvent, { type: T }>;
|
||||
type ChatRuntimeEventBaseFor<T extends ChatRuntimeEventType> = Pick<
|
||||
ChatRuntimeEventFor<T>,
|
||||
'type' | 'runId' | 'sessionKey' | 'seq' | 'ts'
|
||||
>;
|
||||
|
||||
function withBase<T extends ChatRuntimeEventType>(
|
||||
type: T,
|
||||
payload: Record<string, unknown>,
|
||||
): ChatRuntimeEventBaseFor<T> | null {
|
||||
const runId = readString(payload.runId);
|
||||
if (!runId) return null;
|
||||
return {
|
||||
type,
|
||||
runId,
|
||||
sessionKey: readString(payload.sessionKey),
|
||||
seq: readNumber(payload.seq),
|
||||
ts: readNumber(payload.ts),
|
||||
} as ChatRuntimeEventBaseFor<T>;
|
||||
}
|
||||
|
||||
export function normalizeGatewayChatRuntimeEvent(payload: unknown): ChatRuntimeEvent | null {
|
||||
const raw = asRecord(payload);
|
||||
if (!raw) return null;
|
||||
|
||||
const stream = readString(raw.stream);
|
||||
const data = asRecord(raw.data) ?? raw;
|
||||
|
||||
if (stream === 'lifecycle') {
|
||||
const phase = readString(data.phase);
|
||||
if (phase === 'start') {
|
||||
const base = withBase('run.started', raw);
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
startedAt: readNumber(data.startedAt),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (phase === 'completed' || phase === 'done' || phase === 'finished') {
|
||||
const base = withBase('run.ended', raw);
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
status: 'completed',
|
||||
endedAt: readNumber(data.endedAt),
|
||||
livenessState: readString(data.livenessState),
|
||||
replayInvalid: typeof data.replayInvalid === 'boolean' ? data.replayInvalid : undefined,
|
||||
stopReason: readString(data.stopReason),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (phase === 'error' || phase === 'failed') {
|
||||
const base = withBase('run.ended', raw);
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
status: 'error',
|
||||
endedAt: readNumber(data.endedAt),
|
||||
error: readString(data.error),
|
||||
livenessState: readString(data.livenessState),
|
||||
replayInvalid: typeof data.replayInvalid === 'boolean' ? data.replayInvalid : undefined,
|
||||
stopReason: readString(data.stopReason),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (phase === 'aborted' || phase === 'cancelled') {
|
||||
const base = withBase('run.ended', raw);
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
status: 'aborted',
|
||||
endedAt: readNumber(data.endedAt),
|
||||
error: readString(data.error),
|
||||
stopReason: readString(data.stopReason),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stream === 'assistant') {
|
||||
const base = withBase('assistant.delta', raw);
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
text: readString(data.text),
|
||||
delta: readString(data.delta),
|
||||
replace: typeof data.replace === 'boolean' ? data.replace : undefined,
|
||||
phase: readString(data.phase),
|
||||
mediaUrls: Array.isArray(data.mediaUrls)
|
||||
? data.mediaUrls.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||||
: undefined,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (stream === 'thinking') {
|
||||
const base = withBase('thinking.delta', raw);
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
text: readString(data.text),
|
||||
delta: readString(data.delta),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (stream === 'tool') {
|
||||
const phase = readString(data.phase);
|
||||
const toolCallId = readString(data.toolCallId);
|
||||
const name = readString(data.name);
|
||||
if (!toolCallId || !name) return null;
|
||||
|
||||
if (phase === 'start') {
|
||||
const base = withBase('tool.started', raw);
|
||||
return base ? { ...base, toolCallId, name, args: data.args } : null;
|
||||
}
|
||||
if (phase === 'update') {
|
||||
const base = withBase('tool.updated', raw);
|
||||
return base ? { ...base, toolCallId, name, partialResult: data.partialResult } : null;
|
||||
}
|
||||
if (phase === 'result' || phase === 'end') {
|
||||
const base = withBase('tool.completed', raw);
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
toolCallId,
|
||||
name,
|
||||
result: data.result,
|
||||
meta: data.meta,
|
||||
isError: typeof data.isError === 'boolean' ? data.isError : undefined,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stream === 'command_output') {
|
||||
const base = withBase('command.output', raw);
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
itemId: readString(data.itemId),
|
||||
toolCallId: readString(data.toolCallId),
|
||||
name: readString(data.name),
|
||||
title: readString(data.title),
|
||||
output: readString(data.output),
|
||||
status: readString(data.status),
|
||||
phase: readString(data.phase),
|
||||
exitCode: readNumber(data.exitCode),
|
||||
durationMs: readNumber(data.durationMs),
|
||||
cwd: readString(data.cwd),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (stream === 'patch') {
|
||||
const base = withBase('patch.completed', raw);
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
itemId: readString(data.itemId),
|
||||
toolCallId: readString(data.toolCallId),
|
||||
name: readString(data.name),
|
||||
title: readString(data.title),
|
||||
summary: readString(data.summary),
|
||||
added: readNumber(data.added),
|
||||
modified: readNumber(data.modified),
|
||||
deleted: readNumber(data.deleted),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (stream === 'approval') {
|
||||
const base = withBase('approval.updated', raw);
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
itemId: readString(data.itemId),
|
||||
toolCallId: readString(data.toolCallId),
|
||||
title: readString(data.title),
|
||||
kind: readString(data.kind),
|
||||
phase: readString(data.phase),
|
||||
status: readString(data.status),
|
||||
message: readString(data.message),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
+148
-293
@@ -1,29 +1,29 @@
|
||||
/**
|
||||
* ClawHub Service
|
||||
* Manages interactions with the ClawHub CLI for skills management
|
||||
* Maintains marketplace-provider compatibility and managed skill uninstall/open helpers.
|
||||
*/
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { app, shell } from 'electron';
|
||||
import { getOpenClawConfigDir, ensureDir, getClawHubCliBinPath, getClawHubCliEntryPath, quoteForCmd } from '../utils/paths';
|
||||
import { shell } from 'electron';
|
||||
import { getOpenClawConfigDir, ensureDir } from '../utils/paths';
|
||||
import { removeSkillConfig } from '../utils/skill-config';
|
||||
|
||||
export interface ClawHubSearchParams {
|
||||
export interface MarketplaceSearchParams {
|
||||
query: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ClawHubInstallParams {
|
||||
export interface MarketplaceInstallParams {
|
||||
slug: string;
|
||||
version?: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface ClawHubUninstallParams {
|
||||
export interface MarketplaceUninstallParams {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface ClawHubSkillResult {
|
||||
export interface MarketplaceSkillResult {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -33,6 +33,11 @@ export interface ClawHubSkillResult {
|
||||
stars?: number;
|
||||
}
|
||||
|
||||
export type ClawHubSearchParams = MarketplaceSearchParams;
|
||||
export type ClawHubInstallParams = MarketplaceInstallParams;
|
||||
export type ClawHubUninstallParams = MarketplaceUninstallParams;
|
||||
export type ClawHubSkillResult = MarketplaceSkillResult;
|
||||
|
||||
export interface ClawHubInstalledSkillResult {
|
||||
slug: string;
|
||||
version: string;
|
||||
@@ -40,44 +45,154 @@ export interface ClawHubInstalledSkillResult {
|
||||
baseDir?: string;
|
||||
}
|
||||
|
||||
export interface MarketplaceProvider {
|
||||
getCapability(): Promise<{ mode: string; canSearch: boolean; canInstall: boolean; reason?: string }>;
|
||||
search(params: MarketplaceSearchParams): Promise<MarketplaceSkillResult[]>;
|
||||
install(params: MarketplaceInstallParams): Promise<void>;
|
||||
}
|
||||
|
||||
export class ClawHubService {
|
||||
private workDir: string;
|
||||
private cliPath: string;
|
||||
private cliEntryPath: string;
|
||||
private useNodeRunner: boolean;
|
||||
private ansiRegex: RegExp;
|
||||
private marketplaceProvider: MarketplaceProvider | null = null;
|
||||
|
||||
constructor() {
|
||||
// Use the user's OpenClaw config directory (~/.openclaw) for skill management
|
||||
// This avoids installing skills into the project's openclaw submodule
|
||||
this.workDir = getOpenClawConfigDir();
|
||||
ensureDir(this.workDir);
|
||||
|
||||
const binPath = getClawHubCliBinPath();
|
||||
const entryPath = getClawHubCliEntryPath();
|
||||
|
||||
this.cliEntryPath = entryPath;
|
||||
if (!app.isPackaged && fs.existsSync(binPath)) {
|
||||
this.cliPath = binPath;
|
||||
this.useNodeRunner = false;
|
||||
} else {
|
||||
this.cliPath = process.execPath;
|
||||
this.useNodeRunner = true;
|
||||
}
|
||||
const esc = String.fromCharCode(27);
|
||||
const csi = String.fromCharCode(155);
|
||||
const pattern = `(?:${esc}|${csi})[[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]`;
|
||||
this.ansiRegex = new RegExp(pattern, 'g');
|
||||
}
|
||||
|
||||
private stripAnsi(line: string): string {
|
||||
return line.replace(this.ansiRegex, '').trim();
|
||||
setMarketplaceProvider(provider: MarketplaceProvider): void {
|
||||
this.marketplaceProvider = provider;
|
||||
}
|
||||
|
||||
async getMarketplaceCapability(): Promise<{ mode: string; canSearch: boolean; canInstall: boolean; reason?: string }> {
|
||||
if (this.marketplaceProvider) {
|
||||
return this.marketplaceProvider.getCapability();
|
||||
}
|
||||
return {
|
||||
mode: 'local-only',
|
||||
canSearch: false,
|
||||
canInstall: false,
|
||||
reason: 'marketplace-disabled',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for skills via an extension-provided marketplace.
|
||||
*/
|
||||
async search(params: MarketplaceSearchParams): Promise<MarketplaceSkillResult[]> {
|
||||
if (this.marketplaceProvider) {
|
||||
return this.marketplaceProvider.search(params);
|
||||
}
|
||||
throw new Error('Marketplace search is disabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Explore marketplace skills via the registered marketplace provider.
|
||||
*/
|
||||
async explore(params: { limit?: number } = {}): Promise<MarketplaceSkillResult[]> {
|
||||
if (this.marketplaceProvider) {
|
||||
return this.marketplaceProvider.search({ query: '', limit: params.limit });
|
||||
}
|
||||
throw new Error('Marketplace search is disabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a skill through an extension-provided marketplace.
|
||||
*/
|
||||
async install(params: MarketplaceInstallParams): Promise<void> {
|
||||
if (this.marketplaceProvider) {
|
||||
return this.marketplaceProvider.install(params);
|
||||
}
|
||||
throw new Error('Marketplace install is disabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall a managed skill and remove its stored config.
|
||||
*/
|
||||
async uninstall(params: ClawHubUninstallParams): Promise<void> {
|
||||
const fsPromises = fs.promises;
|
||||
|
||||
const skillDir = path.join(this.workDir, 'skills', params.slug);
|
||||
if (fs.existsSync(skillDir)) {
|
||||
console.log(`Deleting skill directory: ${skillDir}`);
|
||||
await fsPromises.rm(skillDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const lockFile = path.join(this.workDir, '.clawhub', 'lock.json');
|
||||
if (fs.existsSync(lockFile)) {
|
||||
try {
|
||||
const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf8')) as {
|
||||
skills?: Record<string, unknown>;
|
||||
};
|
||||
if (lockData.skills && lockData.skills[params.slug]) {
|
||||
console.log(`Removing ${params.slug} from lock.json`);
|
||||
delete lockData.skills[params.slug];
|
||||
await fsPromises.writeFile(lockFile, JSON.stringify(lockData, null, 2));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update ClawHub lock file:', err);
|
||||
}
|
||||
}
|
||||
|
||||
await removeSkillConfig(params.slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* List installed managed skills from the filesystem.
|
||||
*/
|
||||
async listInstalled(): Promise<ClawHubInstalledSkillResult[]> {
|
||||
const skillsRoot = path.join(this.workDir, 'skills');
|
||||
if (!fs.existsSync(skillsRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await fs.promises.readdir(skillsRoot, { withFileTypes: true });
|
||||
const items = await Promise.all(entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map(async (entry) => {
|
||||
const skillDir = path.join(skillsRoot, entry.name);
|
||||
const manifestPath = path.join(skillDir, 'SKILL.md');
|
||||
if (!fs.existsSync(manifestPath)) return null;
|
||||
|
||||
let version = 'unknown';
|
||||
const manifestJsonPath = path.join(skillDir, 'manifest.json');
|
||||
if (fs.existsSync(manifestJsonPath)) {
|
||||
try {
|
||||
const manifestJson = JSON.parse(await fs.promises.readFile(manifestJsonPath, 'utf8')) as { version?: string };
|
||||
version = manifestJson.version?.trim() || version;
|
||||
} catch {
|
||||
// Ignore malformed manifest.json
|
||||
}
|
||||
}
|
||||
|
||||
const originJsonPath = path.join(skillDir, '.clawhub', 'origin.json');
|
||||
if (fs.existsSync(originJsonPath)) {
|
||||
try {
|
||||
const originJson = JSON.parse(await fs.promises.readFile(originJsonPath, 'utf8')) as { installedVersion?: string };
|
||||
version = originJson.installedVersion?.trim() || version;
|
||||
} catch {
|
||||
// Ignore malformed origin.json
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
slug: entry.name,
|
||||
version,
|
||||
source: 'openclaw-managed',
|
||||
baseDir: skillDir,
|
||||
};
|
||||
}));
|
||||
return items.filter((item): item is NonNullable<typeof item> => item !== null);
|
||||
} catch (error) {
|
||||
console.error('ClawHub list error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private 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];
|
||||
@@ -123,257 +238,6 @@ export class ClawHubService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a ClawHub CLI command
|
||||
*/
|
||||
private async runCommand(args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.useNodeRunner && !fs.existsSync(this.cliEntryPath)) {
|
||||
reject(new Error(`ClawHub CLI entry not found at: ${this.cliEntryPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.useNodeRunner && !fs.existsSync(this.cliPath)) {
|
||||
reject(new Error(`ClawHub CLI not found at: ${this.cliPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const commandArgs = this.useNodeRunner ? [this.cliEntryPath, ...args] : args;
|
||||
const displayCommand = [this.cliPath, ...commandArgs].join(' ');
|
||||
console.log(`Running ClawHub command: ${displayCommand}`);
|
||||
|
||||
const isWin = process.platform === 'win32';
|
||||
const useShell = isWin && !this.useNodeRunner;
|
||||
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
|
||||
const env = {
|
||||
...baseEnv,
|
||||
CI: 'true',
|
||||
FORCE_COLOR: '0',
|
||||
};
|
||||
if (this.useNodeRunner) {
|
||||
env.ELECTRON_RUN_AS_NODE = '1';
|
||||
}
|
||||
const spawnCmd = useShell ? quoteForCmd(this.cliPath) : this.cliPath;
|
||||
const spawnArgs = useShell ? commandArgs.map(a => quoteForCmd(a)) : commandArgs;
|
||||
const child = spawn(spawnCmd, spawnArgs, {
|
||||
cwd: this.workDir,
|
||||
shell: useShell,
|
||||
env: {
|
||||
...env,
|
||||
CLAWHUB_WORKDIR: this.workDir,
|
||||
},
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error('ClawHub process error:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
console.error(`ClawHub command failed with code ${code}`);
|
||||
console.error('Stderr:', stderr);
|
||||
reject(new Error(`Command failed: ${stderr || stdout}`));
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for skills
|
||||
*/
|
||||
async search(params: ClawHubSearchParams): Promise<ClawHubSkillResult[]> {
|
||||
try {
|
||||
// If query is empty, use 'explore' to show trending skills
|
||||
if (!params.query || params.query.trim() === '') {
|
||||
return this.explore({ limit: params.limit });
|
||||
}
|
||||
|
||||
const args = ['search', params.query];
|
||||
if (params.limit) {
|
||||
args.push('--limit', String(params.limit));
|
||||
}
|
||||
|
||||
const output = await this.runCommand(args);
|
||||
if (!output || output.includes('No skills found')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = output.split('\n').filter(l => l.trim());
|
||||
return lines.map(line => {
|
||||
const cleanLine = this.stripAnsi(line);
|
||||
|
||||
// Format could be: slug vversion description (score)
|
||||
// Or sometimes: slug vversion description
|
||||
let match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const slug = match[1];
|
||||
const version = match[2];
|
||||
let description = match[3];
|
||||
|
||||
// Clean up score if present at the end
|
||||
description = description.replace(/\(\d+\.\d+\)$/, '').trim();
|
||||
|
||||
return {
|
||||
slug,
|
||||
name: slug,
|
||||
version,
|
||||
description,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for new clawhub search format without version:
|
||||
// slug name/description (score)
|
||||
match = cleanLine.match(/^(\S+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const slug = match[1];
|
||||
let description = match[2];
|
||||
|
||||
// Clean up score if present at the end
|
||||
description = description.replace(/\(\d+\.\d+\)$/, '').trim();
|
||||
|
||||
return {
|
||||
slug,
|
||||
name: slug,
|
||||
version: 'latest', // Fallback version since it's not provided
|
||||
description,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter((s): s is ClawHubSkillResult => s !== null);
|
||||
} catch (error) {
|
||||
console.error('ClawHub search error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explore trending skills
|
||||
*/
|
||||
async explore(params: { limit?: number } = {}): Promise<ClawHubSkillResult[]> {
|
||||
try {
|
||||
const args = ['explore'];
|
||||
if (params.limit) {
|
||||
args.push('--limit', String(params.limit));
|
||||
}
|
||||
|
||||
const output = await this.runCommand(args);
|
||||
if (!output) return [];
|
||||
|
||||
const lines = output.split('\n').filter(l => l.trim());
|
||||
return lines.map(line => {
|
||||
const cleanLine = this.stripAnsi(line);
|
||||
|
||||
// Format: slug vversion time description
|
||||
// Example: my-skill v1.0.0 2 hours ago A great skill
|
||||
const match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)\s+(.+? ago|just now|yesterday)\s+(.+)$/i);
|
||||
if (match) {
|
||||
return {
|
||||
slug: match[1],
|
||||
name: match[1],
|
||||
version: match[2],
|
||||
description: match[4],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter((s): s is ClawHubSkillResult => s !== null);
|
||||
} catch (error) {
|
||||
console.error('ClawHub explore error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a skill
|
||||
*/
|
||||
async install(params: ClawHubInstallParams): Promise<void> {
|
||||
const args = ['install', params.slug];
|
||||
|
||||
if (params.version) {
|
||||
args.push('--version', params.version);
|
||||
}
|
||||
|
||||
if (params.force) {
|
||||
args.push('--force');
|
||||
}
|
||||
|
||||
await this.runCommand(args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall a skill
|
||||
*/
|
||||
async uninstall(params: ClawHubUninstallParams): Promise<void> {
|
||||
const fsPromises = fs.promises;
|
||||
|
||||
// 1. Delete the skill directory
|
||||
const skillDir = path.join(this.workDir, 'skills', params.slug);
|
||||
if (fs.existsSync(skillDir)) {
|
||||
console.log(`Deleting skill directory: ${skillDir}`);
|
||||
await fsPromises.rm(skillDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 2. Remove from lock.json
|
||||
const lockFile = path.join(this.workDir, '.clawhub', 'lock.json');
|
||||
if (fs.existsSync(lockFile)) {
|
||||
try {
|
||||
const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
|
||||
if (lockData.skills && lockData.skills[params.slug]) {
|
||||
console.log(`Removing ${params.slug} from lock.json`);
|
||||
delete lockData.skills[params.slug];
|
||||
await fsPromises.writeFile(lockFile, JSON.stringify(lockData, null, 2));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update ClawHub lock file:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List installed skills
|
||||
*/
|
||||
async listInstalled(): Promise<ClawHubInstalledSkillResult[]> {
|
||||
try {
|
||||
const output = await this.runCommand(['list']);
|
||||
if (!output || output.includes('No installed skills')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = output.split('\n').filter(l => l.trim());
|
||||
return lines.map(line => {
|
||||
const cleanLine = this.stripAnsi(line);
|
||||
const match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)/);
|
||||
if (match) {
|
||||
const slug = match[1];
|
||||
return {
|
||||
slug,
|
||||
version: match[2],
|
||||
source: 'openclaw-managed',
|
||||
baseDir: path.join(this.workDir, 'skills', slug),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter((s): s is ClawHubInstalledSkillResult => s !== null);
|
||||
} catch (error) {
|
||||
console.error('ClawHub list error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSkillDir(skillKeyOrSlug: string, fallbackSlug?: string, preferredBaseDir?: string): string | null {
|
||||
const candidates = [skillKeyOrSlug, fallbackSlug]
|
||||
.filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
|
||||
@@ -388,13 +252,9 @@ export class ClawHubService {
|
||||
return directSkillDir || this.resolveSkillDirByManifestName(uniqueCandidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open skill README/manual in default editor
|
||||
*/
|
||||
async openSkillReadme(skillKeyOrSlug: string, fallbackSlug?: string, preferredBaseDir?: string): Promise<boolean> {
|
||||
const skillDir = this.resolveSkillDir(skillKeyOrSlug, fallbackSlug, preferredBaseDir);
|
||||
|
||||
// Try to find documentation file
|
||||
const possibleFiles = ['SKILL.md', 'README.md', 'skill.md', 'readme.md'];
|
||||
let targetFile = '';
|
||||
|
||||
@@ -409,7 +269,6 @@ export class ClawHubService {
|
||||
}
|
||||
|
||||
if (!targetFile) {
|
||||
// If no md file, just open the directory
|
||||
if (skillDir) {
|
||||
targetFile = skillDir;
|
||||
} else {
|
||||
@@ -418,7 +277,6 @@ export class ClawHubService {
|
||||
}
|
||||
|
||||
try {
|
||||
// Open file with default application
|
||||
await shell.openPath(targetFile);
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -427,9 +285,6 @@ export class ClawHubService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open skill path in file explorer
|
||||
*/
|
||||
async openSkillPath(skillKeyOrSlug: string, fallbackSlug?: string, preferredBaseDir?: string): Promise<boolean> {
|
||||
const skillDir = this.resolveSkillDir(skillKeyOrSlug, fallbackSlug, preferredBaseDir);
|
||||
if (!skillDir) {
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
/**
|
||||
* Gateway WebSocket Client
|
||||
* Provides a typed interface for Gateway RPC calls
|
||||
*/
|
||||
import { GatewayManager, GatewayStatus } from './manager';
|
||||
|
||||
/**
|
||||
* Channel types supported by OpenClaw
|
||||
*/
|
||||
export type ChannelType = 'whatsapp' | 'dingtalk' | 'telegram' | 'discord' | 'wechat';
|
||||
|
||||
/**
|
||||
* Channel status
|
||||
*/
|
||||
export interface Channel {
|
||||
id: string;
|
||||
type: ChannelType;
|
||||
name: string;
|
||||
status: 'connected' | 'disconnected' | 'connecting' | 'error';
|
||||
lastActivity?: string;
|
||||
error?: string;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill definition
|
||||
*/
|
||||
export interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
category?: string;
|
||||
icon?: string;
|
||||
configurable?: boolean;
|
||||
version?: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill bundle definition
|
||||
*/
|
||||
export interface SkillBundle {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
skills: string[];
|
||||
icon?: string;
|
||||
recommended?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat message
|
||||
*/
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: string;
|
||||
channel?: string;
|
||||
toolCalls?: ToolCall[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool call in a message
|
||||
*/
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
status: 'pending' | 'running' | 'completed' | 'error';
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron task definition
|
||||
*/
|
||||
export interface CronTask {
|
||||
id: string;
|
||||
name: string;
|
||||
schedule: string;
|
||||
command: string;
|
||||
enabled: boolean;
|
||||
lastRun?: string;
|
||||
nextRun?: string;
|
||||
status: 'idle' | 'running' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider configuration
|
||||
*/
|
||||
export interface ProviderConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'openai' | 'anthropic' | 'ollama' | 'custom';
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway Client
|
||||
* Typed wrapper around GatewayManager for making RPC calls
|
||||
*/
|
||||
export class GatewayClient {
|
||||
constructor(private manager: GatewayManager) { }
|
||||
|
||||
/**
|
||||
* Get current gateway status
|
||||
*/
|
||||
getStatus(): GatewayStatus {
|
||||
return this.manager.getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if gateway is connected
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.manager.isConnected();
|
||||
}
|
||||
|
||||
// ==================== Channel Methods ====================
|
||||
|
||||
/**
|
||||
* List all channels
|
||||
*/
|
||||
async listChannels(): Promise<Channel[]> {
|
||||
return this.manager.rpc<Channel[]>('channels.list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channel by ID
|
||||
*/
|
||||
async getChannel(channelId: string): Promise<Channel> {
|
||||
return this.manager.rpc<Channel>('channels.get', { channelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a channel
|
||||
*/
|
||||
async connectChannel(channelId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('channels.connect', { channelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a channel
|
||||
*/
|
||||
async disconnectChannel(channelId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('channels.disconnect', { channelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get QR code for channel connection (e.g., WhatsApp)
|
||||
*/
|
||||
async getChannelQRCode(channelType: ChannelType): Promise<string> {
|
||||
return this.manager.rpc<string>('channels.getQRCode', { channelType });
|
||||
}
|
||||
|
||||
// ==================== Skill Methods ====================
|
||||
|
||||
/**
|
||||
* List all skills
|
||||
*/
|
||||
async listSkills(): Promise<Skill[]> {
|
||||
return this.manager.rpc<Skill[]>('skills.list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a skill
|
||||
*/
|
||||
async enableSkill(skillId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('skills.enable', { skillId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a skill
|
||||
*/
|
||||
async disableSkill(skillId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('skills.disable', { skillId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skill configuration
|
||||
*/
|
||||
async getSkillConfig(skillId: string): Promise<Record<string, unknown>> {
|
||||
return this.manager.rpc<Record<string, unknown>>('skills.getConfig', { skillId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update skill configuration
|
||||
*/
|
||||
async updateSkillConfig(skillId: string, config: Record<string, unknown>): Promise<void> {
|
||||
return this.manager.rpc<void>('skills.updateConfig', { skillId, config });
|
||||
}
|
||||
|
||||
// ==================== Chat Methods ====================
|
||||
|
||||
/**
|
||||
* Send a chat message
|
||||
*/
|
||||
async sendMessage(content: string, channelId?: string): Promise<ChatMessage> {
|
||||
return this.manager.rpc<ChatMessage>('chat.send', { content, channelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chat history
|
||||
*/
|
||||
async getChatHistory(limit = 50, offset = 0): Promise<ChatMessage[]> {
|
||||
return this.manager.rpc<ChatMessage[]>('chat.history', { limit, offset });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear chat history
|
||||
*/
|
||||
async clearChatHistory(): Promise<void> {
|
||||
return this.manager.rpc<void>('chat.clear');
|
||||
}
|
||||
|
||||
// ==================== Cron Methods ====================
|
||||
|
||||
/**
|
||||
* List all cron tasks
|
||||
*/
|
||||
async listCronTasks(): Promise<CronTask[]> {
|
||||
return this.manager.rpc<CronTask[]>('cron.list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new cron task
|
||||
*/
|
||||
async createCronTask(task: Omit<CronTask, 'id' | 'status'>): Promise<CronTask> {
|
||||
return this.manager.rpc<CronTask>('cron.create', task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a cron task
|
||||
*/
|
||||
async updateCronTask(taskId: string, updates: Partial<CronTask>): Promise<CronTask> {
|
||||
return this.manager.rpc<CronTask>('cron.update', { taskId, ...updates });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a cron task
|
||||
*/
|
||||
async deleteCronTask(taskId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('cron.delete', { taskId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a cron task immediately
|
||||
*/
|
||||
async runCronTask(taskId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('cron.run', { taskId });
|
||||
}
|
||||
|
||||
// ==================== Provider Methods ====================
|
||||
|
||||
/**
|
||||
* List configured AI providers
|
||||
*/
|
||||
async listProviders(): Promise<ProviderConfig[]> {
|
||||
return this.manager.rpc<ProviderConfig[]>('providers.list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a provider
|
||||
*/
|
||||
async setProvider(provider: ProviderConfig): Promise<void> {
|
||||
return this.manager.rpc<void>('providers.set', provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a provider
|
||||
*/
|
||||
async removeProvider(providerId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('providers.remove', { providerId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Test provider connection
|
||||
*/
|
||||
async testProvider(providerId: string): Promise<{ success: boolean; error?: string }> {
|
||||
return this.manager.rpc<{ success: boolean; error?: string }>('providers.test', { providerId });
|
||||
}
|
||||
|
||||
// ==================== System Methods ====================
|
||||
|
||||
/**
|
||||
* Get Gateway health status
|
||||
*/
|
||||
async getHealth(): Promise<{ status: string; uptime: number; version?: string }> {
|
||||
return this.manager.rpc<{ status: string; uptime: number; version?: string }>('system.health');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Gateway configuration
|
||||
*/
|
||||
async getConfig(): Promise<Record<string, unknown>> {
|
||||
return this.manager.rpc<Record<string, unknown>>('system.config');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Gateway configuration
|
||||
*/
|
||||
async updateConfig(config: Record<string, unknown>): Promise<void> {
|
||||
return this.manager.rpc<void>('system.updateConfig', config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Gateway version info
|
||||
*/
|
||||
async getVersion(): Promise<{ version: string; nodeVersion?: string; platform?: string }> {
|
||||
return this.manager.rpc<{ version: string; nodeVersion?: string; platform?: string }>('system.version');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available skill bundles
|
||||
*/
|
||||
async getSkillBundles(): Promise<SkillBundle[]> {
|
||||
return this.manager.rpc<SkillBundle[]>('skills.bundles');
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a skill bundle
|
||||
*/
|
||||
async installBundle(bundleId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('skills.installBundle', { bundleId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import JSON5 from 'json5';
|
||||
import type { GatewayManager } from './manager';
|
||||
import { withConfigLock } from '../utils/config-mutex';
|
||||
import { resolveOpenClawConfigPath } from '../utils/paths';
|
||||
|
||||
export type OpenClawConfig = Record<string, unknown>;
|
||||
/** Mutators may be replayed after a compare-and-swap conflict and must not perform external writes. */
|
||||
export type OpenClawConfigMutator = (
|
||||
config: OpenClawConfig,
|
||||
) => void | Promise<void>;
|
||||
|
||||
type ConfigDeliveryGatewayManager = Pick<GatewayManager, 'getStatus' | 'rpc'>;
|
||||
|
||||
interface ConfigSnapshot {
|
||||
config?: unknown;
|
||||
raw?: unknown;
|
||||
hash?: unknown;
|
||||
}
|
||||
|
||||
interface ActiveMutationContext {
|
||||
config: OpenClawConfig;
|
||||
active: boolean;
|
||||
sourceExists: boolean;
|
||||
}
|
||||
|
||||
export interface OpenClawConfigSnapshot {
|
||||
config: OpenClawConfig;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
interface FileConfigSnapshot {
|
||||
config: OpenClawConfig;
|
||||
raw: string | undefined;
|
||||
}
|
||||
|
||||
let gatewayManager: ConfigDeliveryGatewayManager | undefined;
|
||||
let transactionTail: Promise<void> = Promise.resolve();
|
||||
const activeMutation = new AsyncLocalStorage<ActiveMutationContext>();
|
||||
|
||||
function parseConfig(raw: string): OpenClawConfig {
|
||||
const parsed = JSON5.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('OpenClaw config must be an object');
|
||||
}
|
||||
return parsed as OpenClawConfig;
|
||||
}
|
||||
|
||||
function serializeConfig(config: OpenClawConfig): string {
|
||||
return `${JSON.stringify(config, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function parseRunningConfigSnapshot(snapshot: ConfigSnapshot | undefined): OpenClawConfig {
|
||||
if (snapshot?.config && typeof snapshot.config === 'object' && !Array.isArray(snapshot.config)) {
|
||||
return structuredClone(snapshot.config) as OpenClawConfig;
|
||||
}
|
||||
const raw = typeof snapshot?.raw === 'string' ? snapshot.raw : '';
|
||||
if (!raw.trim()) {
|
||||
throw new Error('Gateway config.get returned an incomplete config snapshot');
|
||||
}
|
||||
return parseConfig(raw);
|
||||
}
|
||||
|
||||
function isBaseHashConflict(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /config changed since last load; re-run config\.get and retry/i.test(message);
|
||||
}
|
||||
|
||||
async function mutateRunningConfig(
|
||||
manager: ConfigDeliveryGatewayManager,
|
||||
mutator: OpenClawConfigMutator,
|
||||
): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const snapshot = await manager.rpc<ConfigSnapshot>('config.get', {});
|
||||
const hash = typeof snapshot?.hash === 'string' ? snapshot.hash.trim() : '';
|
||||
if (!hash) {
|
||||
throw new Error('Gateway config.get returned an incomplete config snapshot');
|
||||
}
|
||||
|
||||
const config = parseRunningConfigSnapshot(snapshot);
|
||||
if (!await applyMutator(config, mutator, true)) return false;
|
||||
|
||||
try {
|
||||
await manager.rpc('config.set', {
|
||||
raw: serializeConfig(config),
|
||||
baseHash: hash,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (attempt === 0 && isBaseHashConflict(error)) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function applyMutator(
|
||||
config: OpenClawConfig,
|
||||
mutator: OpenClawConfigMutator,
|
||||
sourceExists: boolean,
|
||||
): Promise<boolean> {
|
||||
const baseline = structuredClone(config);
|
||||
const context: ActiveMutationContext = { config, active: true, sourceExists };
|
||||
try {
|
||||
await activeMutation.run(context, async () => await mutator(config));
|
||||
} finally {
|
||||
context.active = false;
|
||||
}
|
||||
return !isDeepStrictEqual(config, baseline);
|
||||
}
|
||||
|
||||
async function applyNestedMutator(
|
||||
context: ActiveMutationContext,
|
||||
mutator: OpenClawConfigMutator,
|
||||
): Promise<boolean> {
|
||||
const baseline = structuredClone(context.config);
|
||||
await mutator(context.config);
|
||||
return !isDeepStrictEqual(context.config, baseline);
|
||||
}
|
||||
|
||||
async function readFileConfig(configPath: string): Promise<FileConfigSnapshot> {
|
||||
try {
|
||||
const raw = await readFile(configPath, 'utf8');
|
||||
return { config: parseConfig(raw), raw };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { config: {}, raw: undefined };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function readFileRaw(configPath: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(configPath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTemporaryFile(temporaryPath: string): Promise<void> {
|
||||
try {
|
||||
await unlink(temporaryPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function mutateFileConfig(
|
||||
manager: ConfigDeliveryGatewayManager | undefined,
|
||||
mutator: OpenClawConfigMutator,
|
||||
): Promise<boolean> {
|
||||
return await withConfigLock(async () => {
|
||||
const configPath = resolveOpenClawConfigPath();
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const snapshot = await readFileConfig(configPath);
|
||||
const changed = await applyMutator(snapshot.config, mutator, snapshot.raw !== undefined);
|
||||
|
||||
if (manager?.getStatus().state === 'running') {
|
||||
return await mutateRunningConfig(manager, mutator);
|
||||
}
|
||||
if (!changed) return false;
|
||||
|
||||
await mkdir(dirname(configPath), { recursive: true });
|
||||
const temporaryPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
|
||||
await writeFile(temporaryPath, serializeConfig(snapshot.config), {
|
||||
encoding: 'utf8',
|
||||
flag: 'wx',
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
try {
|
||||
if (manager?.getStatus().state === 'running') {
|
||||
return await mutateRunningConfig(manager, mutator);
|
||||
}
|
||||
|
||||
const currentRaw = await readFileRaw(configPath);
|
||||
if (manager?.getStatus().state === 'running') {
|
||||
return await mutateRunningConfig(manager, mutator);
|
||||
}
|
||||
if (currentRaw !== snapshot.raw) {
|
||||
if (attempt === 0) continue;
|
||||
throw new Error('OpenClaw config changed during file mutation; retry the mutation');
|
||||
}
|
||||
|
||||
await rename(temporaryPath, configPath);
|
||||
return true;
|
||||
} finally {
|
||||
await removeTemporaryFile(temporaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
async function runMutation(mutator: OpenClawConfigMutator): Promise<boolean> {
|
||||
const manager = gatewayManager;
|
||||
if (manager?.getStatus().state === 'running') {
|
||||
return await mutateRunningConfig(manager, mutator);
|
||||
}
|
||||
return await mutateFileConfig(manager, mutator);
|
||||
}
|
||||
|
||||
async function runRead(): Promise<OpenClawConfigSnapshot> {
|
||||
const manager = gatewayManager;
|
||||
if (manager?.getStatus().state === 'running') {
|
||||
const snapshot = await manager.rpc<ConfigSnapshot>('config.get', {});
|
||||
return { config: parseRunningConfigSnapshot(snapshot), exists: true };
|
||||
}
|
||||
|
||||
const snapshot = await readFileConfig(resolveOpenClawConfigPath());
|
||||
return { config: snapshot.config, exists: snapshot.raw !== undefined };
|
||||
}
|
||||
|
||||
async function runSecretsReload(): Promise<boolean> {
|
||||
const manager = gatewayManager;
|
||||
if (manager?.getStatus().state !== 'running') return false;
|
||||
await manager.rpc('secrets.reload', {});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function registerOpenClawConfigCoordinator(
|
||||
manager: ConfigDeliveryGatewayManager,
|
||||
): void {
|
||||
gatewayManager = manager;
|
||||
}
|
||||
|
||||
export function mutateOpenClawConfig(
|
||||
mutator: OpenClawConfigMutator,
|
||||
): Promise<boolean> {
|
||||
const context = activeMutation.getStore();
|
||||
if (context?.active) {
|
||||
return applyNestedMutator(context, mutator);
|
||||
}
|
||||
|
||||
const transaction = transactionTail.then(
|
||||
() => runMutation(mutator),
|
||||
() => runMutation(mutator),
|
||||
);
|
||||
transactionTail = transaction.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
export function readOpenClawConfigSnapshot(): Promise<OpenClawConfigSnapshot> {
|
||||
const context = activeMutation.getStore();
|
||||
if (context?.active) {
|
||||
return Promise.resolve({
|
||||
config: structuredClone(context.config),
|
||||
exists: context.sourceExists,
|
||||
});
|
||||
}
|
||||
|
||||
const transaction = transactionTail.then(
|
||||
() => runRead(),
|
||||
() => runRead(),
|
||||
);
|
||||
transactionTail = transaction.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
export function reloadOpenClawSecretsIfRunning(): Promise<boolean> {
|
||||
const transaction = transactionTail.then(
|
||||
() => runSecretsReload(),
|
||||
() => runSecretsReload(),
|
||||
);
|
||||
transactionTail = transaction.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
export function resetOpenClawConfigCoordinatorForTests(): void {
|
||||
gatewayManager = undefined;
|
||||
transactionTail = Promise.resolve();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export const SUPERVISED_SYSTEMD_ENV_KEYS = [
|
||||
'OPENCLAW_SYSTEMD_UNIT',
|
||||
'INVOCATION_ID',
|
||||
'SYSTEMD_EXEC_PID',
|
||||
'JOURNAL_STREAM',
|
||||
] as const;
|
||||
|
||||
export type GatewayEnv = Record<string, string | undefined>;
|
||||
|
||||
/**
|
||||
* OpenClaw CLI treats certain environment variables as systemd supervisor hints.
|
||||
* When present in ClawX-owned child-process launches, it can mistakenly enter
|
||||
* a supervised process retry loop. Strip those variables so startup follows
|
||||
* ClawX lifecycle.
|
||||
*/
|
||||
export function stripSystemdSupervisorEnv(env: GatewayEnv): GatewayEnv {
|
||||
const next = { ...env };
|
||||
for (const key of SUPERVISED_SYSTEMD_ENV_KEYS) {
|
||||
delete next[key];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
+462
-69
@@ -1,6 +1,6 @@
|
||||
import { app } from 'electron';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, cpSync, mkdirSync, rmSync } from 'fs';
|
||||
import { existsSync, readFileSync, mkdirSync, readdirSync, symlinkSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
@@ -18,15 +18,36 @@ function fsPath(filePath: string): string {
|
||||
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 {
|
||||
getOpenClawConfigDir,
|
||||
getOpenClawDir,
|
||||
getOpenClawEntryPath,
|
||||
getOpenClawResolvedDir,
|
||||
getOpenClawSkillsDir,
|
||||
isOpenClawPresent,
|
||||
} from '../utils/paths';
|
||||
import { getUvMirrorEnv } from '../utils/uv-env';
|
||||
import { cleanupDanglingWeChatPluginState, listConfiguredChannels } from '../utils/channel-config';
|
||||
import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, syncSessionIdleMinutesToOpenClaw, sanitizeOpenClawConfig } from '../utils/openclaw-auth';
|
||||
import { cleanupDanglingWeChatPluginState, listConfiguredChannelsFromConfig, readOpenClawConfig } from '../utils/channel-config';
|
||||
import { sanitizeOpenClawConfig, batchSyncConfigFields } 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';
|
||||
import { copyPluginFromNodeModules, fixupPluginManifest } from '../utils/plugin-install';
|
||||
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe, buildCandidateSources, repairTrustedOfficialPluginInstallRecords, removeTrustedOfficialPluginInstallRecord, resolvePluginNpmPackagePath } from '../utils/plugin-install';
|
||||
import { safeRmSync } from '../utils/safe-fs';
|
||||
import { CLAWX_OPENAI_IMAGE_PROVIDER_KEY } from '../utils/openclaw-image-relay-constants';
|
||||
import { ensureOpenClaw2026_7_1UpgradeSnapshot } from '../utils/openclaw-upgrade-snapshot';
|
||||
import { stripSystemdSupervisorEnv } from './config-sync-env';
|
||||
import { cleanupAgentsSymlinkedSkills, cleanupStalePluginRuntimeDeps } from './skills-symlink-cleanup';
|
||||
import {
|
||||
buildPrelaunchMaintenanceCacheKey,
|
||||
directoryChildrenSignature,
|
||||
pathSignature,
|
||||
runCachedPrelaunchMaintenanceTask,
|
||||
type PrelaunchMaintenanceRunResult,
|
||||
type PrelaunchMaintenanceTaskName,
|
||||
} from './prelaunch-maintenance-cache';
|
||||
|
||||
|
||||
export interface GatewayLaunchContext {
|
||||
appSettings: Awaited<ReturnType<typeof getAllSettings>>;
|
||||
@@ -41,31 +62,66 @@ export interface GatewayLaunchContext {
|
||||
channelStartupSummary: string;
|
||||
}
|
||||
|
||||
export interface GatewayPrelaunchSyncSummary {
|
||||
timingsMs: Record<string, number>;
|
||||
maintenance: Partial<Record<PrelaunchMaintenanceTaskName, PrelaunchMaintenanceRunResult>>;
|
||||
configuredChannels: string[];
|
||||
}
|
||||
|
||||
// ── Auto-upgrade bundled plugins on startup ──────────────────────
|
||||
|
||||
const CHANNEL_PLUGIN_MAP: Record<string, { dirName: string; npmName: string }> = {
|
||||
dingtalk: { dirName: 'dingtalk', npmName: '@soimy/dingtalk' },
|
||||
wecom: { dirName: 'wecom', npmName: '@wecom/wecom-openclaw-plugin' },
|
||||
feishu: { dirName: 'feishu-openclaw-plugin', npmName: '@larksuite/openclaw-lark' },
|
||||
qqbot: { dirName: 'qqbot', npmName: '@tencent-connect/openclaw-qqbot' },
|
||||
discord: { dirName: 'discord', npmName: '@openclaw/discord' },
|
||||
qqbot: { dirName: 'qqbot', npmName: '@openclaw/qqbot' },
|
||||
whatsapp: { dirName: 'whatsapp', npmName: '@openclaw/whatsapp' },
|
||||
|
||||
'openclaw-weixin': { dirName: 'openclaw-weixin', npmName: '@tencent-weixin/openclaw-weixin' },
|
||||
[CLAWX_OPENAI_IMAGE_PROVIDER_KEY]: { dirName: CLAWX_OPENAI_IMAGE_PROVIDER_KEY, npmName: 'clawx-openai-image-plugin' },
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenClaw 3.22+ ships Discord, Telegram, and other channels as built-in
|
||||
* extensions. If a previous ClawX version copied one of these into
|
||||
* ~/.openclaw/extensions/, the broken copy overrides the working built-in
|
||||
* plugin and must be removed.
|
||||
* OpenClaw ships some channel plugins as bundled extensions under
|
||||
* dist/extensions/. If ClawX previously mirrored one of those ids into
|
||||
* ~/.openclaw/extensions/, the stale copy overrides the bundled plugin.
|
||||
* Only remove extension copies whose id is actually bundled in the
|
||||
* currently resolved OpenClaw runtime (e.g. telegram in 2026.6.10).
|
||||
*/
|
||||
const BUILTIN_CHANNEL_EXTENSIONS = ['discord', 'telegram'];
|
||||
function listBundledOpenClawExtensionPluginIds(): string[] {
|
||||
const extensionsDir = join(getOpenClawResolvedDir(), 'dist', 'extensions');
|
||||
if (!existsSync(fsPath(extensionsDir))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const pluginIds: string[] = [];
|
||||
for (const entry of readdirSync(fsPath(extensionsDir), { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const manifestPath = join(extensionsDir, entry.name, 'openclaw.plugin.json');
|
||||
if (!existsSync(fsPath(manifestPath))) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(fsPath(manifestPath), 'utf-8')) as { id?: unknown };
|
||||
if (typeof parsed.id === 'string' && parsed.id.trim()) {
|
||||
pluginIds.push(parsed.id.trim());
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed manifests
|
||||
}
|
||||
}
|
||||
|
||||
return pluginIds;
|
||||
}
|
||||
|
||||
function cleanupStaleBuiltInExtensions(): void {
|
||||
for (const ext of BUILTIN_CHANNEL_EXTENSIONS) {
|
||||
for (const ext of listBundledOpenClawExtensionPluginIds()) {
|
||||
const extDir = join(homedir(), '.openclaw', 'extensions', ext);
|
||||
if (existsSync(fsPath(extDir))) {
|
||||
logger.info(`[plugin] Removing stale built-in extension copy: ${ext}`);
|
||||
try {
|
||||
rmSync(fsPath(extDir), { recursive: true, force: true });
|
||||
safeRmSync(fsPath(extDir));
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin] Failed to remove stale extension ${ext}:`, err);
|
||||
}
|
||||
@@ -83,17 +139,30 @@ function readPluginVersion(pkgJsonPath: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function buildBundledPluginSources(pluginDirName: string): string[] {
|
||||
return app.isPackaged
|
||||
? [
|
||||
join(process.resourcesPath, 'openclaw-plugins', pluginDirName),
|
||||
join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', pluginDirName),
|
||||
join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', pluginDirName),
|
||||
]
|
||||
: [
|
||||
join(app.getAppPath(), 'build', 'openclaw-plugins', pluginDirName),
|
||||
join(process.cwd(), 'build', 'openclaw-plugins', pluginDirName),
|
||||
];
|
||||
function measureSync<T>(timings: Record<string, number>, key: string, fn: () => T): T {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
timings[key] = Date.now() - startedAt;
|
||||
}
|
||||
}
|
||||
|
||||
async function measureAsync<T>(timings: Record<string, number>, key: string, fn: () => Promise<T>): Promise<T> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
timings[key] = Date.now() - startedAt;
|
||||
}
|
||||
}
|
||||
|
||||
function appVersionForCache(): string {
|
||||
try {
|
||||
return app.getVersion();
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,7 +170,8 @@ function buildBundledPluginSources(pluginDirName: string): string[] {
|
||||
* - Packaged mode: uses bundled plugins from resources/ (includes deps)
|
||||
* - Dev mode: falls back to node_modules/ with pnpm-aware dep collection
|
||||
*/
|
||||
function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void {
|
||||
function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean {
|
||||
let succeeded = true;
|
||||
for (const channelType of configuredChannels) {
|
||||
const pluginInfo = CHANNEL_PLUGIN_MAP[channelType];
|
||||
if (!pluginInfo) continue;
|
||||
@@ -113,7 +183,7 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void {
|
||||
const installedVersion = isInstalled ? readPluginVersion(join(targetDir, 'package.json')) : null;
|
||||
|
||||
// Try bundled sources first (packaged mode or if bundle-plugins was run)
|
||||
const bundledSources = buildBundledPluginSources(dirName);
|
||||
const bundledSources = buildCandidateSources(dirName);
|
||||
const bundledDir = bundledSources.find((dir) => existsSync(fsPath(join(dir, 'openclaw.plugin.json'))));
|
||||
|
||||
if (bundledDir) {
|
||||
@@ -123,52 +193,280 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void {
|
||||
logger.info(`[plugin] ${isInstalled ? 'Auto-upgrading' : 'Installing'} ${channelType} plugin${isInstalled ? `: ${installedVersion} → ${sourceVersion}` : `: ${sourceVersion}`} (bundled)`);
|
||||
try {
|
||||
mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true });
|
||||
rmSync(fsPath(targetDir), { recursive: true, force: true });
|
||||
cpSync(fsPath(bundledDir), fsPath(targetDir), { recursive: true, dereference: true });
|
||||
safeRmSync(fsPath(targetDir));
|
||||
cpSyncSafe(bundledDir, targetDir);
|
||||
fixupPluginManifest(targetDir);
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin:`, err);
|
||||
succeeded = false;
|
||||
}
|
||||
} else if (isInstalled) {
|
||||
// Same version already installed — still patch manifest ID in case it was
|
||||
// never corrected (e.g. installed before MANIFEST_ID_FIXES included this plugin).
|
||||
fixupPluginManifest(targetDir);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dev mode fallback: copy from node_modules/ with pnpm dep resolution
|
||||
if (!app.isPackaged) {
|
||||
const npmPkgPath = join(process.cwd(), 'node_modules', ...npmName.split('/'));
|
||||
if (!existsSync(fsPath(join(npmPkgPath, 'openclaw.plugin.json')))) continue;
|
||||
const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json'));
|
||||
if (!sourceVersion) continue;
|
||||
// Skip only if installed AND same version
|
||||
if (isInstalled && installedVersion && sourceVersion === installedVersion) continue;
|
||||
const npmPkgPath = resolvePluginNpmPackagePath(npmName);
|
||||
if (npmPkgPath && existsSync(fsPath(join(npmPkgPath, 'openclaw.plugin.json')))) {
|
||||
const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json'));
|
||||
if (!sourceVersion) continue;
|
||||
// Skip only if installed AND same version — but still patch manifest ID.
|
||||
if (isInstalled && installedVersion && sourceVersion === installedVersion) {
|
||||
fixupPluginManifest(targetDir);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`[plugin] ${isInstalled ? 'Auto-upgrading' : 'Installing'} ${channelType} plugin${isInstalled ? `: ${installedVersion} → ${sourceVersion}` : `: ${sourceVersion}`} (dev/node_modules)`);
|
||||
try {
|
||||
mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true });
|
||||
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
|
||||
fixupPluginManifest(targetDir);
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin from node_modules:`, err);
|
||||
logger.info(`[plugin] ${isInstalled ? 'Auto-upgrading' : 'Installing'} ${channelType} plugin${isInstalled ? `: ${installedVersion} → ${sourceVersion}` : `: ${sourceVersion}`} (dev/node_modules)`);
|
||||
|
||||
try {
|
||||
mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true });
|
||||
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
|
||||
fixupPluginManifest(targetDir);
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin from node_modules:`, err);
|
||||
succeeded = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return succeeded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove channel plugin extensions from ~/.openclaw/extensions/ when their
|
||||
* corresponding channel is no longer configured. This prevents the Gateway
|
||||
* from scanning residual plugin manifests that were installed by a previous
|
||||
* configuration but are no longer needed.
|
||||
*/
|
||||
function cleanupUnconfiguredChannelPlugins(configuredChannels: string[]): boolean {
|
||||
let succeeded = true;
|
||||
const configuredSet = new Set(configuredChannels);
|
||||
|
||||
for (const [channelType, pluginInfo] of Object.entries(CHANNEL_PLUGIN_MAP)) {
|
||||
if (configuredSet.has(channelType)) continue;
|
||||
|
||||
const { dirName } = pluginInfo;
|
||||
const targetDir = join(homedir(), '.openclaw', 'extensions', dirName);
|
||||
if (!existsSync(fsPath(targetDir))) continue;
|
||||
|
||||
logger.info(`[plugin] Removing unconfigured channel plugin: ${channelType} (${dirName})`);
|
||||
try {
|
||||
safeRmSync(fsPath(targetDir));
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin] Failed to remove unconfigured channel plugin ${channelType}:`, err);
|
||||
succeeded = false;
|
||||
}
|
||||
}
|
||||
return succeeded;
|
||||
}
|
||||
|
||||
async function cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels: string[]): Promise<void> {
|
||||
const configuredSet = new Set(configuredChannels);
|
||||
for (const [channelType, { dirName }] of Object.entries(CHANNEL_PLUGIN_MAP)) {
|
||||
if (configuredSet.has(channelType)) continue;
|
||||
// Metadata can outlive the directory (for example after an interrupted
|
||||
// 2026.6.10 → 2026.7.1 migration). OpenClaw validates tracked records even
|
||||
// when the channel is no longer configured, so reconcile this on every
|
||||
// launch rather than hiding it behind the directory-maintenance cache.
|
||||
await removeTrustedOfficialPluginInstallRecord(dirName);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveImageGenerationPrimary(config: unknown): string | null {
|
||||
if (!config || typeof config !== 'object') return null;
|
||||
const agents = (config as { agents?: unknown }).agents;
|
||||
if (!agents || typeof agents !== 'object') return null;
|
||||
const defaults = (agents as { defaults?: unknown }).defaults;
|
||||
if (!defaults || typeof defaults !== 'object') return null;
|
||||
const imageGenerationModel = (defaults as { imageGenerationModel?: unknown }).imageGenerationModel;
|
||||
if (typeof imageGenerationModel === 'string') return imageGenerationModel.trim() || null;
|
||||
if (imageGenerationModel && typeof imageGenerationModel === 'object') {
|
||||
const primary = (imageGenerationModel as { primary?: unknown }).primary;
|
||||
return typeof primary === 'string' && primary.trim() ? primary.trim() : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function withConfiguredImageGenerationPlugins(configuredChannels: string[], rawConfig: unknown): string[] {
|
||||
const next = [...configuredChannels];
|
||||
const primary = resolveImageGenerationPrimary(rawConfig);
|
||||
const provider = primary?.includes('/') ? primary.slice(0, primary.indexOf('/')).trim() : primary;
|
||||
if (provider === CLAWX_OPENAI_IMAGE_PROVIDER_KEY && !next.includes(CLAWX_OPENAI_IMAGE_PROVIDER_KEY)) {
|
||||
next.push(CLAWX_OPENAI_IMAGE_PROVIDER_KEY);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildPluginSourceSignatures(configuredChannels: string[]): Record<string, unknown> {
|
||||
const signatures: Record<string, unknown> = {};
|
||||
for (const channelType of [...configuredChannels].sort()) {
|
||||
const pluginInfo = CHANNEL_PLUGIN_MAP[channelType];
|
||||
if (!pluginInfo) continue;
|
||||
const bundledSources = buildCandidateSources(pluginInfo.dirName);
|
||||
const bundledDir = bundledSources.find((dir) => existsSync(fsPath(join(dir, 'openclaw.plugin.json'))));
|
||||
const devPkgPath = join(process.cwd(), 'node_modules', ...pluginInfo.npmName.split('/'));
|
||||
const sourceDir = bundledDir || (!app.isPackaged ? devPkgPath : '');
|
||||
signatures[channelType] = sourceDir
|
||||
? {
|
||||
sourceDir,
|
||||
manifest: pathSignature(join(sourceDir, 'openclaw.plugin.json')),
|
||||
packageJson: pathSignature(join(sourceDir, 'package.json')),
|
||||
}
|
||||
: 'missing';
|
||||
}
|
||||
return signatures;
|
||||
}
|
||||
|
||||
function buildPluginMaintenanceCacheKey(openclawDir: string, configuredChannels: string[]): string {
|
||||
return buildPrelaunchMaintenanceCacheKey({
|
||||
task: 'plugin-maintenance',
|
||||
appVersion: appVersionForCache(),
|
||||
openclawDir,
|
||||
cwd: process.cwd(),
|
||||
configuredChannels: [...configuredChannels].sort(),
|
||||
extensionsDir: directoryChildrenSignature(join(homedir(), '.openclaw', 'extensions')),
|
||||
sourceSignatures: buildPluginSourceSignatures(configuredChannels),
|
||||
});
|
||||
}
|
||||
|
||||
function buildSkillsSymlinkCleanupCacheKey(openclawDir: string): string {
|
||||
const workspaceSkillsDir = join(getOpenClawConfigDir(), 'workspace', 'skills');
|
||||
return buildPrelaunchMaintenanceCacheKey({
|
||||
task: 'skills-symlink-cleanup',
|
||||
appVersion: appVersionForCache(),
|
||||
openclawDir,
|
||||
skillsDir: getOpenClawSkillsDir(),
|
||||
skillsDirSignature: directoryChildrenSignature(getOpenClawSkillsDir()),
|
||||
workspaceSkillsDir,
|
||||
workspaceSkillsDirSignature: directoryChildrenSignature(workspaceSkillsDir),
|
||||
});
|
||||
}
|
||||
|
||||
function buildRuntimeDepsCleanupCacheKey(openclawDir: string): string {
|
||||
const runtimeDepsDir = join(getOpenClawConfigDir(), 'plugin-runtime-deps');
|
||||
return buildPrelaunchMaintenanceCacheKey({
|
||||
task: 'runtime-deps-cleanup',
|
||||
appVersion: appVersionForCache(),
|
||||
openclawDir,
|
||||
currentOpenClawDir: getOpenClawResolvedDir(),
|
||||
runtimeDepsDir,
|
||||
runtimeDepsDirSignature: directoryChildrenSignature(runtimeDepsDir),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure extension-specific packages are resolvable from shared dist/ chunks.
|
||||
*
|
||||
* OpenClaw's Rollup bundler creates shared chunks in dist/ (e.g.
|
||||
* sticker-cache-*.js) that eagerly `import "grammy"`. ESM bare specifier
|
||||
* resolution walks from the importing file's directory upward:
|
||||
* dist/node_modules/ → openclaw/node_modules/ → …
|
||||
* It does NOT search `dist/extensions/telegram/node_modules/`.
|
||||
*
|
||||
* NODE_PATH only works for CJS require(), NOT for ESM import statements.
|
||||
*
|
||||
* Fix: create symlinks in openclaw/node_modules/ pointing to packages in
|
||||
* dist/extensions/<ext>/node_modules/. This makes the standard ESM
|
||||
* resolution algorithm find them. Skip-if-exists avoids overwriting
|
||||
* openclaw's own deps (they take priority).
|
||||
*/
|
||||
let _extensionDepsLinked = false;
|
||||
|
||||
/**
|
||||
* Reset the extension-deps-linked cache so the next
|
||||
* ensureExtensionDepsResolvable() call re-scans and links.
|
||||
* Called before each Gateway launch to pick up newly installed extensions.
|
||||
*/
|
||||
export function resetExtensionDepsLinked(): void {
|
||||
_extensionDepsLinked = false;
|
||||
}
|
||||
|
||||
function ensureExtensionDepsResolvable(openclawDir: string): void {
|
||||
if (_extensionDepsLinked) return;
|
||||
|
||||
const extDir = join(openclawDir, 'dist', 'extensions');
|
||||
const topNM = join(openclawDir, 'node_modules');
|
||||
let linkedCount = 0;
|
||||
|
||||
try {
|
||||
if (!existsSync(extDir)) return;
|
||||
|
||||
for (const ext of readdirSync(extDir, { withFileTypes: true })) {
|
||||
if (!ext.isDirectory()) continue;
|
||||
const extNM = join(extDir, ext.name, 'node_modules');
|
||||
if (!existsSync(extNM)) continue;
|
||||
|
||||
for (const pkg of readdirSync(extNM, { withFileTypes: true })) {
|
||||
if (pkg.name === '.bin') continue;
|
||||
|
||||
if (pkg.name.startsWith('@')) {
|
||||
// Scoped package — iterate sub-entries
|
||||
const scopeDir = join(extNM, pkg.name);
|
||||
let scopeEntries;
|
||||
try { scopeEntries = readdirSync(scopeDir, { withFileTypes: true }); } catch { continue; }
|
||||
for (const sub of scopeEntries) {
|
||||
if (!sub.isDirectory()) continue;
|
||||
const dest = join(topNM, pkg.name, sub.name);
|
||||
if (existsSync(dest)) continue;
|
||||
try {
|
||||
mkdirSync(join(topNM, pkg.name), { recursive: true });
|
||||
symlinkSync(join(scopeDir, sub.name), dest);
|
||||
linkedCount++;
|
||||
} catch { /* skip on error — non-fatal */ }
|
||||
}
|
||||
} else {
|
||||
const dest = join(topNM, pkg.name);
|
||||
if (existsSync(dest)) continue;
|
||||
try {
|
||||
mkdirSync(topNM, { recursive: true });
|
||||
symlinkSync(join(extNM, pkg.name), dest);
|
||||
linkedCount++;
|
||||
} catch { /* skip on error — non-fatal */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// extensions dir may not exist or be unreadable — non-fatal
|
||||
}
|
||||
|
||||
if (linkedCount > 0) {
|
||||
logger.info(`[extension-deps] Linked ${linkedCount} extension packages into ${topNM}`);
|
||||
}
|
||||
|
||||
_extensionDepsLinked = true;
|
||||
}
|
||||
|
||||
// ── Pre-launch sync ──────────────────────────────────────────────
|
||||
|
||||
export async function syncGatewayConfigBeforeLaunch(
|
||||
appSettings: Awaited<ReturnType<typeof getAllSettings>>,
|
||||
): Promise<void> {
|
||||
await syncProxyConfigToOpenClaw(appSettings, { preserveExistingWhenDisabled: true });
|
||||
openclawDir: string,
|
||||
): Promise<GatewayPrelaunchSyncSummary> {
|
||||
const timingsMs: Record<string, number> = {};
|
||||
const maintenance: GatewayPrelaunchSyncSummary['maintenance'] = {};
|
||||
let configuredChannels: string[] = [];
|
||||
|
||||
// Reset the extension-deps cache so that newly installed extensions
|
||||
// (e.g. user added a channel while the app was running) get their
|
||||
// node_modules linked on the next Gateway spawn.
|
||||
resetExtensionDepsLinked();
|
||||
|
||||
await measureAsync(timingsMs, 'proxySyncMs', async () => {
|
||||
await syncProxyConfigToOpenClaw(appSettings, { preserveExistingWhenDisabled: true });
|
||||
});
|
||||
|
||||
try {
|
||||
await sanitizeOpenClawConfig();
|
||||
await measureAsync(timingsMs, 'sanitizeMs', sanitizeOpenClawConfig);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to sanitize openclaw.json:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
await cleanupDanglingWeChatPluginState();
|
||||
await measureAsync(timingsMs, 'wechatStateCleanupMs', cleanupDanglingWeChatPluginState);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to clean dangling WeChat plugin state before launch:', err);
|
||||
}
|
||||
@@ -176,37 +474,90 @@ export async function syncGatewayConfigBeforeLaunch(
|
||||
// Remove stale copies of built-in extensions (Discord, Telegram) that
|
||||
// override OpenClaw's working built-in plugins and break channel loading.
|
||||
try {
|
||||
cleanupStaleBuiltInExtensions();
|
||||
measureSync(timingsMs, 'staleBuiltinExtensionCleanupMs', cleanupStaleBuiltInExtensions);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to clean stale built-in extensions:', err);
|
||||
}
|
||||
|
||||
// Remove stray symlinks under ~/.openclaw/skills whose realpath resolves
|
||||
// inside ~/.agents/skills. OpenClaw's hardened skill loader rejects these
|
||||
// on every launch (reason=symlink-escape) and the underlying skills are
|
||||
// still discovered via the agents-skills-personal source, so the symlinks
|
||||
// are pure log noise. Transitional workaround for openclaw/openclaw#59219.
|
||||
try {
|
||||
const result = measureSync(timingsMs, 'skillsCleanupMs', () => runCachedPrelaunchMaintenanceTask(
|
||||
'skills-symlink-cleanup',
|
||||
() => buildSkillsSymlinkCleanupCacheKey(openclawDir),
|
||||
() => (cleanupAgentsSymlinkedSkills().failed ?? 0) === 0,
|
||||
));
|
||||
maintenance['skills-symlink-cleanup'] = result;
|
||||
} catch (err) {
|
||||
logger.warn('Failed to clean .agents/skills-targeted skill symlinks:', err);
|
||||
}
|
||||
|
||||
// Remove stale OpenClaw runtime-deps cache roots that point at an older
|
||||
// worktree/package. Those symlink trees can make Gateway plugin setup spend
|
||||
// a long time in synchronous fs.open/copy calls before the RPC router is
|
||||
// responsive.
|
||||
try {
|
||||
const result = measureSync(timingsMs, 'runtimeDepsCleanupMs', () => runCachedPrelaunchMaintenanceTask(
|
||||
'runtime-deps-cleanup',
|
||||
() => buildRuntimeDepsCleanupCacheKey(openclawDir),
|
||||
() => (cleanupStalePluginRuntimeDeps().failed ?? 0) === 0,
|
||||
));
|
||||
maintenance['runtime-deps-cleanup'] = result;
|
||||
} catch (err) {
|
||||
logger.warn('Failed to clean stale OpenClaw plugin runtime deps:', err);
|
||||
}
|
||||
|
||||
// Auto-upgrade installed plugins before Gateway starts so that
|
||||
// the plugin manifest ID matches what sanitize wrote to the config.
|
||||
// Only install/upgrade plugins for channels that are actually configured
|
||||
// in openclaw.json — do NOT expand the list from plugins.allow.
|
||||
try {
|
||||
const configuredChannels = await listConfiguredChannels();
|
||||
ensureConfiguredPluginsUpgraded(configuredChannels);
|
||||
configuredChannels = await measureAsync(timingsMs, 'configuredChannelsMs', async () => {
|
||||
const rawCfg = await readOpenClawConfig();
|
||||
return withConfiguredImageGenerationPlugins(
|
||||
await listConfiguredChannelsFromConfig(rawCfg),
|
||||
rawCfg,
|
||||
);
|
||||
});
|
||||
|
||||
const result = measureSync(timingsMs, 'pluginMaintenanceMs', () => runCachedPrelaunchMaintenanceTask(
|
||||
'plugin-maintenance',
|
||||
() => buildPluginMaintenanceCacheKey(openclawDir, configuredChannels),
|
||||
() => {
|
||||
const upgradeOk = ensureConfiguredPluginsUpgraded(configuredChannels);
|
||||
const cleanupOk = cleanupUnconfiguredChannelPlugins(configuredChannels);
|
||||
return upgradeOk && cleanupOk;
|
||||
},
|
||||
));
|
||||
maintenance['plugin-maintenance'] = result;
|
||||
// Always refresh trusted install metadata through ClawX — this must not
|
||||
// be skipped when plugin-maintenance is cache-hit, otherwise official
|
||||
// external plugins like WhatsApp fail openKeyedStore at runtime.
|
||||
await measureAsync(timingsMs, 'trustedPluginInstallSyncMs', async () => {
|
||||
await cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels);
|
||||
await repairTrustedOfficialPluginInstallRecords();
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Failed to auto-upgrade plugins:', err);
|
||||
}
|
||||
|
||||
// Batch gateway token, browser config, and session idle into one read+write cycle.
|
||||
try {
|
||||
await syncGatewayTokenToConfig(appSettings.gatewayToken);
|
||||
await measureAsync(timingsMs, 'configFieldSyncMs', async () => {
|
||||
await batchSyncConfigFields(appSettings.gatewayToken);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Failed to sync gateway token to openclaw.json:', err);
|
||||
logger.warn('Failed to batch-sync config fields to openclaw.json:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
await syncBrowserConfigToOpenClaw();
|
||||
} catch (err) {
|
||||
logger.warn('Failed to sync browser config to openclaw.json:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
await syncSessionIdleMinutesToOpenClaw();
|
||||
} catch (err) {
|
||||
logger.warn('Failed to sync session idle minutes to openclaw.json:', err);
|
||||
}
|
||||
return {
|
||||
timingsMs,
|
||||
maintenance,
|
||||
configuredChannels,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadProviderEnv(): Promise<{ providerEnv: Record<string, string>; loadedProviderKeyCount: number }> {
|
||||
@@ -255,7 +606,8 @@ async function resolveChannelStartupPolicy(): Promise<{
|
||||
channelStartupSummary: string;
|
||||
}> {
|
||||
try {
|
||||
const configuredChannels = await listConfiguredChannels();
|
||||
const rawCfg = await readOpenClawConfig();
|
||||
const configuredChannels = await listConfiguredChannelsFromConfig(rawCfg);
|
||||
if (configuredChannels.length === 0) {
|
||||
return {
|
||||
skipChannels: true,
|
||||
@@ -277,6 +629,8 @@ async function resolveChannelStartupPolicy(): Promise<{
|
||||
}
|
||||
|
||||
export async function prepareGatewayLaunchContext(port: number): Promise<GatewayLaunchContext> {
|
||||
const timingsMs: Record<string, number> = {};
|
||||
const totalStartedAt = Date.now();
|
||||
const openclawDir = getOpenClawDir();
|
||||
const entryScript = getOpenClawEntryPath();
|
||||
|
||||
@@ -284,8 +638,23 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
|
||||
throw new Error(`OpenClaw package not found at: ${openclawDir}`);
|
||||
}
|
||||
|
||||
const appSettings = await getAllSettings();
|
||||
await syncGatewayConfigBeforeLaunch(appSettings);
|
||||
await measureAsync(timingsMs, 'upgradeSnapshotMs', async () => {
|
||||
try {
|
||||
const snapshot = await ensureOpenClaw2026_7_1UpgradeSnapshot();
|
||||
if (snapshot.status === 'created') {
|
||||
logger.info(`[upgrade] Created OpenClaw 2026.7.1 pre-migration snapshot (${snapshot.files.length} files): ${snapshot.snapshotDir}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// OpenClaw also maintains migration-specific backups. Keep startup
|
||||
// available if the additional ClawX safety snapshot cannot be written.
|
||||
logger.warn('[upgrade] Failed to create OpenClaw 2026.7.1 pre-migration snapshot:', error);
|
||||
}
|
||||
});
|
||||
|
||||
const appSettings = await measureAsync(timingsMs, 'settingsMs', getAllSettings);
|
||||
const prelaunchSummary = await measureAsync(timingsMs, 'prelaunchSyncMs', async () => (
|
||||
await syncGatewayConfigBeforeLaunch(appSettings, openclawDir)
|
||||
));
|
||||
|
||||
if (!existsSync(entryScript)) {
|
||||
throw new Error(`OpenClaw entry script not found at: ${entryScript}`);
|
||||
@@ -302,9 +671,13 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
|
||||
: 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 { providerEnv, loadedProviderKeyCount } = await measureAsync(timingsMs, 'providerEnvMs', loadProviderEnv);
|
||||
const { skipChannels, channelStartupSummary } = await measureAsync(
|
||||
timingsMs,
|
||||
'channelStartupPolicyMs',
|
||||
resolveChannelStartupPolicy,
|
||||
);
|
||||
const uvEnv = await measureAsync(timingsMs, 'uvEnvMs', getUvMirrorEnv);
|
||||
const proxyEnv = buildProxyEnv(appSettings);
|
||||
const resolvedProxy = resolveProxySettings(appSettings);
|
||||
const proxySummary = appSettings.proxyEnabled
|
||||
@@ -317,16 +690,36 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
|
||||
? prependPathEntry(baseEnvRecord, binPath).env
|
||||
: baseEnvRecord;
|
||||
const forkEnv: Record<string, string | undefined> = {
|
||||
...baseEnvPatched,
|
||||
...stripSystemdSupervisorEnv(baseEnvPatched),
|
||||
...providerEnv,
|
||||
...uvEnv,
|
||||
...proxyEnv,
|
||||
OPENCLAW_GATEWAY_TOKEN: appSettings.gatewayToken,
|
||||
OPENCLAW_SKIP_CHANNELS: skipChannels ? '1' : '',
|
||||
CLAWDBOT_SKIP_CHANNELS: skipChannels ? '1' : '',
|
||||
OPENCLAW_NO_RESPAWN: '1',
|
||||
// Disable OpenClaw's interactive-shell env snapshot. When the Gateway runs
|
||||
// as an Electron utilityProcess, `process.execPath` is the Electron binary,
|
||||
// and OpenClaw captures the shell env by spawning `process.execPath -e
|
||||
// <script>` inside a sanitized login shell that strips ELECTRON_RUN_AS_NODE.
|
||||
// Electron then treats the script as an app path and pops up "Unable to find
|
||||
// Electron app at <cwd>/const safe = new Set(...)". Turning the snapshot off
|
||||
// avoids that broken spawn; exec tools fall back to the Gateway launch env.
|
||||
OPENCLAW_EXEC_SHELL_SNAPSHOT: '0',
|
||||
};
|
||||
|
||||
// Ensure extension-specific packages (e.g. grammy from the telegram
|
||||
// extension) are resolvable by shared dist/ chunks via symlinks in
|
||||
// openclaw/node_modules/. NODE_PATH does NOT work for ESM imports.
|
||||
measureSync(timingsMs, 'extensionDepsMs', () => ensureExtensionDepsResolvable(openclawDir));
|
||||
timingsMs.totalMs = Date.now() - totalStartedAt;
|
||||
|
||||
logger.info('[metric] gateway.prelaunch', {
|
||||
...prelaunchSummary.timingsMs,
|
||||
...timingsMs,
|
||||
maintenance: prelaunchSummary.maintenance,
|
||||
configuredChannelCount: prelaunchSummary.configuredChannels.length,
|
||||
});
|
||||
|
||||
return {
|
||||
appSettings,
|
||||
openclawDir,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { GatewayEventType, type JsonRpcNotification } from './protocol';
|
||||
import { logger } from '../utils/logger';
|
||||
import { normalizeGatewayChatRuntimeEvent } from './chat-runtime-events';
|
||||
import type {
|
||||
GatewayChannelStatusEvent,
|
||||
GatewayChatMessageEvent,
|
||||
GatewayRuntimePayload,
|
||||
} from '@shared/host-events/contract';
|
||||
|
||||
type GatewayEventEmitter = {
|
||||
emit: (event: string, payload: unknown) => boolean;
|
||||
@@ -17,13 +23,25 @@ export function dispatchProtocolEvent(
|
||||
emitter.emit('chat:message', { message: payload });
|
||||
break;
|
||||
case 'agent': {
|
||||
// Keep "agent" on the canonical notification path to avoid double
|
||||
// handling in renderer when both notification and chat-message are wired.
|
||||
emitter.emit('notification', { method: event, params: payload });
|
||||
const normalized = normalizeGatewayChatRuntimeEvent(payload);
|
||||
if (normalized) {
|
||||
emitter.emit('chat:runtime-event', normalized);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'channel.status':
|
||||
emitter.emit('channel:status', payload as { channelId: string; status: string });
|
||||
case 'channel.status_changed':
|
||||
emitter.emit('channel:status', payload as GatewayChannelStatusEvent);
|
||||
break;
|
||||
case 'gateway.ready':
|
||||
case 'ready':
|
||||
emitter.emit('gateway:ready', payload);
|
||||
break;
|
||||
case 'health':
|
||||
emitter.emit('gateway:health', payload as GatewayRuntimePayload);
|
||||
break;
|
||||
case 'presence':
|
||||
emitter.emit('gateway:presence', payload as GatewayRuntimePayload);
|
||||
break;
|
||||
default:
|
||||
emitter.emit('notification', { method: event, params: payload });
|
||||
@@ -34,13 +52,22 @@ export function dispatchJsonRpcNotification(
|
||||
emitter: GatewayEventEmitter,
|
||||
notification: JsonRpcNotification,
|
||||
): void {
|
||||
emitter.emit('notification', notification);
|
||||
if (notification.method === 'agent') {
|
||||
const normalized = normalizeGatewayChatRuntimeEvent(notification.params);
|
||||
if (normalized) {
|
||||
emitter.emit('chat:runtime-event', normalized);
|
||||
}
|
||||
} else {
|
||||
emitter.emit('notification', notification);
|
||||
}
|
||||
switch (notification.method) {
|
||||
case 'agent':
|
||||
break;
|
||||
case GatewayEventType.CHANNEL_STATUS_CHANGED:
|
||||
emitter.emit('channel:status', notification.params as { channelId: string; status: string });
|
||||
emitter.emit('channel:status', notification.params as GatewayChannelStatusEvent);
|
||||
break;
|
||||
case GatewayEventType.MESSAGE_RECEIVED:
|
||||
emitter.emit('chat:message', notification.params as { message: unknown });
|
||||
emitter.emit('chat:message', notification.params as GatewayChatMessageEvent);
|
||||
break;
|
||||
case GatewayEventType.ERROR: {
|
||||
const errorData = notification.params as { message?: string };
|
||||
|
||||
+484
-182
@@ -14,13 +14,19 @@ import {
|
||||
loadOrCreateDeviceIdentity,
|
||||
type DeviceIdentity,
|
||||
} from '../utils/device-identity';
|
||||
import {
|
||||
cancelLocalDeviceAutoApproval,
|
||||
scheduleLocalDeviceAutoApproval,
|
||||
} from '../utils/control-ui-device-pairing';
|
||||
import {
|
||||
DEFAULT_RECONNECT_CONFIG,
|
||||
type ReconnectConfig,
|
||||
type GatewayLifecycleState,
|
||||
getReconnectScheduleDecision,
|
||||
getReconnectSkipReason,
|
||||
isOpenClawFatalConfigExitCode,
|
||||
} from './process-policy';
|
||||
import { removeOpenClaw2026_7_1UpgradeSnapshot } from '../utils/openclaw-upgrade-snapshot';
|
||||
import {
|
||||
clearPendingGatewayRequests,
|
||||
rejectPendingGatewayRequest,
|
||||
@@ -45,12 +51,34 @@ import { launchGatewayProcess } from './process-launcher';
|
||||
import { GatewayRestartController } from './restart-controller';
|
||||
import { GatewayRestartGovernor } from './restart-governor';
|
||||
import {
|
||||
DEFAULT_GATEWAY_RELOAD_POLICY,
|
||||
loadGatewayReloadPolicy,
|
||||
type GatewayReloadPolicy,
|
||||
} from './reload-policy';
|
||||
import { classifyGatewayStderrMessage, recordGatewayStartupStderrLine } from './startup-stderr';
|
||||
classifyGatewayStderrMessage,
|
||||
GATEWAY_STARTUP_SLOW_STAGE_MS,
|
||||
GATEWAY_STARTUP_SLOW_TOTAL_MS,
|
||||
GatewayStartupTraceCollector,
|
||||
recordGatewayStartupStderrLine,
|
||||
} from './startup-stderr';
|
||||
import { runGatewayStartupSequence } from './startup-orchestrator';
|
||||
import {
|
||||
hasFatalRuntimeFailureSignal,
|
||||
hasInvalidConfigFailureSignal,
|
||||
hasStartupMigrationLockSignal,
|
||||
} from './startup-recovery';
|
||||
import {
|
||||
GatewayCapabilityMonitor,
|
||||
type GatewayCapabilityName,
|
||||
type GatewayCapabilitySnapshot,
|
||||
} from './capability-monitor';
|
||||
import {
|
||||
isGatewayWsTraceEnabled,
|
||||
redactGatewayFrameForTrace,
|
||||
summarizeGatewayFrameForTrace,
|
||||
} from './ws-trace';
|
||||
import type {
|
||||
GatewayChannelStatusEvent,
|
||||
GatewayChatMessageEvent,
|
||||
GatewayRuntimePayload,
|
||||
} from '@shared/host-events/contract';
|
||||
import type { ChatRuntimeEvent } from '@shared/chat-runtime-events';
|
||||
|
||||
export interface GatewayStatus {
|
||||
state: GatewayLifecycleState;
|
||||
@@ -61,6 +89,63 @@ export interface GatewayStatus {
|
||||
connectedAt?: number;
|
||||
version?: string;
|
||||
reconnectAttempts?: number;
|
||||
/** True once the gateway's internal subsystems (skills, plugins) are ready for RPC calls. */
|
||||
gatewayReady?: boolean;
|
||||
}
|
||||
|
||||
export type GatewayHealthState = 'healthy' | 'degraded' | 'unresponsive';
|
||||
|
||||
export interface GatewayHealthSummary {
|
||||
state: GatewayHealthState;
|
||||
reasons: string[];
|
||||
consecutiveHeartbeatMisses: number;
|
||||
lastAliveAt?: number;
|
||||
lastRpcSuccessAt?: number;
|
||||
lastRpcFailureAt?: number;
|
||||
lastRpcFailureMethod?: string;
|
||||
lastChannelsStatusOkAt?: number;
|
||||
lastChannelsStatusFailureAt?: number;
|
||||
}
|
||||
|
||||
export interface GatewayHealthReport {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
uptime?: number;
|
||||
version?: string;
|
||||
capabilities: GatewayCapabilitySnapshot;
|
||||
}
|
||||
|
||||
export interface GatewayDiagnosticsSnapshot {
|
||||
lastAliveAt?: number;
|
||||
lastRpcSuccessAt?: number;
|
||||
lastRpcFailureAt?: number;
|
||||
lastRpcFailureMethod?: string;
|
||||
lastHeartbeatTimeoutAt?: number;
|
||||
consecutiveHeartbeatMisses: number;
|
||||
lastSocketCloseAt?: number;
|
||||
lastSocketCloseCode?: number;
|
||||
consecutiveRpcFailures: number;
|
||||
}
|
||||
|
||||
function isCoreRpcMethod(method: string): boolean {
|
||||
return method === 'system-presence';
|
||||
}
|
||||
|
||||
function isTransportRpcFailure(method: string, error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.includes('RPC timeout:')
|
||||
? isCoreRpcMethod(method)
|
||||
: message.includes('Gateway not connected')
|
||||
|| message.includes('Gateway stopped')
|
||||
|| message.includes('Failed to send RPC request:');
|
||||
}
|
||||
|
||||
function classifyCapabilityMethod(method: string): GatewayCapabilityName | null {
|
||||
if (method === 'health') return 'openclawHealth';
|
||||
if (method === 'status') return 'openclawStatus';
|
||||
if (method === 'channels.status') return 'channels';
|
||||
if (method.startsWith('doctor.memory.')) return 'memory';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,8 +157,11 @@ export interface GatewayManagerEvents {
|
||||
notification: (notification: JsonRpcNotification) => void;
|
||||
exit: (code: number | null) => void;
|
||||
error: (error: Error) => void;
|
||||
'channel:status': (data: { channelId: string; status: string }) => void;
|
||||
'chat:message': (data: { message: unknown }) => void;
|
||||
'gateway:health': (data: GatewayRuntimePayload) => void;
|
||||
'gateway:presence': (data: GatewayRuntimePayload) => void;
|
||||
'channel:status': (data: GatewayChannelStatusEvent) => void;
|
||||
'chat:message': (data: GatewayChatMessageEvent) => void;
|
||||
'chat:runtime-event': (data: ChatRuntimeEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,6 +182,7 @@ export class GatewayManager extends EventEmitter {
|
||||
private startLock = false;
|
||||
private lastSpawnSummary: string | null = null;
|
||||
private recentStartupStderrLines: string[] = [];
|
||||
private readonly startupTraceCollector = new GatewayStartupTraceCollector();
|
||||
private pendingRequests: Map<string, PendingGatewayRequest> = new Map();
|
||||
private deviceIdentity: DeviceIdentity | null = null;
|
||||
private restartInFlight: Promise<void> | null = null;
|
||||
@@ -101,19 +190,25 @@ export class GatewayManager extends EventEmitter {
|
||||
private readonly lifecycleController = new GatewayLifecycleController();
|
||||
private readonly restartController = new GatewayRestartController();
|
||||
private readonly restartGovernor = new GatewayRestartGovernor();
|
||||
private reloadDebounceTimer: NodeJS.Timeout | null = null;
|
||||
private reloadPolicy: GatewayReloadPolicy = { ...DEFAULT_GATEWAY_RELOAD_POLICY };
|
||||
private reloadPolicyLoadedAt = 0;
|
||||
private reloadPolicyRefreshPromise: Promise<void> | null = null;
|
||||
private upgradeSnapshotCleanupAttempted = false;
|
||||
private externalShutdownSupported: boolean | null = null;
|
||||
private reconnectAttemptsTotal = 0;
|
||||
private reconnectSuccessTotal = 0;
|
||||
private static readonly RELOAD_POLICY_REFRESH_MS = 15_000;
|
||||
private static readonly HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
private static readonly HEARTBEAT_TIMEOUT_MS = 12_000;
|
||||
private static readonly HEARTBEAT_MAX_MISSES = 3;
|
||||
private static readonly HEARTBEAT_INTERVAL_MS = 60_000;
|
||||
private static readonly HEARTBEAT_TIMEOUT_MS = 30_000;
|
||||
private static readonly HEARTBEAT_MAX_MISSES = 4;
|
||||
public static readonly RESTART_COOLDOWN_MS = 5_000;
|
||||
private static readonly GATEWAY_READY_FALLBACK_PROBE_DELAYS_MS = [1_500, 3_000, 5_000, 8_000, 12_000, 30_000] as const;
|
||||
private lastRestartAt = 0;
|
||||
/** Set by scheduleReconnect() before calling start() to signal auto-reconnect. */
|
||||
private isAutoReconnectStart = false;
|
||||
private gatewayReadyFallbackTimer: NodeJS.Timeout | null = null;
|
||||
private gatewayReadyFallbackAttempt = 0;
|
||||
private readonly capabilityMonitor = new GatewayCapabilityMonitor();
|
||||
private diagnostics: GatewayDiagnosticsSnapshot = {
|
||||
consecutiveHeartbeatMisses: 0,
|
||||
consecutiveRpcFailures: 0,
|
||||
};
|
||||
|
||||
constructor(config?: Partial<ReconnectConfig>) {
|
||||
super();
|
||||
@@ -144,6 +239,21 @@ export class GatewayManager extends EventEmitter {
|
||||
this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config };
|
||||
// Device identity is loaded lazily in start() — not in the constructor —
|
||||
// so that async file I/O and key generation don't block module loading.
|
||||
|
||||
this.on('gateway:ready', () => {
|
||||
this.resetGatewayReadyFallback();
|
||||
if (this.status.state === 'running' && !this.status.gatewayReady) {
|
||||
logger.info('Gateway subsystems ready (event received)');
|
||||
this.setStatus({ gatewayReady: true });
|
||||
}
|
||||
void this.cleanupOpenClawUpgradeSnapshot();
|
||||
});
|
||||
this.on('gateway:health', (payload) => {
|
||||
this.capabilityMonitor.recordOpenClawHealth(payload);
|
||||
});
|
||||
this.on('gateway:presence', (payload) => {
|
||||
this.capabilityMonitor.recordPresence(payload);
|
||||
});
|
||||
}
|
||||
|
||||
private async initDeviceIdentity(): Promise<void> {
|
||||
@@ -177,6 +287,23 @@ export class GatewayManager extends EventEmitter {
|
||||
return this.stateController.getStatus();
|
||||
}
|
||||
|
||||
getDiagnostics(): GatewayDiagnosticsSnapshot {
|
||||
return { ...this.diagnostics };
|
||||
}
|
||||
|
||||
getCapabilitySnapshot(summary?: GatewayHealthSummary): GatewayCapabilitySnapshot {
|
||||
return this.capabilityMonitor.buildSnapshot({
|
||||
status: this.status,
|
||||
transportConnected: this.ws?.readyState === WebSocket.OPEN,
|
||||
diagnostics: this.getDiagnostics(),
|
||||
summary,
|
||||
});
|
||||
}
|
||||
|
||||
recordCapabilityFailure(name: GatewayCapabilityName, error: unknown, durationMs?: number): void {
|
||||
this.capabilityMonitor.recordCapabilityFailure(name, error, durationMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Gateway is connected and ready
|
||||
*/
|
||||
@@ -203,8 +330,6 @@ export class GatewayManager extends EventEmitter {
|
||||
logger.info(`Gateway start requested (port=${this.status.port})`);
|
||||
this.lastSpawnSummary = null;
|
||||
this.shouldReconnect = true;
|
||||
await this.refreshReloadPolicy(true);
|
||||
|
||||
// Lazily load device identity (async file I/O + key generation).
|
||||
// Must happen before connect() which uses the identity for the handshake.
|
||||
await this.initDeviceIdentity();
|
||||
@@ -216,18 +341,29 @@ export class GatewayManager extends EventEmitter {
|
||||
logger.debug('Cleared pending reconnect timer because start was requested manually');
|
||||
}
|
||||
|
||||
this.reconnectAttempts = 0;
|
||||
this.setStatus({ state: 'starting', reconnectAttempts: 0 });
|
||||
// Only reset reconnectAttempts on manual start, not on auto-reconnect.
|
||||
// Auto-reconnect calls start() via scheduleReconnect(); those should
|
||||
// accumulate attempts so the maxAttempts cap works correctly.
|
||||
if (!this.isAutoReconnectStart) {
|
||||
this.reconnectAttempts = 0;
|
||||
}
|
||||
this.isAutoReconnectStart = false; // consume the flag
|
||||
this.setStatus({ state: 'starting', reconnectAttempts: this.reconnectAttempts, gatewayReady: false });
|
||||
this.resetGatewayReadyFallback();
|
||||
|
||||
// Check if Python environment is ready (self-healing) asynchronously.
|
||||
// Fire-and-forget: only needs to run once, not on every retry.
|
||||
warmupManagedPythonReadiness();
|
||||
|
||||
const t0 = Date.now();
|
||||
let tSpawned = 0;
|
||||
let tReady = 0;
|
||||
|
||||
try {
|
||||
await runGatewayStartupSequence({
|
||||
port: this.status.port,
|
||||
ownedPid: this.process?.pid,
|
||||
shouldWaitForPortFree: process.platform === 'win32',
|
||||
hasOwnedProcess: () => this.process?.pid != null && this.ownsProcess,
|
||||
resetStartupStderrLines: () => {
|
||||
this.recentStartupStderrLines = [];
|
||||
},
|
||||
@@ -235,14 +371,18 @@ export class GatewayManager extends EventEmitter {
|
||||
assertLifecycle: (phase) => {
|
||||
this.lifecycleController.assert(startEpoch, phase);
|
||||
},
|
||||
findExistingGateway: async (port, ownedPid) => {
|
||||
return await findExistingGatewayProcess({ port, ownedPid });
|
||||
findExistingGateway: async (port) => {
|
||||
// Always read the current process pid dynamically so that retries
|
||||
// don't treat a just-spawned gateway as an orphan. The ownedPid
|
||||
// snapshot captured at start() entry is stale after startProcess()
|
||||
// replaces this.process — leading to the just-started pid being
|
||||
// immediately killed as a false orphan on the next retry iteration.
|
||||
return await findExistingGatewayProcess({ port, ownedPid: this.process?.pid });
|
||||
},
|
||||
connect: async (port, externalToken) => {
|
||||
await this.connect(port, externalToken);
|
||||
},
|
||||
onConnectedToExistingGateway: () => {
|
||||
|
||||
// If the existing gateway is actually our own spawned UtilityProcess
|
||||
// (e.g. after a self-restart code=1012), keep ownership so that
|
||||
// stop() can still terminate the process during a restart() cycle.
|
||||
@@ -252,6 +392,15 @@ export class GatewayManager extends EventEmitter {
|
||||
this.setStatus({ pid: undefined });
|
||||
}
|
||||
|
||||
// Treat a successful reconnect to the owned process as a restart
|
||||
// completion (e.g. after a Gateway code-1012 in-process restart).
|
||||
// This updates lastRestartCompletedAt so that flushDeferredRestart
|
||||
// drops any deferred restart requested before this reconnect,
|
||||
// avoiding a redundant kill+respawn cycle.
|
||||
if (isOwnProcess) {
|
||||
this.restartController.recordRestartCompleted();
|
||||
}
|
||||
|
||||
this.startHealthCheck();
|
||||
},
|
||||
waitForPortFree: async (port) => {
|
||||
@@ -259,16 +408,35 @@ export class GatewayManager extends EventEmitter {
|
||||
},
|
||||
startProcess: async () => {
|
||||
await this.startProcess();
|
||||
tSpawned = Date.now();
|
||||
},
|
||||
waitForReady: async (port) => {
|
||||
await waitForGatewayReady({
|
||||
port,
|
||||
getProcessExitCode: () => this.processExitCode,
|
||||
});
|
||||
tReady = Date.now();
|
||||
},
|
||||
onConnectedToManagedGateway: () => {
|
||||
this.startHealthCheck();
|
||||
logger.debug('Gateway started successfully');
|
||||
const tConnected = Date.now();
|
||||
const spawnToReadyMs = tReady && tSpawned ? tReady - tSpawned : undefined;
|
||||
const startupTrace = this.startupTraceCollector.getSummary();
|
||||
const startupMetric = {
|
||||
configSyncMs: tSpawned ? tSpawned - t0 : undefined,
|
||||
spawnToReadyMs,
|
||||
readyToConnectMs: tReady ? tConnected - tReady : undefined,
|
||||
totalMs: tConnected - t0,
|
||||
openclawTrace: startupTrace,
|
||||
};
|
||||
logger.info('[metric] gateway.startup', startupMetric);
|
||||
if (spawnToReadyMs !== undefined && spawnToReadyMs >= GATEWAY_STARTUP_SLOW_TOTAL_MS) {
|
||||
logger.warn('[gateway-startup] Slow managed Gateway startup detected', {
|
||||
pid: this.status.pid,
|
||||
spawnToReadyMs,
|
||||
openclawTrace: startupTrace,
|
||||
});
|
||||
}
|
||||
},
|
||||
runDoctorRepair: async () => await runOpenClawDoctorRepair(),
|
||||
onDoctorRepairSuccess: () => {
|
||||
@@ -288,6 +456,20 @@ export class GatewayManager extends EventEmitter {
|
||||
error
|
||||
);
|
||||
this.setStatus({ state: 'error', error: String(error) });
|
||||
const fatalStartupFailure = isOpenClawFatalConfigExitCode(this.processExitCode)
|
||||
|| hasFatalRuntimeFailureSignal(error, this.recentStartupStderrLines)
|
||||
|| hasStartupMigrationLockSignal(error, this.recentStartupStderrLines)
|
||||
|| hasInvalidConfigFailureSignal(error, this.recentStartupStderrLines);
|
||||
if (fatalStartupFailure) {
|
||||
// OpenClaw 2026.7.1 uses EX_CONFIG for fatal configuration failures.
|
||||
// Runtime and SQLite compatibility failures are likewise not repaired
|
||||
// by restarting the same binary, so leave recovery to a manual start.
|
||||
this.shouldReconnect = false;
|
||||
logger.error('Gateway startup failed fatally; automatic reconnect disabled');
|
||||
} else if (this.shouldReconnect) {
|
||||
logger.warn('Gateway start failed; scheduling auto-reconnect recovery');
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
this.startLock = false;
|
||||
@@ -312,6 +494,7 @@ export class GatewayManager extends EventEmitter {
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
logger.info('Gateway stop requested');
|
||||
cancelLocalDeviceAutoApproval();
|
||||
this.lifecycleController.bump('stop');
|
||||
// Disable auto-reconnect
|
||||
this.shouldReconnect = false;
|
||||
@@ -335,9 +518,14 @@ export class GatewayManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Close WebSocket
|
||||
// Close WebSocket — use terminate() to force-close the TCP connection
|
||||
// immediately without waiting for the WebSocket close handshake.
|
||||
// ws.close() sends a close frame and waits for the server to respond;
|
||||
// if the gateway process is being killed concurrently, the handshake
|
||||
// never completes and the connection stays ESTABLISHED indefinitely,
|
||||
// accumulating leaked connections on every restart cycle.
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, 'Gateway stopped by user');
|
||||
try { this.ws.terminate(); } catch { /* ignore */ }
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
@@ -355,7 +543,9 @@ export class GatewayManager extends EventEmitter {
|
||||
clearPendingGatewayRequests(this.pendingRequests, new Error('Gateway stopped'));
|
||||
|
||||
this.restartController.resetDeferredRestart();
|
||||
this.setStatus({ state: 'stopped', error: undefined, pid: undefined, connectedAt: undefined, uptime: undefined });
|
||||
this.isAutoReconnectStart = false;
|
||||
this.diagnostics.consecutiveHeartbeatMisses = 0;
|
||||
this.setStatus({ state: 'stopped', error: undefined, pid: undefined, connectedAt: undefined, uptime: undefined, gatewayReady: undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -421,12 +611,22 @@ export class GatewayManager extends EventEmitter {
|
||||
logger.info(`[gateway-refresh] mode=restart requested pidBefore=${pidBefore ?? 'n/a'}`);
|
||||
this.restartInFlight = (async () => {
|
||||
await this.stop();
|
||||
await this.start();
|
||||
try {
|
||||
await this.start();
|
||||
} catch (err) {
|
||||
// stop() set shouldReconnect=false. Restore it so the gateway
|
||||
// can self-heal via scheduleReconnect() instead of dying permanently.
|
||||
logger.warn('Gateway restart: start() failed after stop(), enabling auto-reconnect recovery', err);
|
||||
this.shouldReconnect = true;
|
||||
this.scheduleReconnect();
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
await this.restartInFlight;
|
||||
this.restartGovernor.recordExecuted();
|
||||
this.restartController.recordRestartCompleted();
|
||||
const observability = this.restartGovernor.getObservability();
|
||||
const props = {
|
||||
gateway_restart_executed_total: observability.executed_total,
|
||||
@@ -472,136 +672,6 @@ export class GatewayManager extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the Gateway process to reload config in-place when possible.
|
||||
* Falls back to restart on unsupported platforms or signaling failures.
|
||||
*/
|
||||
async reload(): Promise<void> {
|
||||
await this.refreshReloadPolicy();
|
||||
|
||||
if (this.reloadPolicy.mode === 'off' || this.reloadPolicy.mode === 'restart') {
|
||||
logger.info(
|
||||
`[gateway-refresh] mode=reload result=policy_forced_restart policy=${this.reloadPolicy.mode}`,
|
||||
);
|
||||
await this.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.restartController.isRestartDeferred({
|
||||
state: this.status.state,
|
||||
startLock: this.startLock,
|
||||
})) {
|
||||
this.restartController.markDeferredRestart('reload', {
|
||||
state: this.status.state,
|
||||
startLock: this.startLock,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pidBefore = this.process?.pid;
|
||||
logger.info(`[gateway-refresh] mode=reload requested pid=${pidBefore ?? 'n/a'} state=${this.status.state}`);
|
||||
|
||||
if (!this.process?.pid || this.status.state !== 'running') {
|
||||
logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=not_running');
|
||||
logger.warn('Gateway reload requested while not running; falling back to restart');
|
||||
await this.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=windows');
|
||||
logger.debug('Windows detected, falling back to Gateway restart for reload');
|
||||
await this.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
const connectedForMs = this.status.connectedAt
|
||||
? Date.now() - this.status.connectedAt
|
||||
: Number.POSITIVE_INFINITY;
|
||||
|
||||
// Avoid signaling a process that just came up; it will already read latest config.
|
||||
if (connectedForMs < 8000) {
|
||||
logger.info(
|
||||
`[gateway-refresh] mode=reload result=skipped_recent_connect connectedForMs=${connectedForMs} pid=${this.process.pid}`,
|
||||
);
|
||||
logger.info(`Gateway connected ${connectedForMs}ms ago, skipping reload signal`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(this.process.pid, 'SIGUSR1');
|
||||
logger.info(`Sent SIGUSR1 to Gateway for config reload (pid=${this.process.pid})`);
|
||||
// Some gateway builds do not handle SIGUSR1 as an in-process reload.
|
||||
// If process state doesn't recover quickly, fall back to restart.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
if (this.status.state !== 'running' || !this.process?.pid) {
|
||||
logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=post_signal_unhealthy');
|
||||
logger.warn('Gateway did not stay running after reload signal, falling back to restart');
|
||||
await this.restart();
|
||||
} else {
|
||||
const pidAfter = this.process.pid;
|
||||
logger.info(
|
||||
`[gateway-refresh] mode=reload result=applied_in_place pidBefore=${pidBefore} pidAfter=${pidAfter}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=signal_error');
|
||||
logger.warn('Gateway reload signal failed, falling back to restart:', error);
|
||||
await this.restart();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced reload — coalesces multiple rapid config-change events into one
|
||||
* in-process reload when possible.
|
||||
*/
|
||||
debouncedReload(delayMs?: number): void {
|
||||
void this.refreshReloadPolicy();
|
||||
const effectiveDelay = delayMs ?? this.reloadPolicy.debounceMs;
|
||||
if (this.reloadPolicy.mode === 'off' || this.reloadPolicy.mode === 'restart') {
|
||||
logger.debug(
|
||||
`Gateway reload policy=${this.reloadPolicy.mode}; routing debouncedReload to debouncedRestart (${effectiveDelay}ms)`,
|
||||
);
|
||||
this.debouncedRestart(effectiveDelay);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reloadDebounceTimer) {
|
||||
clearTimeout(this.reloadDebounceTimer);
|
||||
}
|
||||
logger.debug(`Gateway reload debounced (will fire in ${effectiveDelay}ms)`);
|
||||
this.reloadDebounceTimer = setTimeout(() => {
|
||||
this.reloadDebounceTimer = null;
|
||||
void this.reload().catch((err) => {
|
||||
logger.warn('Debounced Gateway reload failed:', err);
|
||||
});
|
||||
}, effectiveDelay);
|
||||
}
|
||||
|
||||
private async refreshReloadPolicy(force = false): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (!force && now - this.reloadPolicyLoadedAt < GatewayManager.RELOAD_POLICY_REFRESH_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reloadPolicyRefreshPromise) {
|
||||
await this.reloadPolicyRefreshPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.reloadPolicyRefreshPromise = (async () => {
|
||||
const nextPolicy = await loadGatewayReloadPolicy();
|
||||
this.reloadPolicy = nextPolicy;
|
||||
this.reloadPolicyLoadedAt = Date.now();
|
||||
})();
|
||||
|
||||
try {
|
||||
await this.reloadPolicyRefreshPromise;
|
||||
} finally {
|
||||
this.reloadPolicyRefreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all active timers
|
||||
*/
|
||||
@@ -612,9 +682,71 @@ export class GatewayManager extends EventEmitter {
|
||||
}
|
||||
this.connectionMonitor.clear();
|
||||
this.restartController.clearDebounceTimer();
|
||||
if (this.reloadDebounceTimer) {
|
||||
clearTimeout(this.reloadDebounceTimer);
|
||||
this.reloadDebounceTimer = null;
|
||||
this.resetGatewayReadyFallback();
|
||||
}
|
||||
|
||||
private clearGatewayReadyFallbackTimer(): void {
|
||||
if (this.gatewayReadyFallbackTimer) {
|
||||
clearTimeout(this.gatewayReadyFallbackTimer);
|
||||
this.gatewayReadyFallbackTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private resetGatewayReadyFallback(): void {
|
||||
this.clearGatewayReadyFallbackTimer();
|
||||
this.gatewayReadyFallbackAttempt = 0;
|
||||
}
|
||||
|
||||
private getNextGatewayReadyFallbackDelayMs(): number {
|
||||
const delays = GatewayManager.GATEWAY_READY_FALLBACK_PROBE_DELAYS_MS;
|
||||
const index = Math.min(this.gatewayReadyFallbackAttempt, delays.length - 1);
|
||||
const delayMs = delays[index]!;
|
||||
this.gatewayReadyFallbackAttempt += 1;
|
||||
return delayMs;
|
||||
}
|
||||
|
||||
private scheduleGatewayReadyFallback(delayMs?: number): void {
|
||||
if (this.status.state !== 'running' || this.status.gatewayReady) {
|
||||
return;
|
||||
}
|
||||
this.clearGatewayReadyFallbackTimer();
|
||||
const effectiveDelayMs = delayMs ?? this.getNextGatewayReadyFallbackDelayMs();
|
||||
this.gatewayReadyFallbackTimer = setTimeout(() => {
|
||||
this.gatewayReadyFallbackTimer = null;
|
||||
void this.probeGatewayReadyFallback();
|
||||
}, effectiveDelayMs);
|
||||
}
|
||||
|
||||
private async probeGatewayReadyFallback(): Promise<void> {
|
||||
if (this.status.state !== 'running' || this.status.gatewayReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Gateway ready fallback triggered; probing RPC router before marking ready');
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await this.rpc('system-presence', {}, 5_000);
|
||||
this.capabilityMonitor.recordCoreProbe({
|
||||
ok: true,
|
||||
checkedAt: Date.now(),
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
if (this.status.state === 'running' && !this.status.gatewayReady) {
|
||||
logger.info('Gateway ready fallback RPC router probe succeeded');
|
||||
this.resetGatewayReadyFallback();
|
||||
this.setStatus({ gatewayReady: true });
|
||||
}
|
||||
} catch (error) {
|
||||
this.capabilityMonitor.recordCoreProbe({
|
||||
ok: false,
|
||||
checkedAt: Date.now(),
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
logger.warn('Gateway ready fallback RPC router probe failed; waiting for gateway.ready event or heartbeat recovery:', error);
|
||||
if (this.status.state === 'running' && !this.status.gatewayReady) {
|
||||
this.scheduleGatewayReadyFallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -623,7 +755,8 @@ export class GatewayManager extends EventEmitter {
|
||||
* Uses OpenClaw protocol format: { type: "req", id: "...", method: "...", params: {...} }
|
||||
*/
|
||||
async rpc<T>(method: string, params?: unknown, timeoutMs = 30000): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startedAt = Date.now();
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error('Gateway not connected'));
|
||||
return;
|
||||
@@ -652,10 +785,49 @@ export class GatewayManager extends EventEmitter {
|
||||
};
|
||||
|
||||
try {
|
||||
if (isGatewayWsTraceEnabled()) {
|
||||
logger.debug('[gateway-ws-trace] send', {
|
||||
summary: summarizeGatewayFrameForTrace(request),
|
||||
frame: redactGatewayFrameForTrace(request),
|
||||
});
|
||||
}
|
||||
this.ws.send(JSON.stringify(request));
|
||||
} catch (error) {
|
||||
rejectPendingGatewayRequest(this.pendingRequests, id, new Error(`Failed to send RPC request: ${error}`));
|
||||
}
|
||||
}).then((result) => {
|
||||
this.recordRpcSuccess();
|
||||
if (isCoreRpcMethod(method)) {
|
||||
this.capabilityMonitor.recordCoreProbe({
|
||||
ok: true,
|
||||
checkedAt: Date.now(),
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
const capability = classifyCapabilityMethod(method);
|
||||
if (capability) {
|
||||
this.capabilityMonitor.recordCapabilitySuccess(
|
||||
capability,
|
||||
result as GatewayRuntimePayload,
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}).catch((error) => {
|
||||
const capability = classifyCapabilityMethod(method);
|
||||
if (capability) {
|
||||
this.capabilityMonitor.recordCapabilityFailure(capability, error, Date.now() - startedAt);
|
||||
}
|
||||
if (isTransportRpcFailure(method, error)) {
|
||||
this.capabilityMonitor.recordCoreProbe({
|
||||
ok: false,
|
||||
checkedAt: Date.now(),
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
this.recordRpcFailure(method);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -665,7 +837,7 @@ export class GatewayManager extends EventEmitter {
|
||||
private startHealthCheck(): void {
|
||||
this.connectionMonitor.startHealthCheck({
|
||||
shouldCheck: () => this.status.state === 'running',
|
||||
checkHealth: () => this.checkHealth(),
|
||||
checkHealth: () => this.checkTransportHealth(),
|
||||
onUnhealthy: (errorMessage) => {
|
||||
this.emit('error', new Error(errorMessage));
|
||||
},
|
||||
@@ -679,7 +851,7 @@ export class GatewayManager extends EventEmitter {
|
||||
* Check Gateway health via WebSocket ping
|
||||
* OpenClaw Gateway doesn't have an HTTP /health endpoint
|
||||
*/
|
||||
async checkHealth(): Promise<{ ok: boolean; error?: string; uptime?: number }> {
|
||||
private async checkTransportHealth(): Promise<{ ok: boolean; error?: string; uptime?: number }> {
|
||||
try {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
const uptime = this.status.connectedAt
|
||||
@@ -693,6 +865,55 @@ export class GatewayManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
async checkHealth(options?: { probe?: boolean }): Promise<GatewayHealthReport> {
|
||||
const transport = await this.checkTransportHealth();
|
||||
if (transport.ok && this.status.state === 'running' && this.status.gatewayReady !== false) {
|
||||
const timeoutMs = options?.probe ? 8_000 : 3_000;
|
||||
const [healthResult, statusResult] = await Promise.allSettled([
|
||||
this.rpc('health', { probe: options?.probe === true }, timeoutMs),
|
||||
this.rpc('status', {}, timeoutMs),
|
||||
]);
|
||||
|
||||
if (healthResult.status === 'fulfilled') {
|
||||
this.capabilityMonitor.recordOpenClawHealth(healthResult.value as GatewayRuntimePayload);
|
||||
}
|
||||
if (statusResult.status === 'fulfilled') {
|
||||
this.capabilityMonitor.recordOpenClawStatus(statusResult.value as GatewayRuntimePayload);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...transport,
|
||||
capabilities: this.getCapabilitySnapshot(),
|
||||
};
|
||||
}
|
||||
|
||||
private recordGatewayAlive(): void {
|
||||
this.diagnostics.lastAliveAt = Date.now();
|
||||
this.diagnostics.consecutiveHeartbeatMisses = 0;
|
||||
}
|
||||
|
||||
private recordRpcSuccess(): void {
|
||||
this.diagnostics.lastRpcSuccessAt = Date.now();
|
||||
this.diagnostics.consecutiveRpcFailures = 0;
|
||||
}
|
||||
|
||||
private recordRpcFailure(method: string): void {
|
||||
this.diagnostics.lastRpcFailureAt = Date.now();
|
||||
this.diagnostics.lastRpcFailureMethod = method;
|
||||
this.diagnostics.consecutiveRpcFailures += 1;
|
||||
}
|
||||
|
||||
private recordHeartbeatTimeout(consecutiveMisses: number): void {
|
||||
this.diagnostics.lastHeartbeatTimeoutAt = Date.now();
|
||||
this.diagnostics.consecutiveHeartbeatMisses = consecutiveMisses;
|
||||
}
|
||||
|
||||
private recordSocketClose(code: number): void {
|
||||
this.diagnostics.lastSocketCloseAt = Date.now();
|
||||
this.diagnostics.lastSocketCloseCode = code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Gateway process
|
||||
* Uses OpenClaw npm package from node_modules (dev) or resources (production)
|
||||
@@ -702,6 +923,11 @@ export class GatewayManager extends EventEmitter {
|
||||
await unloadLaunchctlGatewayService();
|
||||
this.processExitCode = null;
|
||||
|
||||
// Per-process diagnostics reset on each new spawn so retries never mix
|
||||
// timings or stderr deduplication state from different Gateway children.
|
||||
const stderrDedup = new Map<string, number>();
|
||||
this.startupTraceCollector.reset();
|
||||
|
||||
const { child, lastSpawnSummary } = await launchGatewayProcess({
|
||||
port: this.status.port,
|
||||
launchContext,
|
||||
@@ -710,12 +936,39 @@ export class GatewayManager extends EventEmitter {
|
||||
getShouldReconnect: () => this.shouldReconnect,
|
||||
onStderrLine: (line) => {
|
||||
recordGatewayStartupStderrLine(this.recentStartupStderrLines, line);
|
||||
const traceStage = this.startupTraceCollector.record(line);
|
||||
const classified = classifyGatewayStderrMessage(line);
|
||||
if (classified.level === 'drop') return;
|
||||
|
||||
// Dedup: suppress identical stderr lines after the first occurrence.
|
||||
const count = (stderrDedup.get(classified.normalized) ?? 0) + 1;
|
||||
stderrDedup.set(classified.normalized, count);
|
||||
if (count > 1) {
|
||||
// Log a summary every 50 duplicates to stay visible without flooding.
|
||||
if (count % 50 === 0) {
|
||||
logger.debug(`[Gateway stderr] (suppressed ${count} repeats) ${classified.normalized}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (traceStage) {
|
||||
const message = `[gateway-startup] stage=${traceStage.name} durationMs=${traceStage.durationMs}`
|
||||
+ (traceStage.totalMs === undefined ? '' : ` totalMs=${traceStage.totalMs}`);
|
||||
if (traceStage.durationMs >= GATEWAY_STARTUP_SLOW_STAGE_MS) {
|
||||
logger.warn(`${message} slow=true`);
|
||||
} else {
|
||||
logger.info(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (classified.level === 'debug') {
|
||||
logger.debug(`[Gateway stderr] ${classified.normalized}`);
|
||||
return;
|
||||
}
|
||||
if (classified.level === 'info') {
|
||||
logger.info(`[Gateway stderr] ${classified.normalized}`);
|
||||
return;
|
||||
}
|
||||
logger.warn(`[Gateway stderr] ${classified.normalized}`);
|
||||
},
|
||||
onSpawn: (pid) => {
|
||||
@@ -732,8 +985,26 @@ export class GatewayManager extends EventEmitter {
|
||||
|
||||
if (this.status.state === 'running') {
|
||||
this.setStatus({ state: 'stopped' });
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
const orchestratedStartupFailure = isOpenClawFatalConfigExitCode(code)
|
||||
|| hasFatalRuntimeFailureSignal(undefined, this.recentStartupStderrLines)
|
||||
|| hasStartupMigrationLockSignal(undefined, this.recentStartupStderrLines)
|
||||
|| hasInvalidConfigFailureSignal(undefined, this.recentStartupStderrLines);
|
||||
if (orchestratedStartupFailure) {
|
||||
// During startup the orchestrator may still perform its one bounded
|
||||
// doctor repair. Do not race it with an independent reconnect timer.
|
||||
// If orchestration cannot recover, start() disables reconnect in its
|
||||
// catch path so migration/config failures cannot create an outer loop.
|
||||
if (this.status.state !== 'starting') this.shouldReconnect = false;
|
||||
logger.error(`Gateway process reported a non-retriable startup condition (code=${String(code)}); reconnect not scheduled`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always attempt reconnect from non-fatal process exits.
|
||||
// scheduleReconnect() internally checks shouldReconnect and timer
|
||||
// guards, so intentional stop() remains a no-op.
|
||||
this.scheduleReconnect();
|
||||
},
|
||||
onError: () => {
|
||||
this.ownsProcess = false;
|
||||
@@ -763,22 +1034,40 @@ export class GatewayManager extends EventEmitter {
|
||||
this.ws = ws;
|
||||
ws.on('pong', () => {
|
||||
this.connectionMonitor.markAlive('pong');
|
||||
this.recordGatewayAlive();
|
||||
});
|
||||
this.recordGatewayAlive();
|
||||
this.setStatus({
|
||||
state: 'running',
|
||||
port,
|
||||
connectedAt: Date.now(),
|
||||
});
|
||||
this.startPing();
|
||||
this.scheduleGatewayReadyFallback();
|
||||
scheduleLocalDeviceAutoApproval(this);
|
||||
},
|
||||
onMessage: (message) => {
|
||||
this.handleMessage(message);
|
||||
},
|
||||
onCloseAfterHandshake: () => {
|
||||
onCloseAfterHandshake: (closeCode) => {
|
||||
cancelLocalDeviceAutoApproval();
|
||||
this.connectionMonitor.clear();
|
||||
this.recordSocketClose(closeCode);
|
||||
this.diagnostics.consecutiveHeartbeatMisses = 0;
|
||||
if (this.status.state === 'running') {
|
||||
this.setStatus({ state: 'stopped' });
|
||||
this.scheduleReconnect();
|
||||
// On Windows, skip reconnect from WS close. The Gateway is a local
|
||||
// child process; actual crashes are already caught by the process exit
|
||||
// handler (`onExit`) which calls scheduleReconnect(). Triggering
|
||||
// reconnect from WS close as well races with the exit handler and can
|
||||
// cause double start() attempts or port conflicts during TCP TIME_WAIT.
|
||||
//
|
||||
// Exception: code=1012 means the Gateway is performing an in-process
|
||||
// restart (e.g. config reload). The UtilityProcess stays alive, so
|
||||
// `onExit` will never fire — we MUST reconnect from the WS close path.
|
||||
if (process.platform !== 'win32' || closeCode === 1012) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -789,6 +1078,13 @@ export class GatewayManager extends EventEmitter {
|
||||
*/
|
||||
private handleMessage(message: unknown): void {
|
||||
this.connectionMonitor.markAlive('message');
|
||||
this.recordGatewayAlive();
|
||||
if (isGatewayWsTraceEnabled()) {
|
||||
logger.debug('[gateway-ws-trace] recv', {
|
||||
summary: summarizeGatewayFrameForTrace(message),
|
||||
frame: redactGatewayFrameForTrace(message),
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof message !== 'object' || message === null) {
|
||||
logger.debug('Received non-object Gateway message');
|
||||
@@ -839,7 +1135,7 @@ export class GatewayManager extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start ping interval to keep connection alive
|
||||
* Observe Gateway control-plane responsiveness without owning process recovery.
|
||||
*/
|
||||
private startPing(): void {
|
||||
this.connectionMonitor.startPing({
|
||||
@@ -852,26 +1148,31 @@ export class GatewayManager extends EventEmitter {
|
||||
}
|
||||
},
|
||||
onHeartbeatTimeout: ({ consecutiveMisses, timeoutMs }) => {
|
||||
if (this.status.state !== 'running' || !this.shouldReconnect) {
|
||||
return;
|
||||
}
|
||||
const ws = this.ws;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.recordHeartbeatTimeout(consecutiveMisses);
|
||||
const pid = this.process?.pid ?? 'unknown';
|
||||
logger.warn(
|
||||
`Gateway heartbeat timed out after ${consecutiveMisses} consecutive misses (timeout=${timeoutMs}ms); terminating stale socket`,
|
||||
`Gateway heartbeat: ${consecutiveMisses} consecutive pong misses ` +
|
||||
`(timeout=${timeoutMs}ms, pid=${pid}, state=${this.status.state}, autoReconnect=${this.shouldReconnect}). ` +
|
||||
'No restart requested; relying on process exit and socket close recovery.',
|
||||
);
|
||||
try {
|
||||
ws.terminate();
|
||||
} catch (error) {
|
||||
logger.warn('Failed to terminate stale Gateway socket after heartbeat timeout:', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async cleanupOpenClawUpgradeSnapshot(): Promise<void> {
|
||||
if (this.upgradeSnapshotCleanupAttempted) return;
|
||||
this.upgradeSnapshotCleanupAttempted = true;
|
||||
|
||||
try {
|
||||
const result = await removeOpenClaw2026_7_1UpgradeSnapshot();
|
||||
if (result.status === 'removed') {
|
||||
logger.info(`[upgrade] Removed OpenClaw 2026.7.1 pre-migration snapshot: ${result.snapshotDir}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[upgrade] Failed to remove OpenClaw 2026.7.1 pre-migration snapshot:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule reconnection attempt with exponential backoff
|
||||
*/
|
||||
@@ -932,6 +1233,7 @@ export class GatewayManager extends EventEmitter {
|
||||
try {
|
||||
// Use the guarded start() flow so reconnect attempts cannot bypass
|
||||
// lifecycle locking and accidentally start duplicate Gateway processes.
|
||||
this.isAutoReconnectStart = true;
|
||||
await this.start();
|
||||
this.reconnectSuccessTotal += 1;
|
||||
this.emitReconnectMetric('success', {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { app } from 'electron';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const CACHE_SCHEMA_VERSION = 1;
|
||||
const CACHE_FILE_NAME = 'gateway-prelaunch-maintenance-cache.json';
|
||||
|
||||
export type PrelaunchMaintenanceTaskName =
|
||||
| 'plugin-maintenance'
|
||||
| 'runtime-deps-cleanup'
|
||||
| 'skills-symlink-cleanup';
|
||||
|
||||
export interface PrelaunchMaintenanceRunResult {
|
||||
executed: boolean;
|
||||
reason: 'cache-hit' | 'cache-miss' | 'cache-unavailable' | 'task-failed';
|
||||
}
|
||||
|
||||
type CacheKeyInput = string | (() => string);
|
||||
type MaintenanceTask = () => void | boolean;
|
||||
|
||||
interface CacheEntry {
|
||||
key: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CacheFile {
|
||||
schemaVersion: number;
|
||||
tasks: Partial<Record<PrelaunchMaintenanceTaskName, CacheEntry>>;
|
||||
}
|
||||
|
||||
function getDefaultCachePath(): string {
|
||||
return join(app.getPath('userData'), CACHE_FILE_NAME);
|
||||
}
|
||||
|
||||
function emptyCache(): CacheFile {
|
||||
return {
|
||||
schemaVersion: CACHE_SCHEMA_VERSION,
|
||||
tasks: {},
|
||||
};
|
||||
}
|
||||
|
||||
function readCache(cachePath: string): CacheFile | null {
|
||||
try {
|
||||
if (!existsSync(cachePath)) return emptyCache();
|
||||
const parsed = JSON.parse(readFileSync(cachePath, 'utf-8')) as CacheFile;
|
||||
if (parsed.schemaVersion !== CACHE_SCHEMA_VERSION || !parsed.tasks) {
|
||||
return emptyCache();
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(cachePath: string, cache: CacheFile): boolean {
|
||||
try {
|
||||
mkdirSync(dirname(cachePath), { recursive: true });
|
||||
writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`, 'utf-8');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function stableJson(value: unknown): string {
|
||||
if (value == null || typeof value !== 'object') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableJson(item)).join(',')}]`;
|
||||
}
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableJson(entryValue)}`);
|
||||
return `{${entries.join(',')}}`;
|
||||
}
|
||||
|
||||
export function pathSignature(path: string): string {
|
||||
try {
|
||||
const stat = statSync(path);
|
||||
return `${stat.isDirectory() ? 'dir' : 'file'}:${Math.round(stat.mtimeMs)}:${stat.size}`;
|
||||
} catch {
|
||||
return 'missing';
|
||||
}
|
||||
}
|
||||
|
||||
export function directoryChildrenSignature(path: string, maxEntries = 200): string {
|
||||
try {
|
||||
const entries = readdirSync(path, { withFileTypes: true, encoding: 'utf8' })
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.slice(0, maxEntries)
|
||||
.map((entry) => {
|
||||
const childPath = join(path, entry.name);
|
||||
return [
|
||||
entry.name,
|
||||
entry.isDirectory() ? 'dir' : entry.isSymbolicLink() ? 'symlink' : 'file',
|
||||
pathSignature(childPath),
|
||||
].join(':');
|
||||
});
|
||||
return stableJson(entries);
|
||||
} catch {
|
||||
return 'missing';
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPrelaunchMaintenanceCacheKey(parts: Record<string, unknown>): string {
|
||||
return stableJson({
|
||||
schemaVersion: CACHE_SCHEMA_VERSION,
|
||||
...parts,
|
||||
});
|
||||
}
|
||||
|
||||
export function runCachedPrelaunchMaintenanceTask(
|
||||
taskName: PrelaunchMaintenanceTaskName,
|
||||
cacheKey: CacheKeyInput,
|
||||
task: MaintenanceTask,
|
||||
options: { cachePath?: string } = {},
|
||||
): PrelaunchMaintenanceRunResult {
|
||||
const readCacheKey = (): string => (typeof cacheKey === 'function' ? cacheKey() : cacheKey);
|
||||
const cachePath = options.cachePath ?? getDefaultCachePath();
|
||||
const cache = readCache(cachePath);
|
||||
if (!cache) {
|
||||
task();
|
||||
return { executed: true, reason: 'cache-unavailable' };
|
||||
}
|
||||
|
||||
let initialCacheKey: string;
|
||||
try {
|
||||
initialCacheKey = readCacheKey();
|
||||
} catch {
|
||||
task();
|
||||
return { executed: true, reason: 'cache-unavailable' };
|
||||
}
|
||||
|
||||
if (cache.tasks[taskName]?.key === initialCacheKey) {
|
||||
return { executed: false, reason: 'cache-hit' };
|
||||
}
|
||||
|
||||
const taskResult = task();
|
||||
if (taskResult === false) {
|
||||
return { executed: true, reason: 'task-failed' };
|
||||
}
|
||||
let finalCacheKey: string;
|
||||
try {
|
||||
finalCacheKey = readCacheKey();
|
||||
} catch {
|
||||
return { executed: true, reason: 'cache-unavailable' };
|
||||
}
|
||||
cache.tasks[taskName] = {
|
||||
key: finalCacheKey,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
writeCache(cachePath, cache);
|
||||
return { executed: true, reason: 'cache-miss' };
|
||||
}
|
||||
@@ -32,8 +32,10 @@ const GATEWAY_FETCH_PRELOAD_SOURCE = `'use strict';
|
||||
delete flat['HTTP-Referer'];
|
||||
delete flat['x-title'];
|
||||
delete flat['X-Title'];
|
||||
delete flat['x-openrouter-title'];
|
||||
delete flat['X-OpenRouter-Title'];
|
||||
flat['HTTP-Referer'] = 'https://claw-x.com';
|
||||
flat['X-Title'] = 'ClawX';
|
||||
flat['X-OpenRouter-Title'] = 'ClawX';
|
||||
init.headers = flat;
|
||||
}
|
||||
return _f.call(globalThis, input, init);
|
||||
@@ -78,6 +80,20 @@ const GATEWAY_FETCH_PRELOAD_SOURCE = `'use strict';
|
||||
})();
|
||||
`;
|
||||
|
||||
export function buildGatewayRuntimeEnv(
|
||||
forkEnv: Record<string, string | undefined>,
|
||||
): Record<string, string | undefined> {
|
||||
return {
|
||||
...forkEnv,
|
||||
// ClawX does not expose LAN discovery, so keep Bonjour disabled even if
|
||||
// the parent process inherited an explicit opt-in value.
|
||||
OPENCLAW_DISABLE_BONJOUR: '1',
|
||||
// OpenClaw's built-in trace contains stage names and timings only. Keep it
|
||||
// enabled so packaged startup incidents are diagnosable from normal logs.
|
||||
OPENCLAW_GATEWAY_STARTUP_TRACE: '1',
|
||||
};
|
||||
}
|
||||
|
||||
function ensureGatewayFetchPreload(): string {
|
||||
const dest = path.join(app.getPath('userData'), 'gateway-fetch-preload.cjs');
|
||||
try {
|
||||
@@ -116,7 +132,31 @@ export async function launchGatewayProcess(options: {
|
||||
);
|
||||
const lastSpawnSummary = `mode=${mode}, entry="${entryScript}", args="${options.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}"`;
|
||||
|
||||
const runtimeEnv = { ...forkEnv };
|
||||
const runtimeEnv = buildGatewayRuntimeEnv(forkEnv);
|
||||
|
||||
// Disable OpenClaw's mDNS/Bonjour gateway advertiser unconditionally.
|
||||
//
|
||||
// The OpenClaw gateway advertises `_openclaw-gw._tcp.local` on every
|
||||
// active network interface using a hardcoded `openclaw.local` hostname,
|
||||
// which causes:
|
||||
// - cross-machine name collisions when multiple OpenClaw/ClawX peers
|
||||
// share a LAN (each falls back to "<name> (OpenClaw) (2)")
|
||||
// - self-collisions on multi-homed hosts (Wi-Fi + Tailscale + utun ...)
|
||||
// - "ghost" record collisions after an unclean ClawX exit, because
|
||||
// SIGKILL prevents ciao from emitting the mDNS goodbye record.
|
||||
//
|
||||
// ClawX has no UI for LAN gateway discovery today, so the advertiser is
|
||||
// pure log noise. `OPENCLAW_DISABLE_BONJOUR=1` short-circuits
|
||||
// `startGatewayBonjourAdvertiser()` (openclaw `src/infra/bonjour.ts`,
|
||||
// `isDisabledByEnv()`). Set after the `forkEnv` spread so any
|
||||
// pre-existing value inherited from the user shell cannot re-enable it.
|
||||
// buildGatewayRuntimeEnv() applies both this policy and startup tracing
|
||||
// before any development-only environment augmentation below.
|
||||
|
||||
// Only apply the fetch/child_process preload in dev mode.
|
||||
// In packaged builds Electron's UtilityProcess rejects NODE_OPTIONS
|
||||
// with --require, logging "Most NODE_OPTIONs are not supported in
|
||||
// packaged apps" and the preload never loads.
|
||||
if (!app.isPackaged) {
|
||||
try {
|
||||
const preloadPath = ensureGatewayFetchPreload();
|
||||
@@ -151,14 +191,20 @@ export async function launchGatewayProcess(options: {
|
||||
reject(error);
|
||||
};
|
||||
|
||||
child.on('error', (error) => {
|
||||
child.on('error', (error: unknown) => {
|
||||
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||
logger.error('Gateway process spawn error:', error);
|
||||
options.onError(error);
|
||||
rejectOnce(error);
|
||||
options.onError(normalizedError);
|
||||
rejectOnce(normalizedError);
|
||||
});
|
||||
|
||||
child.on('exit', (code: number) => {
|
||||
const expectedExit = !options.getShouldReconnect() || options.getCurrentState() === 'stopped';
|
||||
// Only check shouldReconnect — not current state. On Windows the WS
|
||||
// close handler fires before the process exit handler and sets state to
|
||||
// 'stopped', which would make an unexpected crash look like a planned
|
||||
// shutdown in logs. shouldReconnect is the reliable indicator: stop()
|
||||
// sets it to false (expected), crashes leave it true (unexpected).
|
||||
const expectedExit = !options.getShouldReconnect();
|
||||
const level = expectedExit ? logger.info : logger.warn;
|
||||
level(`Gateway process exited (code=${code}, expected=${expectedExit ? 'yes' : 'no'})`);
|
||||
options.onExit(child, code);
|
||||
|
||||
@@ -10,6 +10,13 @@ export const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
|
||||
maxDelay: 30000,
|
||||
};
|
||||
|
||||
/** sysexits(3) EX_CONFIG, used by OpenClaw 2026.7.1 for fatal config startup errors. */
|
||||
export const OPENCLAW_EX_CONFIG_EXIT_CODE = 78;
|
||||
|
||||
export function isOpenClawFatalConfigExitCode(code: number | null | undefined): boolean {
|
||||
return code === OPENCLAW_EX_CONFIG_EXIT_CODE;
|
||||
}
|
||||
|
||||
export function nextLifecycleEpoch(currentEpoch: number): number {
|
||||
return currentEpoch + 1;
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export type GatewayReloadMode = 'hybrid' | 'reload' | 'restart' | 'off';
|
||||
|
||||
export type GatewayReloadPolicy = {
|
||||
mode: GatewayReloadMode;
|
||||
debounceMs: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_GATEWAY_RELOAD_POLICY: GatewayReloadPolicy = {
|
||||
mode: 'hybrid',
|
||||
debounceMs: 1200,
|
||||
};
|
||||
|
||||
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
const MAX_DEBOUNCE_MS = 60_000;
|
||||
|
||||
function normalizeMode(value: unknown): GatewayReloadMode {
|
||||
if (value === 'off' || value === 'reload' || value === 'restart' || value === 'hybrid') {
|
||||
return value;
|
||||
}
|
||||
return DEFAULT_GATEWAY_RELOAD_POLICY.mode;
|
||||
}
|
||||
|
||||
function normalizeDebounceMs(value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return DEFAULT_GATEWAY_RELOAD_POLICY.debounceMs;
|
||||
}
|
||||
const rounded = Math.round(value);
|
||||
if (rounded < 0) return 0;
|
||||
if (rounded > MAX_DEBOUNCE_MS) return MAX_DEBOUNCE_MS;
|
||||
return rounded;
|
||||
}
|
||||
|
||||
export function parseGatewayReloadPolicy(config: unknown): GatewayReloadPolicy {
|
||||
if (!config || typeof config !== 'object') {
|
||||
return { ...DEFAULT_GATEWAY_RELOAD_POLICY };
|
||||
}
|
||||
const root = config as Record<string, unknown>;
|
||||
const gateway = (root.gateway && typeof root.gateway === 'object'
|
||||
? root.gateway
|
||||
: {}) as Record<string, unknown>;
|
||||
const reload = (gateway.reload && typeof gateway.reload === 'object'
|
||||
? gateway.reload
|
||||
: {}) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
mode: normalizeMode(reload.mode),
|
||||
debounceMs: normalizeDebounceMs(reload.debounceMs),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadGatewayReloadPolicy(): Promise<GatewayReloadPolicy> {
|
||||
try {
|
||||
const raw = await readFile(OPENCLAW_CONFIG_PATH, 'utf-8');
|
||||
return parseGatewayReloadPolicy(JSON.parse(raw));
|
||||
} catch {
|
||||
return { ...DEFAULT_GATEWAY_RELOAD_POLICY };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ type DeferredRestartContext = RestartDeferralState & {
|
||||
|
||||
export class GatewayRestartController {
|
||||
private deferredRestartPending = false;
|
||||
private deferredRestartRequestedAt = 0;
|
||||
private lastRestartCompletedAt = 0;
|
||||
private restartDebounceTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
isRestartDeferred(context: RestartDeferralState): boolean {
|
||||
@@ -33,6 +35,13 @@ export class GatewayRestartController {
|
||||
);
|
||||
}
|
||||
this.deferredRestartPending = true;
|
||||
if (this.deferredRestartRequestedAt === 0) {
|
||||
this.deferredRestartRequestedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
recordRestartCompleted(): void {
|
||||
this.lastRestartCompletedAt = Date.now();
|
||||
}
|
||||
|
||||
flushDeferredRestart(
|
||||
@@ -55,7 +64,9 @@ export class GatewayRestartController {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedAt = this.deferredRestartRequestedAt;
|
||||
this.deferredRestartPending = false;
|
||||
this.deferredRestartRequestedAt = 0;
|
||||
if (action === 'drop') {
|
||||
logger.info(
|
||||
`Dropping deferred Gateway restart (${trigger}) because lifecycle already recovered (state=${context.state}, shouldReconnect=${context.shouldReconnect})`,
|
||||
@@ -63,6 +74,16 @@ export class GatewayRestartController {
|
||||
return;
|
||||
}
|
||||
|
||||
// If a restart already completed after this deferred request was made,
|
||||
// the current process is already running with the latest config —
|
||||
// skip the redundant restart to avoid "just started then restart" loops.
|
||||
if (requestedAt > 0 && this.lastRestartCompletedAt >= requestedAt) {
|
||||
logger.info(
|
||||
`Dropping deferred Gateway restart (${trigger}): a restart already completed after the request (requested=${requestedAt}, completed=${this.lastRestartCompletedAt})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Executing deferred Gateway restart now (${trigger})`);
|
||||
executeRestart();
|
||||
}
|
||||
@@ -87,5 +108,6 @@ export class GatewayRestartController {
|
||||
|
||||
resetDeferredRestart(): void {
|
||||
this.deferredRestartPending = false;
|
||||
this.deferredRestartRequestedAt = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,79 +2,60 @@ export type RestartDecision =
|
||||
| { allow: true }
|
||||
| {
|
||||
allow: false;
|
||||
reason: 'circuit_open' | 'budget_exceeded' | 'cooldown_active';
|
||||
reason: 'cooldown_active';
|
||||
retryAfterMs: number;
|
||||
};
|
||||
|
||||
type RestartGovernorOptions = {
|
||||
maxRestartsPerWindow: number;
|
||||
windowMs: number;
|
||||
baseCooldownMs: number;
|
||||
maxCooldownMs: number;
|
||||
circuitOpenMs: number;
|
||||
stableResetMs: number;
|
||||
/** Minimum interval between consecutive restarts (ms). */
|
||||
cooldownMs: number;
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONS: RestartGovernorOptions = {
|
||||
maxRestartsPerWindow: 4,
|
||||
windowMs: 10 * 60 * 1000,
|
||||
baseCooldownMs: 2500,
|
||||
maxCooldownMs: 2 * 60 * 1000,
|
||||
circuitOpenMs: 10 * 60 * 1000,
|
||||
stableResetMs: 2 * 60 * 1000,
|
||||
cooldownMs: 2500,
|
||||
};
|
||||
|
||||
/**
|
||||
* Lightweight restart rate-limiter.
|
||||
*
|
||||
* Prevents rapid-fire restarts by enforcing a simple cooldown between
|
||||
* consecutive restart executions. Nothing more — no circuit breakers,
|
||||
* no sliding-window budgets, no exponential back-off. Those mechanisms
|
||||
* were previously present but removed because:
|
||||
*
|
||||
* 1. The root causes of infinite restart loops (stale ownedPid, port
|
||||
* contention, leaked WebSocket connections) have been fixed at their
|
||||
* source.
|
||||
* 2. A 10-minute circuit-breaker lockout actively hurt the user
|
||||
* experience: legitimate config changes were silently dropped.
|
||||
* 3. The complexity made the restart path harder to reason about during
|
||||
* debugging.
|
||||
*/
|
||||
export class GatewayRestartGovernor {
|
||||
private readonly options: RestartGovernorOptions;
|
||||
private restartTimestamps: number[] = [];
|
||||
private circuitOpenUntil = 0;
|
||||
private consecutiveRestarts = 0;
|
||||
private lastRestartAt = 0;
|
||||
private lastRunningAt = 0;
|
||||
private suppressedTotal = 0;
|
||||
private executedTotal = 0;
|
||||
private static readonly MAX_COUNTER = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
constructor(options?: Partial<RestartGovernorOptions>) {
|
||||
this.options = { ...DEFAULT_OPTIONS, ...options };
|
||||
}
|
||||
|
||||
onRunning(now = Date.now()): void {
|
||||
this.lastRunningAt = now;
|
||||
/** No-op kept for interface compatibility with callers. */
|
||||
onRunning(_now = Date.now()): void {
|
||||
// Previously used to track "stable running" for exponential back-off
|
||||
// reset. No longer needed with the simplified cooldown model.
|
||||
}
|
||||
|
||||
decide(now = Date.now()): RestartDecision {
|
||||
this.pruneOld(now);
|
||||
this.maybeResetConsecutive(now);
|
||||
|
||||
if (now < this.circuitOpenUntil) {
|
||||
this.suppressedTotal = this.incrementCounter(this.suppressedTotal);
|
||||
return {
|
||||
allow: false,
|
||||
reason: 'circuit_open',
|
||||
retryAfterMs: this.circuitOpenUntil - now,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.restartTimestamps.length >= this.options.maxRestartsPerWindow) {
|
||||
this.circuitOpenUntil = now + this.options.circuitOpenMs;
|
||||
this.suppressedTotal = this.incrementCounter(this.suppressedTotal);
|
||||
return {
|
||||
allow: false,
|
||||
reason: 'budget_exceeded',
|
||||
retryAfterMs: this.options.circuitOpenMs,
|
||||
};
|
||||
}
|
||||
|
||||
const requiredCooldown = this.getCooldownMs();
|
||||
if (this.lastRestartAt > 0) {
|
||||
const sinceLast = now - this.lastRestartAt;
|
||||
if (sinceLast < requiredCooldown) {
|
||||
this.suppressedTotal = this.incrementCounter(this.suppressedTotal);
|
||||
if (sinceLast < this.options.cooldownMs) {
|
||||
this.suppressedTotal = this.safeIncrement(this.suppressedTotal);
|
||||
return {
|
||||
allow: false,
|
||||
reason: 'cooldown_active',
|
||||
retryAfterMs: requiredCooldown - sinceLast,
|
||||
retryAfterMs: this.options.cooldownMs - sinceLast,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -83,11 +64,8 @@ export class GatewayRestartGovernor {
|
||||
}
|
||||
|
||||
recordExecuted(now = Date.now()): void {
|
||||
this.executedTotal = this.incrementCounter(this.executedTotal);
|
||||
this.executedTotal = this.safeIncrement(this.executedTotal);
|
||||
this.lastRestartAt = now;
|
||||
this.consecutiveRestarts += 1;
|
||||
this.restartTimestamps.push(now);
|
||||
this.pruneOld(now);
|
||||
}
|
||||
|
||||
getCounters(): { executedTotal: number; suppressedTotal: number } {
|
||||
@@ -105,41 +83,12 @@ export class GatewayRestartGovernor {
|
||||
return {
|
||||
suppressed_total: this.suppressedTotal,
|
||||
executed_total: this.executedTotal,
|
||||
circuit_open_until: this.circuitOpenUntil,
|
||||
circuit_open_until: 0, // Always 0 — no circuit breaker
|
||||
};
|
||||
}
|
||||
|
||||
private getCooldownMs(): number {
|
||||
const factor = Math.pow(2, Math.max(0, this.consecutiveRestarts));
|
||||
return Math.min(this.options.baseCooldownMs * factor, this.options.maxCooldownMs);
|
||||
}
|
||||
|
||||
private maybeResetConsecutive(now: number): void {
|
||||
if (this.lastRunningAt <= 0) return;
|
||||
if (now - this.lastRunningAt >= this.options.stableResetMs) {
|
||||
this.consecutiveRestarts = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private pruneOld(now: number): void {
|
||||
// Detect time rewind (system clock moved backwards) and clear all
|
||||
// time-based guard state to avoid stale lockouts.
|
||||
if (this.restartTimestamps.length > 0 && now < this.restartTimestamps[this.restartTimestamps.length - 1]) {
|
||||
this.restartTimestamps = [];
|
||||
this.circuitOpenUntil = 0;
|
||||
this.lastRestartAt = 0;
|
||||
this.lastRunningAt = 0;
|
||||
this.consecutiveRestarts = 0;
|
||||
return;
|
||||
}
|
||||
const threshold = now - this.options.windowMs;
|
||||
while (this.restartTimestamps.length > 0 && this.restartTimestamps[0] < threshold) {
|
||||
this.restartTimestamps.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private incrementCounter(current: number): number {
|
||||
if (current >= GatewayRestartGovernor.MAX_COUNTER) return 0;
|
||||
private safeIncrement(current: number): number {
|
||||
if (current >= Number.MAX_SAFE_INTEGER) return 0;
|
||||
return current + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Pre-launch cleanup for stray skill symlinks under OpenClaw skill roots.
|
||||
*
|
||||
* Background: since openclaw commit 253e159700 ("fix: harden workspace skill
|
||||
* path containment"), the Gateway rejects any candidate under a skills root
|
||||
* whose realpath escapes that root, logging a noisy
|
||||
* `Skipping escaped skill path outside its configured root.
|
||||
* reason=symlink-escape source=openclaw-managed ...`
|
||||
* warning per offending entry on every start.
|
||||
*
|
||||
* Common offenders are one-shot install scripts that drop symlinks into:
|
||||
* - ~/.openclaw/skills/<name> -> ~/.agents/skills/<name>
|
||||
* - ~/.openclaw/workspace/skills/<name> -> ~/.openclaw/workspace/.agents/skills/<name>
|
||||
* - ~/.openclaw/skills/<name> -> ~/workspace/<repo>/skills/<name>
|
||||
* The hardened loader rejects these because their realpath escapes the
|
||||
* configured managed root, so they are pure log noise — entries that the
|
||||
* loader can never accept from this root.
|
||||
*
|
||||
* This helper is invoked before each Gateway launch to remove those
|
||||
* specific symlinks. Scope is intentionally narrow:
|
||||
* - source dirs: ~/.openclaw/skills and ~/.openclaw/workspace/skills
|
||||
* - target dirs: anything outside the matching managed skills root
|
||||
* Symlinks whose realpath stays inside the same managed skills root are left
|
||||
* untouched.
|
||||
*
|
||||
* Removal uses fs.rmSync({ force: true, recursive: true }) rather than
|
||||
* fs.unlinkSync so that directory symlinks and Windows junctions (the form
|
||||
* that non-admin Windows installs end up creating) are deleted correctly.
|
||||
* unlinkSync raises EPERM on those on Windows, and rmSync without recursive
|
||||
* can reject directory symlinks on some platforms.
|
||||
*
|
||||
* This is a transitional workaround. Once openclaw/openclaw#59219 lands and
|
||||
* the loader stops rejecting managed-source symlinks whose realpath escapes
|
||||
* the managed root, this helper can be removed entirely.
|
||||
*/
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
readlinkSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
type Dirent,
|
||||
} from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { getOpenClawConfigDir, getOpenClawResolvedDir, getOpenClawSkillsDir } from '../utils/paths';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface CleanupOptions {
|
||||
/** Override for ~/.openclaw/skills (mainly for tests). */
|
||||
skillsDir?: string;
|
||||
/** Override for ~/.agents/skills (mainly for tests/log context). */
|
||||
agentsDir?: string;
|
||||
/** Override for ~/.openclaw/workspace/skills (mainly for tests). */
|
||||
workspaceSkillsDir?: string;
|
||||
/** Override for ~/.openclaw/workspace/.agents/skills (mainly for tests). */
|
||||
workspaceAgentsDir?: string;
|
||||
}
|
||||
|
||||
export interface CleanupResult {
|
||||
/** Symlink names that were unlinked from the skills dir. */
|
||||
removed: string[];
|
||||
/** Total number of symlink entries that were inspected. */
|
||||
examined: number;
|
||||
/** Cleanup operations that could not be completed and should be retried later. */
|
||||
failed?: number;
|
||||
}
|
||||
|
||||
export interface PluginRuntimeDepsCleanupOptions {
|
||||
/** Override for ~/.openclaw/plugin-runtime-deps (mainly for tests). */
|
||||
runtimeDepsDir?: string;
|
||||
/** Override for the current bundled OpenClaw package dir (mainly for tests). */
|
||||
currentOpenClawDir?: string;
|
||||
}
|
||||
|
||||
function defaultSkillsDir(): string {
|
||||
return getOpenClawSkillsDir();
|
||||
}
|
||||
|
||||
function recordCleanupFailure(result: CleanupResult): void {
|
||||
result.failed = (result.failed ?? 0) + 1;
|
||||
}
|
||||
|
||||
function defaultAgentsDir(): string {
|
||||
return path.join(homedir(), '.agents', 'skills');
|
||||
}
|
||||
|
||||
function defaultWorkspaceSkillsDir(): string {
|
||||
return path.join(getOpenClawConfigDir(), 'workspace', 'skills');
|
||||
}
|
||||
|
||||
function defaultWorkspaceAgentsDir(): string {
|
||||
return path.join(getOpenClawConfigDir(), 'workspace', '.agents', 'skills');
|
||||
}
|
||||
|
||||
function defaultPluginRuntimeDepsDir(): string {
|
||||
return path.join(getOpenClawConfigDir(), 'plugin-runtime-deps');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the agents skills directory to its real path. When the directory
|
||||
* itself does not exist yet (fresh install), fall back to realpath'ing its
|
||||
* parent and re-appending the basename so a `~/.agents -> /opt/agents`
|
||||
* indirection is still honored. As a final fallback returns the lexical
|
||||
* resolved path.
|
||||
*/
|
||||
function resolveAgentsRealRoot(agentsDir: string): string {
|
||||
if (existsSync(agentsDir)) {
|
||||
try {
|
||||
return realpathSync(agentsDir);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(agentsDir);
|
||||
const tail = path.basename(agentsDir);
|
||||
if (parent && parent !== agentsDir && existsSync(parent)) {
|
||||
try {
|
||||
return path.join(realpathSync(parent), tail);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
return path.resolve(agentsDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower-case path strings on Win32 only so the `path.relative` byte-wise
|
||||
* comparison aligns with NTFS case-insensitive semantics. No-op elsewhere.
|
||||
*/
|
||||
function normalizeForCompare(p: string): string {
|
||||
return process.platform === 'win32' ? p.toLowerCase() : p;
|
||||
}
|
||||
|
||||
function isInside(parent: string, child: string): boolean {
|
||||
const rel = path.relative(normalizeForCompare(parent), normalizeForCompare(child));
|
||||
if (rel === '') return true;
|
||||
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveSymlinkTarget(linkPath: string): string | null {
|
||||
try {
|
||||
const target = readlinkSync(linkPath);
|
||||
return path.resolve(path.dirname(linkPath), target);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeOpenClawPackagePath(candidate: string): boolean {
|
||||
const normalized = candidate.replace(/\\/g, '/');
|
||||
return /\/node_modules(?:\/\.pnpm\/[^/]+\/node_modules)?\/openclaw(?:\/|$)/.test(normalized);
|
||||
}
|
||||
|
||||
function resolveCurrentOpenClawRoots(currentOpenClawDir: string): string[] {
|
||||
const roots = new Set<string>([path.resolve(currentOpenClawDir)]);
|
||||
try {
|
||||
roots.add(realpathSync(currentOpenClawDir));
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return Array.from(roots);
|
||||
}
|
||||
|
||||
export function cleanupAgentsSymlinkedSkills(opts: CleanupOptions = {}): CleanupResult {
|
||||
const hasMainOverrides = opts.skillsDir !== undefined || opts.agentsDir !== undefined;
|
||||
const hasWorkspaceOverrides =
|
||||
opts.workspaceSkillsDir !== undefined || opts.workspaceAgentsDir !== undefined;
|
||||
const roots = [
|
||||
{
|
||||
skillsDir: opts.skillsDir ?? defaultSkillsDir(),
|
||||
agentsDir: opts.agentsDir ?? defaultAgentsDir(),
|
||||
},
|
||||
];
|
||||
|
||||
if (!hasMainOverrides || hasWorkspaceOverrides) {
|
||||
roots.push({
|
||||
skillsDir: opts.workspaceSkillsDir ?? defaultWorkspaceSkillsDir(),
|
||||
agentsDir: opts.workspaceAgentsDir ?? defaultWorkspaceAgentsDir(),
|
||||
});
|
||||
}
|
||||
|
||||
const result: CleanupResult = { removed: [], examined: 0 };
|
||||
const seenRoots = new Set<string>();
|
||||
|
||||
for (const root of roots) {
|
||||
const rootKey = `${path.resolve(root.skillsDir)}\0${path.resolve(root.agentsDir)}`;
|
||||
if (seenRoots.has(rootKey)) continue;
|
||||
seenRoots.add(rootKey);
|
||||
|
||||
const rootResult = cleanupSkillsDir(root.skillsDir, root.agentsDir);
|
||||
result.removed.push(...rootResult.removed);
|
||||
result.examined += rootResult.examined;
|
||||
if (rootResult.failed) {
|
||||
result.failed = (result.failed ?? 0) + rootResult.failed;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove stale OpenClaw plugin runtime dependency cache roots.
|
||||
*
|
||||
* OpenClaw can materialize `~/.openclaw/plugin-runtime-deps/openclaw-*` as a
|
||||
* symlink tree back into the package's `dist` files. After app upgrades or
|
||||
* worktree switches those symlinks can point at an old `node_modules/openclaw`
|
||||
* path. The Gateway may then spend a long time synchronously opening/copying
|
||||
* old runtime files during plugin setup, which blocks RPC readiness.
|
||||
*
|
||||
* Scope is intentionally narrow: only immediate cache roots named `openclaw-*`
|
||||
* are removed, and only when a symlink inside points at an OpenClaw package
|
||||
* path outside the current bundled package. The cache is regenerated by
|
||||
* OpenClaw on demand.
|
||||
*/
|
||||
export function cleanupStalePluginRuntimeDeps(
|
||||
opts: PluginRuntimeDepsCleanupOptions = {},
|
||||
): CleanupResult {
|
||||
const runtimeDepsDir = opts.runtimeDepsDir ?? defaultPluginRuntimeDepsDir();
|
||||
const currentRoots = resolveCurrentOpenClawRoots(opts.currentOpenClawDir ?? getOpenClawResolvedDir());
|
||||
const result: CleanupResult = { removed: [], examined: 0 };
|
||||
|
||||
if (!existsSync(runtimeDepsDir)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(runtimeDepsDir, { withFileTypes: true, encoding: 'utf8' });
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin-runtime-deps-cleanup] Failed to list ${runtimeDepsDir}:`, err);
|
||||
recordCleanupFailure(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith('openclaw-')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cacheRoot = path.join(runtimeDepsDir, entry.name);
|
||||
const scan = scanRuntimeDepsRootForStaleOpenClawSymlink(cacheRoot, currentRoots);
|
||||
result.examined += scan.examined;
|
||||
if (!scan.stale) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
rmSync(cacheRoot, { force: true, recursive: true });
|
||||
result.removed.push(entry.name);
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin-runtime-deps-cleanup] Failed to remove ${cacheRoot}:`, err);
|
||||
recordCleanupFailure(result);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.removed.length > 0) {
|
||||
logger.info(
|
||||
`[plugin-runtime-deps-cleanup] Removed ${result.removed.length} stale OpenClaw runtime cache root(s): ` +
|
||||
result.removed.join(', '),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function scanRuntimeDepsRootForStaleOpenClawSymlink(
|
||||
cacheRoot: string,
|
||||
currentOpenClawRoots: string[],
|
||||
): { stale: boolean; examined: number } {
|
||||
const stack = [cacheRoot];
|
||||
let examined = 0;
|
||||
const maxEntries = 5000;
|
||||
|
||||
while (stack.length > 0 && examined < maxEntries) {
|
||||
const dir = stack.pop()!;
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (examined >= maxEntries) break;
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(entryPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
let isSymlink = entry.isSymbolicLink();
|
||||
if (!isSymlink) {
|
||||
try {
|
||||
isSymlink = lstatSync(entryPath).isSymbolicLink();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!isSymlink) continue;
|
||||
|
||||
examined++;
|
||||
const target = resolveSymlinkTarget(entryPath);
|
||||
if (!target || !looksLikeOpenClawPackagePath(target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pointsAtCurrentOpenClaw = currentOpenClawRoots.some((root) => isInside(root, target));
|
||||
if (!pointsAtCurrentOpenClaw) {
|
||||
return { stale: true, examined };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { stale: false, examined };
|
||||
}
|
||||
|
||||
function cleanupSkillsDir(skillsDir: string, agentsDir: string): CleanupResult {
|
||||
const result: CleanupResult = { removed: [], examined: 0 };
|
||||
if (!existsSync(skillsDir)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(skillsDir, { withFileTypes: true, encoding: 'utf8' });
|
||||
} catch (err) {
|
||||
logger.warn(`[skills-cleanup] Failed to list ${skillsDir}:`, err);
|
||||
recordCleanupFailure(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const agentsRealRoot = resolveAgentsRealRoot(agentsDir);
|
||||
const skillsRealRoot = resolveAgentsRealRoot(skillsDir);
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(skillsDir, entry.name);
|
||||
|
||||
let isSymlink = entry.isSymbolicLink();
|
||||
if (!isSymlink) {
|
||||
try {
|
||||
isSymlink = lstatSync(entryPath).isSymbolicLink();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!isSymlink) continue;
|
||||
|
||||
result.examined++;
|
||||
|
||||
let realTarget: string;
|
||||
try {
|
||||
realTarget = realpathSync(entryPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isInside(skillsRealRoot, realTarget)) continue;
|
||||
|
||||
try {
|
||||
// rmSync handles file symlinks, directory symlinks, and Windows
|
||||
// junctions uniformly. unlinkSync would raise EPERM on directory
|
||||
// symlinks/junctions on Windows.
|
||||
rmSync(entryPath, { force: true, recursive: true });
|
||||
result.removed.push(entry.name);
|
||||
} catch (err) {
|
||||
logger.warn(`[skills-cleanup] Failed to remove ${entryPath}:`, err);
|
||||
recordCleanupFailure(result);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.removed.length > 0) {
|
||||
logger.info(
|
||||
`[skills-cleanup] Removed ${result.removed.length} stray skill symlink(s) ` +
|
||||
`under ${skillsDir} that escaped managed root ${skillsRealRoot} ` +
|
||||
`(workaround for openclaw/openclaw#59219): ` +
|
||||
result.removed.join(', '),
|
||||
);
|
||||
} else if (result.examined > 0) {
|
||||
logger.debug(
|
||||
`[skills-cleanup] Examined ${result.examined} symlink(s) under ${skillsDir}; ` +
|
||||
`none escaped managed root (agents context: ${agentsRealRoot})`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { logger } from '../utils/logger';
|
||||
import { LifecycleSupersededError } from './lifecycle-controller';
|
||||
import { getGatewayStartupRecoveryAction } from './startup-recovery';
|
||||
import { connectGatewayWithStartupRetry, getGatewayStartupRecoveryAction } from './startup-recovery';
|
||||
|
||||
export interface ExistingGatewayInfo {
|
||||
port: number;
|
||||
@@ -9,13 +9,15 @@ export interface ExistingGatewayInfo {
|
||||
|
||||
type StartupHooks = {
|
||||
port: number;
|
||||
ownedPid?: number;
|
||||
ownedPid?: never; // Removed: pid is now read dynamically in findExistingGateway to avoid stale-snapshot bug
|
||||
shouldWaitForPortFree: boolean;
|
||||
maxStartAttempts?: number;
|
||||
/** Returns true when the manager still owns a living Gateway process (e.g. after a code-1012 in-process restart). */
|
||||
hasOwnedProcess: () => boolean;
|
||||
resetStartupStderrLines: () => void;
|
||||
getStartupStderrLines: () => string[];
|
||||
assertLifecycle: (phase: string) => void;
|
||||
findExistingGateway: (port: number, ownedPid?: number) => Promise<ExistingGatewayInfo | null>;
|
||||
findExistingGateway: (port: number) => Promise<ExistingGatewayInfo | null>;
|
||||
connect: (port: number, externalToken?: string) => Promise<void>;
|
||||
onConnectedToExistingGateway: () => void;
|
||||
waitForPortFree: (port: number) => Promise<void>;
|
||||
@@ -27,6 +29,22 @@ type StartupHooks = {
|
||||
delay: (ms: number) => Promise<void>;
|
||||
};
|
||||
|
||||
async function connectWithStartupRetry(
|
||||
hooks: StartupHooks,
|
||||
port: number,
|
||||
externalToken?: string,
|
||||
): Promise<void> {
|
||||
await connectGatewayWithStartupRetry({
|
||||
connect: hooks.connect,
|
||||
port,
|
||||
externalToken,
|
||||
delay: hooks.delay,
|
||||
beforeAttempt: () => hooks.assertLifecycle('start/connect-retry'),
|
||||
logWarn: (message) => logger.warn(message),
|
||||
logInfo: (message) => logger.info(message),
|
||||
});
|
||||
}
|
||||
|
||||
export async function runGatewayStartupSequence(hooks: StartupHooks): Promise<void> {
|
||||
let configRepairAttempted = false;
|
||||
let startAttempts = 0;
|
||||
@@ -39,16 +57,32 @@ export async function runGatewayStartupSequence(hooks: StartupHooks): Promise<vo
|
||||
|
||||
try {
|
||||
logger.debug('Checking for existing Gateway...');
|
||||
const existing = await hooks.findExistingGateway(hooks.port, hooks.ownedPid);
|
||||
const existing = await hooks.findExistingGateway(hooks.port);
|
||||
hooks.assertLifecycle('start/find-existing');
|
||||
if (existing) {
|
||||
logger.debug(`Found existing Gateway on port ${existing.port}`);
|
||||
await hooks.connect(existing.port, existing.externalToken);
|
||||
await connectWithStartupRetry(hooks, existing.port, existing.externalToken);
|
||||
hooks.assertLifecycle('start/connect-existing');
|
||||
hooks.onConnectedToExistingGateway();
|
||||
return;
|
||||
}
|
||||
|
||||
// When the Gateway did an in-process restart (WS close 1012), the
|
||||
// UtilityProcess is still alive but its WS server may be mid-rebuild,
|
||||
// so findExistingGateway's quick probe returns null. Rather than
|
||||
// waiting for the port to free (it never will — the process holds it)
|
||||
// and then spawning a duplicate, wait for the existing process to
|
||||
// become ready and reconnect to it.
|
||||
if (hooks.hasOwnedProcess()) {
|
||||
logger.info('Owned Gateway process still alive (likely in-process restart); waiting for it to become ready');
|
||||
await hooks.waitForReady(hooks.port);
|
||||
hooks.assertLifecycle('start/wait-ready-owned');
|
||||
await connectWithStartupRetry(hooks, hooks.port);
|
||||
hooks.assertLifecycle('start/connect-owned');
|
||||
hooks.onConnectedToExistingGateway();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('No existing Gateway found, starting new process...');
|
||||
|
||||
if (hooks.shouldWaitForPortFree) {
|
||||
@@ -62,7 +96,7 @@ export async function runGatewayStartupSequence(hooks: StartupHooks): Promise<vo
|
||||
await hooks.waitForReady(hooks.port);
|
||||
hooks.assertLifecycle('start/wait-ready');
|
||||
|
||||
await hooks.connect(hooks.port);
|
||||
await connectWithStartupRetry(hooks, hooks.port);
|
||||
hooks.assertLifecycle('start/connect');
|
||||
|
||||
hooks.onConnectedToManagedGateway();
|
||||
|
||||
@@ -8,18 +8,40 @@
|
||||
const INVALID_CONFIG_PATTERNS: RegExp[] = [
|
||||
/\binvalid config\b/i,
|
||||
/\bconfig invalid\b/i,
|
||||
/\bfatal configuration error\b/i,
|
||||
/\bunrecognized key\b/i,
|
||||
/\bstartup migration(?:s)?\b.*\b(?:blocked|failed|did not complete cleanly)\b/i,
|
||||
/\bmigration\b.*\bopenclaw doctor --fix\b/i,
|
||||
/\brun:\s*openclaw doctor --fix\b/i,
|
||||
];
|
||||
|
||||
const FATAL_RUNTIME_PATTERNS: RegExp[] = [
|
||||
/\bNode(?:\.js)?\b.*\boutside the supported range\b/i,
|
||||
/\buses SQLite\b.*\bnot WAL-reset-safe\b/i,
|
||||
/\bSQLite\b.*\bWAL-reset-safe runtime required\b/i,
|
||||
/\bInstall Node 24\.15\+.*\bNode 22\.22\.3\+\b/i,
|
||||
];
|
||||
|
||||
const STARTUP_MIGRATION_LOCK_PATTERNS: RegExp[] = [
|
||||
/\bstartup migrations? (?:is|are) already running\b/i,
|
||||
/\bretry after the other gateway finishes\b/i,
|
||||
];
|
||||
|
||||
const TRANSIENT_START_ERROR_PATTERNS: RegExp[] = [
|
||||
/WebSocket closed before handshake/i,
|
||||
/ECONNREFUSED/i,
|
||||
/Gateway process exited before becoming ready/i,
|
||||
/Timed out waiting for connect\.challenge/i,
|
||||
/Connect handshake timeout/i,
|
||||
// OpenClaw can emit connect.challenge before the connect RPC is accepted.
|
||||
/gateway starting/i,
|
||||
// Port occupied after orphan kill: transient, worth retrying with backoff
|
||||
/Port \d+ still occupied after \d+ms/i,
|
||||
];
|
||||
|
||||
/** Backoff between connect() attempts when the Gateway rejects with "still starting". */
|
||||
export const GATEWAY_CONNECT_STARTUP_RETRY_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000, 8_000] as const;
|
||||
|
||||
function normalizeLogLine(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
@@ -54,6 +76,33 @@ export function hasInvalidConfigFailureSignal(
|
||||
return isInvalidConfigSignal(errorText);
|
||||
}
|
||||
|
||||
function startupFailureCandidates(startupError: unknown, startupStderrLines: string[]): string[] {
|
||||
return [
|
||||
...startupStderrLines,
|
||||
startupError instanceof Error
|
||||
? `${startupError.name}: ${startupError.message}`
|
||||
: String(startupError ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/** Returns true for OpenClaw runtime/SQLite failures that doctor cannot repair. */
|
||||
export function hasFatalRuntimeFailureSignal(
|
||||
startupError: unknown,
|
||||
startupStderrLines: string[],
|
||||
): boolean {
|
||||
return startupFailureCandidates(startupError, startupStderrLines)
|
||||
.some((text) => FATAL_RUNTIME_PATTERNS.some((pattern) => pattern.test(text)));
|
||||
}
|
||||
|
||||
/** Returns true while another/stale OpenClaw startup migration lease is active. */
|
||||
export function hasStartupMigrationLockSignal(
|
||||
startupError: unknown,
|
||||
startupStderrLines: string[],
|
||||
): boolean {
|
||||
return startupFailureCandidates(startupError, startupStderrLines)
|
||||
.some((text) => STARTUP_MIGRATION_LOCK_PATTERNS.some((pattern) => pattern.test(text)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry guard for one-time config repair during a single startup flow.
|
||||
*/
|
||||
@@ -73,6 +122,53 @@ export function isTransientGatewayStartError(error: unknown): boolean {
|
||||
return TRANSIENT_START_ERROR_PATTERNS.some((pattern) => pattern.test(errorText));
|
||||
}
|
||||
|
||||
export function isGatewayStillStartingError(error: unknown): boolean {
|
||||
const errorText = error instanceof Error
|
||||
? error.message
|
||||
: String(error ?? '');
|
||||
return /gateway starting/i.test(errorText);
|
||||
}
|
||||
|
||||
export async function connectGatewayWithStartupRetry(options: {
|
||||
connect: (port: number, externalToken?: string) => Promise<void>;
|
||||
port: number;
|
||||
externalToken?: string;
|
||||
delay: (ms: number) => Promise<void>;
|
||||
retryDelaysMs?: readonly number[];
|
||||
beforeAttempt?: () => void;
|
||||
logWarn?: (message: string) => void;
|
||||
logInfo?: (message: string) => void;
|
||||
}): Promise<void> {
|
||||
const retryDelaysMs = options.retryDelaysMs ?? GATEWAY_CONNECT_STARTUP_RETRY_DELAYS_MS;
|
||||
const logWarn = options.logWarn ?? (() => {});
|
||||
const logInfo = options.logInfo ?? (() => {});
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) {
|
||||
options.beforeAttempt?.();
|
||||
try {
|
||||
await options.connect(options.port, options.externalToken);
|
||||
if (attempt > 0) {
|
||||
logInfo(`Gateway connect succeeded after ${attempt + 1} attempt(s)`);
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isGatewayStillStartingError(error) || attempt >= retryDelaysMs.length) {
|
||||
throw error;
|
||||
}
|
||||
const delayMs = retryDelaysMs[attempt] ?? retryDelaysMs[retryDelaysMs.length - 1]!;
|
||||
logWarn(
|
||||
`Gateway connect rejected while still starting (${String(error)}); `
|
||||
+ `retrying in ${delayMs}ms (${attempt + 1}/${retryDelaysMs.length})`,
|
||||
);
|
||||
await options.delay(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error(String(lastError ?? 'Gateway connect failed'));
|
||||
}
|
||||
|
||||
export type GatewayStartupRecoveryAction = 'repair' | 'retry' | 'fail';
|
||||
|
||||
export function getGatewayStartupRecoveryAction(options: {
|
||||
@@ -82,12 +178,18 @@ export function getGatewayStartupRecoveryAction(options: {
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
}): GatewayStartupRecoveryAction {
|
||||
if (shouldAttemptConfigAutoRepair(
|
||||
options.startupError,
|
||||
options.startupStderrLines,
|
||||
options.configRepairAttempted,
|
||||
)) {
|
||||
return 'repair';
|
||||
if (
|
||||
hasFatalRuntimeFailureSignal(options.startupError, options.startupStderrLines)
|
||||
|| hasStartupMigrationLockSignal(options.startupError, options.startupStderrLines)
|
||||
) {
|
||||
return 'fail';
|
||||
}
|
||||
|
||||
if (hasInvalidConfigFailureSignal(options.startupError, options.startupStderrLines)) {
|
||||
// One doctor pass is the only automated repair. If the same migration or
|
||||
// config failure remains afterward, stop instead of treating the generic
|
||||
// process-exited error as transient.
|
||||
return options.configRepairAttempted ? 'fail' : 'repair';
|
||||
}
|
||||
|
||||
if (options.attempt < options.maxAttempts && isTransientGatewayStartError(options.startupError)) {
|
||||
|
||||
@@ -1,37 +1,137 @@
|
||||
export type GatewayStderrClassification = {
|
||||
level: 'drop' | 'debug' | 'warn';
|
||||
level: 'drop' | 'debug' | 'info' | 'warn';
|
||||
normalized: string;
|
||||
};
|
||||
|
||||
export type GatewayStartupTraceStage = {
|
||||
name: string;
|
||||
durationMs: number;
|
||||
totalMs?: number;
|
||||
};
|
||||
|
||||
export type GatewayStartupTraceSummary = {
|
||||
stageCount: number;
|
||||
lastStage?: string;
|
||||
traceTotalMs?: number;
|
||||
slowestStage?: string;
|
||||
slowestStageMs?: number;
|
||||
};
|
||||
|
||||
export const GATEWAY_STARTUP_SLOW_STAGE_MS = 10_000;
|
||||
export const GATEWAY_STARTUP_SLOW_TOTAL_MS = 30_000;
|
||||
|
||||
const MAX_STDERR_LINES = 120;
|
||||
const ANSI_ESCAPE_PATTERN = new RegExp(String.raw`\u001B\[[0-?]*[ -/]*[@-~]`, 'g');
|
||||
const STARTUP_TRACE_PATTERN = /startup trace:\s+([^\s]+)\s+(\d+(?:\.\d+)?)ms(?:\s+total=(\d+(?:\.\d+)?)ms)?/i;
|
||||
|
||||
export function parseGatewayStartupTraceStage(message: string): GatewayStartupTraceStage | null {
|
||||
const match = STARTUP_TRACE_PATTERN.exec(message.replace(ANSI_ESCAPE_PATTERN, ''));
|
||||
if (!match) return null;
|
||||
|
||||
const durationMs = Number(match[2]);
|
||||
const totalMs = match[3] === undefined ? undefined : Number(match[3]);
|
||||
if (!Number.isFinite(durationMs) || (totalMs !== undefined && !Number.isFinite(totalMs))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: match[1]!,
|
||||
durationMs,
|
||||
...(totalMs === undefined ? {} : { totalMs }),
|
||||
};
|
||||
}
|
||||
|
||||
export class GatewayStartupTraceCollector {
|
||||
private stageCount = 0;
|
||||
private lastStage: GatewayStartupTraceStage | null = null;
|
||||
private slowestStage: GatewayStartupTraceStage | null = null;
|
||||
private maxTraceTotalMs: number | undefined;
|
||||
|
||||
reset(): void {
|
||||
this.stageCount = 0;
|
||||
this.lastStage = null;
|
||||
this.slowestStage = null;
|
||||
this.maxTraceTotalMs = undefined;
|
||||
}
|
||||
|
||||
record(message: string): GatewayStartupTraceStage | null {
|
||||
const stage = parseGatewayStartupTraceStage(message);
|
||||
if (!stage) return null;
|
||||
|
||||
this.stageCount += 1;
|
||||
this.lastStage = stage;
|
||||
if (!this.slowestStage || stage.durationMs > this.slowestStage.durationMs) {
|
||||
this.slowestStage = stage;
|
||||
}
|
||||
if (stage.totalMs !== undefined) {
|
||||
this.maxTraceTotalMs = Math.max(this.maxTraceTotalMs ?? 0, stage.totalMs);
|
||||
}
|
||||
return stage;
|
||||
}
|
||||
|
||||
getSummary(): GatewayStartupTraceSummary {
|
||||
return {
|
||||
stageCount: this.stageCount,
|
||||
...(this.lastStage ? { lastStage: this.lastStage.name } : {}),
|
||||
...(this.maxTraceTotalMs === undefined ? {} : { traceTotalMs: this.maxTraceTotalMs }),
|
||||
...(this.slowestStage ? {
|
||||
slowestStage: this.slowestStage.name,
|
||||
slowestStageMs: this.slowestStage.durationMs,
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function classifyGatewayStderrMessage(message: string): GatewayStderrClassification {
|
||||
const msg = message.trim();
|
||||
if (!msg) {
|
||||
return { level: 'drop', normalized: msg };
|
||||
}
|
||||
const plain = msg.replace(ANSI_ESCAPE_PATTERN, '');
|
||||
|
||||
// OpenClaw startup timing traces are expected diagnostics, not failures.
|
||||
if (plain.includes('startup trace:')) {
|
||||
return { level: 'info', normalized: msg };
|
||||
}
|
||||
|
||||
// Known noisy lines that are not actionable for Gateway lifecycle debugging.
|
||||
if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) {
|
||||
if (plain.includes('openclaw-control-ui') && plain.includes('token_mismatch')) {
|
||||
return { level: 'drop', normalized: msg };
|
||||
}
|
||||
if (msg.includes('closed before connect') && msg.includes('token mismatch')) {
|
||||
if (plain.includes('closed before connect') && plain.includes('token mismatch')) {
|
||||
return { level: 'drop', normalized: msg };
|
||||
}
|
||||
if (msg.includes('[ws] closed before connect') && msg.includes('code=1005')) {
|
||||
if (plain.includes('[ws] closed before connect') && plain.includes('code=1005')) {
|
||||
return { level: 'debug', normalized: msg };
|
||||
}
|
||||
if (msg.includes('security warning: dangerous config flags enabled')) {
|
||||
if (
|
||||
plain.includes('[ws] closed before connect')
|
||||
&& plain.includes('code=1006')
|
||||
&& plain.includes('phase=ws_upgrade_started')
|
||||
&& plain.includes('ua=n/a')
|
||||
) {
|
||||
return { level: 'debug', normalized: msg };
|
||||
}
|
||||
if (plain.includes('security warning: dangerous config flags enabled')) {
|
||||
return { level: 'debug', normalized: msg };
|
||||
}
|
||||
|
||||
// Downgrade frequent non-fatal noise.
|
||||
if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg };
|
||||
if (msg.includes('DeprecationWarning')) return { level: 'debug', normalized: msg };
|
||||
if (msg.includes('Debugger attached')) return { level: 'debug', normalized: msg };
|
||||
if (plain.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg };
|
||||
if (plain.includes('DeprecationWarning')) return { level: 'debug', normalized: msg };
|
||||
if (plain.includes('Rename them by replacing the legacy prefix with OPENCLAW_')) {
|
||||
return { level: 'debug', normalized: msg };
|
||||
}
|
||||
if (plain.includes('--trace-deprecation') && plain.includes('show where the warning was created')) {
|
||||
return { level: 'debug', normalized: msg };
|
||||
}
|
||||
if (plain.includes('Debugger attached')) return { level: 'debug', normalized: msg };
|
||||
|
||||
// Gateway config warnings (e.g. stale plugin entries) are informational, not actionable.
|
||||
if (plain.includes('Config warnings:')) return { level: 'debug', normalized: msg };
|
||||
|
||||
// Electron restricts NODE_OPTIONS in packaged apps; this is expected and harmless.
|
||||
if (msg.includes('node: --require is not allowed in NODE_OPTIONS')) {
|
||||
if (plain.includes('node: --require is not allowed in NODE_OPTIONS')) {
|
||||
return { level: 'debug', normalized: msg };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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';
|
||||
import { probeGatewayReady } from './ws-client';
|
||||
|
||||
export function warmupManagedPythonReadiness(): void {
|
||||
void isPythonReady().then((pythonReady) => {
|
||||
@@ -156,7 +156,8 @@ export async function waitForPortFree(port: number, timeoutMs = 30000): Promise<
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
logger.warn(`Port ${port} still occupied after ${timeoutMs}ms, proceeding anyway`);
|
||||
logger.error(`Port ${port} still occupied after ${timeoutMs}ms; aborting startup to avoid port conflict`);
|
||||
throw new Error(`Port ${port} still occupied after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
async function getListeningProcessIds(port: number): Promise<string[]> {
|
||||
@@ -254,24 +255,8 @@ export async function findExistingGatewayProcess(options: {
|
||||
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);
|
||||
});
|
||||
});
|
||||
const ready = await probeGatewayReady(port, 5000);
|
||||
return ready ? { port } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,14 @@ import {
|
||||
signDevicePayload,
|
||||
} from '../utils/device-identity';
|
||||
import { logger } from '../utils/logger';
|
||||
import {
|
||||
isGatewayWsTraceEnabled,
|
||||
redactGatewayFrameForTrace,
|
||||
summarizeGatewayFrameForTrace,
|
||||
} from './ws-trace';
|
||||
|
||||
export const GATEWAY_CHALLENGE_TIMEOUT_MS = 10_000;
|
||||
export const GATEWAY_CONNECT_HANDSHAKE_TIMEOUT_MS = 20_000;
|
||||
|
||||
export async function probeGatewayReady(
|
||||
port: number,
|
||||
@@ -21,7 +29,10 @@ export async function probeGatewayReady(
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
testWs.close();
|
||||
// Use terminate() (TCP RST) instead of close() (WS close handshake)
|
||||
// to avoid leaving TIME_WAIT connections on Windows. These probe
|
||||
// WebSockets are short-lived and don't need a graceful close.
|
||||
testWs.terminate();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -96,6 +107,8 @@ export async function waitForGatewayReady(options: {
|
||||
throw new Error(`Gateway failed to start after ${retries} retries (port ${options.port})`);
|
||||
}
|
||||
|
||||
const GATEWAY_PROTOCOL_VERSION = 4;
|
||||
|
||||
export function buildGatewayConnectFrame(options: {
|
||||
challengeNonce: string;
|
||||
token: string;
|
||||
@@ -139,8 +152,8 @@ export function buildGatewayConnectFrame(options: {
|
||||
id: connectId,
|
||||
method: 'connect',
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
minProtocol: GATEWAY_PROTOCOL_VERSION,
|
||||
maxProtocol: GATEWAY_PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: clientId,
|
||||
displayName: 'ClawX',
|
||||
@@ -151,7 +164,7 @@ export function buildGatewayConnectFrame(options: {
|
||||
auth: {
|
||||
token: options.token,
|
||||
},
|
||||
caps: [],
|
||||
caps: ['tool-events'],
|
||||
role,
|
||||
scopes,
|
||||
device,
|
||||
@@ -168,9 +181,13 @@ export async function connectGatewaySocket(options: {
|
||||
getToken: () => Promise<string>;
|
||||
onHandshakeComplete: (ws: WebSocket) => void;
|
||||
onMessage: (message: unknown) => void;
|
||||
onCloseAfterHandshake: () => void;
|
||||
onCloseAfterHandshake: (code: number) => void;
|
||||
challengeTimeoutMs?: number;
|
||||
connectTimeoutMs?: number;
|
||||
}): Promise<WebSocket> {
|
||||
logger.debug(`Connecting Gateway WebSocket (ws://localhost:${options.port}/ws)`);
|
||||
const challengeTimeoutMs = options.challengeTimeoutMs ?? GATEWAY_CHALLENGE_TIMEOUT_MS;
|
||||
const connectTimeoutMs = options.connectTimeoutMs ?? GATEWAY_CONNECT_HANDSHAKE_TIMEOUT_MS;
|
||||
|
||||
return await new Promise<WebSocket>((resolve, reject) => {
|
||||
const wsUrl = `ws://localhost:${options.port}/ws`;
|
||||
@@ -211,6 +228,13 @@ export async function connectGatewaySocket(options: {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanupHandshakeRequest();
|
||||
if (!handshakeComplete) {
|
||||
try {
|
||||
ws.terminate();
|
||||
} catch {
|
||||
// ignore cleanup errors during failed startup handshakes
|
||||
}
|
||||
}
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
};
|
||||
|
||||
@@ -226,6 +250,12 @@ export async function connectGatewaySocket(options: {
|
||||
});
|
||||
connectId = connectPayload.connectId;
|
||||
|
||||
if (isGatewayWsTraceEnabled()) {
|
||||
logger.debug('[gateway-ws-trace] send', {
|
||||
summary: summarizeGatewayFrameForTrace(connectPayload.frame),
|
||||
frame: redactGatewayFrameForTrace(connectPayload.frame),
|
||||
});
|
||||
}
|
||||
ws.send(JSON.stringify(connectPayload.frame));
|
||||
|
||||
const requestTimeout = setTimeout(() => {
|
||||
@@ -234,7 +264,7 @@ export async function connectGatewaySocket(options: {
|
||||
ws.close();
|
||||
rejectOnce(new Error('Connect handshake timeout'));
|
||||
}
|
||||
}, 10000);
|
||||
}, connectTimeoutMs);
|
||||
handshakeTimeout = requestTimeout;
|
||||
|
||||
options.pendingRequests.set(connectId, {
|
||||
@@ -258,7 +288,7 @@ export async function connectGatewaySocket(options: {
|
||||
ws.close();
|
||||
rejectOnce(new Error('Timed out waiting for connect.challenge from Gateway'));
|
||||
}
|
||||
}, 10000);
|
||||
}, challengeTimeoutMs);
|
||||
|
||||
ws.on('open', () => {
|
||||
logger.debug('Gateway WebSocket opened, waiting for connect.challenge...');
|
||||
@@ -267,6 +297,12 @@ export async function connectGatewaySocket(options: {
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
if (isGatewayWsTraceEnabled()) {
|
||||
logger.debug('[gateway-ws-trace] recv', {
|
||||
summary: summarizeGatewayFrameForTrace(message),
|
||||
frame: redactGatewayFrameForTrace(message),
|
||||
});
|
||||
}
|
||||
if (
|
||||
!challengeReceived &&
|
||||
typeof message === 'object' && message !== null &&
|
||||
@@ -301,7 +337,7 @@ export async function connectGatewaySocket(options: {
|
||||
return;
|
||||
}
|
||||
cleanupHandshakeRequest();
|
||||
options.onCloseAfterHandshake();
|
||||
options.onCloseAfterHandshake(code);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
const SECRET_KEYS = new Set([
|
||||
'token',
|
||||
'authorization',
|
||||
'apikey',
|
||||
'api_key',
|
||||
'signature',
|
||||
'cookie',
|
||||
'set-cookie',
|
||||
'accesstoken',
|
||||
'refreshtoken',
|
||||
]);
|
||||
const CONFIG_WRITE_METHODS = new Set(['config.set', 'config.patch', 'config.apply']);
|
||||
|
||||
export function isGatewayWsTraceEnabled(): boolean {
|
||||
return process.env.CLAWX_GATEWAY_WS_TRACE === '1';
|
||||
}
|
||||
|
||||
export function redactGatewayFrameForTrace(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => redactGatewayFrameForTrace(item));
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const redactConfigRaw = typeof record.method === 'string' && CONFIG_WRITE_METHODS.has(record.method);
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(record)) {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
if (redactConfigRaw && key === 'params' && item && typeof item === 'object' && !Array.isArray(item)) {
|
||||
const params = redactGatewayFrameForTrace(item) as Record<string, unknown>;
|
||||
if (Object.hasOwn(params, 'raw')) params.raw = '[redacted]';
|
||||
result[key] = params;
|
||||
continue;
|
||||
}
|
||||
result[key] = SECRET_KEYS.has(normalizedKey)
|
||||
? '[redacted]'
|
||||
: redactGatewayFrameForTrace(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function summarizeGatewayFrameForTrace(value: unknown): string {
|
||||
if (!value || typeof value !== 'object') return typeof value;
|
||||
const frame = value as Record<string, unknown>;
|
||||
if (frame.type === 'req') {
|
||||
return `req id=${String(frame.id ?? '-')} method=${String(frame.method ?? '-')}`;
|
||||
}
|
||||
if (frame.type === 'res') {
|
||||
return `res id=${String(frame.id ?? '-')} ok=${String(frame.ok ?? !frame.error)}`;
|
||||
}
|
||||
if (frame.type === 'event') {
|
||||
return `event ${String(frame.event ?? '-')}`;
|
||||
}
|
||||
if (typeof frame.method === 'string') {
|
||||
return `jsonrpc method=${frame.method}`;
|
||||
}
|
||||
return 'unknown gateway frame';
|
||||
}
|
||||
+234
-115
@@ -2,13 +2,15 @@
|
||||
* Electron Main Process Entry
|
||||
* Manages window creation, system tray, and IPC handlers
|
||||
*/
|
||||
import { app, BrowserWindow, nativeImage, session, shell } from 'electron';
|
||||
import type { Server } from 'node:http';
|
||||
import { app, BrowserWindow, nativeImage, session, shell, type Session } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { GatewayManager } from '../gateway/manager';
|
||||
import { registerOpenClawConfigCoordinator } from '../gateway/config-delivery';
|
||||
import { registerIpcHandlers } from './ipc-handlers';
|
||||
import { HostApiRegistry } from './ipc/host-invoke';
|
||||
import { createTray } from './tray';
|
||||
import { createMenu } from './menu';
|
||||
import { registerZoomShortcuts } from './zoom-shortcuts';
|
||||
|
||||
import { appUpdater, registerUpdateHandlers } from './updater';
|
||||
import { logger } from '../utils/logger';
|
||||
@@ -16,11 +18,23 @@ import { warmupNetworkOptimization } from '../utils/uv-env';
|
||||
import { initTelemetry } from '../utils/telemetry';
|
||||
|
||||
import { ClawHubService } from '../gateway/clawhub';
|
||||
import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace';
|
||||
import { extensionRegistry } from '../extensions/registry';
|
||||
import { loadExtensionsFromManifest } from '../extensions/loader';
|
||||
import { registerAllBuiltinExtensions } from '../extensions/builtin';
|
||||
import { loadExternalMainExtensions } from '../extensions/_ext-bridge.generated';
|
||||
import {
|
||||
ensureClawXContext,
|
||||
ensureClawXDefaultIdentity,
|
||||
repairClawXOnlyBootstrapFiles,
|
||||
} from '../utils/openclaw-workspace';
|
||||
import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToProfile } from '../utils/openclaw-cli';
|
||||
import { isQuitting, setQuitting } from './app-state';
|
||||
import { getMacTrafficLightPosition, syncMacTrafficLightPosition } from './traffic-light-layout';
|
||||
import { getSetting } from '../utils/store';
|
||||
import { applyProxySettings } from './proxy';
|
||||
import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup';
|
||||
import { WebBrowserGuestRegistry, installWebBrowserGuestPolicy } from './web-browser-policy';
|
||||
import { configureWebBrowserSession } from './web-browser-session';
|
||||
import {
|
||||
clearPendingSecondInstanceFocus,
|
||||
consumeMainWindowReady,
|
||||
@@ -34,40 +48,33 @@ import {
|
||||
} from './quit-lifecycle';
|
||||
import { createSignalQuitHandler } from './signal-quit';
|
||||
import { acquireProcessInstanceFileLock } from './process-instance-lock';
|
||||
import { getSetting } from '../utils/store';
|
||||
import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '../utils/skill-config';
|
||||
import { ensureAllBundledPluginsInstalled } from '../utils/plugin-install';
|
||||
import { startHostApiServer } from '../api/server';
|
||||
import { HostEventBus } from '../api/event-bus';
|
||||
import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled, trimBundledOpenClawSkillsAndConfigs } from '../utils/skill-config';
|
||||
|
||||
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';
|
||||
|
||||
const WINDOWS_APP_USER_MODEL_ID = 'app.clawx.desktop';
|
||||
const isE2EMode = process.env.CLAWX_E2E === '1';
|
||||
const requestedUserDataDir = process.env.CLAWX_USER_DATA_DIR?.trim();
|
||||
const requestedRemoteDebuggingPort = process.env.CLAWX_REMOTE_DEBUGGING_PORT?.trim();
|
||||
|
||||
// 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();
|
||||
if (requestedRemoteDebuggingPort) {
|
||||
app.commandLine.appendSwitch('remote-debugging-port', requestedRemoteDebuggingPort);
|
||||
}
|
||||
|
||||
if (isE2EMode && requestedUserDataDir) {
|
||||
app.setPath('userData', requestedUserDataDir);
|
||||
}
|
||||
|
||||
// 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');
|
||||
const linuxApp = app as typeof app & { setDesktopName?: (desktopName: string) => void };
|
||||
linuxApp.setDesktopName?.('clawx.desktop');
|
||||
}
|
||||
|
||||
// Prevent multiple instances of the app from running simultaneously.
|
||||
@@ -75,18 +82,19 @@ if (process.platform === 'linux') {
|
||||
// same port, then each treats the other's gateway as "orphaned" and kills
|
||||
// it — creating an infinite kill/restart loop on Windows.
|
||||
// The losing process must exit immediately so it never reaches Gateway startup.
|
||||
const gotElectronLock = app.requestSingleInstanceLock();
|
||||
const gotElectronLock = isE2EMode ? true : app.requestSingleInstanceLock();
|
||||
if (!gotElectronLock) {
|
||||
console.info('[ClawX] Another instance already holds the single-instance lock; exiting duplicate process');
|
||||
app.exit(0);
|
||||
}
|
||||
let releaseProcessInstanceFileLock: () => void = () => {};
|
||||
let gotFileLock = true;
|
||||
if (gotElectronLock) {
|
||||
if (gotElectronLock && !isE2EMode) {
|
||||
try {
|
||||
const fileLock = acquireProcessInstanceFileLock({
|
||||
userDataDir: app.getPath('userData'),
|
||||
lockName: 'clawx',
|
||||
force: true, // Electron lock already guarantees exclusivity; force-clean orphan/recycled-PID locks
|
||||
});
|
||||
gotFileLock = fileLock.acquired;
|
||||
releaseProcessInstanceFileLock = fileLock.release;
|
||||
@@ -111,11 +119,18 @@ const gotTheLock = gotElectronLock && gotFileLock;
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let gatewayManager!: GatewayManager;
|
||||
let clawHubService!: ClawHubService;
|
||||
let hostEventBus!: HostEventBus;
|
||||
let hostApiServer: Server | null = null;
|
||||
const hostApiRegistry = new HostApiRegistry();
|
||||
const webBrowserGuestRegistry = new WebBrowserGuestRegistry();
|
||||
let webBrowserSession!: Session;
|
||||
const mainWindowFocusState = createMainWindowFocusState();
|
||||
const quitLifecycleState = createQuitLifecycleState();
|
||||
|
||||
function sendMainWindowEvent(channel: string, payload: unknown): void {
|
||||
const win = mainWindow;
|
||||
if (!win || win.isDestroyed()) return;
|
||||
win.webContents.send(channel, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the icons directory path (works in both dev and packaged mode)
|
||||
*/
|
||||
@@ -150,7 +165,6 @@ function createWindow(): BrowserWindow {
|
||||
const isMac = process.platform === 'darwin';
|
||||
const isWindows = process.platform === 'win32';
|
||||
const useCustomTitleBar = isWindows;
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
@@ -165,11 +179,20 @@ function createWindow(): BrowserWindow {
|
||||
webviewTag: true, // Enable <webview> for embedding OpenClaw Control UI
|
||||
},
|
||||
titleBarStyle: isMac ? 'hiddenInset' : useCustomTitleBar ? 'hidden' : 'default',
|
||||
trafficLightPosition: isMac ? { x: 16, y: 16 } : undefined,
|
||||
trafficLightPosition: isMac
|
||||
? getMacTrafficLightPosition(false)
|
||||
: undefined,
|
||||
frame: isMac || !useCustomTitleBar,
|
||||
show: false,
|
||||
});
|
||||
|
||||
installWebBrowserGuestPolicy(win.webContents, {
|
||||
browserSession: webBrowserSession,
|
||||
registry: webBrowserGuestRegistry,
|
||||
});
|
||||
|
||||
registerZoomShortcuts(win);
|
||||
|
||||
// Handle external links — only allow safe protocols to prevent arbitrary
|
||||
// command execution via shell.openExternal() (e.g. file://, ms-msdt:, etc.)
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
@@ -186,17 +209,30 @@ function createWindow(): BrowserWindow {
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
// Load the app
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(process.env.VITE_DEV_SERVER_URL);
|
||||
win.webContents.openDevTools();
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../../dist/index.html'));
|
||||
}
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
function loadMainWindow(win: BrowserWindow): void {
|
||||
const shouldSkipSetupForE2E = process.env.CLAWX_E2E_SKIP_SETUP === '1';
|
||||
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
const rendererUrl = new URL(process.env.VITE_DEV_SERVER_URL);
|
||||
if (shouldSkipSetupForE2E) {
|
||||
rendererUrl.searchParams.set('e2eSkipSetup', '1');
|
||||
}
|
||||
win.loadURL(rendererUrl.toString());
|
||||
if (!isE2EMode) {
|
||||
win.webContents.openDevTools();
|
||||
}
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../../dist/index.html'), {
|
||||
query: shouldSkipSetupForE2E
|
||||
? { e2eSkipSetup: '1' }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function focusWindow(win: BrowserWindow): void {
|
||||
if (win.isDestroyed()) {
|
||||
return;
|
||||
@@ -227,6 +263,12 @@ function createMainWindow(): BrowserWindow {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
void getSetting('sidebarCollapsed').then((sidebarCollapsed) => {
|
||||
syncMacTrafficLightPosition(win, sidebarCollapsed);
|
||||
});
|
||||
}
|
||||
|
||||
const action = consumeMainWindowReady(mainWindowFocusState);
|
||||
if (action === 'focus') {
|
||||
focusWindow(win);
|
||||
@@ -237,7 +279,7 @@ function createMainWindow(): BrowserWindow {
|
||||
});
|
||||
|
||||
win.on('close', (event) => {
|
||||
if (!isQuitting()) {
|
||||
if (!isQuitting() && !isE2EMode) {
|
||||
event.preventDefault();
|
||||
win.hide();
|
||||
}
|
||||
@@ -264,25 +306,31 @@ async function initialize(): Promise<void> {
|
||||
`Runtime: platform=${process.platform}/${process.arch}, electron=${process.versions.electron}, node=${process.versions.node}, packaged=${app.isPackaged}, pid=${process.pid}, ppid=${process.ppid}`
|
||||
);
|
||||
|
||||
// Warm up network optimization (non-blocking)
|
||||
void warmupNetworkOptimization();
|
||||
webBrowserSession = configureWebBrowserSession({
|
||||
registry: webBrowserGuestRegistry,
|
||||
getMainWindow: () => mainWindow,
|
||||
});
|
||||
|
||||
// Initialize Telemetry early
|
||||
await initTelemetry();
|
||||
if (!isE2EMode) {
|
||||
// Warm up network optimization (non-blocking)
|
||||
void warmupNetworkOptimization();
|
||||
|
||||
// Apply persisted proxy settings before creating windows or network requests.
|
||||
await applyProxySettings();
|
||||
await syncLaunchAtStartupSettingFromStore();
|
||||
// Initialize Telemetry early
|
||||
await initTelemetry();
|
||||
|
||||
// Apply persisted proxy settings before creating windows or network requests.
|
||||
await applyProxySettings();
|
||||
await syncLaunchAtStartupSettingFromStore();
|
||||
} else {
|
||||
logger.info('Running in E2E mode: startup side effects minimized');
|
||||
}
|
||||
|
||||
// Set application menu
|
||||
createMenu();
|
||||
await createMenu();
|
||||
|
||||
// Create the main window
|
||||
const window = createMainWindow();
|
||||
|
||||
// Create system tray
|
||||
createTray(window);
|
||||
|
||||
// Override security headers ONLY for the OpenClaw Gateway Control UI.
|
||||
// The URL filter ensures this callback only fires for gateway requests,
|
||||
// avoiding unnecessary overhead on every other HTTP response.
|
||||
@@ -307,52 +355,104 @@ async function initialize(): Promise<void> {
|
||||
);
|
||||
|
||||
// Register IPC handlers
|
||||
registerIpcHandlers(gatewayManager, clawHubService, window);
|
||||
|
||||
hostApiServer = startHostApiServer({
|
||||
registerIpcHandlers(
|
||||
gatewayManager,
|
||||
clawHubService,
|
||||
eventBus: hostEventBus,
|
||||
mainWindow: window,
|
||||
window,
|
||||
hostApiRegistry,
|
||||
webBrowserSession,
|
||||
webBrowserGuestRegistry,
|
||||
);
|
||||
|
||||
loadMainWindow(window);
|
||||
|
||||
// Create system tray
|
||||
if (!isE2EMode) {
|
||||
createTray(window);
|
||||
}
|
||||
|
||||
// Initialize extension system
|
||||
await extensionRegistry.initialize({
|
||||
gatewayManager,
|
||||
getMainWindow: () => mainWindow,
|
||||
hostApi: {
|
||||
register: (extensionId, contributions) => (
|
||||
hostApiRegistry.registerExtensionContributions(extensionId, contributions)
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
// Wire marketplace provider to ClawHubService if an extension provides one
|
||||
const marketplaceProvider = extensionRegistry.getMarketplaceProvider();
|
||||
if (marketplaceProvider) {
|
||||
clawHubService.setMarketplaceProvider(marketplaceProvider);
|
||||
}
|
||||
|
||||
// Register update handlers
|
||||
registerUpdateHandlers(appUpdater, window);
|
||||
|
||||
// Note: Auto-check for updates is driven by the renderer (update store init)
|
||||
// so it respects the user's "Auto-check for updates" setting.
|
||||
|
||||
// Seed a stable default IDENTITY.md before the Gateway initializes the
|
||||
// workspace so ClawX desktop sessions skip OpenClaw's chat-first bootstrap.
|
||||
if (!isE2EMode) {
|
||||
void ensureClawXDefaultIdentity().catch((error) => {
|
||||
logger.warn('Failed to seed default ClawX identity:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Repair any bootstrap files that only contain ClawX markers (no OpenClaw
|
||||
// template content). This fixes a race condition where ensureClawXContext()
|
||||
// previously created the file before the gateway could seed the full template.
|
||||
void repairClawXOnlyBootstrapFiles().catch((error) => {
|
||||
logger.warn('Failed to repair bootstrap files:', error);
|
||||
});
|
||||
if (!isE2EMode) {
|
||||
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);
|
||||
});
|
||||
if (!isE2EMode) {
|
||||
void ensureBuiltinSkillsInstalled().catch((error) => {
|
||||
logger.warn('Failed to install built-in skills:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Keep community builds aligned with Clawx-biz by physically trimming
|
||||
// bundled OpenClaw consumer skills on startup (dev + packaged), keeping only
|
||||
// `skill-creator`. This also prunes stale openclaw.json entries for trimmed
|
||||
// bundled skills so we do not keep `enabled: false` config for skills that no
|
||||
// longer exist.
|
||||
if (!isE2EMode) {
|
||||
void trimBundledOpenClawSkillsAndConfigs().then(({ removed, removedConfigs, kept }) => {
|
||||
if (removed > 0 || removedConfigs > 0) {
|
||||
logger.info(
|
||||
`Trimmed bundled OpenClaw skills: removed ${removed}, pruned configs ${removedConfigs}, kept ${kept.join(', ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Pre-deploy bundled third-party skills from resources/preinstalled-skills.
|
||||
// This installs full skill directories (not only SKILL.md) in an idempotent,
|
||||
// non-destructive way and never blocks startup.
|
||||
void ensurePreinstalledSkillsInstalled().catch((error) => {
|
||||
logger.warn('Failed to install preinstalled skills:', error);
|
||||
});
|
||||
if (!isE2EMode) {
|
||||
void ensurePreinstalledSkillsInstalled().catch((error) => {
|
||||
logger.warn('Failed to install preinstalled skills:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Pre-deploy/upgrade bundled OpenClaw plugins (dingtalk, wecom, qqbot, feishu, wechat)
|
||||
// to ~/.openclaw/extensions/ so they are always up-to-date after an app update.
|
||||
void ensureAllBundledPluginsInstalled().catch((error) => {
|
||||
logger.warn('Failed to install/upgrade bundled plugins:', error);
|
||||
});
|
||||
// Plugin installation is now configuration-driven:
|
||||
// - When a channel is added via UI: ensureXxxPluginInstalled() in IPC handlers
|
||||
// - When Gateway starts: ensureConfiguredPluginsUpgraded() in config-sync.ts
|
||||
// No need to pre-install all bundled plugins at app startup.
|
||||
|
||||
// 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') {
|
||||
sendMainWindowEvent('gateway:status-changed', status);
|
||||
if (status.state === 'running' && !isE2EMode) {
|
||||
void ensureClawXContext().catch((error) => {
|
||||
logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error);
|
||||
});
|
||||
@@ -360,72 +460,76 @@ async function initialize(): Promise<void> {
|
||||
});
|
||||
|
||||
gatewayManager.on('error', (error) => {
|
||||
hostEventBus.emit('gateway:error', { message: error.message });
|
||||
sendMainWindowEvent('gateway:error', { message: error.message });
|
||||
});
|
||||
|
||||
gatewayManager.on('notification', (notification) => {
|
||||
hostEventBus.emit('gateway:notification', notification);
|
||||
sendMainWindowEvent('gateway:notification', notification);
|
||||
});
|
||||
|
||||
gatewayManager.on('gateway:health', (data) => {
|
||||
sendMainWindowEvent('gateway:health-changed', data);
|
||||
});
|
||||
|
||||
gatewayManager.on('gateway:presence', (data) => {
|
||||
sendMainWindowEvent('gateway:presence-changed', data);
|
||||
});
|
||||
|
||||
gatewayManager.on('chat:message', (data) => {
|
||||
hostEventBus.emit('gateway:chat-message', data);
|
||||
sendMainWindowEvent('gateway:chat-message', data);
|
||||
});
|
||||
|
||||
gatewayManager.on('chat:runtime-event', (data) => {
|
||||
sendMainWindowEvent('chat:runtime-event', data);
|
||||
});
|
||||
|
||||
gatewayManager.on('channel:status', (data) => {
|
||||
hostEventBus.emit('gateway:channel-status', data);
|
||||
sendMainWindowEvent('gateway:channel-status', data);
|
||||
});
|
||||
|
||||
gatewayManager.on('exit', (code) => {
|
||||
hostEventBus.emit('gateway:exit', { code });
|
||||
sendMainWindowEvent('gateway:exit', { code });
|
||||
});
|
||||
|
||||
deviceOAuthManager.on('oauth:code', (payload) => {
|
||||
hostEventBus.emit('oauth:code', payload);
|
||||
});
|
||||
|
||||
deviceOAuthManager.on('oauth:start', (payload) => {
|
||||
hostEventBus.emit('oauth:start', payload);
|
||||
sendMainWindowEvent('oauth:code', payload);
|
||||
});
|
||||
|
||||
deviceOAuthManager.on('oauth:success', (payload) => {
|
||||
hostEventBus.emit('oauth:success', { ...payload, success: true });
|
||||
sendMainWindowEvent('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);
|
||||
sendMainWindowEvent('oauth:error', error);
|
||||
});
|
||||
|
||||
browserOAuthManager.on('oauth:code', (payload) => {
|
||||
hostEventBus.emit('oauth:code', payload);
|
||||
sendMainWindowEvent('oauth:code', payload);
|
||||
});
|
||||
|
||||
browserOAuthManager.on('oauth:success', (payload) => {
|
||||
hostEventBus.emit('oauth:success', { ...payload, success: true });
|
||||
sendMainWindowEvent('oauth:success', { ...payload, success: true });
|
||||
});
|
||||
|
||||
browserOAuthManager.on('oauth:error', (error) => {
|
||||
hostEventBus.emit('oauth:error', error);
|
||||
sendMainWindowEvent('oauth:error', error);
|
||||
});
|
||||
|
||||
whatsAppLoginManager.on('qr', (data) => {
|
||||
hostEventBus.emit('channel:whatsapp-qr', data);
|
||||
sendMainWindowEvent('channel:whatsapp-qr', data);
|
||||
});
|
||||
|
||||
whatsAppLoginManager.on('success', (data) => {
|
||||
hostEventBus.emit('channel:whatsapp-success', data);
|
||||
sendMainWindowEvent('channel:whatsapp-success', data);
|
||||
});
|
||||
|
||||
whatsAppLoginManager.on('error', (error) => {
|
||||
hostEventBus.emit('channel:whatsapp-error', error);
|
||||
sendMainWindowEvent('channel:whatsapp-error', error);
|
||||
});
|
||||
|
||||
// Start Gateway automatically (this seeds missing bootstrap files with full templates)
|
||||
const gatewayAutoStart = await getSetting('gatewayAutoStart');
|
||||
if (gatewayAutoStart) {
|
||||
if (!isE2EMode && gatewayAutoStart) {
|
||||
try {
|
||||
await syncAllProviderAuthToRuntime();
|
||||
logger.debug('Auto-starting Gateway...');
|
||||
@@ -435,6 +539,8 @@ async function initialize(): Promise<void> {
|
||||
logger.error('Gateway auto-start failed:', error);
|
||||
mainWindow?.webContents.send('gateway:error', String(error));
|
||||
}
|
||||
} else if (isE2EMode) {
|
||||
logger.info('Gateway auto-start skipped in E2E mode');
|
||||
} else {
|
||||
logger.info('Gateway auto-start disabled in settings');
|
||||
}
|
||||
@@ -442,19 +548,23 @@ async function initialize(): Promise<void> {
|
||||
// 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);
|
||||
});
|
||||
if (!isE2EMode) {
|
||||
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);
|
||||
});
|
||||
if (!isE2EMode) {
|
||||
void autoInstallCliIfNeeded((installedPath) => {
|
||||
mainWindow?.webContents.send('openclaw:cli-installed', installedPath);
|
||||
}).then(() => {
|
||||
generateCompletionCache();
|
||||
installCompletionToProfile();
|
||||
}).catch((error) => {
|
||||
logger.warn('CLI auto-install failed:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (gotTheLock) {
|
||||
@@ -479,8 +589,15 @@ if (gotTheLock) {
|
||||
}
|
||||
|
||||
gatewayManager = new GatewayManager();
|
||||
registerOpenClawConfigCoordinator(gatewayManager);
|
||||
clawHubService = new ClawHubService();
|
||||
hostEventBus = new HostEventBus();
|
||||
|
||||
// Register builtin extensions and load manifest
|
||||
registerAllBuiltinExtensions();
|
||||
loadExternalMainExtensions();
|
||||
void loadExtensionsFromManifest().catch((err) => {
|
||||
logger.warn('Failed to load extensions from manifest:', err);
|
||||
});
|
||||
|
||||
// When a second instance is launched, focus the existing window instead.
|
||||
app.on('second-instance', () => {
|
||||
@@ -500,16 +617,19 @@ if (gotTheLock) {
|
||||
});
|
||||
|
||||
// Application lifecycle
|
||||
app.whenReady().then(() => {
|
||||
void initialize().catch((error) => {
|
||||
app.whenReady().then(async () => {
|
||||
try {
|
||||
await initialize();
|
||||
} catch (error) {
|
||||
logger.error('Application initialization failed:', error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Register activate handler AFTER app is ready to prevent
|
||||
// "Cannot create BrowserWindow before app is ready" on macOS.
|
||||
// Register only after initialization so activation cannot race the initial
|
||||
// window or claim the single browser guest before host handlers are ready.
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createMainWindow();
|
||||
loadMainWindow(createMainWindow());
|
||||
} else {
|
||||
focusMainWindow();
|
||||
}
|
||||
@@ -517,7 +637,7 @@ if (gotTheLock) {
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
if (process.platform !== 'darwin' || isE2EMode) {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
@@ -537,8 +657,7 @@ if (gotTheLock) {
|
||||
return;
|
||||
}
|
||||
|
||||
hostEventBus.closeAll();
|
||||
hostApiServer?.close();
|
||||
void extensionRegistry.teardownAll();
|
||||
|
||||
const stopPromise = gatewayManager.stop().catch((err) => {
|
||||
logger.warn('gatewayManager.stop() error during quit:', err);
|
||||
|
||||
+580
-1512
File diff suppressed because it is too large
Load Diff
@@ -1,74 +0,0 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
||||
import { PORTS } from '../../utils/config';
|
||||
import { getHostApiToken } from '../../api/server';
|
||||
|
||||
type HostApiFetchRequest = {
|
||||
path: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
export function registerHostApiProxyHandlers(): void {
|
||||
// Expose the per-session auth token to the renderer so the browser-fallback
|
||||
// path in host-api.ts can authenticate against the Host API server.
|
||||
ipcMain.handle('hostapi:token', () => getHostApiToken());
|
||||
|
||||
ipcMain.handle('hostapi:fetch', async (_, request: HostApiFetchRequest) => {
|
||||
try {
|
||||
const path = typeof request?.path === 'string' ? request.path : '';
|
||||
if (!path || !path.startsWith('/')) {
|
||||
throw new Error(`Invalid host API path: ${String(request?.path)}`);
|
||||
}
|
||||
|
||||
const method = (request.method || 'GET').toUpperCase();
|
||||
const headers: Record<string, string> = { ...(request.headers || {}) };
|
||||
// Inject the per-session auth token so the Host API server accepts this request.
|
||||
headers['Authorization'] = `Bearer ${getHostApiToken()}`;
|
||||
let body: string | undefined;
|
||||
|
||||
if (request.body !== undefined && request.body !== null) {
|
||||
if (typeof request.body === 'string') {
|
||||
body = request.body;
|
||||
} else {
|
||||
body = JSON.stringify(request.body);
|
||||
}
|
||||
// Ensure Content-Type is set for requests with a body so the
|
||||
// server's anti-CSRF Content-Type gate does not reject them.
|
||||
if (!headers['Content-Type'] && !headers['content-type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
}
|
||||
|
||||
const response = await proxyAwareFetch(`http://127.0.0.1:${PORTS.CLAWX_HOST_API}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
const data: { status: number; ok: boolean; json?: unknown; text?: string } = {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
};
|
||||
|
||||
if (response.status !== 204) {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
data.json = await response.json().catch(() => undefined);
|
||||
} else {
|
||||
data.text = await response.text().catch(() => '');
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, data };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { HostApiContract } from '@shared/host-api/contract';
|
||||
|
||||
export type HostRequest = {
|
||||
id: string;
|
||||
module: string;
|
||||
action: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
export type HostErrorCode = 'VALIDATION' | 'UNSUPPORTED' | 'INTERNAL';
|
||||
|
||||
export type HostResponse<T = unknown> =
|
||||
| { id?: string; ok: true; data: T }
|
||||
| { id?: string; ok: false; error: { code: HostErrorCode; message: string; details?: unknown } };
|
||||
|
||||
export type RuntimeHostAction = (payload?: unknown) => Promise<unknown> | unknown;
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
type HostServiceFunction<TFunction> = TFunction extends (...args: infer Args) => infer Result
|
||||
? (...args: Args) => MaybePromise<Awaited<Result>>
|
||||
: never;
|
||||
|
||||
type HostServiceModule<TModule> = {
|
||||
[A in keyof TModule]: HostServiceFunction<TModule[A]>;
|
||||
};
|
||||
|
||||
export type HostServiceRegistry = {
|
||||
[M in keyof HostApiContract]?: Partial<HostServiceModule<HostApiContract[M]>>;
|
||||
};
|
||||
export type CompleteHostServiceRegistry = {
|
||||
[M in keyof HostApiContract]: HostServiceModule<HostApiContract[M]>;
|
||||
};
|
||||
|
||||
export type HostApiContribution = {
|
||||
module: string;
|
||||
actions: Record<string, RuntimeHostAction>;
|
||||
};
|
||||
|
||||
export type HostApiContributionRegistrar = {
|
||||
register: (extensionId: string, contributions: HostApiContribution[]) => () => void;
|
||||
};
|
||||
|
||||
export function isHostRequest(value: unknown): value is HostRequest {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
return typeof record.id === 'string'
|
||||
&& record.id.length > 0
|
||||
&& typeof record.module === 'string'
|
||||
&& record.module.length > 0
|
||||
&& typeof record.action === 'string'
|
||||
&& record.action.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import {
|
||||
type HostApiContribution,
|
||||
type HostResponse,
|
||||
type HostServiceRegistry,
|
||||
type RuntimeHostAction,
|
||||
isHostRequest,
|
||||
} from './host-contract';
|
||||
|
||||
type RegisteredHostAction = {
|
||||
action: RuntimeHostAction;
|
||||
ownerId: string;
|
||||
};
|
||||
|
||||
function assertValidContributionKey(kind: 'module' | 'action', value: string): void {
|
||||
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(value)) {
|
||||
throw new Error(`Invalid host API ${kind}: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class HostApiRegistry {
|
||||
private modules = new Map<string, Map<string, RegisteredHostAction>>();
|
||||
|
||||
registerCoreServices(services: HostServiceRegistry): void {
|
||||
for (const [moduleName, actions] of Object.entries(services)) {
|
||||
if (!actions || typeof actions !== 'object') continue;
|
||||
for (const [actionName, action] of Object.entries(actions)) {
|
||||
if (typeof action !== 'function') continue;
|
||||
this.registerAction(moduleName, actionName, action as RuntimeHostAction, 'core');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerExtensionContributions(extensionId: string, contributions: HostApiContribution[]): () => void {
|
||||
const registered: Array<{ module: string; action: string }> = [];
|
||||
|
||||
for (const contribution of contributions) {
|
||||
assertValidContributionKey('module', contribution.module);
|
||||
for (const [actionName, action] of Object.entries(contribution.actions)) {
|
||||
assertValidContributionKey('action', actionName);
|
||||
this.registerAction(contribution.module, actionName, action, extensionId);
|
||||
registered.push({ module: contribution.module, action: actionName });
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const { module, action } of registered) {
|
||||
const moduleActions = this.modules.get(module);
|
||||
const registeredAction = moduleActions?.get(action);
|
||||
if (registeredAction?.ownerId === extensionId) {
|
||||
moduleActions?.delete(action);
|
||||
}
|
||||
if (moduleActions?.size === 0) {
|
||||
this.modules.delete(module);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
resolve(moduleName: string, actionName: string): RuntimeHostAction | undefined {
|
||||
return this.modules.get(moduleName)?.get(actionName)?.action;
|
||||
}
|
||||
|
||||
private registerAction(
|
||||
moduleName: string,
|
||||
actionName: string,
|
||||
action: RuntimeHostAction,
|
||||
ownerId: string,
|
||||
): void {
|
||||
const moduleActions = this.modules.get(moduleName) ?? new Map<string, RegisteredHostAction>();
|
||||
if (moduleActions.has(actionName)) {
|
||||
throw new Error(`Host API action already registered: ${moduleName}.${actionName}`);
|
||||
}
|
||||
moduleActions.set(actionName, { action, ownerId });
|
||||
this.modules.set(moduleName, moduleActions);
|
||||
}
|
||||
}
|
||||
|
||||
function toHostApiRegistry(registryOrServices: HostApiRegistry | HostServiceRegistry): HostApiRegistry {
|
||||
if (registryOrServices instanceof HostApiRegistry) {
|
||||
return registryOrServices;
|
||||
}
|
||||
const registry = new HostApiRegistry();
|
||||
registry.registerCoreServices(registryOrServices);
|
||||
return registry;
|
||||
}
|
||||
|
||||
export function createHostInvokeDispatcher(registryOrServices: HostApiRegistry | HostServiceRegistry) {
|
||||
const registry = toHostApiRegistry(registryOrServices);
|
||||
return async function dispatchHostRequest(request: unknown): Promise<HostResponse> {
|
||||
const requestId = request && typeof request === 'object'
|
||||
? String((request as Record<string, unknown>).id ?? '')
|
||||
: undefined;
|
||||
|
||||
if (!isHostRequest(request)) {
|
||||
return {
|
||||
id: requestId,
|
||||
ok: false,
|
||||
error: { code: 'VALIDATION', message: 'Invalid host request format' },
|
||||
};
|
||||
}
|
||||
|
||||
const action = registry.resolve(request.module, request.action);
|
||||
if (typeof action !== 'function') {
|
||||
return {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'UNSUPPORTED',
|
||||
message: `Unsupported host request: ${request.module}.${request.action}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await action(request.payload);
|
||||
return { id: request.id, ok: true, data };
|
||||
} catch (error) {
|
||||
return {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'INTERNAL',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function registerHostInvokeHandler(registry: HostApiRegistry): void {
|
||||
const dispatch = createHostInvokeDispatcher(registry);
|
||||
ipcMain.handle('host:invoke', async (_event, request: unknown) => dispatch(request));
|
||||
}
|
||||
+81
-56
@@ -3,12 +3,33 @@
|
||||
* Creates the native application menu for macOS/Windows/Linux
|
||||
*/
|
||||
import { Menu, app, shell, BrowserWindow } from 'electron';
|
||||
import { MENU_LABELS } from '@shared/i18n/resources';
|
||||
import { resolveSupportedLanguage, type LanguageCode } from '@shared/language';
|
||||
import { getSetting } from '../utils/store';
|
||||
|
||||
function applyAppName(label: string): string {
|
||||
return label.replaceAll('{{appName}}', app.name);
|
||||
}
|
||||
|
||||
async function resolveMenuLanguage(language?: string): Promise<LanguageCode> {
|
||||
if (language) return resolveSupportedLanguage(language);
|
||||
try {
|
||||
return resolveSupportedLanguage(await getSetting('language'));
|
||||
} catch {
|
||||
return resolveSupportedLanguage(app.getLocale());
|
||||
}
|
||||
}
|
||||
|
||||
function getMenuTargetWindow(): BrowserWindow | null {
|
||||
return BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create application menu
|
||||
*/
|
||||
export function createMenu(): void {
|
||||
export async function createMenu(language?: string): Promise<void> {
|
||||
const isMac = process.platform === 'darwin';
|
||||
const labels = MENU_LABELS[await resolveMenuLanguage(language)];
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
// App menu (macOS only)
|
||||
@@ -17,24 +38,24 @@ export function createMenu(): void {
|
||||
{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: 'about' as const },
|
||||
{ role: 'about' as const, label: applyAppName(labels.app.about) },
|
||||
{ type: 'separator' as const },
|
||||
{
|
||||
label: 'Preferences...',
|
||||
label: labels.app.preferences,
|
||||
accelerator: 'Cmd+,',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
const win = getMenuTargetWindow();
|
||||
win?.webContents.send('navigate', '/settings');
|
||||
},
|
||||
},
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'services' as const },
|
||||
{ role: 'services' as const, label: labels.app.services },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'hide' as const },
|
||||
{ role: 'hideOthers' as const },
|
||||
{ role: 'unhide' as const },
|
||||
{ role: 'hide' as const, label: applyAppName(labels.app.hide) },
|
||||
{ role: 'hideOthers' as const, label: labels.app.hideOthers },
|
||||
{ role: 'unhide' as const, label: labels.app.unhide },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'quit' as const },
|
||||
{ role: 'quit' as const, label: applyAppName(labels.app.quit) },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -42,110 +63,113 @@ export function createMenu(): void {
|
||||
|
||||
// File menu
|
||||
{
|
||||
label: 'File',
|
||||
label: labels.file.label,
|
||||
submenu: [
|
||||
{
|
||||
label: 'New Chat',
|
||||
id: 'new-chat',
|
||||
label: labels.file.newChat,
|
||||
accelerator: 'CmdOrCtrl+N',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/chat');
|
||||
const win = getMenuTargetWindow();
|
||||
win?.webContents.send('new-chat');
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
isMac ? { role: 'close' } : { role: 'quit' },
|
||||
isMac
|
||||
? { role: 'close', label: labels.file.close }
|
||||
: { role: 'quit', label: applyAppName(labels.app.quit) },
|
||||
],
|
||||
},
|
||||
|
||||
// Edit menu
|
||||
{
|
||||
label: 'Edit',
|
||||
label: labels.edit.label,
|
||||
submenu: [
|
||||
{ role: 'undo' },
|
||||
{ role: 'redo' },
|
||||
{ role: 'undo', label: labels.edit.undo },
|
||||
{ role: 'redo', label: labels.edit.redo },
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
{ role: 'copy' },
|
||||
{ role: 'paste' },
|
||||
{ role: 'cut', label: labels.edit.cut },
|
||||
{ role: 'copy', label: labels.edit.copy },
|
||||
{ role: 'paste', label: labels.edit.paste },
|
||||
...(isMac
|
||||
? [
|
||||
{ role: 'pasteAndMatchStyle' as const },
|
||||
{ role: 'delete' as const },
|
||||
{ role: 'selectAll' as const },
|
||||
{ role: 'pasteAndMatchStyle' as const, label: labels.edit.pasteAndMatchStyle },
|
||||
{ role: 'delete' as const, label: labels.edit.delete },
|
||||
{ role: 'selectAll' as const, label: labels.edit.selectAll },
|
||||
]
|
||||
: [
|
||||
{ role: 'delete' as const },
|
||||
{ role: 'delete' as const, label: labels.edit.delete },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'selectAll' as const },
|
||||
{ role: 'selectAll' as const, label: labels.edit.selectAll },
|
||||
]),
|
||||
],
|
||||
},
|
||||
|
||||
// View menu
|
||||
{
|
||||
label: 'View',
|
||||
label: labels.view.label,
|
||||
submenu: [
|
||||
{ role: 'reload' },
|
||||
{ role: 'forceReload' },
|
||||
{ role: 'toggleDevTools' },
|
||||
{ role: 'reload', label: labels.view.reload },
|
||||
{ role: 'forceReload', label: labels.view.forceReload },
|
||||
{ role: 'toggleDevTools', label: labels.view.toggleDevTools },
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
{ role: 'zoomOut' },
|
||||
{ role: 'resetZoom', label: labels.view.resetZoom },
|
||||
{ role: 'zoomIn', label: labels.view.zoomIn },
|
||||
{ role: 'zoomOut', label: labels.view.zoomOut },
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' },
|
||||
{ role: 'togglefullscreen', label: labels.view.toggleFullscreen },
|
||||
],
|
||||
},
|
||||
|
||||
// Navigate menu
|
||||
{
|
||||
label: 'Navigate',
|
||||
label: labels.navigate.label,
|
||||
submenu: [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
label: labels.navigate.dashboard,
|
||||
accelerator: 'CmdOrCtrl+1',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
const win = getMenuTargetWindow();
|
||||
win?.webContents.send('navigate', '/');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Chat',
|
||||
label: labels.navigate.chat,
|
||||
accelerator: 'CmdOrCtrl+2',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/chat');
|
||||
const win = getMenuTargetWindow();
|
||||
win?.webContents.send('navigate', '/');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Channels',
|
||||
label: labels.navigate.channels,
|
||||
accelerator: 'CmdOrCtrl+3',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
const win = getMenuTargetWindow();
|
||||
win?.webContents.send('navigate', '/channels');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Skills',
|
||||
label: labels.navigate.skills,
|
||||
accelerator: 'CmdOrCtrl+4',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
const win = getMenuTargetWindow();
|
||||
win?.webContents.send('navigate', '/skills');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Cron Tasks',
|
||||
label: labels.navigate.cronTasks,
|
||||
accelerator: 'CmdOrCtrl+5',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
const win = getMenuTargetWindow();
|
||||
win?.webContents.send('navigate', '/cron');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
label: labels.navigate.settings,
|
||||
accelerator: isMac ? 'Cmd+,' : 'Ctrl+,',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
const win = getMenuTargetWindow();
|
||||
win?.webContents.send('navigate', '/settings');
|
||||
},
|
||||
},
|
||||
@@ -154,40 +178,41 @@ export function createMenu(): void {
|
||||
|
||||
// Window menu
|
||||
{
|
||||
label: 'Window',
|
||||
label: labels.window.label,
|
||||
submenu: [
|
||||
{ role: 'minimize' },
|
||||
{ role: 'zoom' },
|
||||
{ role: 'minimize', label: labels.window.minimize },
|
||||
{ role: 'zoom', label: labels.window.zoom },
|
||||
...(isMac
|
||||
? [
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'front' as const },
|
||||
{ role: 'front' as const, label: labels.window.front },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'window' as const },
|
||||
{ role: 'window' as const, label: labels.window.label },
|
||||
]
|
||||
: [{ role: 'close' as const }]),
|
||||
: [{ role: 'close' as const, label: labels.window.close }]),
|
||||
],
|
||||
},
|
||||
|
||||
// Help menu
|
||||
{
|
||||
role: 'help',
|
||||
label: labels.help.label,
|
||||
submenu: [
|
||||
{
|
||||
label: 'Documentation',
|
||||
label: labels.help.documentation,
|
||||
click: async () => {
|
||||
await shell.openExternal('https://claw-x.com');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Report Issue',
|
||||
label: labels.help.reportIssue,
|
||||
click: async () => {
|
||||
await shell.openExternal('https://github.com/ValueCell-ai/ClawX/issues');
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'OpenClaw Documentation',
|
||||
label: labels.help.openClawDocumentation,
|
||||
click: async () => {
|
||||
await shell.openExternal('https://docs.openclaw.ai');
|
||||
},
|
||||
|
||||
@@ -17,6 +17,14 @@ export interface ProcessInstanceFileLockOptions {
|
||||
lockName: string;
|
||||
pid?: number;
|
||||
isPidAlive?: (pid: number) => boolean;
|
||||
/**
|
||||
* When true, unconditionally remove any existing lock file before attempting
|
||||
* to acquire. Use this when an external mechanism (e.g. Electron's
|
||||
* `requestSingleInstanceLock`) already guarantees that no other real instance
|
||||
* is running, so a surviving lock file can only be stale (orphan child
|
||||
* process, PID recycling on Windows, etc.).
|
||||
*/
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
function defaultPidAlive(pid: number): boolean {
|
||||
@@ -101,6 +109,23 @@ export function acquireProcessInstanceFileLock(
|
||||
mkdirSync(options.userDataDir, { recursive: true });
|
||||
const lockPath = join(options.userDataDir, `${options.lockName}.instance.lock`);
|
||||
|
||||
// When force mode is enabled, unconditionally remove any existing lock file
|
||||
// before attempting acquisition. This is safe because an external mechanism
|
||||
// (Electron's requestSingleInstanceLock) already guarantees exclusivity.
|
||||
if (options.force && existsSync(lockPath)) {
|
||||
const staleOwner = readLockOwner(lockPath);
|
||||
try {
|
||||
rmSync(lockPath, { force: true });
|
||||
} catch {
|
||||
// best-effort; fall through to normal acquisition
|
||||
}
|
||||
if (staleOwner.kind !== 'unknown') {
|
||||
console.info(
|
||||
`[ClawX] Force-cleaned stale instance lock (pid=${staleOwner.pid}, format=${staleOwner.kind})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let ownerPid: number | undefined;
|
||||
let ownerFormat: ProcessInstanceFileLock['ownerFormat'] = 'unknown';
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { getProviderConfig } from '../utils/provider-registry';
|
||||
import { getOpenClawProviderKeyForType, isOAuthProviderType } from '../utils/provider-keys';
|
||||
import type { ProviderConfig } from '../utils/secure-storage';
|
||||
import {
|
||||
piAiModelsJsonModelEntry,
|
||||
type PiAiModelCostRates,
|
||||
} from '../shared/pi-ai-model-cost';
|
||||
|
||||
export interface AgentProviderUpdatePayload {
|
||||
providerKey: string;
|
||||
@@ -8,7 +12,7 @@ export interface AgentProviderUpdatePayload {
|
||||
baseUrl: string;
|
||||
api: string;
|
||||
apiKey: string | undefined;
|
||||
models: Array<{ id: string; name: string }>;
|
||||
models: Array<{ id: string; name: string; cost: PiAiModelCostRates }>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,7 +46,7 @@ export function buildNonOAuthAgentProviderUpdate(
|
||||
baseUrl,
|
||||
api,
|
||||
apiKey: meta?.apiKeyEnv,
|
||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||
models: modelId ? [piAiModelsJsonModelEntry(modelId)] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,14 @@ import { buildElectronProxyConfig } from '../utils/proxy';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export async function applyProxySettings(
|
||||
partialSettings?: Pick<AppSettings, 'proxyEnabled' | 'proxyServer' | 'proxyBypassRules'>
|
||||
partialSettings?: Pick<AppSettings,
|
||||
| 'proxyEnabled'
|
||||
| 'proxyServer'
|
||||
| 'proxyHttpServer'
|
||||
| 'proxyHttpsServer'
|
||||
| 'proxyAllServer'
|
||||
| 'proxyBypassRules'
|
||||
>,
|
||||
): Promise<void> {
|
||||
const settings = partialSettings ?? await getAllSettings();
|
||||
const config = buildElectronProxyConfig(settings);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { release } from 'node:os';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
const MAC_SIDEBAR_CHROME_HEIGHT = 28;
|
||||
const MAC_TRAFFIC_LIGHT_GAP = 8;
|
||||
const MAC_TRAFFIC_LIGHT_FRAME_HEIGHT = 16;
|
||||
const MAC_TRAFFIC_LIGHT_FRAME_HEIGHT_TAHOE = 14;
|
||||
|
||||
function getMacTrafficLightFrameHeight(darwinMajor: number): number {
|
||||
return darwinMajor >= 25
|
||||
? MAC_TRAFFIC_LIGHT_FRAME_HEIGHT_TAHOE
|
||||
: MAC_TRAFFIC_LIGHT_FRAME_HEIGHT;
|
||||
}
|
||||
|
||||
function getMacTrafficLightChromeOffset(buttonFrameHeight: number): number {
|
||||
return Math.floor((MAC_SIDEBAR_CHROME_HEIGHT - buttonFrameHeight) / 2);
|
||||
}
|
||||
|
||||
export function getMacTrafficLightPosition(sidebarCollapsed: boolean): { x: number; y: number } {
|
||||
const darwinMajor = Number.parseInt(release().split('.')[0] ?? '0', 10);
|
||||
const buttonFrameHeight = getMacTrafficLightFrameHeight(darwinMajor);
|
||||
const offset = getMacTrafficLightChromeOffset(buttonFrameHeight);
|
||||
|
||||
if (sidebarCollapsed) {
|
||||
return { x: MAC_TRAFFIC_LIGHT_GAP, y: Math.max(MAC_TRAFFIC_LIGHT_GAP, offset) };
|
||||
}
|
||||
|
||||
return { x: offset + 1, y: offset };
|
||||
}
|
||||
|
||||
export function syncMacTrafficLightPosition(
|
||||
win: BrowserWindow,
|
||||
sidebarCollapsed: boolean,
|
||||
): void {
|
||||
if (process.platform !== 'darwin' || win.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
win.setWindowButtonPosition(getMacTrafficLightPosition(sidebarCollapsed));
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export class AppUpdater extends EventEmitter {
|
||||
});
|
||||
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
|
||||
autoUpdater.logger = {
|
||||
info: (msg: string) => logger.info('[Updater]', msg),
|
||||
@@ -131,10 +131,6 @@ 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) => {
|
||||
@@ -234,7 +230,7 @@ export class AppUpdater extends EventEmitter {
|
||||
* Start a countdown that auto-installs the downloaded update.
|
||||
* Sends `update:auto-install-countdown` events to the renderer each second.
|
||||
*/
|
||||
private startAutoInstallCountdown(): void {
|
||||
startAutoInstallCountdown(): void {
|
||||
this.clearAutoInstallTimer();
|
||||
this.autoInstallCountdown = AppUpdater.AUTO_INSTALL_DELAY_SECONDS;
|
||||
this.sendToRenderer('update:auto-install-countdown', { seconds: this.autoInstallCountdown });
|
||||
@@ -270,10 +266,15 @@ export class AppUpdater extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set auto-download preference
|
||||
* Set auto-download preference.
|
||||
*
|
||||
* ClawX uses a prompt-first update flow: finding an update shows a UI prompt,
|
||||
* and downloads/installations only start after the user chooses an action.
|
||||
* Keep this legacy IPC method as a no-op-compatible setter so stale renderer
|
||||
* settings cannot re-enable electron-updater's implicit auto-download path.
|
||||
*/
|
||||
setAutoDownload(enable: boolean): void {
|
||||
autoUpdater.autoDownload = enable;
|
||||
setAutoDownload(_enable: boolean): void {
|
||||
autoUpdater.autoDownload = false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { Session, WebContents, WebPreferences } from 'electron';
|
||||
import {
|
||||
WEB_BROWSER_INITIAL_URL,
|
||||
WEB_BROWSER_PARTITION,
|
||||
WEB_BROWSER_USER_AGENT,
|
||||
normalizeWebBrowserHtmlFileUrl,
|
||||
} from '../../shared/web-browser';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const DENY_WINDOW_OPEN = { action: 'deny' } as const;
|
||||
const INERT_LINK_CSS = `
|
||||
a, area {
|
||||
color: inherit !important;
|
||||
cursor: inherit !important;
|
||||
pointer-events: none !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export class WebBrowserGuestRegistry {
|
||||
private guest: WebContents | null = null;
|
||||
private pendingAttachment = false;
|
||||
|
||||
beginAttachment(): boolean {
|
||||
this.dropDestroyedGuest();
|
||||
if (this.pendingAttachment || this.guest) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.pendingAttachment = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
completeAttachment(guest: WebContents): void {
|
||||
if (!this.pendingAttachment || this.guest) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingAttachment = false;
|
||||
this.guest = guest;
|
||||
guest.once('destroyed', () => {
|
||||
if (this.guest === guest) {
|
||||
this.guest = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancelAttachment(): void {
|
||||
this.pendingAttachment = false;
|
||||
}
|
||||
|
||||
current(): WebContents | null {
|
||||
this.dropDestroyedGuest();
|
||||
return this.guest;
|
||||
}
|
||||
|
||||
owns(contents: WebContents | null): boolean {
|
||||
return contents !== null && this.current() === contents;
|
||||
}
|
||||
|
||||
hasLiveGuest(): boolean {
|
||||
return this.current() !== null;
|
||||
}
|
||||
|
||||
private dropDestroyedGuest(): void {
|
||||
if (this.guest?.isDestroyed()) {
|
||||
this.guest = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isExpectedWebBrowserAttachment(
|
||||
params: Record<string, unknown>,
|
||||
): boolean {
|
||||
return params.partition === WEB_BROWSER_PARTITION
|
||||
&& params.src === WEB_BROWSER_INITIAL_URL
|
||||
&& params.useragent === WEB_BROWSER_USER_AGENT
|
||||
&& params.allowpopups !== true
|
||||
&& params.preload === '';
|
||||
}
|
||||
|
||||
export function hardenWebBrowserPreferences(preferences: WebPreferences): void {
|
||||
delete preferences.preload;
|
||||
preferences.nodeIntegration = false;
|
||||
preferences.nodeIntegrationInSubFrames = false;
|
||||
preferences.nodeIntegrationInWorker = false;
|
||||
preferences.plugins = false;
|
||||
preferences.allowRunningInsecureContent = false;
|
||||
preferences.contextIsolation = true;
|
||||
preferences.sandbox = true;
|
||||
preferences.webSecurity = true;
|
||||
}
|
||||
|
||||
export function installWebBrowserGuestPolicy(
|
||||
embedder: WebContents,
|
||||
options: {
|
||||
browserSession: Session;
|
||||
registry: WebBrowserGuestRegistry;
|
||||
},
|
||||
): () => void {
|
||||
const { browserSession, registry } = options;
|
||||
let attachmentPending = false;
|
||||
let cleanupGuestPolicy: (() => void) | null = null;
|
||||
|
||||
const handleWillAttach = (
|
||||
event: Electron.Event,
|
||||
preferences: WebPreferences,
|
||||
params: Record<string, unknown>,
|
||||
): void => {
|
||||
if (!isExpectedWebBrowserAttachment(params)) {
|
||||
logger.warn('[WebBrowser] Rejected webview attachment with unexpected identity');
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!registry.beginAttachment()) {
|
||||
logger.warn('[WebBrowser] Rejected additional webview attachment');
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
attachmentPending = true;
|
||||
hardenWebBrowserPreferences(preferences);
|
||||
};
|
||||
|
||||
const handleDidAttach = (_event: Electron.Event, guest: WebContents): void => {
|
||||
if (!attachmentPending) {
|
||||
logger.warn('[WebBrowser] Ignored attached guest without a reserved slot');
|
||||
return;
|
||||
}
|
||||
attachmentPending = false;
|
||||
|
||||
if (guest.getType() !== 'webview' || guest.session !== browserSession) {
|
||||
logger.warn('[WebBrowser] Rejected attached guest with unexpected type or session');
|
||||
registry.cancelAttachment();
|
||||
return;
|
||||
}
|
||||
|
||||
registry.completeAttachment(guest);
|
||||
if (!registry.owns(guest)) {
|
||||
logger.warn('[WebBrowser] Failed to register reserved guest');
|
||||
return;
|
||||
}
|
||||
|
||||
guest.setUserAgent(WEB_BROWSER_USER_AGENT);
|
||||
let committedHtmlUrl = normalizeWebBrowserHtmlFileUrl(guest.getURL());
|
||||
let restoringCommittedUrl = false;
|
||||
|
||||
const blockPageNavigation = (
|
||||
details: Electron.Event<Electron.WebContentsWillFrameNavigateEventParams>,
|
||||
): void => {
|
||||
logger.warn(`[WebBrowser] Blocked guest navigation to ${details.url}`);
|
||||
details.preventDefault();
|
||||
};
|
||||
|
||||
const stopInvalidProgrammaticNavigation = (
|
||||
details: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>,
|
||||
): void => {
|
||||
if (!details.isMainFrame || normalizeWebBrowserHtmlFileUrl(details.url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(`[WebBrowser] Stopped invalid programmatic navigation to ${details.url}`);
|
||||
guest.stop();
|
||||
};
|
||||
|
||||
const blockRedirect = (
|
||||
details: Electron.Event<Electron.WebContentsWillRedirectEventParams>,
|
||||
): void => {
|
||||
logger.warn(`[WebBrowser] Blocked guest redirect to ${details.url}`);
|
||||
details.preventDefault();
|
||||
};
|
||||
|
||||
guest.setWindowOpenHandler(({ url }) => {
|
||||
logger.warn(`[WebBrowser] Blocked popup target ${url}`);
|
||||
return DENY_WINDOW_OPEN;
|
||||
});
|
||||
|
||||
const makeLinksVisuallyInert = (): void => {
|
||||
void guest.insertCSS(INERT_LINK_CSS, { cssOrigin: 'user' }).catch((error) => {
|
||||
logger.warn('[WebBrowser] Failed to neutralize HTML links:', error);
|
||||
});
|
||||
};
|
||||
|
||||
const rememberCommittedHtml = (_event: Electron.Event, url: string): void => {
|
||||
const normalizedUrl = normalizeWebBrowserHtmlFileUrl(url);
|
||||
if (normalizedUrl) {
|
||||
committedHtmlUrl = normalizedUrl;
|
||||
}
|
||||
restoringCommittedUrl = false;
|
||||
};
|
||||
|
||||
const restoreAfterInPageNavigation = (
|
||||
_event: Electron.Event,
|
||||
url: string,
|
||||
isMainFrame: boolean,
|
||||
): void => {
|
||||
if (!isMainFrame || !committedHtmlUrl || url === committedHtmlUrl || restoringCommittedUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
restoringCommittedUrl = true;
|
||||
logger.warn(`[WebBrowser] Reverting blocked in-page navigation to ${url}`);
|
||||
void guest.loadURL(committedHtmlUrl).catch((error) => {
|
||||
restoringCommittedUrl = false;
|
||||
logger.warn(`[WebBrowser] Failed to restore local HTML preview ${committedHtmlUrl}:`, error);
|
||||
});
|
||||
};
|
||||
|
||||
let cleaned = false;
|
||||
const cleanup = (): void => {
|
||||
if (cleaned) {
|
||||
return;
|
||||
}
|
||||
cleaned = true;
|
||||
|
||||
guest.off('will-frame-navigate', blockPageNavigation);
|
||||
guest.off('did-start-navigation', stopInvalidProgrammaticNavigation);
|
||||
guest.off('will-redirect', blockRedirect);
|
||||
guest.off('did-finish-load', makeLinksVisuallyInert);
|
||||
guest.off('did-navigate', rememberCommittedHtml);
|
||||
guest.off('did-navigate-in-page', restoreAfterInPageNavigation);
|
||||
guest.off('destroyed', cleanup);
|
||||
if (!guest.isDestroyed()) {
|
||||
guest.setWindowOpenHandler(() => DENY_WINDOW_OPEN);
|
||||
}
|
||||
if (cleanupGuestPolicy === cleanup) {
|
||||
cleanupGuestPolicy = null;
|
||||
}
|
||||
};
|
||||
|
||||
guest.on('will-frame-navigate', blockPageNavigation);
|
||||
guest.on('did-start-navigation', stopInvalidProgrammaticNavigation);
|
||||
guest.on('will-redirect', blockRedirect);
|
||||
guest.on('did-finish-load', makeLinksVisuallyInert);
|
||||
guest.on('did-navigate', rememberCommittedHtml);
|
||||
guest.on('did-navigate-in-page', restoreAfterInPageNavigation);
|
||||
guest.once('destroyed', cleanup);
|
||||
cleanupGuestPolicy = cleanup;
|
||||
};
|
||||
|
||||
embedder.on('will-attach-webview', handleWillAttach);
|
||||
embedder.on('did-attach-webview', handleDidAttach);
|
||||
|
||||
return () => {
|
||||
embedder.off('will-attach-webview', handleWillAttach);
|
||||
embedder.off('did-attach-webview', handleDidAttach);
|
||||
attachmentPending = false;
|
||||
registry.cancelAttachment();
|
||||
cleanupGuestPolicy?.();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
session,
|
||||
type BrowserWindow,
|
||||
type Session,
|
||||
} from 'electron';
|
||||
import {
|
||||
WEB_BROWSER_PARTITION,
|
||||
WEB_BROWSER_USER_AGENT,
|
||||
normalizeWebBrowserHtmlFileUrl,
|
||||
} from '@shared/web-browser';
|
||||
import type { WebBrowserGuestRegistry } from './web-browser-policy';
|
||||
|
||||
const DOWNLOAD_OBSERVED_SESSIONS = new WeakSet<Session>();
|
||||
|
||||
export interface ConfigureWebBrowserSessionOptions {
|
||||
registry: WebBrowserGuestRegistry;
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getLanguage?: () => Promise<string | undefined>;
|
||||
}
|
||||
|
||||
export function configureWebBrowserSession(
|
||||
_options: ConfigureWebBrowserSessionOptions,
|
||||
): Session {
|
||||
const browserSession = session.fromPartition(WEB_BROWSER_PARTITION, { cache: true });
|
||||
|
||||
// Keep a deterministic identity even though this session may load only local HTML.
|
||||
browserSession.setUserAgent(WEB_BROWSER_USER_AGENT);
|
||||
|
||||
browserSession.setPermissionCheckHandler(() => false);
|
||||
browserSession.setPermissionRequestHandler((_contents, _permission, callback) => {
|
||||
callback(false);
|
||||
});
|
||||
browserSession.setDevicePermissionHandler(() => false);
|
||||
browserSession.setDisplayMediaRequestHandler((_request, callback) => {
|
||||
callback({});
|
||||
});
|
||||
|
||||
browserSession.webRequest.onBeforeRequest(
|
||||
{ urls: ['file://*/*', 'http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*'] },
|
||||
(details, callback) => {
|
||||
const isNetworkRequest = /^(?:https?|wss?):/i.test(details.url);
|
||||
const isInvalidMainDocument = details.resourceType === 'mainFrame'
|
||||
&& normalizeWebBrowserHtmlFileUrl(details.url) === null;
|
||||
callback({ cancel: isNetworkRequest || isInvalidMainDocument });
|
||||
},
|
||||
);
|
||||
|
||||
if (!DOWNLOAD_OBSERVED_SESSIONS.has(browserSession)) {
|
||||
DOWNLOAD_OBSERVED_SESSIONS.add(browserSession);
|
||||
browserSession.on('will-download', (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
return browserSession;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
export type ZoomShortcutAction = 'in' | 'out' | 'reset';
|
||||
|
||||
type ZoomShortcutInput = Pick<Electron.Input, 'key' | 'code' | 'control' | 'meta' | 'alt'>;
|
||||
|
||||
export function getZoomShortcutAction(input: ZoomShortcutInput): ZoomShortcutAction | null {
|
||||
if ((!input.control && !input.meta) || input.alt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = input.key.toLowerCase();
|
||||
|
||||
if (key === '+' || key === '=' || input.code === 'Equal' || input.code === 'NumpadAdd') {
|
||||
return 'in';
|
||||
}
|
||||
|
||||
if (key === '-' || input.code === 'Minus' || input.code === 'NumpadSubtract') {
|
||||
return 'out';
|
||||
}
|
||||
|
||||
if (key === '0' || input.code === 'Digit0' || input.code === 'Numpad0') {
|
||||
return 'reset';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerZoomShortcuts(win: BrowserWindow): void {
|
||||
win.webContents.on('before-input-event', (event, input) => {
|
||||
const action = getZoomShortcutAction(input);
|
||||
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (action === 'reset') {
|
||||
win.webContents.setZoomLevel(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = action === 'in' ? 1 : -1;
|
||||
win.webContents.setZoomLevel(win.webContents.getZoomLevel() + delta);
|
||||
});
|
||||
}
|
||||
+34
-129
@@ -2,7 +2,20 @@
|
||||
* Preload Script
|
||||
* Exposes safe APIs to the renderer process via contextBridge
|
||||
*/
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import { contextBridge, ipcRenderer, webUtils } from 'electron';
|
||||
import type { HostRequest } from '@shared/host-api/types';
|
||||
import { HOST_EVENT_CHANNELS } from '@shared/host-events/contract';
|
||||
|
||||
const validStaticEventChannels: Set<string> = new Set(
|
||||
Object.values(HOST_EVENT_CHANNELS).flatMap((moduleChannels) => Object.values(moduleChannels)),
|
||||
);
|
||||
const DYNAMIC_CHANNEL_EVENT_RE = /^channel:[a-z0-9_-]+-(?:qr|success|error)$/i;
|
||||
|
||||
function isValidEventChannel(channel: string): boolean {
|
||||
return validStaticEventChannels.has(channel)
|
||||
|| DYNAMIC_CHANNEL_EVENT_RE.test(channel)
|
||||
|| channel.startsWith('ext:');
|
||||
}
|
||||
|
||||
/**
|
||||
* IPC renderer methods exposed to the renderer process
|
||||
@@ -16,40 +29,26 @@ const electronAPI = {
|
||||
const validChannels = [
|
||||
// Gateway
|
||||
'gateway:status',
|
||||
'gateway:isConnected',
|
||||
'gateway:start',
|
||||
'gateway:stop',
|
||||
'gateway:restart',
|
||||
'gateway:rpc',
|
||||
'gateway:httpProxy',
|
||||
'hostapi:fetch',
|
||||
'hostapi:token',
|
||||
'gateway:health',
|
||||
'gateway:getControlUiUrl',
|
||||
// OpenClaw
|
||||
'openclaw:status',
|
||||
'openclaw:isReady',
|
||||
// Shell
|
||||
'shell:openExternal',
|
||||
'shell:showItemInFolder',
|
||||
'shell:openPath',
|
||||
// Dialog
|
||||
'dialog:open',
|
||||
'dialog:save',
|
||||
'dialog:message',
|
||||
// App
|
||||
'app:version',
|
||||
'app:name',
|
||||
'app:getPath',
|
||||
'app:platform',
|
||||
'app:quit',
|
||||
'app:relaunch',
|
||||
'app:request',
|
||||
// Window controls
|
||||
'window:minimize',
|
||||
'window:maximize',
|
||||
'window:close',
|
||||
'window:isMaximized',
|
||||
'window:syncTrafficLightPosition',
|
||||
// Settings
|
||||
'settings:get',
|
||||
'settings:set',
|
||||
@@ -83,59 +82,14 @@ const electronAPI = {
|
||||
'provider:setDefault',
|
||||
'provider:getDefault',
|
||||
'provider:validateKey',
|
||||
'provider:requestOAuth',
|
||||
'provider:cancelOAuth',
|
||||
// Cron
|
||||
'cron:list',
|
||||
'cron:create',
|
||||
'cron:update',
|
||||
'cron:delete',
|
||||
'cron:toggle',
|
||||
'cron:trigger',
|
||||
// Channel Config
|
||||
'channel:saveConfig',
|
||||
'channel:getConfig',
|
||||
'channel:getFormValues',
|
||||
'channel:deleteConfig',
|
||||
'channel:listConfigured',
|
||||
'channel:setEnabled',
|
||||
'channel:validate',
|
||||
'channel:validate',
|
||||
'channel:validateCredentials',
|
||||
// WhatsApp
|
||||
'channel:requestWhatsAppQr',
|
||||
'channel:cancelWhatsAppQr',
|
||||
// ClawHub
|
||||
'clawhub:search',
|
||||
'clawhub:install',
|
||||
'clawhub:uninstall',
|
||||
'clawhub:list',
|
||||
'clawhub:openSkillReadme',
|
||||
// UV
|
||||
'uv:check',
|
||||
'uv:install-all',
|
||||
// Skill config (direct file access)
|
||||
'skill:updateConfig',
|
||||
'skill:getConfig',
|
||||
'skill:getAllConfigs',
|
||||
// Logs
|
||||
'log:getRecent',
|
||||
'log:readFile',
|
||||
'log:getFilePath',
|
||||
'log:getDir',
|
||||
'log:listFiles',
|
||||
// File staging & media
|
||||
'file:stage',
|
||||
'file:stageBuffer',
|
||||
'media:getThumbnails',
|
||||
'media:saveImage',
|
||||
// Chat send with media (reads staged files in main process)
|
||||
'chat:sendWithMedia',
|
||||
// Session management
|
||||
'session:delete',
|
||||
// File preview (sandboxed read/write/list/tree)
|
||||
'file:readText',
|
||||
'file:readBinary',
|
||||
'file:writeText',
|
||||
'file:stat',
|
||||
'file:listDir',
|
||||
'file:listTree',
|
||||
// OpenClaw extras
|
||||
'openclaw:getDir',
|
||||
'openclaw:getConfigDir',
|
||||
'openclaw:getSkillsDir',
|
||||
'openclaw:getCliCommand',
|
||||
];
|
||||
@@ -151,38 +105,7 @@ const electronAPI = {
|
||||
* Listen for events from main process
|
||||
*/
|
||||
on: (channel: string, callback: (...args: unknown[]) => void) => {
|
||||
const validChannels = [
|
||||
'gateway:status-changed',
|
||||
'gateway:message',
|
||||
'gateway:notification',
|
||||
'gateway:channel-status',
|
||||
'gateway:chat-message',
|
||||
'channel:whatsapp-qr',
|
||||
'channel:whatsapp-success',
|
||||
'channel:whatsapp-error',
|
||||
'channel:wechat-qr',
|
||||
'channel:wechat-success',
|
||||
'channel:wechat-error',
|
||||
'gateway:exit',
|
||||
'gateway:error',
|
||||
'navigate',
|
||||
'update:status-changed',
|
||||
'update:checking',
|
||||
'update:available',
|
||||
'update:not-available',
|
||||
'update:progress',
|
||||
'update:downloaded',
|
||||
'update:error',
|
||||
'update:auto-install-countdown',
|
||||
'cron:updated',
|
||||
'oauth:code',
|
||||
'oauth:success',
|
||||
'oauth:error',
|
||||
'openclaw:cli-installed',
|
||||
];
|
||||
|
||||
if (validChannels.includes(channel)) {
|
||||
// Wrap the callback to strip the event
|
||||
if (isValidEventChannel(channel)) {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => {
|
||||
callback(...args);
|
||||
};
|
||||
@@ -201,35 +124,7 @@ const electronAPI = {
|
||||
* Listen for a single event from main process
|
||||
*/
|
||||
once: (channel: string, callback: (...args: unknown[]) => void) => {
|
||||
const validChannels = [
|
||||
'gateway:status-changed',
|
||||
'gateway:message',
|
||||
'gateway:notification',
|
||||
'gateway:channel-status',
|
||||
'gateway:chat-message',
|
||||
'channel:whatsapp-qr',
|
||||
'channel:whatsapp-success',
|
||||
'channel:whatsapp-error',
|
||||
'channel:wechat-qr',
|
||||
'channel:wechat-success',
|
||||
'channel:wechat-error',
|
||||
'gateway:exit',
|
||||
'gateway:error',
|
||||
'navigate',
|
||||
'update:status-changed',
|
||||
'update:checking',
|
||||
'update:available',
|
||||
'update:not-available',
|
||||
'update:progress',
|
||||
'update:downloaded',
|
||||
'update:error',
|
||||
'update:auto-install-countdown',
|
||||
'oauth:code',
|
||||
'oauth:success',
|
||||
'oauth:error',
|
||||
];
|
||||
|
||||
if (validChannels.includes(channel)) {
|
||||
if (isValidEventChannel(channel)) {
|
||||
ipcRenderer.once(channel, (_event, ...args) => callback(...args));
|
||||
return;
|
||||
}
|
||||
@@ -257,6 +152,11 @@ const electronAPI = {
|
||||
return ipcRenderer.invoke('shell:openExternal', url);
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve the on-disk path for a native drag/drop or <input type="file"> File.
|
||||
*/
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||
|
||||
/**
|
||||
* Get current platform
|
||||
*/
|
||||
@@ -268,8 +168,13 @@ const electronAPI = {
|
||||
isDev: process.env.NODE_ENV === 'development' || !!process.env.VITE_DEV_SERVER_URL,
|
||||
};
|
||||
|
||||
const clawxAPI = {
|
||||
hostInvoke: (request: HostRequest) => ipcRenderer.invoke('host:invoke', request),
|
||||
};
|
||||
|
||||
// Expose the API to the renderer process
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI);
|
||||
contextBridge.exposeInMainWorld('clawx', clawxAPI);
|
||||
|
||||
// Type declarations for the renderer process
|
||||
export type ElectronAPI = typeof electronAPI;
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { fork, type ChildProcess } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
PROTOCOL_VERSION,
|
||||
type Client,
|
||||
type ContentBlock,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import { HOST_EVENT_CHANNELS } from '@shared/host-events/contract';
|
||||
import type {
|
||||
AcpChatCancelPayload,
|
||||
AcpChatLoadPayload,
|
||||
AcpChatOperationResult,
|
||||
AcpChatPromptPayload,
|
||||
AcpChatRespondPermissionPayload,
|
||||
AcpPermissionRequestEnvelope,
|
||||
AcpSessionUpdateEnvelope,
|
||||
} from '@shared/acp-chat/types';
|
||||
import { getOpenClawEmbeddedForkSpec } from '../utils/openclaw-cli';
|
||||
import {
|
||||
approvePendingLocalDeviceRequests,
|
||||
type GatewayPairingRpcClient,
|
||||
} from '../utils/control-ui-device-pairing';
|
||||
import { logger } from '../utils/logger';
|
||||
import { recordAcpTrace } from './acp-trace';
|
||||
import { AcpSessionAccessRegistry, type AcpSessionAccessContext } from './acp-session-access-registry';
|
||||
import { expandPath } from '../utils/paths';
|
||||
|
||||
type AcpConnection = Pick<ClientSideConnection, 'initialize' | 'newSession' | 'loadSession' | 'prompt' | 'cancel'>;
|
||||
type MainWindowLike = {
|
||||
webContents: Pick<BrowserWindow['webContents'], 'send'>;
|
||||
};
|
||||
type PermissionWaiter = {
|
||||
sessionKey: string;
|
||||
generation: number;
|
||||
resolve: (response: RequestPermissionResponse) => void;
|
||||
};
|
||||
type AcpSessionLoadBatch = {
|
||||
sessionKey: string;
|
||||
generation: number;
|
||||
sessionUpdates: Array<{
|
||||
acpSessionId: string;
|
||||
envelope: AcpSessionUpdateEnvelope;
|
||||
}>;
|
||||
};
|
||||
type AcpLivePromptContext = {
|
||||
sessionKey: string;
|
||||
acpSessionId: string;
|
||||
generation: number;
|
||||
accessGrant: AcpSessionAccessContext;
|
||||
};
|
||||
type AcpChildProcess = ChildProcess & {
|
||||
stdin: NonNullable<ChildProcess['stdin']>;
|
||||
stdout: NonNullable<ChildProcess['stdout']>;
|
||||
stderr: NonNullable<ChildProcess['stderr']>;
|
||||
};
|
||||
|
||||
function ok(generation?: number, sessionUpdates?: AcpSessionUpdateEnvelope[]): AcpChatOperationResult {
|
||||
return {
|
||||
success: true,
|
||||
...(generation != null ? { generation } : {}),
|
||||
...(sessionUpdates?.length ? { sessionUpdates } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function fail(error: unknown): AcpChatOperationResult {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
||||
function cancelledPermissionResponse(): RequestPermissionResponse {
|
||||
return { outcome: { outcome: 'cancelled' } };
|
||||
}
|
||||
|
||||
function isValidSessionKey(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.startsWith('agent:') && value.length > 'agent:'.length;
|
||||
}
|
||||
|
||||
function sessionUpdateType(notification: SessionNotification): string | undefined {
|
||||
const update = (notification as { update?: { sessionUpdate?: unknown } }).update;
|
||||
return typeof update?.sessionUpdate === 'string' ? update.sessionUpdate : undefined;
|
||||
}
|
||||
|
||||
// OpenClaw can emit clack/doctor diagnostics to stdout during ACP startup.
|
||||
// Keep those lines away from the SDK's strict NDJSON parser.
|
||||
// Upstream fixed this in https://github.com/openclaw/openclaw/pull/89997 .
|
||||
function filterAcpStdoutDiagnostics(output: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const reader = output.getReader();
|
||||
let buffered = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
|
||||
buffered += decoder.decode(value, { stream: true });
|
||||
const lines = buffered.split('\n');
|
||||
buffered = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine) continue;
|
||||
if (trimmedLine.startsWith('{')) {
|
||||
controller.enqueue(encoder.encode(`${line}\n`));
|
||||
} else {
|
||||
logger.info(`[acp-chat] [stdout] ${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export class AcpChatService {
|
||||
private child: AcpChildProcess | null = null;
|
||||
private connection: AcpConnection | null;
|
||||
private initializing: Promise<AcpConnection> | null = null;
|
||||
private initialized = false;
|
||||
private generation = 0;
|
||||
private generationSeq = 0;
|
||||
private activeSessionKey: string | null = null;
|
||||
private activeAcpSessionId: string | null = null;
|
||||
private loadedSessionKey: string | null = null;
|
||||
private loadedAcpSessionId: string | null = null;
|
||||
private historicalSessionKey: string | null = null;
|
||||
private historicalGeneration: number | null = null;
|
||||
private permissionsEnabled = false;
|
||||
private loadQueue: Promise<void> | null = null;
|
||||
private activeLoadBatch: AcpSessionLoadBatch | null = null;
|
||||
private readonly livePrompts = new Map<string, AcpLivePromptContext>();
|
||||
private permissionSeq = 0;
|
||||
private readonly permissionWaiters = new Map<string, PermissionWaiter>();
|
||||
readonly client: Client;
|
||||
|
||||
constructor(
|
||||
private readonly mainWindow: MainWindowLike,
|
||||
private readonly accessRegistry: AcpSessionAccessRegistry,
|
||||
injectedConnection?: AcpConnection,
|
||||
private readonly gateway?: GatewayPairingRpcClient,
|
||||
) {
|
||||
this.connection = injectedConnection ?? null;
|
||||
this.client = {
|
||||
sessionUpdate: async (notification) => this.emitSessionUpdate(notification),
|
||||
requestPermission: async (request) => this.requestPermission(request),
|
||||
};
|
||||
}
|
||||
|
||||
private trace(
|
||||
event: string,
|
||||
input: { direction?: string; sessionKey?: string | null; generation?: number; details?: unknown } = {},
|
||||
): void {
|
||||
try {
|
||||
const sessionKey = input.sessionKey === null
|
||||
? undefined
|
||||
: input.sessionKey ?? this.activeSessionKey ?? undefined;
|
||||
const generation = input.generation ?? (this.generation > 0 ? this.generation : undefined);
|
||||
recordAcpTrace({
|
||||
source: 'main',
|
||||
event,
|
||||
...(input.direction ? { direction: input.direction } : {}),
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
...(generation != null ? { generation } : {}),
|
||||
...(input.details !== undefined ? { details: input.details } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(`[acp-chat] trace failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
loadSession(payload: AcpChatLoadPayload): Promise<AcpChatOperationResult> {
|
||||
const previousLoad = this.loadQueue;
|
||||
let releaseLoad!: () => void;
|
||||
const currentLoad = new Promise<void>((resolve) => {
|
||||
releaseLoad = resolve;
|
||||
});
|
||||
this.loadQueue = currentLoad;
|
||||
|
||||
const run = async () => {
|
||||
if (previousLoad) await previousLoad;
|
||||
try {
|
||||
return await this.performLoadSession(payload);
|
||||
} finally {
|
||||
releaseLoad();
|
||||
if (this.loadQueue === currentLoad) this.loadQueue = null;
|
||||
}
|
||||
};
|
||||
return run();
|
||||
}
|
||||
|
||||
private async performLoadSession(payload: AcpChatLoadPayload): Promise<AcpChatOperationResult> {
|
||||
if (!isValidSessionKey(payload.sessionKey) || !payload.workspaceRoot || !payload.cwd) {
|
||||
return fail('Invalid ACP session load payload');
|
||||
}
|
||||
const previousPermissionsEnabled = this.permissionsEnabled;
|
||||
this.permissionsEnabled = false;
|
||||
this.trace('session/load:start', {
|
||||
sessionKey: payload.sessionKey,
|
||||
details: { createIfMissing: !!payload.createIfMissing, cwdPresent: Boolean(payload.cwd) },
|
||||
});
|
||||
|
||||
let previousSessionKey = this.activeSessionKey;
|
||||
let previousAcpSessionId = this.activeAcpSessionId;
|
||||
let previousLoadedSessionKey = this.loadedSessionKey;
|
||||
let previousLoadedAcpSessionId = this.loadedAcpSessionId;
|
||||
let previousHistoricalSessionKey = this.historicalSessionKey;
|
||||
let previousHistoricalGeneration = this.historicalGeneration;
|
||||
let previousGeneration = this.generation;
|
||||
let nextGeneration = this.generationSeq + 1;
|
||||
let stateAdvanced = false;
|
||||
let loadBatch: AcpSessionLoadBatch | null = null;
|
||||
let previousAccessGrant: AcpSessionAccessContext | null = null;
|
||||
|
||||
try {
|
||||
const connection = await this.ensureConnection();
|
||||
const livePrompt = this.livePrompts.get(payload.sessionKey);
|
||||
if (livePrompt) {
|
||||
const preparedAccessGrant = await this.accessRegistry.prepareGrant({
|
||||
sessionKey: payload.sessionKey,
|
||||
generation: livePrompt.generation,
|
||||
workspaceRoot: payload.workspaceRoot,
|
||||
executionCwd: payload.cwd,
|
||||
});
|
||||
if (
|
||||
preparedAccessGrant.workspaceRoot !== livePrompt.accessGrant.workspaceRoot
|
||||
|| preparedAccessGrant.executionCwd !== livePrompt.accessGrant.executionCwd
|
||||
) {
|
||||
throw new Error('Cannot change workspace while an ACP prompt is active');
|
||||
}
|
||||
this.generation = livePrompt.generation;
|
||||
this.activeSessionKey = livePrompt.sessionKey;
|
||||
this.activeAcpSessionId = livePrompt.acpSessionId;
|
||||
this.loadedSessionKey = livePrompt.sessionKey;
|
||||
this.loadedAcpSessionId = livePrompt.acpSessionId;
|
||||
this.historicalSessionKey = null;
|
||||
this.historicalGeneration = null;
|
||||
this.permissionsEnabled = true;
|
||||
this.accessRegistry.commitGrant(livePrompt.accessGrant);
|
||||
this.trace('session/load:resumed-active-prompt', {
|
||||
sessionKey: livePrompt.sessionKey,
|
||||
generation: livePrompt.generation,
|
||||
details: { acpSessionId: livePrompt.acpSessionId },
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
generation: livePrompt.generation,
|
||||
resumedActivePrompt: true,
|
||||
};
|
||||
}
|
||||
previousSessionKey = this.activeSessionKey;
|
||||
previousAcpSessionId = this.activeAcpSessionId;
|
||||
previousLoadedSessionKey = this.loadedSessionKey;
|
||||
previousLoadedAcpSessionId = this.loadedAcpSessionId;
|
||||
previousHistoricalSessionKey = this.historicalSessionKey;
|
||||
previousHistoricalGeneration = this.historicalGeneration;
|
||||
previousGeneration = this.generation;
|
||||
nextGeneration = this.generationSeq + 1;
|
||||
previousAccessGrant = this.accessRegistry.snapshot();
|
||||
const preparedAccessGrant = await this.accessRegistry.prepareGrant({
|
||||
sessionKey: payload.sessionKey,
|
||||
generation: nextGeneration,
|
||||
workspaceRoot: payload.workspaceRoot,
|
||||
executionCwd: payload.cwd,
|
||||
});
|
||||
|
||||
this.generation = nextGeneration;
|
||||
this.activeSessionKey = payload.sessionKey;
|
||||
this.activeAcpSessionId = payload.createIfMissing ? null : payload.sessionKey;
|
||||
this.loadedSessionKey = null;
|
||||
this.loadedAcpSessionId = null;
|
||||
this.historicalSessionKey = payload.createIfMissing ? null : payload.sessionKey;
|
||||
this.historicalGeneration = payload.createIfMissing ? null : nextGeneration;
|
||||
loadBatch = {
|
||||
sessionKey: payload.sessionKey,
|
||||
generation: nextGeneration,
|
||||
sessionUpdates: [],
|
||||
};
|
||||
this.activeLoadBatch = loadBatch;
|
||||
stateAdvanced = true;
|
||||
if (previousSessionKey && !this.livePrompts.has(previousSessionKey)) {
|
||||
this.resolvePermissionWaitersForSession(previousSessionKey, cancelledPermissionResponse());
|
||||
}
|
||||
|
||||
let acpSessionId = payload.sessionKey;
|
||||
if (payload.createIfMissing) {
|
||||
const created = await connection.newSession({
|
||||
cwd: preparedAccessGrant.executionCwd,
|
||||
mcpServers: [],
|
||||
_meta: { sessionKey: payload.sessionKey, prefixCwd: true },
|
||||
});
|
||||
acpSessionId = created.sessionId;
|
||||
} else {
|
||||
await connection.loadSession({
|
||||
sessionId: payload.sessionKey,
|
||||
cwd: preparedAccessGrant.executionCwd,
|
||||
mcpServers: [],
|
||||
});
|
||||
}
|
||||
this.activeAcpSessionId = acpSessionId;
|
||||
this.loadedSessionKey = payload.sessionKey;
|
||||
this.loadedAcpSessionId = acpSessionId;
|
||||
this.generationSeq = nextGeneration;
|
||||
this.accessRegistry.commitGrant(preparedAccessGrant);
|
||||
this.trace('session/load:success', {
|
||||
sessionKey: payload.sessionKey,
|
||||
generation: nextGeneration,
|
||||
details: { createIfMissing: !!payload.createIfMissing, acpSessionId },
|
||||
});
|
||||
if (this.activeLoadBatch === loadBatch) this.activeLoadBatch = null;
|
||||
return ok(
|
||||
nextGeneration,
|
||||
loadBatch.sessionUpdates
|
||||
.filter((entry) => entry.acpSessionId === acpSessionId)
|
||||
.map((entry) => entry.envelope),
|
||||
);
|
||||
} catch (error) {
|
||||
if (this.activeLoadBatch === loadBatch) this.activeLoadBatch = null;
|
||||
this.resolvePermissionWaitersForSession(payload.sessionKey, cancelledPermissionResponse());
|
||||
if (
|
||||
stateAdvanced
|
||||
&& this.activeSessionKey === payload.sessionKey
|
||||
&& this.generation === nextGeneration
|
||||
) {
|
||||
this.generation = previousGeneration;
|
||||
this.activeSessionKey = previousSessionKey;
|
||||
this.activeAcpSessionId = previousAcpSessionId;
|
||||
this.loadedSessionKey = previousLoadedSessionKey;
|
||||
this.loadedAcpSessionId = previousLoadedAcpSessionId;
|
||||
this.historicalSessionKey = previousHistoricalSessionKey;
|
||||
this.historicalGeneration = previousHistoricalGeneration;
|
||||
this.permissionsEnabled = previousPermissionsEnabled;
|
||||
this.accessRegistry.restore(previousAccessGrant);
|
||||
}
|
||||
logger.error(`[acp-chat] loadSession failed: ${String(error)}`);
|
||||
this.trace('session/load:failed', {
|
||||
sessionKey: payload.sessionKey,
|
||||
generation: previousGeneration,
|
||||
details: { error: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
return fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
async sendPrompt(payload: AcpChatPromptPayload): Promise<AcpChatOperationResult> {
|
||||
if (!isValidSessionKey(payload.sessionKey) || !payload.cwd) return fail('Invalid ACP prompt payload');
|
||||
if (!this.activeSessionKey) return fail('No active ACP session');
|
||||
if (payload.sessionKey !== this.activeSessionKey) return fail('ACP prompt session is not active');
|
||||
if (this.loadedSessionKey !== payload.sessionKey || !this.loadedAcpSessionId) return fail('ACP session is not loaded');
|
||||
if (this.livePrompts.has(payload.sessionKey)) return fail('ACP prompt is already active');
|
||||
const generation = this.generation;
|
||||
const acpSessionId = this.loadedAcpSessionId;
|
||||
const accessGrant = this.accessRegistry.get(payload.sessionKey, generation);
|
||||
if (!accessGrant) return fail('ACP session access grant is not active');
|
||||
const promptContext: AcpLivePromptContext = {
|
||||
sessionKey: payload.sessionKey,
|
||||
acpSessionId,
|
||||
generation,
|
||||
accessGrant,
|
||||
};
|
||||
this.livePrompts.set(payload.sessionKey, promptContext);
|
||||
try {
|
||||
const promptCwd = payload.cwd === accessGrant.executionCwd
|
||||
? payload.cwd
|
||||
: await import('node:fs/promises')
|
||||
.then((fsP) => fsP.realpath(expandPath(payload.cwd)))
|
||||
.catch(() => null);
|
||||
if (promptCwd !== accessGrant.executionCwd) {
|
||||
return fail('ACP prompt cwd does not match the registered execution cwd');
|
||||
}
|
||||
this.trace('session/prompt:start', {
|
||||
sessionKey: payload.sessionKey,
|
||||
generation,
|
||||
details: { messageLength: payload.message?.length ?? 0, mediaCount: payload.media?.length ?? 0 },
|
||||
});
|
||||
const connection = await this.ensureConnection();
|
||||
const prompt = await this.buildPromptBlocks(payload);
|
||||
if (this.historicalSessionKey === payload.sessionKey) {
|
||||
this.historicalSessionKey = null;
|
||||
this.historicalGeneration = null;
|
||||
}
|
||||
this.permissionsEnabled = true;
|
||||
const messageId = payload.messageId ?? randomUUID();
|
||||
const isSlashCommand = payload.message?.trimStart().startsWith('/') === true;
|
||||
await connection.prompt({
|
||||
sessionId: acpSessionId,
|
||||
prompt,
|
||||
// ACP 1.1 removed messageId from the PromptRequest wire shape. Keep
|
||||
// ClawX correlation metadata in the protocol extension envelope.
|
||||
// OpenClaw must receive slash commands without its textual cwd prefix
|
||||
// so the Gateway can classify and fold command replies into chat final.
|
||||
_meta: { sessionKey: payload.sessionKey, prefixCwd: !isSlashCommand, messageId },
|
||||
});
|
||||
this.trace('session/prompt:success', {
|
||||
sessionKey: payload.sessionKey,
|
||||
generation,
|
||||
details: { blockCount: prompt.length, acpSessionId },
|
||||
});
|
||||
return ok(generation);
|
||||
} catch (error) {
|
||||
logger.error(`[acp-chat] prompt failed: ${String(error)}`);
|
||||
this.trace('session/prompt:failed', {
|
||||
sessionKey: payload.sessionKey,
|
||||
details: { error: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
return fail(error);
|
||||
} finally {
|
||||
if (this.livePrompts.get(payload.sessionKey) === promptContext) {
|
||||
this.livePrompts.delete(payload.sessionKey);
|
||||
this.resolvePermissionWaitersForSession(payload.sessionKey, cancelledPermissionResponse());
|
||||
}
|
||||
this.permissionsEnabled = this.activeSessionKey != null && this.livePrompts.has(this.activeSessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
async cancelSession(payload: AcpChatCancelPayload): Promise<AcpChatOperationResult> {
|
||||
if (!isValidSessionKey(payload.sessionKey)) return fail('Invalid ACP cancel payload');
|
||||
if (payload.sessionKey !== this.activeSessionKey || !this.loadedAcpSessionId) return fail('ACP session is not loaded');
|
||||
|
||||
try {
|
||||
this.trace('session/cancel:start', { sessionKey: payload.sessionKey });
|
||||
const connection = await this.ensureConnection();
|
||||
await connection.cancel({ sessionId: this.loadedAcpSessionId });
|
||||
this.permissionsEnabled = false;
|
||||
this.resolvePermissionWaitersForSession(payload.sessionKey, cancelledPermissionResponse());
|
||||
this.trace('session/cancel:success', { sessionKey: payload.sessionKey });
|
||||
return ok(this.generation);
|
||||
} catch (error) {
|
||||
logger.error(`[acp-chat] cancel failed: ${String(error)}`);
|
||||
this.trace('session/cancel:failed', {
|
||||
sessionKey: payload.sessionKey,
|
||||
details: { error: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
return fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
async respondPermission(payload: AcpChatRespondPermissionPayload): Promise<AcpChatOperationResult> {
|
||||
const waiter = this.permissionWaiters.get(payload.requestId);
|
||||
if (!waiter || waiter.sessionKey !== payload.sessionKey) return fail('Unknown ACP permission request');
|
||||
|
||||
waiter.resolve({ outcome: payload.outcome });
|
||||
this.permissionWaiters.delete(payload.requestId);
|
||||
this.trace('permission/responded', {
|
||||
sessionKey: payload.sessionKey,
|
||||
details: { requestId: payload.requestId, outcome: payload.outcome.outcome },
|
||||
});
|
||||
return ok(waiter.generation);
|
||||
}
|
||||
|
||||
private async ensureConnection(): Promise<AcpConnection> {
|
||||
if (this.connection && this.initialized) return this.connection;
|
||||
if (this.initializing) return this.initializing;
|
||||
|
||||
this.initializing = this.initializeConnection();
|
||||
try {
|
||||
return await this.initializing;
|
||||
} finally {
|
||||
this.initializing = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeConnection(): Promise<AcpConnection> {
|
||||
await this.approveLocalDeviceRequests();
|
||||
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
try {
|
||||
return await this.initializeConnectionOnce(attempt);
|
||||
} catch (error) {
|
||||
if (attempt >= 2) throw error;
|
||||
logger.info(
|
||||
`[acp-chat] ACP connect failed on attempt ${attempt}; auto-approving local device requests and retrying: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
await this.approveLocalDeviceRequests();
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('ACP connection failed');
|
||||
}
|
||||
|
||||
private async initializeConnectionOnce(attempt: number): Promise<AcpConnection> {
|
||||
if (!this.connection) this.connection = this.spawnConnection();
|
||||
const connection = this.connection;
|
||||
const child = this.child;
|
||||
|
||||
this.trace('connection/initialize:start', { details: { attempt } });
|
||||
const initOutcome = await Promise.race([
|
||||
connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: {},
|
||||
}).then((result) => ({ kind: 'initialized' as const, result })),
|
||||
this.waitForChildExit(child).then((exitCode) => ({ kind: 'exited' as const, exitCode })),
|
||||
]);
|
||||
|
||||
if (initOutcome.kind === 'exited') {
|
||||
if (child) this.dropConnectionForChild(child);
|
||||
throw new Error(`ACP process exited with code ${String(initOutcome.exitCode)}`);
|
||||
}
|
||||
|
||||
const result = initOutcome.result;
|
||||
if (this.connection !== connection) {
|
||||
throw new Error('ACP connection closed during initialization');
|
||||
}
|
||||
if (!result.agentCapabilities?.loadSession) {
|
||||
this.trace('connection/initialize:failed', { details: { reason: 'missing-loadSession-capability' } });
|
||||
throw new Error('ACP agent does not support session/load');
|
||||
}
|
||||
this.initialized = true;
|
||||
this.trace('connection/initialize:success', { details: { protocolVersion: PROTOCOL_VERSION, attempt } });
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
private async approveLocalDeviceRequests(): Promise<void> {
|
||||
if (!this.gateway) return;
|
||||
try {
|
||||
await approvePendingLocalDeviceRequests(this.gateway);
|
||||
} catch (error) {
|
||||
logger.debug(`[acp-chat] Local device auto-approve skipped: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private waitForChildExit(child: AcpChildProcess | null): Promise<number | null> {
|
||||
if (!child) return Promise.resolve(null);
|
||||
if (child.exitCode !== null) return Promise.resolve(child.exitCode);
|
||||
if (child.signalCode) return Promise.resolve(child.exitCode);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const onExit = (code: number | null) => {
|
||||
child.off('exit', onExit);
|
||||
resolve(code);
|
||||
};
|
||||
child.on('exit', onExit);
|
||||
});
|
||||
}
|
||||
|
||||
private spawnConnection(): ClientSideConnection {
|
||||
const spec = getOpenClawEmbeddedForkSpec(['acp']);
|
||||
const forked = fork(spec.modulePath, spec.args, spec.options);
|
||||
if (!forked.stdin || !forked.stdout || !forked.stderr) {
|
||||
forked.kill();
|
||||
throw new Error('ACP process did not expose stdio pipes');
|
||||
}
|
||||
this.child = forked as AcpChildProcess;
|
||||
|
||||
const child = this.child;
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
const message = String(chunk).trimEnd();
|
||||
if (message) logger.info(`[acp-chat] ${message}`);
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
logger.error(`[acp-chat] ACP process error: ${String(error)}`);
|
||||
this.dropConnectionForChild(child);
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
logger.info(`[acp-chat] ACP process exited with code ${String(code)}`);
|
||||
this.dropConnectionForChild(child);
|
||||
});
|
||||
|
||||
const input = Writable.toWeb(child.stdin) as WritableStream<Uint8Array>;
|
||||
const output = filterAcpStdoutDiagnostics(Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>);
|
||||
const stream = ndJsonStream(input, output);
|
||||
return new ClientSideConnection(() => this.client, stream);
|
||||
}
|
||||
|
||||
private dropConnectionForChild(child: AcpChildProcess): void {
|
||||
if (this.child !== child) return;
|
||||
this.trace('connection/dropped', { details: { pendingPermissionCount: this.permissionWaiters.size } });
|
||||
this.resolveAllPermissionWaiters(cancelledPermissionResponse());
|
||||
this.initialized = false;
|
||||
this.initializing = null;
|
||||
this.connection = null;
|
||||
this.child = null;
|
||||
this.loadedSessionKey = null;
|
||||
this.loadedAcpSessionId = null;
|
||||
this.historicalSessionKey = null;
|
||||
this.historicalGeneration = null;
|
||||
this.permissionsEnabled = false;
|
||||
this.livePrompts.clear();
|
||||
}
|
||||
|
||||
private emitSessionUpdate(notification: SessionNotification): void {
|
||||
const acpSessionId = notification.sessionId;
|
||||
const livePrompt = [...this.livePrompts.values()].find((context) => context.acpSessionId === acpSessionId);
|
||||
const sessionKey = livePrompt?.sessionKey ?? this.activeSessionKey;
|
||||
const generation = livePrompt?.generation ?? this.generation;
|
||||
const updateType = sessionUpdateType(notification);
|
||||
this.trace('session-update:received', {
|
||||
direction: 'upstream',
|
||||
sessionKey: sessionKey ?? null,
|
||||
details: { acpSessionId, updateType },
|
||||
});
|
||||
if (!sessionKey) {
|
||||
this.trace('session-update:ignored', {
|
||||
direction: 'upstream',
|
||||
sessionKey: null,
|
||||
details: { reason: 'no-active-session', acpSessionId, updateType },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!livePrompt && this.activeAcpSessionId && acpSessionId !== this.activeAcpSessionId) {
|
||||
this.trace('session-update:ignored', {
|
||||
direction: 'upstream',
|
||||
sessionKey,
|
||||
details: { reason: 'session-mismatch', acpSessionId, activeAcpSessionId: this.activeAcpSessionId, updateType },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const envelope: AcpSessionUpdateEnvelope = {
|
||||
sessionKey,
|
||||
generation,
|
||||
...(!livePrompt && this.historicalSessionKey === sessionKey && this.historicalGeneration === generation
|
||||
? { historical: true }
|
||||
: {}),
|
||||
notification: { ...notification, sessionId: sessionKey },
|
||||
};
|
||||
const loadBatch = this.activeLoadBatch;
|
||||
if (loadBatch?.sessionKey === sessionKey && loadBatch.generation === generation) {
|
||||
loadBatch.sessionUpdates.push({ acpSessionId, envelope });
|
||||
this.trace('session-update:buffered', {
|
||||
direction: 'downstream',
|
||||
sessionKey,
|
||||
details: { acpSessionId, updateType, historical: !!envelope.historical },
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.mainWindow.webContents.send(HOST_EVENT_CHANNELS.chat.acpSessionUpdate, envelope);
|
||||
this.trace('session-update:forwarded', {
|
||||
direction: 'downstream',
|
||||
sessionKey,
|
||||
details: { acpSessionId, updateType, historical: !!envelope.historical },
|
||||
});
|
||||
}
|
||||
|
||||
private requestPermission(request: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
const acpSessionId = request.sessionId;
|
||||
const livePrompt = [...this.livePrompts.values()].find((context) => context.acpSessionId === acpSessionId);
|
||||
const sessionKey = livePrompt?.sessionKey ?? this.activeSessionKey;
|
||||
const generation = livePrompt?.generation ?? this.generation;
|
||||
if (!livePrompt && !this.permissionsEnabled) {
|
||||
this.trace('permission:ignored', {
|
||||
direction: 'upstream',
|
||||
sessionKey: sessionKey ?? null,
|
||||
details: { reason: 'no-active-prompt', acpSessionId },
|
||||
});
|
||||
return Promise.resolve(cancelledPermissionResponse());
|
||||
}
|
||||
if (this.activeLoadBatch && !livePrompt) {
|
||||
this.trace('permission:ignored', {
|
||||
direction: 'upstream',
|
||||
sessionKey: sessionKey ?? null,
|
||||
details: { reason: 'session-loading', acpSessionId },
|
||||
});
|
||||
return Promise.resolve(cancelledPermissionResponse());
|
||||
}
|
||||
if (!sessionKey || (!livePrompt && this.activeAcpSessionId && acpSessionId !== this.activeAcpSessionId)) {
|
||||
this.trace('permission:ignored', {
|
||||
direction: 'upstream',
|
||||
sessionKey: sessionKey ?? null,
|
||||
details: {
|
||||
reason: !sessionKey ? 'no-active-session' : 'session-mismatch',
|
||||
acpSessionId,
|
||||
activeAcpSessionId: this.activeAcpSessionId,
|
||||
},
|
||||
});
|
||||
return Promise.resolve(cancelledPermissionResponse());
|
||||
}
|
||||
|
||||
const requestId = `acp-permission-${Date.now()}-${this.permissionSeq += 1}`;
|
||||
const envelope: AcpPermissionRequestEnvelope = {
|
||||
sessionKey,
|
||||
generation,
|
||||
requestId,
|
||||
request: { ...request, sessionId: sessionKey },
|
||||
};
|
||||
this.mainWindow.webContents.send(HOST_EVENT_CHANNELS.chat.acpPermissionRequest, envelope);
|
||||
this.trace('permission:forwarded', {
|
||||
direction: 'downstream',
|
||||
sessionKey,
|
||||
details: { requestId, acpSessionId, optionCount: request.options.length },
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.permissionWaiters.set(requestId, { sessionKey, generation, resolve });
|
||||
});
|
||||
}
|
||||
|
||||
private resolvePermissionWaitersForSession(sessionKey: string, response: RequestPermissionResponse): void {
|
||||
for (const [requestId, waiter] of this.permissionWaiters) {
|
||||
if (waiter.sessionKey !== sessionKey) continue;
|
||||
waiter.resolve(response);
|
||||
this.permissionWaiters.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAllPermissionWaiters(response: RequestPermissionResponse): void {
|
||||
for (const [requestId, waiter] of this.permissionWaiters) {
|
||||
waiter.resolve(response);
|
||||
this.permissionWaiters.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
private async buildPromptBlocks(payload: AcpChatPromptPayload): Promise<ContentBlock[]> {
|
||||
const blocks: ContentBlock[] = [];
|
||||
const text = payload.message?.trim();
|
||||
if (text) blocks.push({ type: 'text', text });
|
||||
|
||||
const media = payload.media ?? [];
|
||||
if (media.length > 0) {
|
||||
const fsP = await import('node:fs/promises');
|
||||
for (const item of media) {
|
||||
const mimeType = item.mimeType || 'application/octet-stream';
|
||||
if (mimeType.startsWith('image/')) {
|
||||
const data = await fsP.readFile(item.filePath, 'base64');
|
||||
blocks.push({
|
||||
type: 'image',
|
||||
data,
|
||||
mimeType,
|
||||
uri: item.filePath,
|
||||
_meta: {
|
||||
clawx: {
|
||||
stagingId: item.stagingId,
|
||||
...(item.fileName ? { fileName: item.fileName } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
blocks.push({
|
||||
type: 'resource_link',
|
||||
uri: item.filePath,
|
||||
name: item.fileName ?? item.filePath,
|
||||
mimeType: item.mimeType,
|
||||
_meta: {
|
||||
clawx: {
|
||||
stagingId: item.stagingId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (blocks.length === 0) blocks.push({ type: 'text', text: '' });
|
||||
return blocks;
|
||||
}
|
||||
}
|
||||
|
||||
export function createAcpChatService(
|
||||
mainWindow: MainWindowLike,
|
||||
accessRegistry: AcpSessionAccessRegistry,
|
||||
gateway?: GatewayPairingRpcClient,
|
||||
): AcpChatService {
|
||||
return new AcpChatService(mainWindow, accessRegistry, undefined, gateway);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { realpath, stat } from 'node:fs/promises';
|
||||
import { isAbsolute, relative, sep } from 'node:path';
|
||||
import { expandPath } from '../utils/paths';
|
||||
|
||||
export type AcpSessionAccessContext = {
|
||||
sessionKey: string;
|
||||
generation: number;
|
||||
workspaceRoot: string;
|
||||
executionCwd: string;
|
||||
};
|
||||
|
||||
async function canonicalDirectory(input: string, label: string): Promise<string> {
|
||||
const canonicalPath = await realpath(expandPath(input));
|
||||
const directoryStat = await stat(canonicalPath);
|
||||
if (!directoryStat.isDirectory()) throw new Error(`${label} must be a directory`);
|
||||
return canonicalPath;
|
||||
}
|
||||
|
||||
function isInside(child: string, parent: string): boolean {
|
||||
const relativePath = relative(parent, child);
|
||||
return relativePath === ''
|
||||
|| (!isAbsolute(relativePath) && relativePath !== '..' && !relativePath.startsWith(`..${sep}`));
|
||||
}
|
||||
|
||||
export class AcpSessionAccessRegistry {
|
||||
private activeGrant: AcpSessionAccessContext | null = null;
|
||||
|
||||
async prepareGrant(input: AcpSessionAccessContext): Promise<AcpSessionAccessContext> {
|
||||
const workspaceRoot = await canonicalDirectory(input.workspaceRoot, 'ACP workspace root');
|
||||
const executionCwd = await canonicalDirectory(input.executionCwd, 'ACP execution cwd');
|
||||
if (!isInside(executionCwd, workspaceRoot)) {
|
||||
throw new Error('ACP execution cwd must be inside the workspace root');
|
||||
}
|
||||
return { ...input, workspaceRoot, executionCwd };
|
||||
}
|
||||
|
||||
snapshot(): AcpSessionAccessContext | null {
|
||||
return this.activeGrant ? { ...this.activeGrant } : null;
|
||||
}
|
||||
|
||||
commitGrant(context: AcpSessionAccessContext): void {
|
||||
this.activeGrant = { ...context };
|
||||
}
|
||||
|
||||
restore(snapshot: AcpSessionAccessContext | null): void {
|
||||
this.activeGrant = snapshot ? { ...snapshot } : null;
|
||||
}
|
||||
|
||||
get(sessionKey: string, generation: number): AcpSessionAccessContext | null {
|
||||
if (this.activeGrant?.sessionKey !== sessionKey || this.activeGrant.generation !== generation) return null;
|
||||
return { ...this.activeGrant };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type {
|
||||
AcpTraceEntry,
|
||||
AcpTraceRecordPayload,
|
||||
AcpTraceSnapshot,
|
||||
AttachmentAccessError,
|
||||
} from '@shared/host-api/contract';
|
||||
import { isRecord } from './payload-utils';
|
||||
|
||||
type AcpTraceRecordInput = Omit<AcpTraceEntry, 'seq' | 'timestamp'>;
|
||||
|
||||
const MAX_ACP_TRACE_ENTRIES = 500;
|
||||
const MAX_STRING_LENGTH = 300;
|
||||
const SENSITIVE_KEY_RE = /(authorization|api[_-]?key|token|secret|password|bearer)/i;
|
||||
const OPENCLAW_MEDIA_DETAIL_KEYS = new Set([
|
||||
'source',
|
||||
'reason',
|
||||
'candidateCount',
|
||||
'matchedCount',
|
||||
'rejectedCount',
|
||||
'attachmentCount',
|
||||
'imageCount',
|
||||
'missingCount',
|
||||
'previewCount',
|
||||
'evidenceHash',
|
||||
'identityHash',
|
||||
'operationId',
|
||||
'latestGeneration',
|
||||
'error',
|
||||
]);
|
||||
|
||||
let sequence = 0;
|
||||
let entries: AcpTraceEntry[] = [];
|
||||
|
||||
function sanitize(value: unknown, depth = 0): unknown {
|
||||
if (value == null || typeof value === 'boolean' || typeof value === 'number') return value;
|
||||
if (typeof value === 'string') {
|
||||
if (/bearer\s+\S+/i.test(value) || /^sk-[A-Za-z0-9_-]{8,}/.test(value)) return '[redacted]';
|
||||
if (value.length <= MAX_STRING_LENGTH) return value;
|
||||
return `${value.slice(0, MAX_STRING_LENGTH)}...[truncated ${value.length - MAX_STRING_LENGTH} chars]`;
|
||||
}
|
||||
if (depth >= 4) return '[max-depth]';
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.slice(0, 20).map((item) => sanitize(item, depth + 1));
|
||||
return value.length > 20 ? { type: 'array', length: value.length, items } : items;
|
||||
}
|
||||
if (!isRecord(value)) return String(value);
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
output[key] = SENSITIVE_KEY_RE.test(key) ? '[redacted]' : sanitize(nested, depth + 1);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, maxLength = 120): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim().slice(0, maxLength) : undefined;
|
||||
}
|
||||
|
||||
export function recordAcpTrace(input: AcpTraceRecordInput): AcpTraceEntry {
|
||||
const entry: AcpTraceEntry = {
|
||||
seq: sequence += 1,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: input.source,
|
||||
event: input.event.slice(0, 120),
|
||||
...(input.direction ? { direction: input.direction } : {}),
|
||||
...(input.sessionKey ? { sessionKey: input.sessionKey } : {}),
|
||||
...(typeof input.generation === 'number' ? { generation: input.generation } : {}),
|
||||
...(input.details !== undefined ? { details: sanitize(input.details) } : {}),
|
||||
};
|
||||
entries.push(entry);
|
||||
if (entries.length > MAX_ACP_TRACE_ENTRIES) entries = entries.slice(-MAX_ACP_TRACE_ENTRIES);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function getAcpTraceSnapshot(): AcpTraceSnapshot {
|
||||
return {
|
||||
capturedAt: Date.now(),
|
||||
maxSize: MAX_ACP_TRACE_ENTRIES,
|
||||
size: entries.length,
|
||||
entries: entries.map((entry) => ({ ...entry })),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeRendererAcpTracePayload(payload: unknown): AcpTraceRecordInput | null {
|
||||
if (!isRecord(payload)) return null;
|
||||
const event = optionalString(payload.event);
|
||||
if (!event) return null;
|
||||
const sessionKey = optionalString(payload.sessionKey, 200);
|
||||
const direction = optionalString(payload.direction, 80) ?? 'projection';
|
||||
const generation = typeof payload.generation === 'number' && Number.isFinite(payload.generation)
|
||||
? payload.generation
|
||||
: undefined;
|
||||
const details = event.startsWith('openclaw-media:') && isRecord(payload.details)
|
||||
? Object.fromEntries(Object.entries(payload.details).filter(([key]) => OPENCLAW_MEDIA_DETAIL_KEYS.has(key)))
|
||||
: payload.details;
|
||||
return {
|
||||
source: 'renderer',
|
||||
event,
|
||||
direction,
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
...(generation != null ? { generation } : {}),
|
||||
...(details !== undefined ? { details } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function recordRendererAcpTrace(payload: AcpTraceRecordPayload): { success: boolean; error?: string } {
|
||||
const normalized = normalizeRendererAcpTracePayload(payload);
|
||||
if (!normalized) return { success: false, error: 'Invalid ACP trace payload' };
|
||||
recordAcpTrace(normalized);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export function recordAttachmentOpenTrace(input: {
|
||||
ok: boolean;
|
||||
reason: AttachmentAccessError | 'success';
|
||||
sourceKind: 'local' | 'remote' | 'invalid';
|
||||
sessionKey: string;
|
||||
generation: number;
|
||||
identity: string;
|
||||
}): AcpTraceEntry {
|
||||
return recordAcpTrace({
|
||||
source: 'main',
|
||||
event: `attachment/open:${input.ok ? 'success' : 'failure'}`,
|
||||
direction: 'open',
|
||||
sessionKey: input.sessionKey,
|
||||
generation: input.generation,
|
||||
details: {
|
||||
reason: input.reason,
|
||||
sourceKind: input.sourceKind,
|
||||
identity: input.identity.slice(0, 64),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function clearAcpTraceForTests(): void {
|
||||
sequence = 0;
|
||||
entries = [];
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import {
|
||||
assignChannelToAgent,
|
||||
clearChannelBinding,
|
||||
createAgent,
|
||||
deleteAgentConfig,
|
||||
listAgentsSnapshot,
|
||||
removeAgentWorkspaceDirectory,
|
||||
resolveAccountIdForAgent,
|
||||
updateAgentModel,
|
||||
updateAgentName,
|
||||
} from '../utils/agent-config';
|
||||
import { deleteChannelAccountConfig } from '../utils/channel-config';
|
||||
import { ensureClawXContext } from '../utils/openclaw-workspace';
|
||||
import { isRecord } from './payload-utils';
|
||||
import { syncAgentModelOverrideToRuntime, syncAllProviderAuthToRuntime } from './providers/provider-runtime-sync';
|
||||
|
||||
type AgentsApiContext = {
|
||||
gatewayManager: GatewayManager;
|
||||
};
|
||||
|
||||
function requireString(payload: unknown, key: string): string {
|
||||
if (!isRecord(payload) || typeof payload[key] !== 'string' || !payload[key].trim()) {
|
||||
throw new Error(`${key} is required`);
|
||||
}
|
||||
return payload[key].trim();
|
||||
}
|
||||
|
||||
export function createAgentsApi(_ctx: AgentsApiContext): CompleteHostServiceRegistry['agents'] {
|
||||
return {
|
||||
list: async () => ({ success: true, ...(await listAgentsSnapshot()) }),
|
||||
create: async (payload) => {
|
||||
const name = requireString(payload, 'name');
|
||||
const inheritWorkspace = isRecord(payload) ? payload.inheritWorkspace === true : undefined;
|
||||
const snapshot = await createAgent(name, { inheritWorkspace });
|
||||
syncAllProviderAuthToRuntime().catch((err) => {
|
||||
console.warn('[agents] Failed to sync provider auth after agent creation:', err);
|
||||
});
|
||||
void ensureClawXContext({ waitForAllConfiguredWorkspaces: true }).catch((err) => {
|
||||
console.warn('[agents] Failed to ensure ClawX context after agent creation:', err);
|
||||
});
|
||||
return { success: true, ...snapshot };
|
||||
},
|
||||
update: async (payload) => {
|
||||
const agentId = requireString(payload, 'id');
|
||||
const name = requireString(payload, 'name');
|
||||
const snapshot = await updateAgentName(agentId, name);
|
||||
return { success: true, ...snapshot };
|
||||
},
|
||||
updateModel: async (payload) => {
|
||||
const agentId = requireString(payload, 'id');
|
||||
const modelRef = isRecord(payload) && typeof payload.modelRef === 'string' ? payload.modelRef : null;
|
||||
const snapshot = await updateAgentModel(agentId, modelRef);
|
||||
await syncAllProviderAuthToRuntime();
|
||||
await syncAgentModelOverrideToRuntime(agentId);
|
||||
return { success: true, ...snapshot };
|
||||
},
|
||||
delete: async (payload) => {
|
||||
const agentId = requireString(payload, 'id');
|
||||
const { snapshot, removedEntry } = await deleteAgentConfig(agentId);
|
||||
await removeAgentWorkspaceDirectory(removedEntry).catch((err) => {
|
||||
console.warn('[agents] Failed to remove workspace after agent deletion:', err);
|
||||
});
|
||||
return { success: true, ...snapshot };
|
||||
},
|
||||
assignChannel: async (payload) => {
|
||||
const agentId = requireString(payload, 'id');
|
||||
const channelType = requireString(payload, 'channelType');
|
||||
const snapshot = await assignChannelToAgent(agentId, channelType);
|
||||
return { success: true, ...snapshot };
|
||||
},
|
||||
removeChannel: async (payload) => {
|
||||
const agentId = requireString(payload, 'id');
|
||||
const channelType = requireString(payload, 'channelType');
|
||||
const ownerId = agentId.trim().toLowerCase();
|
||||
const snapshotBefore = await listAgentsSnapshot();
|
||||
const ownedAccountIds = Object.entries(snapshotBefore.channelAccountOwners)
|
||||
.filter(([channelAccountKey, owner]) => {
|
||||
if (owner !== ownerId) return false;
|
||||
return channelAccountKey.startsWith(`${channelType}:`);
|
||||
})
|
||||
.map(([channelAccountKey]) => channelAccountKey.slice(channelAccountKey.indexOf(':') + 1));
|
||||
if (ownedAccountIds.length === 0) {
|
||||
const legacyAccountId = resolveAccountIdForAgent(agentId);
|
||||
if (snapshotBefore.channelAccountOwners[`${channelType}:${legacyAccountId}`] === ownerId) {
|
||||
ownedAccountIds.push(legacyAccountId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const accountId of ownedAccountIds) {
|
||||
await deleteChannelAccountConfig(channelType, accountId);
|
||||
await clearChannelBinding(channelType, accountId);
|
||||
}
|
||||
const snapshot = await listAgentsSnapshot();
|
||||
return { success: true, ...snapshot };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../utils/openclaw-doctor';
|
||||
import { isRecord } from './payload-utils';
|
||||
|
||||
type OpenClawDoctorPayload = {
|
||||
mode?: unknown;
|
||||
};
|
||||
|
||||
export function createAppApi(): CompleteHostServiceRegistry['app'] {
|
||||
return {
|
||||
openClawDoctor: async (payload) => {
|
||||
const body = isRecord(payload) ? payload as OpenClawDoctorPayload : {};
|
||||
return body.mode === 'fix' ? runOpenClawDoctorFix() : runOpenClawDoctor();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,903 @@
|
||||
import { shell as electronShell } from 'electron';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { constants } from 'node:fs';
|
||||
import type { Stats } from 'node:fs';
|
||||
import type { FileHandle } from 'node:fs/promises';
|
||||
import {
|
||||
basename,
|
||||
extname,
|
||||
isAbsolute,
|
||||
join,
|
||||
posix,
|
||||
relative,
|
||||
resolve,
|
||||
sep,
|
||||
win32,
|
||||
} from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type {
|
||||
AttachmentAccessError,
|
||||
AttachmentFileRef,
|
||||
AttachmentOpenHandlersResult,
|
||||
AttachmentSourceRef,
|
||||
OpenAttachmentResult,
|
||||
OpenAttachmentWithPayload,
|
||||
ReadAttachmentBinaryPayload,
|
||||
ReadAttachmentBinaryResult,
|
||||
ReadAttachmentTextResult,
|
||||
ResolveAttachmentPayload,
|
||||
ResolveAttachmentResult,
|
||||
} from '@shared/host-api/contract';
|
||||
import {
|
||||
FILE_PREVIEW_MAX_BINARY_BYTES,
|
||||
FILE_PREVIEW_MAX_TEXT_BYTES,
|
||||
} from '@shared/file-preview/limits';
|
||||
import type { AcpSessionAccessRegistry } from './acp-session-access-registry';
|
||||
import { recordAttachmentOpenTrace } from './acp-trace';
|
||||
import {
|
||||
HANDLER_ID_MAX_LENGTH,
|
||||
type AttachmentOpenWithService,
|
||||
} from './attachment-open-with';
|
||||
import {
|
||||
expandPath,
|
||||
resolveOpenClawConfigDir,
|
||||
resolveOpenClawStateDir,
|
||||
} from '../utils/paths';
|
||||
|
||||
const MAX_REFERENCE_LENGTH = 4096;
|
||||
const MAX_DISPLAY_NAME_LENGTH = 160;
|
||||
const MAX_OUTGOING_RECORD_BYTES = 64 * 1024;
|
||||
const SAFE_ATTACHMENT_ID = /^[A-Za-z0-9._-]+$/;
|
||||
const DIRECTORY_MIME_TYPE = 'application/x-directory';
|
||||
|
||||
const EXT_MIME_MAP: Record<string, string> = {
|
||||
'.bmp': 'image/bmp',
|
||||
'.csv': 'text/csv',
|
||||
'.gif': 'image/gif',
|
||||
'.htm': 'text/html',
|
||||
'.html': 'text/html',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.json': 'application/json',
|
||||
'.md': 'text/markdown',
|
||||
'.pdf': 'application/pdf',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.txt': 'text/plain',
|
||||
'.webp': 'image/webp',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.zip': 'application/zip',
|
||||
};
|
||||
|
||||
type AttachmentFs = {
|
||||
lstat: (path: string) => Promise<Stats>;
|
||||
open: (path: string, flags: number) => Promise<FileHandle>;
|
||||
realpath: (path: string) => Promise<string>;
|
||||
stat: (path: string) => Promise<Stats>;
|
||||
};
|
||||
|
||||
type AttachmentShell = {
|
||||
openPath: (path: string) => Promise<string>;
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
showItemInFolder: (path: string) => void;
|
||||
};
|
||||
|
||||
export type AttachmentAccess = {
|
||||
resolveAttachment: (payload: ResolveAttachmentPayload) => Promise<ResolveAttachmentResult>;
|
||||
readAttachmentText: (ref: AttachmentFileRef) => Promise<ReadAttachmentTextResult>;
|
||||
readAttachmentBinary: (payload: ReadAttachmentBinaryPayload) => Promise<ReadAttachmentBinaryResult>;
|
||||
openAttachment: (ref: AttachmentSourceRef) => Promise<OpenAttachmentResult>;
|
||||
listAttachmentOpenHandlers: (ref: AttachmentFileRef) => Promise<AttachmentOpenHandlersResult>;
|
||||
openAttachmentWith: (payload: OpenAttachmentWithPayload) => Promise<OpenAttachmentResult>;
|
||||
revealAttachment: (ref: AttachmentFileRef) => Promise<OpenAttachmentResult>;
|
||||
};
|
||||
|
||||
type AttachmentAccessDependencies = {
|
||||
sessionAccessRegistry: AcpSessionAccessRegistry;
|
||||
stagedAttachments: StagedAttachmentRegistry;
|
||||
stateDir?: string;
|
||||
configDir?: string;
|
||||
fs?: AttachmentFs;
|
||||
shell?: AttachmentShell;
|
||||
openWith: AttachmentOpenWithService;
|
||||
};
|
||||
|
||||
type LocalScope = 'workspace' | 'openclaw-media' | 'staging';
|
||||
|
||||
type ResolvedLocal = {
|
||||
kind: 'local';
|
||||
entryKind: 'file' | 'directory';
|
||||
canonicalPath: string;
|
||||
scope: LocalScope;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
authorizationRoot?: string;
|
||||
};
|
||||
|
||||
type ResolvedRemote = {
|
||||
kind: 'remote';
|
||||
normalizedUrl: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
type ResolvedTarget = ResolvedLocal | ResolvedRemote;
|
||||
|
||||
type PinnedDirectory = {
|
||||
canonicalPath: string;
|
||||
dev: number;
|
||||
ino: number;
|
||||
};
|
||||
|
||||
type ManagedAuthoritySlot = {
|
||||
lexicalParent: string;
|
||||
parent?: PinnedDirectory;
|
||||
media?: PinnedDirectory;
|
||||
pinning?: Promise<void>;
|
||||
mediaPinning?: Promise<void>;
|
||||
};
|
||||
|
||||
class AttachmentFailure extends Error {
|
||||
constructor(readonly code: AttachmentAccessError) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
export class StagedAttachmentRegistry {
|
||||
private readonly files = new Map<string, { canonicalPath: string; displayPath?: string }>();
|
||||
|
||||
register(id: string, canonicalPath: string, displayPath?: string): void {
|
||||
if (id && canonicalPath) this.files.set(id, {
|
||||
canonicalPath,
|
||||
...(displayPath ? { displayPath } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
get(id: string): string | null {
|
||||
return this.files.get(id)?.canonicalPath ?? null;
|
||||
}
|
||||
|
||||
getDisplayPath(id: string): string | null {
|
||||
return this.files.get(id)?.displayPath ?? null;
|
||||
}
|
||||
|
||||
hasPath(canonicalPath: string): boolean {
|
||||
return Array.from(this.files.values()).some((file) => isSamePath(file.canonicalPath, canonicalPath));
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveClawXStagingDir(stateDir = resolveOpenClawStateDir()): string {
|
||||
return join(resolve(stateDir), 'media', 'outbound', 'clawx-staging');
|
||||
}
|
||||
|
||||
function attachmentFailure(error: unknown): AttachmentAccessError {
|
||||
if (error instanceof AttachmentFailure) return error.code;
|
||||
const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
|
||||
if (code === 'ENOENT') return 'unavailable';
|
||||
return 'operationFailed';
|
||||
}
|
||||
|
||||
function opaqueIdentity(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function safeDisplayName(value: unknown, fallback: string): string {
|
||||
const raw = typeof value === 'string' && value.trim() ? value : fallback;
|
||||
const filename = posix.basename(raw.replace(/\\/gu, '/'));
|
||||
const withoutControls = Array.from(filename.slice(0, MAX_DISPLAY_NAME_LENGTH * 4), (character) => {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
const isControl = codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
|
||||
const isBidiFormatting = codePoint === 0x061c
|
||||
|| codePoint === 0x200e
|
||||
|| codePoint === 0x200f
|
||||
|| (codePoint >= 0x202a && codePoint <= 0x202e)
|
||||
|| (codePoint >= 0x2066 && codePoint <= 0x2069);
|
||||
return isControl || isBidiFormatting ? ' ' : character;
|
||||
}).join('');
|
||||
const cleaned = withoutControls
|
||||
.replace(/\s+/gu, ' ')
|
||||
.trim()
|
||||
.slice(0, MAX_DISPLAY_NAME_LENGTH);
|
||||
return cleaned || 'attachment';
|
||||
}
|
||||
|
||||
function decodedBasename(uri: string): string {
|
||||
try {
|
||||
if (/^https?:/i.test(uri)) {
|
||||
const url = new URL(uri);
|
||||
return decodeURIComponent(posix.basename(url.pathname)) || url.hostname;
|
||||
}
|
||||
if (/^file:/i.test(uri)) return basename(fileURLToPath(uri));
|
||||
} catch {
|
||||
// A safe generic label is returned below for malformed input.
|
||||
}
|
||||
return basename(uri.replace(/[?#].*$/u, '')) || 'attachment';
|
||||
}
|
||||
|
||||
function mimeTypeForPath(path: string): string {
|
||||
return EXT_MIME_MAP[extname(path).toLowerCase()] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
function hasTraversal(value: string): boolean {
|
||||
return value.split(/[\\/]+/u).includes('..');
|
||||
}
|
||||
|
||||
function validateReferenceSyntax(uri: unknown): asserts uri is string {
|
||||
if (typeof uri !== 'string' || !uri.trim() || uri.length > MAX_REFERENCE_LENGTH || uri.includes('\0')) {
|
||||
throw new AttachmentFailure('invalidReference');
|
||||
}
|
||||
if (uri.startsWith('\\\\') || uri.startsWith('//')) throw new AttachmentFailure('invalidReference');
|
||||
if (hasTraversal(uri)) throw new AttachmentFailure('invalidReference');
|
||||
if (uri.includes('%')) {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(uri);
|
||||
} catch {
|
||||
throw new AttachmentFailure('invalidReference');
|
||||
}
|
||||
if (decoded.includes('\0') || hasTraversal(decoded)) throw new AttachmentFailure('invalidReference');
|
||||
}
|
||||
}
|
||||
|
||||
function isInside(child: string, parent: string): boolean {
|
||||
const relativePath = relative(parent, child);
|
||||
return relativePath === ''
|
||||
|| (!isAbsolute(relativePath) && relativePath !== '..' && !relativePath.startsWith(`..${sep}`));
|
||||
}
|
||||
|
||||
function isSamePath(left: string, right: string): boolean {
|
||||
const resolvedLeft = resolve(left);
|
||||
const resolvedRight = resolve(right);
|
||||
return process.platform === 'win32'
|
||||
? resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
|
||||
: resolvedLeft === resolvedRight;
|
||||
}
|
||||
|
||||
function localPathFromUri(uri: string, executionCwd: string): string {
|
||||
if (/^file:/i.test(uri)) {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(uri);
|
||||
} catch {
|
||||
throw new AttachmentFailure('invalidReference');
|
||||
}
|
||||
if (url.username || url.password || (url.hostname && url.hostname.toLowerCase() !== 'localhost')) {
|
||||
throw new AttachmentFailure('invalidReference');
|
||||
}
|
||||
try {
|
||||
return fileURLToPath(url);
|
||||
} catch {
|
||||
throw new AttachmentFailure('invalidReference');
|
||||
}
|
||||
}
|
||||
if (/^[A-Za-z][A-Za-z0-9+.-]*:/u.test(uri) && !win32.isAbsolute(uri)) {
|
||||
throw new AttachmentFailure('invalidReference');
|
||||
}
|
||||
if (uri.startsWith('~')) return resolve(expandPath(uri));
|
||||
if (isAbsolute(uri) || win32.isAbsolute(uri)) return resolve(uri);
|
||||
return resolve(executionCwd, uri);
|
||||
}
|
||||
|
||||
function parseOutgoingUrl(uri: string): { attachmentId: string; sessionKey: string } | null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = uri.startsWith('/') ? new URL(uri, 'http://clawx.local') : new URL(uri);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const isRelativeGatewayUrl = uri.startsWith('/');
|
||||
const isLocalGatewayUrl = (url.protocol === 'http:' || url.protocol === 'https:')
|
||||
&& (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]');
|
||||
if (!isRelativeGatewayUrl && !isLocalGatewayUrl) return null;
|
||||
if (url.username || url.password) throw new AttachmentFailure('unsafeUrl');
|
||||
const segments = url.pathname.split('/');
|
||||
if (segments.length !== 8
|
||||
|| segments[1] !== 'api'
|
||||
|| segments[2] !== 'chat'
|
||||
|| segments[3] !== 'media'
|
||||
|| segments[4] !== 'outgoing'
|
||||
|| segments[7] !== 'full') {
|
||||
return null;
|
||||
}
|
||||
let sessionKey: string;
|
||||
let attachmentId: string;
|
||||
try {
|
||||
sessionKey = decodeURIComponent(segments[5]);
|
||||
attachmentId = decodeURIComponent(segments[6]);
|
||||
} catch {
|
||||
throw new AttachmentFailure('invalidReference');
|
||||
}
|
||||
if (!sessionKey || !SAFE_ATTACHMENT_ID.test(attachmentId)) {
|
||||
throw new AttachmentFailure('invalidReference');
|
||||
}
|
||||
return { attachmentId, sessionKey };
|
||||
}
|
||||
|
||||
async function canonicalManagedMediaRoots(
|
||||
stateDir: string,
|
||||
configDir: string,
|
||||
fs: AttachmentFs,
|
||||
): Promise<string[]> {
|
||||
// OpenClaw 2026.6.10 exposes only resolveStateDir()/media and resolveConfigDir()/media.
|
||||
// Keep this list exact until the distributed runtime adds a real media-root setting.
|
||||
const managedRoots = await Promise.all([stateDir, configDir].map(async (parentPath) => {
|
||||
try {
|
||||
const parent = await fs.realpath(parentPath);
|
||||
if (!(await fs.stat(parent)).isDirectory()) return null;
|
||||
const mediaPath = join(parentPath, 'media');
|
||||
if ((await fs.lstat(mediaPath)).isSymbolicLink()) return null;
|
||||
const media = await fs.realpath(mediaPath);
|
||||
return (await fs.stat(media)).isDirectory() && isInside(media, parent) ? media : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
return Array.from(new Set([
|
||||
...managedRoots.filter((root): root is string => root !== null),
|
||||
]));
|
||||
}
|
||||
|
||||
function pinnedDirectory(path: string, stat: Stats): PinnedDirectory {
|
||||
return { canonicalPath: path, dev: stat.dev, ino: stat.ino };
|
||||
}
|
||||
|
||||
async function ensureManagedAuthority(
|
||||
slot: ManagedAuthoritySlot,
|
||||
fs: AttachmentFs,
|
||||
): Promise<{ parent: PinnedDirectory; media: PinnedDirectory | null } | null> {
|
||||
if (!slot.parent) {
|
||||
if (!slot.pinning) {
|
||||
slot.pinning = (async () => {
|
||||
try {
|
||||
const parentPath = await fs.realpath(slot.lexicalParent);
|
||||
const parentStat = await fs.stat(parentPath);
|
||||
if (!parentStat.isDirectory()) return;
|
||||
slot.parent = pinnedDirectory(parentPath, parentStat);
|
||||
} catch {
|
||||
// A configured parent that does not exist yet is retried on a later operation.
|
||||
}
|
||||
})().finally(() => {
|
||||
slot.pinning = undefined;
|
||||
});
|
||||
}
|
||||
await slot.pinning;
|
||||
}
|
||||
if (!slot.parent) return null;
|
||||
|
||||
const mediaPath = join(slot.lexicalParent, 'media');
|
||||
if (!slot.media) {
|
||||
if (!slot.mediaPinning) {
|
||||
slot.mediaPinning = (async () => {
|
||||
try {
|
||||
if ((await fs.lstat(mediaPath)).isSymbolicLink()) return;
|
||||
const canonicalMedia = await fs.realpath(mediaPath);
|
||||
const mediaStat = await fs.stat(canonicalMedia);
|
||||
if (!mediaStat.isDirectory() || !isInside(canonicalMedia, slot.parent!.canonicalPath)) return;
|
||||
slot.media = pinnedDirectory(canonicalMedia, mediaStat);
|
||||
} catch {
|
||||
// Media dir not available yet.
|
||||
}
|
||||
})().finally(() => {
|
||||
slot.mediaPinning = undefined;
|
||||
});
|
||||
}
|
||||
try {
|
||||
await slot.mediaPinning;
|
||||
} catch {
|
||||
return { parent: slot.parent, media: null };
|
||||
}
|
||||
}
|
||||
return { parent: slot.parent, media: slot.media ?? null };
|
||||
}
|
||||
|
||||
async function frozenCanonicalDirectory(path: string, fs: AttachmentFs): Promise<string> {
|
||||
try {
|
||||
return await fs.realpath(path);
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
async function readOpenedFile(handle: FileHandle, maxBytes: number): Promise<Buffer | null> {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
while (total <= maxBytes) {
|
||||
const length = Math.min(64 * 1024, maxBytes + 1 - total);
|
||||
const chunk = Buffer.allocUnsafe(length);
|
||||
const { bytesRead } = await handle.read(chunk, 0, length, total);
|
||||
if (bytesRead === 0) break;
|
||||
chunks.push(chunk.subarray(0, bytesRead));
|
||||
total += bytesRead;
|
||||
}
|
||||
return total > maxBytes ? null : Buffer.concat(chunks, total);
|
||||
}
|
||||
|
||||
function looksLikeBinary(buffer: Buffer): boolean {
|
||||
const length = Math.min(buffer.length, 8192);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (buffer[index] === 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function openRevalidatedLocal(local: ResolvedLocal, fs: AttachmentFs): Promise<{
|
||||
handle: FileHandle;
|
||||
stat: Stats;
|
||||
}> {
|
||||
let handle: FileHandle | undefined;
|
||||
try {
|
||||
const noFollow = process.platform === 'win32' ? 0 : constants.O_NOFOLLOW;
|
||||
handle = await fs.open(local.canonicalPath, constants.O_RDONLY | noFollow);
|
||||
const handleStat = await handle.stat();
|
||||
if (!handleStat.isFile()) {
|
||||
throw new AttachmentFailure('notFile');
|
||||
}
|
||||
return { handle, stat: handleStat };
|
||||
} catch (error) {
|
||||
await handle?.close().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRemote(uri: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(uri);
|
||||
} catch {
|
||||
throw new AttachmentFailure('unsafeUrl');
|
||||
}
|
||||
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname || url.username || url.password) {
|
||||
throw new AttachmentFailure('unsafeUrl');
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
|
||||
function boundedBinaryCap(value: unknown): number {
|
||||
const requested = typeof value === 'number' && Number.isFinite(value) ? value : FILE_PREVIEW_MAX_BINARY_BYTES;
|
||||
return Math.max(1, Math.min(requested, FILE_PREVIEW_MAX_BINARY_BYTES));
|
||||
}
|
||||
|
||||
export async function resolveOutgoingMediaAttachment(input: {
|
||||
uri: string;
|
||||
expectedSessionKey?: string;
|
||||
transcriptMessageId?: string;
|
||||
stateDir?: string;
|
||||
configDir?: string;
|
||||
managedMediaRoots?: string[];
|
||||
fs?: AttachmentFs;
|
||||
}): Promise<{ path: string; mimeType: string; size: number; authorizationRoot: string } | null> {
|
||||
try {
|
||||
validateReferenceSyntax(input.uri);
|
||||
const outgoing = parseOutgoingUrl(input.uri);
|
||||
if (!outgoing || (input.expectedSessionKey && outgoing.sessionKey !== input.expectedSessionKey)) return null;
|
||||
const fs = input.fs ?? await import('node:fs/promises');
|
||||
const stateDir = resolve(input.stateDir ?? resolveOpenClawStateDir());
|
||||
const configDir = resolve(input.configDir ?? resolveOpenClawConfigDir());
|
||||
const recordPath = join(stateDir, 'media', 'outgoing', 'records', `${outgoing.attachmentId}.json`);
|
||||
let handle: FileHandle | undefined;
|
||||
let raw: Buffer | null;
|
||||
try {
|
||||
const noFollow = process.platform === 'win32' ? 0 : constants.O_NOFOLLOW;
|
||||
handle = await fs.open(recordPath, constants.O_RDONLY | noFollow);
|
||||
if (!(await handle.stat()).isFile()) return null;
|
||||
raw = await readOpenedFile(handle, MAX_OUTGOING_RECORD_BYTES);
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
if (!raw) return null;
|
||||
|
||||
const record = JSON.parse(raw.toString('utf8')) as Record<string, unknown>;
|
||||
const original = record.original && typeof record.original === 'object'
|
||||
? record.original as Record<string, unknown>
|
||||
: null;
|
||||
const recordId = typeof record.attachmentId === 'string' ? record.attachmentId : undefined;
|
||||
if (recordId !== outgoing.attachmentId
|
||||
|| record.sessionKey !== outgoing.sessionKey
|
||||
|| (input.transcriptMessageId && typeof record.messageId === 'string'
|
||||
&& record.messageId !== input.transcriptMessageId)
|
||||
|| !original
|
||||
|| typeof original.path !== 'string') {
|
||||
return null;
|
||||
}
|
||||
validateReferenceSyntax(original.path);
|
||||
const originalPath = localPathFromUri(original.path, stateDir);
|
||||
const roots = input.managedMediaRoots ?? await canonicalManagedMediaRoots(stateDir, configDir, fs);
|
||||
const canonicalPath = await fs.realpath(originalPath);
|
||||
const authorizationRoot = roots.find((root) => isInside(canonicalPath, root));
|
||||
if (!authorizationRoot) return null;
|
||||
const fileStat = await fs.stat(canonicalPath);
|
||||
if (!fileStat.isFile()) return null;
|
||||
return {
|
||||
path: canonicalPath,
|
||||
mimeType: typeof original.contentType === 'string' && original.contentType
|
||||
? original.contentType
|
||||
: mimeTypeForPath(canonicalPath),
|
||||
size: fileStat.size,
|
||||
authorizationRoot,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createAttachmentAccess(dependencies: AttachmentAccessDependencies): AttachmentAccess {
|
||||
const stateDir = resolve(dependencies.stateDir ?? resolveOpenClawStateDir());
|
||||
const configDir = resolve(dependencies.configDir ?? resolveOpenClawConfigDir());
|
||||
const shell = dependencies.shell ?? electronShell;
|
||||
const getFs = async (): Promise<AttachmentFs> => dependencies.fs ?? await import('node:fs/promises');
|
||||
const stateAuthority: ManagedAuthoritySlot = { lexicalParent: stateDir };
|
||||
const configAuthority: ManagedAuthoritySlot = { lexicalParent: configDir };
|
||||
const initializeAuthorities = async () => {
|
||||
const fs = await getFs();
|
||||
await Promise.all([
|
||||
ensureManagedAuthority(stateAuthority, fs),
|
||||
ensureManagedAuthority(configAuthority, fs),
|
||||
]);
|
||||
};
|
||||
void initializeAuthorities().catch(() => undefined);
|
||||
|
||||
const verifyManagedAuthorities = async (fs: AttachmentFs) => {
|
||||
const [state, config] = await Promise.all([
|
||||
ensureManagedAuthority(stateAuthority, fs),
|
||||
ensureManagedAuthority(configAuthority, fs),
|
||||
]);
|
||||
return {
|
||||
stateParent: state?.parent ?? null,
|
||||
mediaRoots: Array.from(new Set([
|
||||
...(state?.media ? [state.media.canonicalPath] : []),
|
||||
...(config?.media ? [config.media.canonicalPath] : []),
|
||||
])),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveLocalCandidate = async (
|
||||
ref: AttachmentSourceRef,
|
||||
candidateInput: string,
|
||||
mimeTypeHint?: string,
|
||||
mediaOnly = false,
|
||||
): Promise<ResolvedLocal> => {
|
||||
const context = dependencies.sessionAccessRegistry.get(ref.sessionKey, ref.generation);
|
||||
if (!context) throw new AttachmentFailure('staleSession');
|
||||
const fs = await getFs();
|
||||
const candidate = resolve(candidateInput);
|
||||
|
||||
if (ref.stagingId) {
|
||||
const stagedPath = dependencies.stagedAttachments.get(ref.stagingId);
|
||||
if (stagedPath) {
|
||||
let canonicalCandidate: string;
|
||||
try {
|
||||
canonicalCandidate = await fs.realpath(candidate);
|
||||
} catch (error) {
|
||||
throw new AttachmentFailure(attachmentFailure(error));
|
||||
}
|
||||
if (!isSamePath(canonicalCandidate, stagedPath)) throw new AttachmentFailure('invalidReference');
|
||||
const stagedStat = await fs.stat(canonicalCandidate);
|
||||
const entryKind = stagedStat.isFile()
|
||||
? 'file'
|
||||
: stagedStat.isDirectory()
|
||||
? 'directory'
|
||||
: null;
|
||||
if (!entryKind) throw new AttachmentFailure('notFile');
|
||||
return {
|
||||
kind: 'local',
|
||||
entryKind,
|
||||
canonicalPath: canonicalCandidate,
|
||||
scope: 'staging',
|
||||
mimeType: entryKind === 'directory'
|
||||
? DIRECTORY_MIME_TYPE
|
||||
: mimeTypeHint || mimeTypeForPath(canonicalCandidate),
|
||||
size: entryKind === 'directory' ? 0 : stagedStat.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let canonicalCandidate: string;
|
||||
try {
|
||||
canonicalCandidate = await fs.realpath(candidate);
|
||||
} catch (error) {
|
||||
throw new AttachmentFailure(attachmentFailure(error));
|
||||
}
|
||||
const targetStat = await fs.stat(canonicalCandidate);
|
||||
const entryKind = targetStat.isFile()
|
||||
? 'file'
|
||||
: targetStat.isDirectory()
|
||||
? 'directory'
|
||||
: null;
|
||||
if (!entryKind) throw new AttachmentFailure('notFile');
|
||||
|
||||
const workspaceRoot = mediaOnly ? null : await frozenCanonicalDirectory(context.workspaceRoot, fs);
|
||||
const scope: LocalScope = workspaceRoot && isInside(canonicalCandidate, workspaceRoot)
|
||||
? 'workspace'
|
||||
: 'openclaw-media';
|
||||
|
||||
return {
|
||||
kind: 'local',
|
||||
entryKind,
|
||||
canonicalPath: canonicalCandidate,
|
||||
scope,
|
||||
mimeType: entryKind === 'directory'
|
||||
? DIRECTORY_MIME_TYPE
|
||||
: mimeTypeHint || mimeTypeForPath(canonicalCandidate),
|
||||
size: entryKind === 'directory' ? 0 : targetStat.size,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveOutgoing = async (
|
||||
ref: AttachmentSourceRef,
|
||||
outgoing: { attachmentId: string; sessionKey: string },
|
||||
): Promise<ResolvedLocal> => {
|
||||
if (outgoing.sessionKey !== ref.sessionKey) throw new AttachmentFailure('invalidReference');
|
||||
const fs = await getFs();
|
||||
const { mediaRoots } = await verifyManagedAuthorities(fs);
|
||||
const resolved = await resolveOutgoingMediaAttachment({
|
||||
uri: ref.uri,
|
||||
expectedSessionKey: ref.sessionKey,
|
||||
transcriptMessageId: ref.transcriptMessageId,
|
||||
stateDir,
|
||||
configDir,
|
||||
managedMediaRoots: mediaRoots,
|
||||
fs,
|
||||
});
|
||||
if (!resolved) throw new AttachmentFailure('invalidReference');
|
||||
return {
|
||||
kind: 'local',
|
||||
entryKind: 'file',
|
||||
canonicalPath: resolved.path,
|
||||
scope: 'openclaw-media',
|
||||
mimeType: resolved.mimeType,
|
||||
size: resolved.size,
|
||||
authorizationRoot: resolved.authorizationRoot,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveTarget = async (
|
||||
ref: AttachmentSourceRef,
|
||||
metadata: Pick<ResolveAttachmentPayload, 'mimeType' | 'size'> = {},
|
||||
): Promise<ResolvedTarget> => {
|
||||
if (!ref || typeof ref.sessionKey !== 'string' || !Number.isFinite(ref.generation)) {
|
||||
throw new AttachmentFailure('invalidReference');
|
||||
}
|
||||
validateReferenceSyntax(ref.uri);
|
||||
const context = dependencies.sessionAccessRegistry.get(ref.sessionKey, ref.generation);
|
||||
if (!context) throw new AttachmentFailure('staleSession');
|
||||
|
||||
const outgoing = parseOutgoingUrl(ref.uri);
|
||||
if (outgoing) return resolveOutgoing(ref, outgoing);
|
||||
if (/^https?:/i.test(ref.uri)) {
|
||||
return {
|
||||
kind: 'remote',
|
||||
normalizedUrl: normalizeRemote(ref.uri),
|
||||
mimeType: metadata.mimeType || mimeTypeForPath(new URL(ref.uri).pathname),
|
||||
size: typeof metadata.size === 'number' && Number.isFinite(metadata.size) && metadata.size >= 0
|
||||
? metadata.size
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
const localPath = localPathFromUri(ref.uri, context.executionCwd);
|
||||
return resolveLocalCandidate(ref, localPath, metadata.mimeType);
|
||||
};
|
||||
|
||||
const resolveAttachment = async (payload: ResolveAttachmentPayload): Promise<ResolveAttachmentResult> => {
|
||||
const ref = payload?.ref;
|
||||
const fallbackName = decodedBasename(typeof ref?.uri === 'string' ? ref.uri : 'attachment');
|
||||
const displayName = safeDisplayName(payload?.name, fallbackName);
|
||||
try {
|
||||
const target = await resolveTarget(ref, payload);
|
||||
const localName = target.kind === 'local' ? basename(target.canonicalPath) : fallbackName;
|
||||
const finalDisplayName = safeDisplayName(payload?.name, localName);
|
||||
if (target.kind === 'remote') {
|
||||
return {
|
||||
ok: true,
|
||||
identity: opaqueIdentity(target.normalizedUrl),
|
||||
displayName: finalDisplayName,
|
||||
mimeType: target.mimeType,
|
||||
size: target.size,
|
||||
target: { kind: 'remote', ref, url: target.normalizedUrl },
|
||||
};
|
||||
}
|
||||
const displayPath = ref.stagingId
|
||||
? dependencies.stagedAttachments.getDisplayPath(ref.stagingId)
|
||||
: null;
|
||||
return {
|
||||
ok: true,
|
||||
identity: opaqueIdentity(target.canonicalPath),
|
||||
displayName: finalDisplayName,
|
||||
...(displayPath ? { displayPath } : {}),
|
||||
mimeType: target.mimeType,
|
||||
size: target.size,
|
||||
target: { kind: 'local', scope: target.scope, entryKind: target.entryKind, ref },
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false, displayName, error: attachmentFailure(error) };
|
||||
}
|
||||
};
|
||||
|
||||
const readAttachmentText = async (ref: AttachmentFileRef): Promise<ReadAttachmentTextResult> => {
|
||||
let opened: { handle: FileHandle; stat: Stats } | undefined;
|
||||
try {
|
||||
const target = await resolveTarget(ref);
|
||||
if (target.kind !== 'local') throw new AttachmentFailure('invalidReference');
|
||||
if (target.entryKind !== 'file') throw new AttachmentFailure('notFile');
|
||||
opened = await openRevalidatedLocal(target, await getFs());
|
||||
if (!dependencies.sessionAccessRegistry.get(ref.sessionKey, ref.generation)) {
|
||||
throw new AttachmentFailure('staleSession');
|
||||
}
|
||||
if (opened.stat.size > FILE_PREVIEW_MAX_TEXT_BYTES) {
|
||||
return { ok: false, error: 'tooLarge', size: opened.stat.size };
|
||||
}
|
||||
const buffer = await readOpenedFile(opened.handle, FILE_PREVIEW_MAX_TEXT_BYTES);
|
||||
if (!buffer) return { ok: false, error: 'tooLarge', size: FILE_PREVIEW_MAX_TEXT_BYTES + 1 };
|
||||
if (looksLikeBinary(buffer)) return { ok: false, error: 'binary', size: buffer.length };
|
||||
return {
|
||||
ok: true,
|
||||
content: buffer.toString('utf8'),
|
||||
mimeType: target.mimeType,
|
||||
size: buffer.length,
|
||||
readOnly: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false, error: attachmentFailure(error) };
|
||||
} finally {
|
||||
await opened?.handle.close().catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const readAttachmentBinary = async (
|
||||
payload: ReadAttachmentBinaryPayload,
|
||||
): Promise<ReadAttachmentBinaryResult> => {
|
||||
let opened: { handle: FileHandle; stat: Stats } | undefined;
|
||||
try {
|
||||
const target = await resolveTarget(payload?.ref);
|
||||
if (target.kind !== 'local') throw new AttachmentFailure('invalidReference');
|
||||
if (target.entryKind !== 'file') throw new AttachmentFailure('notFile');
|
||||
opened = await openRevalidatedLocal(target, await getFs());
|
||||
if (!dependencies.sessionAccessRegistry.get(payload.ref.sessionKey, payload.ref.generation)) {
|
||||
throw new AttachmentFailure('staleSession');
|
||||
}
|
||||
const cap = boundedBinaryCap(payload.maxBytes);
|
||||
if (opened.stat.size > cap) return { ok: false, error: 'tooLarge', size: opened.stat.size };
|
||||
const buffer = await readOpenedFile(opened.handle, cap);
|
||||
if (!buffer) return { ok: false, error: 'tooLarge', size: cap + 1 };
|
||||
return {
|
||||
ok: true,
|
||||
data: new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength),
|
||||
mimeType: target.mimeType,
|
||||
size: buffer.length,
|
||||
readOnly: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false, error: attachmentFailure(error) };
|
||||
} finally {
|
||||
await opened?.handle.close().catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const openAttachment = async (ref: AttachmentSourceRef): Promise<OpenAttachmentResult> => {
|
||||
let identity = opaqueIdentity(typeof ref?.uri === 'string' ? ref.uri : 'invalid');
|
||||
let sourceKind: 'local' | 'remote' | 'invalid' = typeof ref?.uri === 'string'
|
||||
? (/^https?:/i.test(ref.uri) ? 'remote' : 'local')
|
||||
: 'invalid';
|
||||
try {
|
||||
const target = await resolveTarget(ref);
|
||||
if (target.kind === 'remote') {
|
||||
sourceKind = 'remote';
|
||||
identity = opaqueIdentity(target.normalizedUrl);
|
||||
if (!dependencies.sessionAccessRegistry.get(ref.sessionKey, ref.generation)) {
|
||||
throw new AttachmentFailure('staleSession');
|
||||
}
|
||||
await shell.openExternal(target.normalizedUrl);
|
||||
} else {
|
||||
sourceKind = 'local';
|
||||
identity = opaqueIdentity(target.canonicalPath);
|
||||
const revalidated = await resolveTarget(ref);
|
||||
if (revalidated.kind !== 'local') throw new AttachmentFailure('invalidReference');
|
||||
identity = opaqueIdentity(revalidated.canonicalPath);
|
||||
if (!dependencies.sessionAccessRegistry.get(ref.sessionKey, ref.generation)) {
|
||||
throw new AttachmentFailure('staleSession');
|
||||
}
|
||||
const error = await shell.openPath(revalidated.canonicalPath);
|
||||
if (error) throw new AttachmentFailure('operationFailed');
|
||||
}
|
||||
recordAttachmentOpenTrace({
|
||||
ok: true,
|
||||
reason: 'success',
|
||||
sourceKind,
|
||||
sessionKey: ref.sessionKey,
|
||||
generation: ref.generation,
|
||||
identity,
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const reason = attachmentFailure(error);
|
||||
recordAttachmentOpenTrace({
|
||||
ok: false,
|
||||
reason,
|
||||
sourceKind,
|
||||
sessionKey: typeof ref?.sessionKey === 'string' ? ref.sessionKey : '',
|
||||
generation: typeof ref?.generation === 'number' ? ref.generation : -1,
|
||||
identity,
|
||||
});
|
||||
return { ok: false, error: reason };
|
||||
}
|
||||
};
|
||||
|
||||
const requireCurrentLocalFileTarget = async (ref: AttachmentFileRef): Promise<ResolvedLocal> => {
|
||||
const target = await resolveTarget(ref);
|
||||
if (target.kind !== 'local') throw new AttachmentFailure('invalidReference');
|
||||
if (target.entryKind !== 'file') throw new AttachmentFailure('notFile');
|
||||
if (!dependencies.sessionAccessRegistry.get(ref.sessionKey, ref.generation)) {
|
||||
throw new AttachmentFailure('staleSession');
|
||||
}
|
||||
return target;
|
||||
};
|
||||
|
||||
const listAttachmentOpenHandlers = async (
|
||||
ref: AttachmentFileRef,
|
||||
): Promise<AttachmentOpenHandlersResult> => {
|
||||
try {
|
||||
const target = await requireCurrentLocalFileTarget(ref);
|
||||
if (dependencies.openWith.platform === 'linux') {
|
||||
return { ok: true, platform: 'linux', handlers: [] };
|
||||
}
|
||||
const handlers = await dependencies.openWith.list(target.canonicalPath);
|
||||
if (!dependencies.sessionAccessRegistry.get(ref.sessionKey, ref.generation)) {
|
||||
throw new AttachmentFailure('staleSession');
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
platform: dependencies.openWith.platform,
|
||||
handlers: handlers.map(({ id, name, iconDataUrl, isDefault }) => ({
|
||||
handlerId: id,
|
||||
name,
|
||||
...(iconDataUrl ? { iconDataUrl } : {}),
|
||||
isDefault,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false, error: attachmentFailure(error) };
|
||||
}
|
||||
};
|
||||
|
||||
const openAttachmentWith = async (
|
||||
payload: OpenAttachmentWithPayload,
|
||||
): Promise<OpenAttachmentResult> => {
|
||||
try {
|
||||
const target = await requireCurrentLocalFileTarget(payload?.ref);
|
||||
if (typeof payload?.handlerId !== 'string'
|
||||
|| !payload.handlerId.trim()
|
||||
|| payload.handlerId.length > HANDLER_ID_MAX_LENGTH) {
|
||||
throw new AttachmentFailure('invalidReference');
|
||||
}
|
||||
await dependencies.openWith.open(
|
||||
target.canonicalPath,
|
||||
payload.handlerId,
|
||||
async () => (await requireCurrentLocalFileTarget(payload.ref)).canonicalPath,
|
||||
);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: attachmentFailure(error) };
|
||||
}
|
||||
};
|
||||
|
||||
const revealAttachment = async (ref: AttachmentFileRef): Promise<OpenAttachmentResult> => {
|
||||
try {
|
||||
await requireCurrentLocalFileTarget(ref);
|
||||
const revalidated = await requireCurrentLocalFileTarget(ref);
|
||||
shell.showItemInFolder(revalidated.canonicalPath);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: attachmentFailure(error) };
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
resolveAttachment,
|
||||
readAttachmentText,
|
||||
readAttachmentBinary,
|
||||
openAttachment,
|
||||
listAttachmentOpenHandlers,
|
||||
openAttachmentWith,
|
||||
revealAttachment,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
import { app, type NativeImage } from 'electron';
|
||||
import {
|
||||
execFile as nodeExecFile,
|
||||
spawn as nodeSpawn,
|
||||
type ChildProcess,
|
||||
type ChildProcessWithoutNullStreams,
|
||||
type ExecFileException,
|
||||
type ExecFileOptionsWithStringEncoding,
|
||||
type SpawnOptionsWithoutStdio,
|
||||
} from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
|
||||
export const PROCESS_TIMEOUT_MS = 5_000;
|
||||
export const PROCESS_MAX_BUFFER_BYTES = 1_048_576;
|
||||
export const HANDLER_NAME_MAX_LENGTH = 256;
|
||||
export const HANDLER_ID_MAX_LENGTH = 512;
|
||||
export const NATIVE_PATH_MAX_LENGTH = 4_096;
|
||||
export const ICON_DATA_URL_MAX_BYTES = 65_536;
|
||||
export const CACHE_TTL_MS = 300_000;
|
||||
export const CACHE_MAX_ENTRIES = 128;
|
||||
|
||||
export type OpenWithPlatform = 'darwin' | 'win32' | 'linux';
|
||||
|
||||
export type SystemOpenHandler = {
|
||||
id: string;
|
||||
name: string;
|
||||
iconDataUrl?: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
export type AttachmentOpenWithService = {
|
||||
platform: OpenWithPlatform;
|
||||
list(filePath: string): Promise<SystemOpenHandler[]>;
|
||||
open(
|
||||
filePath: string,
|
||||
handlerId: string,
|
||||
revalidateFile: () => Promise<string>,
|
||||
): Promise<void>;
|
||||
};
|
||||
|
||||
type ExecFileDependency = (
|
||||
file: string,
|
||||
args: string[],
|
||||
options: ExecFileOptionsWithStringEncoding,
|
||||
callback: (error: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => ChildProcess;
|
||||
|
||||
type SpawnDependency = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: SpawnOptionsWithoutStdio,
|
||||
) => ChildProcessWithoutNullStreams;
|
||||
|
||||
type IconImage = Pick<NativeImage, 'isEmpty' | 'toPNG'>;
|
||||
|
||||
export type AttachmentOpenWithDependencies = {
|
||||
platform?: OpenWithPlatform;
|
||||
clock?: () => number;
|
||||
execFile?: ExecFileDependency;
|
||||
spawn?: SpawnDependency;
|
||||
loadIcon?: (filePath: string) => Promise<IconImage>;
|
||||
resolveHelperPath?: () => string;
|
||||
};
|
||||
|
||||
type NativeOpenHandler = SystemOpenHandler & {
|
||||
nativeId: string;
|
||||
applicationPath?: string;
|
||||
iconSourcePath?: string;
|
||||
};
|
||||
|
||||
type CacheEntry = {
|
||||
createdAt: number;
|
||||
handlers: NativeOpenHandler[];
|
||||
};
|
||||
|
||||
const cacheSizeReaders = new WeakMap<AttachmentOpenWithService, () => number>();
|
||||
|
||||
/** @internal Test-only retention check; never exposes cache keys or values. */
|
||||
export function getAttachmentOpenWithCacheSizeForTest(service: AttachmentOpenWithService): number {
|
||||
return cacheSizeReaders.get(service)?.() ?? 0;
|
||||
}
|
||||
|
||||
const WINDOWS_PUBLIC_ID = /^[a-f0-9]{64}$/;
|
||||
const POWERSHELL_PREFIX_ARGS = [
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-File',
|
||||
];
|
||||
|
||||
const MACOS_JXA_PROGRAM = String.raw`
|
||||
ObjC.import('Foundation');
|
||||
ObjC.import('AppKit');
|
||||
|
||||
function stringValue(value) {
|
||||
if (!value) return '';
|
||||
try { return ObjC.unwrap(value); } catch (_) { return String(value); }
|
||||
}
|
||||
|
||||
function iconPngBase64(workspace, bundlePath) {
|
||||
try {
|
||||
var image = workspace.iconForFile(bundlePath);
|
||||
var targetSize = $.NSMakeSize(32, 32);
|
||||
var resized = $.NSImage.alloc.initWithSize(targetSize);
|
||||
resized.lockFocus;
|
||||
image.drawInRectFromRectOperationFraction(
|
||||
$.NSMakeRect(0, 0, 32, 32),
|
||||
$.NSZeroRect,
|
||||
$.NSCompositingOperationCopy,
|
||||
1.0
|
||||
);
|
||||
resized.unlockFocus;
|
||||
var bitmap = $.NSBitmapImageRep.imageRepWithData(resized.TIFFRepresentation);
|
||||
if (!bitmap) return '';
|
||||
var png = bitmap.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $({}));
|
||||
if (!png || Number(png.length) === 0) return '';
|
||||
return stringValue(png.base64EncodedStringWithOptions(0));
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function run(argv) {
|
||||
if (!argv || (argv.length !== 1 && argv.length !== 2)) return JSON.stringify([]);
|
||||
var includeIcons = argv.length === 2 && argv[1] === 'icons';
|
||||
if (argv.length === 2 && !includeIcons) return JSON.stringify([]);
|
||||
var fileURL = $.NSURL.fileURLWithPath(argv[0]);
|
||||
var workspace = $.NSWorkspace.sharedWorkspace;
|
||||
var applicationURLs = workspace.URLsForApplicationsToOpenURL(fileURL);
|
||||
var defaultURL = workspace.URLForApplicationToOpenURL(fileURL);
|
||||
var records = [];
|
||||
|
||||
for (var index = 0; index < Number(applicationURLs.count); index += 1) {
|
||||
var applicationURL = applicationURLs.objectAtIndex(index);
|
||||
var bundle = $.NSBundle.bundleWithURL(applicationURL);
|
||||
var bundleId = stringValue(bundle.bundleIdentifier);
|
||||
var bundlePath = stringValue(applicationURL.path);
|
||||
var name = stringValue($.NSFileManager.defaultManager.displayNameAtPath(bundlePath));
|
||||
if (!bundleId || !name || !bundlePath) continue;
|
||||
var record = {
|
||||
nativeId: bundleId,
|
||||
name: name,
|
||||
applicationPath: bundlePath,
|
||||
isDefault: Boolean(defaultURL && applicationURL.isEqual(defaultURL))
|
||||
};
|
||||
if (includeIcons) {
|
||||
var icon = iconPngBase64(workspace, bundlePath);
|
||||
if (icon) record.iconPngBase64 = icon;
|
||||
}
|
||||
records.push(record);
|
||||
}
|
||||
|
||||
return JSON.stringify(records);
|
||||
}
|
||||
`;
|
||||
|
||||
function hasControlCharacters(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const codeUnit = value.charCodeAt(index);
|
||||
if (codeUnit <= 0x1f || (codeUnit >= 0x7f && codeUnit <= 0x9f)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isBoundedString(value: unknown, maxLength: number): value is string {
|
||||
return typeof value === 'string'
|
||||
&& value.length > 0
|
||||
&& value.length <= maxLength
|
||||
&& !hasControlCharacters(value);
|
||||
}
|
||||
|
||||
function isOptionalBoundedPath(value: unknown): value is string | undefined {
|
||||
return value === undefined || isBoundedString(value, NATIVE_PATH_MAX_LENGTH);
|
||||
}
|
||||
|
||||
function iconDataUrlFromBase64(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string' || value.length === 0) return undefined;
|
||||
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const iconDataUrl = `data:image/png;base64,${value}`;
|
||||
if (Buffer.byteLength(iconDataUrl, 'utf8') > ICON_DATA_URL_MAX_BYTES) return undefined;
|
||||
const png = Buffer.from(value, 'base64');
|
||||
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
if (png.length < pngSignature.length || !png.subarray(0, pngSignature.length).equals(pngSignature)) {
|
||||
return undefined;
|
||||
}
|
||||
return iconDataUrl;
|
||||
}
|
||||
|
||||
function associationKey(platform: OpenWithPlatform, filePath: string): string {
|
||||
const pathApi = platform === 'win32' ? path.win32 : path.posix;
|
||||
const basename = pathApi.basename(filePath).toLocaleLowerCase('en-US');
|
||||
const extension = pathApi.extname(basename);
|
||||
return extension || basename;
|
||||
}
|
||||
|
||||
function processEnvironment(platform: OpenWithPlatform): NodeJS.ProcessEnv {
|
||||
if (platform === 'win32') {
|
||||
const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows';
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
SystemRoot: systemRoot,
|
||||
WINDIR: systemRoot,
|
||||
PATH: [
|
||||
path.win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0'),
|
||||
path.win32.join(systemRoot, 'System32'),
|
||||
systemRoot,
|
||||
].join(';'),
|
||||
PATHEXT: '.COM;.EXE;.BAT;.CMD',
|
||||
};
|
||||
for (const key of ['TEMP', 'TMP', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA'] as const) {
|
||||
if (process.env[key]) env[key] = process.env[key];
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
const env: NodeJS.ProcessEnv = { PATH: '/usr/bin:/bin:/usr/sbin:/sbin' };
|
||||
for (const key of ['HOME', 'TMPDIR', 'USER', 'LOGNAME', 'LANG', 'LC_ALL'] as const) {
|
||||
if (process.env[key]) env[key] = process.env[key];
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function publicHandlers(handlers: NativeOpenHandler[]): SystemOpenHandler[] {
|
||||
return handlers.map(({ id, name, iconDataUrl, isDefault }) => ({
|
||||
id,
|
||||
name,
|
||||
...(iconDataUrl ? { iconDataUrl } : {}),
|
||||
isDefault,
|
||||
}));
|
||||
}
|
||||
|
||||
function windowsPublicId(nativeId: string): string {
|
||||
return createHash('sha256').update(`win32\0${nativeId}`, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function normalizeRecords(platform: OpenWithPlatform, output: string): NativeOpenHandler[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(output.replace(/^\uFEFF/, '')) as unknown;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
const deduplicated = new Map<string, NativeOpenHandler>();
|
||||
for (const value of parsed) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (!isBoundedString(record.nativeId, HANDLER_ID_MAX_LENGTH)) continue;
|
||||
if (!isBoundedString(record.name, HANDLER_NAME_MAX_LENGTH)) continue;
|
||||
if (typeof record.isDefault !== 'boolean') continue;
|
||||
if (!isOptionalBoundedPath(record.applicationPath)) continue;
|
||||
if (!isOptionalBoundedPath(record.iconSourcePath)) continue;
|
||||
if (platform === 'darwin' && !isBoundedString(record.applicationPath, NATIVE_PATH_MAX_LENGTH)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = platform === 'win32' ? windowsPublicId(record.nativeId) : record.nativeId;
|
||||
const iconDataUrl = platform === 'darwin'
|
||||
? iconDataUrlFromBase64(record.iconPngBase64)
|
||||
: undefined;
|
||||
const existing = deduplicated.get(id);
|
||||
if (existing) {
|
||||
if (record.isDefault) existing.isDefault = true;
|
||||
if (!existing.iconDataUrl && iconDataUrl) existing.iconDataUrl = iconDataUrl;
|
||||
continue;
|
||||
}
|
||||
deduplicated.set(id, {
|
||||
id,
|
||||
nativeId: record.nativeId,
|
||||
name: record.name,
|
||||
...(record.applicationPath ? { applicationPath: record.applicationPath } : {}),
|
||||
...(record.iconSourcePath ? { iconSourcePath: record.iconSourcePath } : {}),
|
||||
...(iconDataUrl ? { iconDataUrl } : {}),
|
||||
isDefault: record.isDefault,
|
||||
});
|
||||
}
|
||||
|
||||
const handlers = [...deduplicated.values()];
|
||||
return [
|
||||
...handlers.filter((handler) => handler.isDefault),
|
||||
...handlers.filter((handler) => !handler.isDefault),
|
||||
];
|
||||
}
|
||||
|
||||
function defaultHelperPath(): string {
|
||||
const root = app.isPackaged ? process.resourcesPath : app.getAppPath();
|
||||
return path.join(root, 'resources', 'scripts', 'attachment-open-with.ps1');
|
||||
}
|
||||
|
||||
function processError(reason: string): Error {
|
||||
return new Error(`attachment-open-with:${reason}`);
|
||||
}
|
||||
|
||||
export function createAttachmentOpenWithService(
|
||||
dependencies: AttachmentOpenWithDependencies = {},
|
||||
): AttachmentOpenWithService {
|
||||
const platform = dependencies.platform ?? (
|
||||
process.platform === 'darwin' || process.platform === 'win32' ? process.platform : 'linux'
|
||||
);
|
||||
const clock = dependencies.clock ?? Date.now;
|
||||
const execFile = dependencies.execFile ?? (nodeExecFile as ExecFileDependency);
|
||||
const spawn = dependencies.spawn ?? (nodeSpawn as SpawnDependency);
|
||||
const loadIcon = dependencies.loadIcon ?? ((filePath: string) => app.getFileIcon(filePath, { size: 'normal' }));
|
||||
const resolveHelperPath = dependencies.resolveHelperPath ?? defaultHelperPath;
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
function runExec(command: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(command, args, {
|
||||
timeout: PROCESS_TIMEOUT_MS,
|
||||
maxBuffer: PROCESS_MAX_BUFFER_BYTES,
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
shell: false,
|
||||
env: processEnvironment(platform),
|
||||
}, (error, stdout) => {
|
||||
if (error) {
|
||||
reject(processError('process-failed'));
|
||||
return;
|
||||
}
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function discover(filePath: string, includeIcons = false): Promise<NativeOpenHandler[]> {
|
||||
try {
|
||||
if (platform === 'darwin') {
|
||||
const output = await runExec('/usr/bin/osascript', [
|
||||
'-l',
|
||||
'JavaScript',
|
||||
'-e',
|
||||
MACOS_JXA_PROGRAM,
|
||||
'--',
|
||||
filePath,
|
||||
...(includeIcons ? ['icons'] : []),
|
||||
]);
|
||||
return normalizeRecords(platform, output);
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
const output = await runExec('powershell.exe', [
|
||||
...POWERSHELL_PREFIX_ARGS,
|
||||
resolveHelperPath(),
|
||||
'list',
|
||||
filePath,
|
||||
]);
|
||||
return normalizeRecords(platform, output);
|
||||
}
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichIcons(handlers: NativeOpenHandler[]): Promise<NativeOpenHandler[]> {
|
||||
if (platform === 'darwin') return handlers;
|
||||
return await Promise.all(handlers.map(async (handler) => {
|
||||
const iconPath = handler.iconSourcePath ?? handler.applicationPath;
|
||||
if (!iconPath) return handler;
|
||||
try {
|
||||
const image = await loadIcon(iconPath);
|
||||
if (image.isEmpty()) return handler;
|
||||
const png = image.toPNG();
|
||||
if (png.length === 0) return handler;
|
||||
const iconDataUrl = `data:image/png;base64,${png.toString('base64')}`;
|
||||
if (Buffer.byteLength(iconDataUrl, 'utf8') > ICON_DATA_URL_MAX_BYTES) return handler;
|
||||
return { ...handler, iconDataUrl };
|
||||
} catch {
|
||||
return handler;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async function list(filePath: string): Promise<SystemOpenHandler[]> {
|
||||
const now = clock();
|
||||
for (const [key, entry] of cache) {
|
||||
if (now - entry.createdAt >= CACHE_TTL_MS) cache.delete(key);
|
||||
}
|
||||
if (platform === 'linux') return [];
|
||||
if (!isBoundedString(filePath, NATIVE_PATH_MAX_LENGTH)) return [];
|
||||
const cacheKey = `${platform}:${associationKey(platform, filePath)}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached && now - cached.createdAt < CACHE_TTL_MS) {
|
||||
return publicHandlers(cached.handlers);
|
||||
}
|
||||
|
||||
const handlers = await enrichIcons(await discover(filePath, true));
|
||||
cache.set(cacheKey, { createdAt: now, handlers });
|
||||
while (cache.size > CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = cache.keys().next().value;
|
||||
if (oldestKey === undefined) break;
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
return publicHandlers(handlers);
|
||||
}
|
||||
|
||||
async function openMac(
|
||||
filePath: string,
|
||||
handlerId: string,
|
||||
revalidateFile: () => Promise<string>,
|
||||
): Promise<void> {
|
||||
const handlers = await discover(filePath);
|
||||
const selected = handlers.find((handler) => handler.id === handlerId && handler.applicationPath);
|
||||
if (!selected?.applicationPath) throw processError('unknown-handler');
|
||||
const revalidatedPath = await revalidateFile();
|
||||
if (!isBoundedString(revalidatedPath, NATIVE_PATH_MAX_LENGTH)) throw processError('invalid-path');
|
||||
if (associationKey(platform, revalidatedPath) !== associationKey(platform, filePath)) {
|
||||
throw processError('association-changed');
|
||||
}
|
||||
try {
|
||||
await runExec('/usr/bin/open', ['-a', selected.applicationPath, revalidatedPath]);
|
||||
} catch {
|
||||
throw processError('invoke-failed');
|
||||
}
|
||||
}
|
||||
|
||||
function openWindows(
|
||||
filePath: string,
|
||||
handlerId: string,
|
||||
revalidateFile: () => Promise<string>,
|
||||
): Promise<void> {
|
||||
if (!WINDOWS_PUBLIC_ID.test(handlerId)) return Promise.reject(processError('unknown-handler'));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let child: ChildProcessWithoutNullStreams;
|
||||
try {
|
||||
child = spawn('powershell.exe', [
|
||||
...POWERSHELL_PREFIX_ARGS,
|
||||
resolveHelperPath(),
|
||||
'prepare-open',
|
||||
filePath,
|
||||
handlerId,
|
||||
], {
|
||||
windowsHide: true,
|
||||
shell: false,
|
||||
stdio: 'pipe',
|
||||
env: processEnvironment(platform),
|
||||
});
|
||||
} catch {
|
||||
reject(processError('helper-start-failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
let ready = false;
|
||||
let invokeSent = false;
|
||||
let outputBytes = 0;
|
||||
let stdoutBuffer = '';
|
||||
|
||||
const finish = (error?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
|
||||
const abort = (error: Error) => {
|
||||
if (settled) return;
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// The bounded rejection is sufficient if the process already exited.
|
||||
}
|
||||
finish(error);
|
||||
};
|
||||
|
||||
const handleReady = async () => {
|
||||
try {
|
||||
const revalidatedPath = await revalidateFile();
|
||||
if (settled) return;
|
||||
if (!isBoundedString(revalidatedPath, NATIVE_PATH_MAX_LENGTH)) {
|
||||
abort(processError('invalid-path'));
|
||||
return;
|
||||
}
|
||||
if (associationKey(platform, revalidatedPath) !== associationKey(platform, filePath)) {
|
||||
abort(processError('association-changed'));
|
||||
return;
|
||||
}
|
||||
invokeSent = true;
|
||||
child.stdin.end(`${JSON.stringify({ command: 'invoke', path: revalidatedPath })}\n`, 'utf8');
|
||||
} catch (error) {
|
||||
abort(error instanceof Error ? error : processError('revalidation-failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => abort(processError('helper-timeout')), PROCESS_TIMEOUT_MS);
|
||||
|
||||
const accountOutput = (chunk: Buffer | string): boolean => {
|
||||
outputBytes += Buffer.byteLength(chunk);
|
||||
if (outputBytes > PROCESS_MAX_BUFFER_BYTES) {
|
||||
abort(processError('helper-output-limit'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer | string) => {
|
||||
if (settled || !accountOutput(chunk)) return;
|
||||
if (ready) {
|
||||
abort(processError('helper-protocol'));
|
||||
return;
|
||||
}
|
||||
stdoutBuffer += chunk.toString();
|
||||
const newline = stdoutBuffer.indexOf('\n');
|
||||
if (newline < 0) return;
|
||||
const line = stdoutBuffer.slice(0, newline).replace(/\r$/, '');
|
||||
const remainder = stdoutBuffer.slice(newline + 1);
|
||||
let message: unknown;
|
||||
try {
|
||||
message = JSON.parse(line) as unknown;
|
||||
} catch {
|
||||
abort(processError('helper-protocol'));
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== 'object' || Array.isArray(message)) {
|
||||
abort(processError('helper-protocol'));
|
||||
return;
|
||||
}
|
||||
const record = message as Record<string, unknown>;
|
||||
if (record.ready !== true || Object.keys(record).length !== 1 || remainder.trim()) {
|
||||
abort(processError('helper-protocol'));
|
||||
return;
|
||||
}
|
||||
ready = true;
|
||||
stdoutBuffer = '';
|
||||
void handleReady();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer | string) => {
|
||||
if (!settled) accountOutput(chunk);
|
||||
});
|
||||
child.stdin.on('error', () => abort(processError('helper-stdin')));
|
||||
child.on('error', () => abort(processError('helper-start-failed')));
|
||||
child.on('close', (code) => {
|
||||
if (settled) return;
|
||||
if (code === 0 && invokeSent) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
finish(processError(ready ? 'helper-failed' : 'unknown-handler'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function open(
|
||||
filePath: string,
|
||||
handlerId: string,
|
||||
revalidateFile: () => Promise<string>,
|
||||
): Promise<void> {
|
||||
if (!isBoundedString(filePath, NATIVE_PATH_MAX_LENGTH)) throw processError('invalid-path');
|
||||
if (!isBoundedString(handlerId, HANDLER_ID_MAX_LENGTH)) throw processError('unknown-handler');
|
||||
if (platform === 'linux') throw processError('unsupported-platform');
|
||||
if (platform === 'darwin') return await openMac(filePath, handlerId, revalidateFile);
|
||||
return await openWindows(filePath, handlerId, revalidateFile);
|
||||
}
|
||||
|
||||
const service = { platform, list, open };
|
||||
cacheSizeReaders.set(service, () => cache.size);
|
||||
return service;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import { createAcpChatService } from './acp-chat-service';
|
||||
import type { AcpSessionAccessRegistry } from './acp-session-access-registry';
|
||||
|
||||
export function createChatApi({
|
||||
gatewayManager,
|
||||
mainWindow,
|
||||
acpSessionAccessRegistry,
|
||||
}: {
|
||||
gatewayManager: GatewayManager;
|
||||
mainWindow: BrowserWindow;
|
||||
acpSessionAccessRegistry: AcpSessionAccessRegistry;
|
||||
}): CompleteHostServiceRegistry['chat'] {
|
||||
const acpChat = createAcpChatService(mainWindow, acpSessionAccessRegistry, gatewayManager);
|
||||
|
||||
return {
|
||||
loadAcpSession: (payload) => acpChat.loadSession(payload),
|
||||
sendAcpPrompt: (payload) => acpChat.sendPrompt(payload),
|
||||
cancelAcpSession: (payload) => acpChat.cancelSession(payload),
|
||||
respondAcpPermission: (payload) => acpChat.respondPermission(payload),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import type { RawMessage } from '@shared/chat/types';
|
||||
import type { CronJob, CronJobDelivery, CronSchedule } from '@shared/types/cron';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import { getOpenClawConfigDir } from '../utils/paths';
|
||||
import { resolveAgentIdFromChannel } from '../utils/agent-config';
|
||||
import { toOpenClawChannelType, toUiChannelType } from '../utils/channel-alias';
|
||||
import { resolveAccountIdFromSessionHistory } from '../utils/session-util';
|
||||
import { loadSessionTranscriptByKey } from './sessions-api';
|
||||
import { isRecord } from './payload-utils';
|
||||
|
||||
interface GatewayCronJob {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
schedule: { kind: string; expr?: string; everyMs?: number; at?: string; tz?: string };
|
||||
payload: { kind: string; message?: string; text?: string };
|
||||
delivery?: { mode: string; channel?: string; to?: string; accountId?: string };
|
||||
sessionTarget?: string;
|
||||
state: {
|
||||
nextRunAtMs?: number;
|
||||
runningAtMs?: number;
|
||||
lastRunAtMs?: number;
|
||||
lastStatus?: string;
|
||||
lastError?: string;
|
||||
lastDurationMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface CronRunLogEntry {
|
||||
jobId?: string;
|
||||
action?: string;
|
||||
status?: string;
|
||||
error?: string;
|
||||
summary?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
ts?: number;
|
||||
runAtMs?: number;
|
||||
durationMs?: number;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
interface CronSessionKeyParts {
|
||||
agentId: string;
|
||||
jobId: string;
|
||||
runSessionId?: string;
|
||||
}
|
||||
|
||||
interface CronSessionFallbackMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
const OPENCLAW_CRON_SUMMARY_TRUNCATION_MIN_CHARS = 2_000;
|
||||
|
||||
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 getMessageText(content: RawMessage['content']): string {
|
||||
if (typeof content === 'string') return content.trim();
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.map((block) => {
|
||||
if (!block || typeof block !== 'object') return '';
|
||||
const value = block as { type?: unknown; text?: unknown };
|
||||
return value.type === 'text' && typeof value.text === 'string' ? value.text : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getFinalAssistantReply(messages: RawMessage[]): string {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message?.role !== 'assistant') continue;
|
||||
const text = getMessageText(message.content);
|
||||
if (text) return text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function isBoundedCronSummary(summary: string): boolean {
|
||||
return summary.length >= OPENCLAW_CRON_SUMMARY_TRUNCATION_MIN_CHARS
|
||||
&& summary.endsWith('…');
|
||||
}
|
||||
|
||||
function resolveCronRunSessionKey(
|
||||
parsed: CronSessionKeyParts,
|
||||
entry: CronRunLogEntry,
|
||||
): string | null {
|
||||
const explicitSessionKey = typeof entry.sessionKey === 'string' ? entry.sessionKey.trim() : '';
|
||||
if (explicitSessionKey && parseCronSessionKey(explicitSessionKey)?.runSessionId) {
|
||||
return explicitSessionKey;
|
||||
}
|
||||
const sessionId = typeof entry.sessionId === 'string' ? entry.sessionId.trim() : '';
|
||||
if (!sessionId) return null;
|
||||
return `agent:${parsed.agentId}:cron:${parsed.jobId}:run:${sessionId}`;
|
||||
}
|
||||
|
||||
async function loadFullCronRunReplies(
|
||||
parsed: CronSessionKeyParts,
|
||||
runs: CronRunLogEntry[],
|
||||
): Promise<Map<CronRunLogEntry, string>> {
|
||||
const replies = new Map<CronRunLogEntry, string>();
|
||||
await Promise.all(runs.map(async (entry) => {
|
||||
const summary = typeof entry.summary === 'string' ? entry.summary.trim() : '';
|
||||
if (!isBoundedCronSummary(summary)) return;
|
||||
|
||||
const runSessionKey = resolveCronRunSessionKey(parsed, entry);
|
||||
if (!runSessionKey) return;
|
||||
const transcript = await loadSessionTranscriptByKey(runSessionKey, 1_000);
|
||||
if (!transcript?.length) return;
|
||||
|
||||
const fullReply = getFinalAssistantReply(transcript);
|
||||
const summaryPrefix = summary.slice(0, -1);
|
||||
if (fullReply.length > summaryPrefix.length && fullReply.startsWith(summaryPrefix)) {
|
||||
replies.set(entry, fullReply);
|
||||
}
|
||||
}));
|
||||
return replies;
|
||||
}
|
||||
|
||||
function buildCronRunMessage(
|
||||
entry: CronRunLogEntry,
|
||||
index: number,
|
||||
fullReply?: string,
|
||||
): 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 = fullReply?.trim() || 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: 'assistant',
|
||||
content,
|
||||
timestamp,
|
||||
...(status === 'error' ? { isError: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function readLegacyCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
|
||||
const logPath = join(getOpenClawConfigDir(), 'cron', 'runs', `${jobId}.jsonl`);
|
||||
const raw = await readFile(logPath, 'utf8').catch(() => '');
|
||||
if (!raw.trim()) return [];
|
||||
|
||||
const entries: CronRunLogEntry[] = [];
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const entry = JSON.parse(trimmed) as CronRunLogEntry;
|
||||
if (!entry || entry.jobId !== jobId) continue;
|
||||
if (entry.action && entry.action !== 'finished') continue;
|
||||
entries.push(entry);
|
||||
} catch {
|
||||
// Ignore malformed log lines.
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function readCronRunHistory(
|
||||
gatewayManager: GatewayManager,
|
||||
jobId: string,
|
||||
limit: number,
|
||||
): Promise<CronRunLogEntry[]> {
|
||||
try {
|
||||
const result = await gatewayManager.rpc<{ entries?: CronRunLogEntry[] }>('cron.runs', {
|
||||
id: jobId,
|
||||
limit,
|
||||
sortDir: 'asc',
|
||||
}, 8000);
|
||||
if (Array.isArray(result?.entries)) return result.entries;
|
||||
} catch {
|
||||
// OpenClaw versions before SQLite cron history may not expose cron.runs.
|
||||
}
|
||||
return readLegacyCronRunLog(jobId);
|
||||
}
|
||||
|
||||
async function readSessionStoreEntry(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
const storePath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json');
|
||||
const raw = await readFile(storePath, 'utf8').catch(() => '');
|
||||
if (!raw.trim()) return undefined;
|
||||
|
||||
try {
|
||||
const store = JSON.parse(raw) as Record<string, unknown>;
|
||||
const directEntry = store[sessionKey];
|
||||
if (directEntry && typeof directEntry === 'object') return directEntry as Record<string, unknown>;
|
||||
|
||||
const sessions = (store as { sessions?: unknown }).sessions;
|
||||
if (Array.isArray(sessions)) {
|
||||
const arrayEntry = sessions.find((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return false;
|
||||
const record = entry as Record<string, unknown>;
|
||||
return record.key === sessionKey || record.sessionKey === sessionKey;
|
||||
});
|
||||
if (arrayEntry && typeof arrayEntry === 'object') return arrayEntry as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildCronSessionFallbackMessages(params: {
|
||||
sessionKey: string;
|
||||
job?: Pick<GatewayCronJob, 'name' | 'payload' | 'state'>;
|
||||
runs: CronRunLogEntry[];
|
||||
fullReplies?: Map<CronRunLogEntry, string>;
|
||||
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) {
|
||||
messages.push({
|
||||
id: `cron-meta-${parsed.jobId}`,
|
||||
role: 'user',
|
||||
content: prompt || taskName,
|
||||
timestamp: Math.max(0, (firstRelevantTimestamp ?? Date.now()) - 1),
|
||||
});
|
||||
}
|
||||
|
||||
matchingRuns.forEach((entry, index) => {
|
||||
const message = buildCronRunMessage(entry, index, params.fullReplies?.get(entry));
|
||||
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: 'assistant',
|
||||
content: 'This scheduled task is still running in OpenClaw, but no chat transcript is available yet.',
|
||||
timestamp: runningAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const limit = typeof params.limit === 'number' && Number.isFinite(params.limit)
|
||||
? Math.max(1, Math.floor(params.limit))
|
||||
: messages.length;
|
||||
return messages.slice(-limit);
|
||||
}
|
||||
|
||||
function getUnsupportedCronDeliveryError(_channel: string | undefined): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeCronDelivery(
|
||||
rawDelivery: unknown,
|
||||
fallbackMode: CronJobDelivery['mode'] = 'none',
|
||||
): CronJobDelivery {
|
||||
if (!rawDelivery || typeof rawDelivery !== 'object') return { mode: fallbackMode };
|
||||
|
||||
const delivery = rawDelivery as JsonRecord;
|
||||
const mode = delivery.mode === 'announce' ? 'announce' : fallbackMode;
|
||||
const channel = typeof delivery.channel === 'string' && delivery.channel.trim()
|
||||
? toOpenClawChannelType(delivery.channel.trim())
|
||||
: undefined;
|
||||
const to = typeof delivery.to === 'string' && delivery.to.trim() ? delivery.to.trim() : undefined;
|
||||
const accountId = typeof delivery.accountId === 'string' && delivery.accountId.trim()
|
||||
? delivery.accountId.trim()
|
||||
: undefined;
|
||||
|
||||
if (mode === 'announce' && !channel) return { mode: 'none' };
|
||||
return {
|
||||
mode,
|
||||
...(channel ? { channel } : {}),
|
||||
...(to ? { to } : {}),
|
||||
...(accountId ? { accountId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCronSchedule(schedule: GatewayCronJob['schedule']): CronJob['schedule'] {
|
||||
if (schedule.kind === 'at' && typeof schedule.at === 'string') {
|
||||
return { kind: 'at', at: schedule.at };
|
||||
}
|
||||
if (schedule.kind === 'every' && typeof schedule.everyMs === 'number') {
|
||||
return {
|
||||
kind: 'every',
|
||||
everyMs: schedule.everyMs,
|
||||
...(typeof (schedule as CronSchedule & { anchorMs?: unknown }).anchorMs === 'number'
|
||||
? { anchorMs: (schedule as CronSchedule & { anchorMs: number }).anchorMs }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
if (schedule.kind === 'cron' && typeof schedule.expr === 'string') {
|
||||
return { kind: 'cron', expr: schedule.expr, ...(schedule.tz ? { tz: schedule.tz } : {}) };
|
||||
}
|
||||
return typeof schedule.expr === 'string' ? schedule.expr : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a UI-supplied schedule (plain cron string or structured CronSchedule)
|
||||
* into the structured form the Gateway expects. Plain strings become a cron
|
||||
* schedule; structured `at` / `every` / `cron` objects pass through after a
|
||||
* minimal shape check.
|
||||
*/
|
||||
function normalizeScheduleInput(schedule: unknown): CronSchedule {
|
||||
if (typeof schedule === 'string') {
|
||||
return { kind: 'cron', expr: schedule };
|
||||
}
|
||||
if (schedule && typeof schedule === 'object') {
|
||||
const record = schedule as Record<string, unknown>;
|
||||
if (record.kind === 'at' && typeof record.at === 'string' && record.at.trim()) {
|
||||
return { kind: 'at', at: record.at };
|
||||
}
|
||||
if (record.kind === 'every' && typeof record.everyMs === 'number' && Number.isFinite(record.everyMs)) {
|
||||
return {
|
||||
kind: 'every',
|
||||
everyMs: record.everyMs,
|
||||
...(typeof record.anchorMs === 'number' ? { anchorMs: record.anchorMs } : {}),
|
||||
};
|
||||
}
|
||||
if (record.kind === 'cron' && typeof record.expr === 'string') {
|
||||
return { kind: 'cron', expr: record.expr, ...(typeof record.tz === 'string' && record.tz ? { tz: record.tz } : {}) };
|
||||
}
|
||||
}
|
||||
throw new Error('Invalid schedule: expected a cron expression string or a CronSchedule object');
|
||||
}
|
||||
|
||||
function normalizeCronDeliveryPatch(rawDelivery: unknown): Record<string, unknown> {
|
||||
if (!rawDelivery || typeof rawDelivery !== 'object') return {};
|
||||
|
||||
const delivery = rawDelivery as JsonRecord;
|
||||
const patch: Record<string, unknown> = {};
|
||||
if ('mode' in delivery) {
|
||||
patch.mode = typeof delivery.mode === 'string' && delivery.mode.trim() ? delivery.mode.trim() : 'none';
|
||||
}
|
||||
if ('channel' in delivery) {
|
||||
patch.channel = typeof delivery.channel === 'string' && delivery.channel.trim()
|
||||
? toOpenClawChannelType(delivery.channel.trim())
|
||||
: '';
|
||||
}
|
||||
if ('to' in delivery) patch.to = typeof delivery.to === 'string' ? delivery.to : '';
|
||||
if ('accountId' in delivery) patch.accountId = typeof delivery.accountId === 'string' ? delivery.accountId : '';
|
||||
return patch;
|
||||
}
|
||||
|
||||
function buildCronUpdatePatch(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const patch = { ...input };
|
||||
if ('schedule' in patch && patch.schedule !== undefined) patch.schedule = normalizeScheduleInput(patch.schedule);
|
||||
if (typeof patch.message === 'string') {
|
||||
patch.payload = { kind: 'agentTurn', message: patch.message };
|
||||
delete patch.message;
|
||||
}
|
||||
if ('delivery' in patch) patch.delivery = normalizeCronDeliveryPatch(patch.delivery);
|
||||
if ('agentId' in patch) {
|
||||
patch.agentId = typeof patch.agentId === 'string' && patch.agentId.trim() ? patch.agentId.trim() : 'main';
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
function transformCronJob(job: GatewayCronJob): CronJob {
|
||||
const message = job.payload?.message || job.payload?.text || '';
|
||||
const gatewayDelivery = normalizeCronDelivery(job.delivery);
|
||||
const channelType = gatewayDelivery.channel ? toUiChannelType(gatewayDelivery.channel) : undefined;
|
||||
const delivery = channelType ? { ...gatewayDelivery, channel: channelType } : gatewayDelivery;
|
||||
const target = channelType
|
||||
? {
|
||||
channelType,
|
||||
channelId: delivery.accountId || gatewayDelivery.channel || channelType,
|
||||
channelName: channelType,
|
||||
recipient: delivery.to,
|
||||
}
|
||||
: undefined;
|
||||
const lastRun = job.state?.lastRunAtMs
|
||||
? {
|
||||
time: new Date(job.state.lastRunAtMs).toISOString(),
|
||||
success: job.state.lastStatus === 'ok',
|
||||
error: job.state.lastError,
|
||||
duration: job.state.lastDurationMs,
|
||||
}
|
||||
: undefined;
|
||||
const nextRun = job.state?.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : undefined;
|
||||
const agentId = (job as unknown as { agentId?: string }).agentId || 'main';
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
message,
|
||||
schedule: normalizeCronSchedule(job.schedule),
|
||||
delivery,
|
||||
target,
|
||||
enabled: job.enabled,
|
||||
createdAt: new Date(job.createdAtMs).toISOString(),
|
||||
updatedAt: new Date(job.updatedAtMs).toISOString(),
|
||||
lastRun,
|
||||
nextRun,
|
||||
agentId,
|
||||
};
|
||||
}
|
||||
|
||||
async function listCronJobs(gatewayManager: GatewayManager): Promise<CronJob[]> {
|
||||
let jobs: GatewayCronJob[] = [];
|
||||
let usedFallback = false;
|
||||
|
||||
try {
|
||||
const result = await gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000);
|
||||
const data = result as { jobs?: GatewayCronJob[] };
|
||||
jobs = data?.jobs ?? (Array.isArray(result) ? result as GatewayCronJob[] : []);
|
||||
} catch {
|
||||
try {
|
||||
const cronJsonPath = join(getOpenClawConfigDir(), 'cron', 'cron.json');
|
||||
const raw = await readFile(cronJsonPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
jobs = (Array.isArray(parsed) ? parsed : (parsed?.jobs ?? [])) as GatewayCronJob[];
|
||||
usedFallback = true;
|
||||
} catch {
|
||||
// No fallback data available.
|
||||
}
|
||||
}
|
||||
|
||||
if (!usedFallback && jobs.length > 0) {
|
||||
repairCronJobsInBackground(gatewayManager, jobs);
|
||||
}
|
||||
|
||||
return jobs.map((job) => ({ ...transformCronJob(job), ...(usedFallback ? { _fromFallback: true } : {}) }));
|
||||
}
|
||||
|
||||
function repairCronJobsInBackground(gatewayManager: GatewayManager, jobs: GatewayCronJob[]): void {
|
||||
const jobsToRepairDelivery = jobs.filter((job) => {
|
||||
const isIsolatedAgent = (job.sessionTarget === 'isolated' || !job.sessionTarget)
|
||||
&& job.payload?.kind === 'agentTurn';
|
||||
return isIsolatedAgent && job.delivery?.mode === 'announce' && !job.delivery?.channel;
|
||||
});
|
||||
|
||||
if (jobsToRepairDelivery.length > 0) {
|
||||
void (async () => {
|
||||
for (const job of jobsToRepairDelivery) {
|
||||
try {
|
||||
await gatewayManager.rpc('cron.update', {
|
||||
id: job.id,
|
||||
patch: { delivery: { mode: 'none' } },
|
||||
});
|
||||
} catch {
|
||||
// ignore per-job repair failure
|
||||
}
|
||||
}
|
||||
})();
|
||||
for (const job of jobsToRepairDelivery) {
|
||||
job.delivery = { mode: 'none' };
|
||||
if (job.state?.lastError?.includes('Channel is required')) {
|
||||
job.state.lastError = undefined;
|
||||
job.state.lastStatus = 'ok';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const jobsToRepairAgent = jobs.filter((job) => {
|
||||
const jobAgentId = (job as unknown as { agentId?: string }).agentId;
|
||||
return (
|
||||
(job.sessionTarget === 'isolated' || !job.sessionTarget)
|
||||
&& job.payload?.kind === 'agentTurn'
|
||||
&& job.delivery?.mode === 'announce'
|
||||
&& job.delivery?.channel
|
||||
&& jobAgentId === undefined
|
||||
);
|
||||
});
|
||||
|
||||
if (jobsToRepairAgent.length > 0) {
|
||||
void (async () => {
|
||||
for (const job of jobsToRepairAgent) {
|
||||
try {
|
||||
const channel = toOpenClawChannelType(job.delivery!.channel!);
|
||||
const accountId = job.delivery!.accountId;
|
||||
const toAddress = job.delivery!.to;
|
||||
let correctAgentId = await resolveAgentIdFromChannel(channel, accountId);
|
||||
let resolvedAccountId: string | null = null;
|
||||
if (!correctAgentId && !accountId && toAddress) {
|
||||
resolvedAccountId = await resolveAccountIdFromSessionHistory(toAddress, channel);
|
||||
if (resolvedAccountId) {
|
||||
correctAgentId = await resolveAgentIdFromChannel(channel, resolvedAccountId);
|
||||
}
|
||||
}
|
||||
if (correctAgentId) {
|
||||
const patch: Record<string, unknown> = { agentId: correctAgentId };
|
||||
if (resolvedAccountId && !accountId) patch.delivery = { accountId: resolvedAccountId };
|
||||
await gatewayManager.rpc('cron.update', { id: job.id, patch });
|
||||
(job as unknown as { agentId: string }).agentId = correctAgentId;
|
||||
if (resolvedAccountId && !accountId && job.delivery) job.delivery.accountId = resolvedAccountId;
|
||||
}
|
||||
} catch {
|
||||
// ignore per-job repair failure
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
function getId(payload: unknown): string {
|
||||
const body = isRecord(payload) ? payload : {};
|
||||
const id = body.id;
|
||||
if (typeof id !== 'string' || !id.trim()) throw new Error('id is required');
|
||||
return id.trim();
|
||||
}
|
||||
|
||||
export function createCronApi({ gatewayManager }: { gatewayManager: GatewayManager }): CompleteHostServiceRegistry['cron'] {
|
||||
return {
|
||||
list: async () => listCronJobs(gatewayManager),
|
||||
create: async (payload) => {
|
||||
const input = payload;
|
||||
const agentId = typeof input.agentId === 'string' && input.agentId.trim() ? input.agentId.trim() : 'main';
|
||||
const delivery = normalizeCronDelivery(input.delivery);
|
||||
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(delivery.channel);
|
||||
if (delivery.mode === 'announce' && unsupportedDeliveryError) {
|
||||
throw new Error(unsupportedDeliveryError);
|
||||
}
|
||||
const result = await gatewayManager.rpc('cron.add', {
|
||||
name: input.name,
|
||||
schedule: normalizeScheduleInput(input.schedule),
|
||||
payload: { kind: 'agentTurn', message: input.message },
|
||||
enabled: typeof input.enabled === 'boolean' ? input.enabled : true,
|
||||
wakeMode: 'next-heartbeat',
|
||||
sessionTarget: 'isolated',
|
||||
agentId,
|
||||
delivery,
|
||||
});
|
||||
if (!result || typeof result !== 'object') {
|
||||
throw new Error('Cron create returned an invalid job');
|
||||
}
|
||||
return transformCronJob(result as GatewayCronJob);
|
||||
},
|
||||
update: async (payload) => {
|
||||
const body = payload;
|
||||
const id = getId(body);
|
||||
const input = isRecord(body.input) ? body.input : {};
|
||||
const patch = buildCronUpdatePatch(input);
|
||||
delete patch.id;
|
||||
delete patch.input;
|
||||
const deliveryPatch = patch.delivery && typeof patch.delivery === 'object'
|
||||
? patch.delivery as Record<string, unknown>
|
||||
: undefined;
|
||||
const deliveryChannel = typeof deliveryPatch?.channel === 'string' && deliveryPatch.channel.trim()
|
||||
? deliveryPatch.channel.trim()
|
||||
: undefined;
|
||||
const deliveryMode = typeof deliveryPatch?.mode === 'string' && deliveryPatch.mode.trim()
|
||||
? deliveryPatch.mode.trim()
|
||||
: undefined;
|
||||
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(deliveryChannel);
|
||||
if (unsupportedDeliveryError && deliveryMode !== 'none') {
|
||||
throw new Error(unsupportedDeliveryError);
|
||||
}
|
||||
const result = await gatewayManager.rpc('cron.update', { id, patch });
|
||||
if (!result || typeof result !== 'object') {
|
||||
throw new Error('Cron update returned an invalid job');
|
||||
}
|
||||
return transformCronJob(result as GatewayCronJob);
|
||||
},
|
||||
delete: async (payload) => gatewayManager.rpc('cron.remove', { id: getId(payload) }),
|
||||
toggle: async (payload) => {
|
||||
const body = payload;
|
||||
return gatewayManager.rpc('cron.update', {
|
||||
id: getId(body),
|
||||
patch: { enabled: body.enabled === true },
|
||||
});
|
||||
},
|
||||
trigger: async (payload) => gatewayManager.rpc('cron.run', { id: getId(payload), mode: 'force' }),
|
||||
sessionHistory: async (payload) => {
|
||||
const body = payload;
|
||||
const sessionKey = typeof body.sessionKey === 'string' ? body.sessionKey.trim() : '';
|
||||
const parsedSession = parseCronSessionKey(sessionKey);
|
||||
if (!parsedSession) return { success: false, error: `Invalid cron sessionKey: ${sessionKey}` };
|
||||
|
||||
const rawLimit = typeof body.limit === 'number' ? body.limit : Number(body.limit || 200);
|
||||
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 200) : 200;
|
||||
const [jobsResult, runs, sessionEntry] = await Promise.all([
|
||||
gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000)
|
||||
.catch(() => ({ jobs: [] as GatewayCronJob[] })),
|
||||
readCronRunHistory(gatewayManager, parsedSession.jobId, limit),
|
||||
readSessionStoreEntry(parsedSession.agentId, sessionKey),
|
||||
]);
|
||||
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
|
||||
const job = jobs.find((item) => item.id === parsedSession.jobId);
|
||||
const fullReplies = await loadFullCronRunReplies(parsedSession, runs);
|
||||
return {
|
||||
messages: buildCronSessionFallbackMessages({
|
||||
sessionKey,
|
||||
job,
|
||||
runs,
|
||||
fullReplies,
|
||||
sessionEntry: sessionEntry ? {
|
||||
label: typeof sessionEntry.label === 'string' ? sessionEntry.label : undefined,
|
||||
updatedAt: normalizeTimestampMs(sessionEntry.updatedAt),
|
||||
} : undefined,
|
||||
limit,
|
||||
}),
|
||||
};
|
||||
},
|
||||
deliveryTargets: async () => ({ success: true, targets: [] }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { open } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import { logger } from '../utils/logger';
|
||||
import { getOpenClawConfigDir } from '../utils/paths';
|
||||
import { buildGatewayHealthSummary } from '../utils/gateway-health';
|
||||
import { buildChannelAccountsView, getChannelStatusDiagnostics } from './channels-api';
|
||||
import { getAcpTraceSnapshot, recordRendererAcpTrace } from './acp-trace';
|
||||
|
||||
const DEFAULT_TAIL_LINES = 200;
|
||||
|
||||
type DiagnosticsApiContext = {
|
||||
gatewayManager: GatewayManager;
|
||||
};
|
||||
|
||||
async function readTail(filePath: string, tailLines = DEFAULT_TAIL_LINES): Promise<string> {
|
||||
const safeTailLines = Math.max(1, Math.floor(tailLines));
|
||||
try {
|
||||
const file = await open(filePath, 'r');
|
||||
try {
|
||||
const stat = await file.stat();
|
||||
if (stat.size === 0) return '';
|
||||
|
||||
const chunkSize = 64 * 1024;
|
||||
let position = stat.size;
|
||||
let content = '';
|
||||
let lineCount = 0;
|
||||
|
||||
while (position > 0 && lineCount <= safeTailLines) {
|
||||
const bytesToRead = Math.min(chunkSize, position);
|
||||
position -= bytesToRead;
|
||||
const buffer = Buffer.allocUnsafe(bytesToRead);
|
||||
const { bytesRead } = await file.read(buffer, 0, bytesToRead, position);
|
||||
content = `${buffer.subarray(0, bytesRead).toString('utf-8')}${content}`;
|
||||
lineCount = content.split('\n').length - 1;
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
return lines.length <= safeTailLines ? content : lines.slice(-safeTailLines).join('\n');
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function createDiagnosticsApi(ctx: DiagnosticsApiContext): CompleteHostServiceRegistry['diagnostics'] {
|
||||
return {
|
||||
gatewaySnapshot: async () => {
|
||||
const { channels } = await buildChannelAccountsView(ctx, { probe: false });
|
||||
const diagnostics = ctx.gatewayManager.getDiagnostics?.() ?? {
|
||||
consecutiveHeartbeatMisses: 0,
|
||||
consecutiveRpcFailures: 0,
|
||||
};
|
||||
const channelStatusDiagnostics = getChannelStatusDiagnostics();
|
||||
const gatewayStatus = ctx.gatewayManager.getStatus();
|
||||
const gatewaySummary = buildGatewayHealthSummary({
|
||||
status: gatewayStatus,
|
||||
diagnostics,
|
||||
lastChannelsStatusOkAt: channelStatusDiagnostics.lastChannelsStatusOkAt,
|
||||
lastChannelsStatusFailureAt: channelStatusDiagnostics.lastChannelsStatusFailureAt,
|
||||
});
|
||||
const gateway = {
|
||||
...gatewayStatus,
|
||||
...gatewaySummary,
|
||||
capabilities: typeof ctx.gatewayManager.getCapabilitySnapshot === 'function'
|
||||
? ctx.gatewayManager.getCapabilitySnapshot(gatewaySummary)
|
||||
: undefined,
|
||||
};
|
||||
const openClawDir = getOpenClawConfigDir();
|
||||
return {
|
||||
capturedAt: Date.now(),
|
||||
platform: process.platform,
|
||||
gateway,
|
||||
channels,
|
||||
clawxLogTail: await logger.readLogFile(DEFAULT_TAIL_LINES),
|
||||
gatewayLogTail: await readTail(join(openClawDir, 'logs', 'gateway.log')),
|
||||
gatewayErrLogTail: await readTail(join(openClawDir, 'logs', 'gateway.err.log')),
|
||||
};
|
||||
},
|
||||
acpTrace: async () => getAcpTraceSnapshot(),
|
||||
recordAcpTrace: async (payload) => recordRendererAcpTrace(payload),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { dialog, type MessageBoxOptions, type OpenDialogOptions } from 'electron';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
|
||||
export function createDialogApi(): CompleteHostServiceRegistry['dialog'] {
|
||||
return {
|
||||
open: (payload) => dialog.showOpenDialog(payload as OpenDialogOptions),
|
||||
message: (payload) => dialog.showMessageBox(payload as MessageBoxOptions),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import { PORTS } from '../utils/config';
|
||||
import { approvePendingLocalDeviceRequests } from '../utils/control-ui-device-pairing';
|
||||
import { logger } from '../utils/logger';
|
||||
import { buildOpenClawControlUiUrl } from '../utils/openclaw-control-ui';
|
||||
import { getSetting } from '../utils/store';
|
||||
import { isRecord } from './payload-utils';
|
||||
|
||||
type HealthPayload = {
|
||||
probe?: unknown;
|
||||
};
|
||||
|
||||
type RpcPayload = {
|
||||
method?: unknown;
|
||||
params?: unknown;
|
||||
timeoutMs?: unknown;
|
||||
};
|
||||
|
||||
function parseTimeoutMs(timeoutMs: unknown): number | undefined {
|
||||
if (timeoutMs === undefined) return undefined;
|
||||
if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new Error('Invalid gateway RPC timeout');
|
||||
}
|
||||
return timeoutMs;
|
||||
}
|
||||
|
||||
export function createGatewayApi(gatewayManager: GatewayManager): CompleteHostServiceRegistry['gateway'] {
|
||||
return {
|
||||
status: () => gatewayManager.getStatus(),
|
||||
start: async () => {
|
||||
await gatewayManager.start();
|
||||
return { success: true };
|
||||
},
|
||||
stop: async () => {
|
||||
await gatewayManager.stop();
|
||||
return { success: true };
|
||||
},
|
||||
restart: async () => {
|
||||
await gatewayManager.restart();
|
||||
return { success: true };
|
||||
},
|
||||
health: async (payload) => {
|
||||
const body = isRecord(payload) ? payload as HealthPayload : {};
|
||||
return gatewayManager.checkHealth({ probe: body.probe === true });
|
||||
},
|
||||
controlUi: async () => {
|
||||
const status = gatewayManager.getStatus();
|
||||
const token = await getSetting('gatewayToken');
|
||||
const port = status.port || PORTS.OPENCLAW_GATEWAY;
|
||||
const url = buildOpenClawControlUiUrl(port, token);
|
||||
void approvePendingLocalDeviceRequests(gatewayManager).catch((error) => {
|
||||
logger.debug(`[gateway] Control UI device auto-approve skipped: ${String(error)}`);
|
||||
});
|
||||
return { success: true, url, token, port };
|
||||
},
|
||||
rpc: async (payload) => {
|
||||
const body = isRecord(payload) ? payload as RpcPayload : {};
|
||||
const method = typeof body.method === 'string' ? body.method.trim() : '';
|
||||
if (!method) {
|
||||
throw new Error('Invalid gateway RPC method');
|
||||
}
|
||||
const timeoutMs = parseTimeoutMs(body.timeoutMs);
|
||||
return gatewayManager.rpc(method, body.params, timeoutMs);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { extname, relative, resolve, sep } from 'node:path';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import { logger } from '../utils/logger';
|
||||
import { isRecord } from './payload-utils';
|
||||
|
||||
type RecentPayload = {
|
||||
tailLines?: unknown;
|
||||
};
|
||||
|
||||
type ReadFilePayload = RecentPayload & {
|
||||
path?: unknown;
|
||||
};
|
||||
|
||||
type MemoryPayload = {
|
||||
count?: unknown;
|
||||
};
|
||||
|
||||
function safePositiveInteger(value: unknown, fallback: number): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
||||
return Math.max(1, Math.floor(value));
|
||||
}
|
||||
|
||||
function isPathInside(parentDir: string, childPath: string): boolean {
|
||||
const relativePath = relative(parentDir, childPath);
|
||||
return relativePath.length > 0
|
||||
&& !relativePath.startsWith('..')
|
||||
&& !relativePath.includes(`..${sep}`);
|
||||
}
|
||||
|
||||
async function validateLogFilePath(path: unknown): Promise<string> {
|
||||
if (typeof path !== 'string' || path.length === 0) {
|
||||
throw new Error('Invalid log file path');
|
||||
}
|
||||
|
||||
const resolvedPath = resolve(path);
|
||||
const files = await logger.listLogFiles();
|
||||
if (files.some((file) => resolve(file.path) === resolvedPath)) {
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
const logDir = logger.getLogDir();
|
||||
if (!logDir) {
|
||||
throw new Error('Invalid log file path');
|
||||
}
|
||||
|
||||
const resolvedLogDir = resolve(logDir);
|
||||
if (!isPathInside(resolvedLogDir, resolvedPath) || extname(resolvedPath) !== '.log') {
|
||||
throw new Error('Invalid log file path');
|
||||
}
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
async function readLogFileTail(path: string, tailLines: number): Promise<string> {
|
||||
const content = await readFile(path, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const hasTrailingNewline = lines.at(-1) === '';
|
||||
if (hasTrailingNewline) {
|
||||
lines.pop();
|
||||
}
|
||||
if (lines.length <= tailLines) return content;
|
||||
const tail = lines.slice(-tailLines).join('\n');
|
||||
return hasTrailingNewline ? `${tail}\n` : tail;
|
||||
}
|
||||
|
||||
export function createLogsApi(): CompleteHostServiceRegistry['logs'] {
|
||||
return {
|
||||
recent: async (payload) => {
|
||||
const body = isRecord(payload) ? payload as RecentPayload : {};
|
||||
return { content: await logger.readLogFile(safePositiveInteger(body.tailLines, 100)) };
|
||||
},
|
||||
memory: (payload) => {
|
||||
const body = isRecord(payload) ? payload as MemoryPayload : {};
|
||||
return logger.getRecentLogs(
|
||||
body.count === undefined ? undefined : safePositiveInteger(body.count, 100),
|
||||
);
|
||||
},
|
||||
dir: () => ({ dir: logger.getLogDir() }),
|
||||
filePath: () => ({ path: logger.getLogFilePath() }),
|
||||
listFiles: async () => ({ files: await logger.listLogFiles() }),
|
||||
readFile: async (payload) => {
|
||||
const body = isRecord(payload) ? payload as ReadFilePayload : {};
|
||||
const path = await validateLogFilePath(body.path);
|
||||
return { content: await readLogFileTail(path, safePositiveInteger(body.tailLines, 200)) };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { dialog, nativeImage } from 'electron';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import type { AttachmentFileRef } from '@shared/host-api/contract';
|
||||
import { resolveOutgoingMediaAttachment, type AttachmentAccess } from './attachment-access';
|
||||
import { resolveOpenClawStateDir } from '../utils/paths';
|
||||
import {
|
||||
CLAWX_OPENAI_IMAGE_DEFAULT_MODEL,
|
||||
CLAWX_OPENAI_IMAGE_PROVIDER_KEY,
|
||||
} from '../utils/openclaw-image-relay-constants';
|
||||
import {
|
||||
applyOpenAiImageRelaySettings,
|
||||
getImageGenerationSettingsSnapshot,
|
||||
listImageGenerationProvidersFromRuntime,
|
||||
runImageGenerationTest,
|
||||
setImageGenerationConfig,
|
||||
type ImageGenerationModelConfig,
|
||||
} from '../utils/openclaw-image-generation';
|
||||
import { isRecord } from './payload-utils';
|
||||
|
||||
type ThumbnailEntry = {
|
||||
filePath?: unknown;
|
||||
gatewayUrl?: unknown;
|
||||
attachmentFileRef?: unknown;
|
||||
key?: unknown;
|
||||
mimeType?: unknown;
|
||||
};
|
||||
|
||||
type MediaApiDependencies = {
|
||||
attachmentAccess?: Pick<AttachmentAccess, 'resolveAttachment' | 'readAttachmentBinary'>;
|
||||
};
|
||||
|
||||
const OPAQUE_ATTACHMENT_KEY = /^[a-f0-9]{64}$/;
|
||||
|
||||
type SaveImagePayload = {
|
||||
base64?: unknown;
|
||||
mimeType?: unknown;
|
||||
filePath?: unknown;
|
||||
defaultFileName?: unknown;
|
||||
};
|
||||
|
||||
type ImageGenerationSettingsPayload = {
|
||||
timeoutMs?: unknown;
|
||||
openAiRelayEnabled?: unknown;
|
||||
openAiRelayBaseUrl?: unknown;
|
||||
openAiRelayModel?: unknown;
|
||||
openAiRelayApiKey?: unknown;
|
||||
};
|
||||
|
||||
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
|
||||
try {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
if (mimeType === 'image/svg+xml') {
|
||||
const buf = await readFile(filePath);
|
||||
return `data:${mimeType};base64,${buf.toString('base64')}`;
|
||||
}
|
||||
|
||||
const img = nativeImage.createFromPath(filePath);
|
||||
if (img.isEmpty()) return null;
|
||||
const size = img.getSize();
|
||||
const maxDim = 512;
|
||||
if (size.width > maxDim || size.height > maxDim) {
|
||||
const resized = size.width >= size.height
|
||||
? img.resize({ width: maxDim })
|
||||
: img.resize({ height: maxDim });
|
||||
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
|
||||
}
|
||||
const buf = await readFile(filePath);
|
||||
return `data:${mimeType};base64,${buf.toString('base64')}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function generateImagePreviewFromBuffer(buffer: Buffer, mimeType: string): string | null {
|
||||
try {
|
||||
if (mimeType === 'image/svg+xml') {
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`;
|
||||
}
|
||||
const img = nativeImage.createFromBuffer(buffer);
|
||||
if (img.isEmpty()) return null;
|
||||
const size = img.getSize();
|
||||
const maxDim = 512;
|
||||
if (size.width > maxDim || size.height > maxDim) {
|
||||
const resized = size.width >= size.height
|
||||
? img.resize({ width: maxDim })
|
||||
: img.resize({ height: maxDim });
|
||||
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
|
||||
}
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeThumbnailEntries(payload: unknown): ThumbnailEntry[] {
|
||||
const value = isRecord(payload) ? payload.paths : payload;
|
||||
return Array.isArray(value) ? value as ThumbnailEntry[] : [];
|
||||
}
|
||||
|
||||
export function createMediaApi(dependencies: MediaApiDependencies = {}): CompleteHostServiceRegistry['media'] {
|
||||
return {
|
||||
thumbnails: async (payload) => {
|
||||
const entries = normalizeThumbnailEntries(payload);
|
||||
const fsP = await import('node:fs/promises');
|
||||
const results: Record<string, { preview: string | null; fileSize: number }> = {};
|
||||
for (const entry of entries) {
|
||||
const mimeType = typeof entry.mimeType === 'string' ? entry.mimeType : 'application/octet-stream';
|
||||
if (entry.attachmentFileRef && typeof entry.attachmentFileRef === 'object') {
|
||||
const key = typeof entry.key === 'string' && entry.key ? entry.key : null;
|
||||
if (!key || !OPAQUE_ATTACHMENT_KEY.test(key) || !dependencies.attachmentAccess) continue;
|
||||
const ref = entry.attachmentFileRef as AttachmentFileRef;
|
||||
const resolution = await dependencies.attachmentAccess.resolveAttachment({ ref });
|
||||
if (!resolution.ok
|
||||
|| resolution.identity !== key
|
||||
|| resolution.target.kind !== 'local') {
|
||||
continue;
|
||||
}
|
||||
const readResult = await dependencies.attachmentAccess.readAttachmentBinary({
|
||||
ref,
|
||||
});
|
||||
if (!readResult.ok) {
|
||||
results[key] = { preview: null, fileSize: 0 };
|
||||
continue;
|
||||
}
|
||||
const effectiveMimeType = mimeType === 'application/octet-stream'
|
||||
? readResult.mimeType
|
||||
: mimeType;
|
||||
const buffer = Buffer.from(
|
||||
readResult.data.buffer,
|
||||
readResult.data.byteOffset,
|
||||
readResult.data.byteLength,
|
||||
);
|
||||
results[key] = {
|
||||
preview: effectiveMimeType.startsWith('image/')
|
||||
? generateImagePreviewFromBuffer(buffer, effectiveMimeType)
|
||||
: null,
|
||||
fileSize: readResult.size,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (typeof entry.filePath === 'string' && entry.filePath) {
|
||||
try {
|
||||
const stat = await fsP.stat(entry.filePath);
|
||||
const preview = mimeType.startsWith('image/')
|
||||
? await generateImagePreview(entry.filePath, mimeType)
|
||||
: null;
|
||||
results[entry.filePath] = { preview, fileSize: stat.size };
|
||||
} catch {
|
||||
results[entry.filePath] = { preview: null, fileSize: 0 };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof entry.gatewayUrl === 'string' && entry.gatewayUrl) {
|
||||
const resolved = await resolveOutgoingMediaAttachment({
|
||||
uri: entry.gatewayUrl,
|
||||
stateDir: resolveOpenClawStateDir(),
|
||||
});
|
||||
if (!resolved) {
|
||||
results[entry.gatewayUrl] = { preview: null, fileSize: 0 };
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const stat = await fsP.stat(resolved.path);
|
||||
const preview = resolved.mimeType.startsWith('image/')
|
||||
? await generateImagePreview(resolved.path, resolved.mimeType)
|
||||
: null;
|
||||
results[entry.gatewayUrl] = { preview, fileSize: stat.size };
|
||||
} catch {
|
||||
results[entry.gatewayUrl] = { preview: null, fileSize: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
saveImage: async (payload) => {
|
||||
const body = isRecord(payload) ? payload as SaveImagePayload : {};
|
||||
const defaultFileName = typeof body.defaultFileName === 'string' && body.defaultFileName
|
||||
? body.defaultFileName
|
||||
: 'image.png';
|
||||
const mimeType = typeof body.mimeType === 'string' ? body.mimeType : undefined;
|
||||
const ext = defaultFileName.includes('.')
|
||||
? defaultFileName.split('.').pop()!
|
||||
: (mimeType?.split('/')[1] || 'png');
|
||||
const result = await dialog.showSaveDialog({
|
||||
defaultPath: join(homedir(), 'Downloads', defaultFileName),
|
||||
filters: [
|
||||
{ name: 'Images', extensions: [ext, 'png', 'jpg', 'jpeg', 'webp', 'gif'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
});
|
||||
if (result.canceled || !result.filePath) return { success: false };
|
||||
|
||||
const fsP = await import('node:fs/promises');
|
||||
if (typeof body.filePath === 'string' && body.filePath) {
|
||||
try {
|
||||
await fsP.access(body.filePath);
|
||||
await fsP.copyFile(body.filePath, result.filePath);
|
||||
} catch {
|
||||
return { success: false, error: 'Source file not found' };
|
||||
}
|
||||
} else if (typeof body.base64 === 'string' && body.base64) {
|
||||
await fsP.writeFile(result.filePath, Buffer.from(body.base64, 'base64'));
|
||||
} else {
|
||||
return { success: false, error: 'No image data provided' };
|
||||
}
|
||||
return { success: true, savedPath: result.filePath };
|
||||
},
|
||||
imageGenerationSettings: async () => ({
|
||||
success: true,
|
||||
...(await getImageGenerationSettingsSnapshot()),
|
||||
}),
|
||||
saveImageGenerationSettings: async (payload) => {
|
||||
const body = isRecord(payload) ? payload as ImageGenerationSettingsPayload : {};
|
||||
const current = await getImageGenerationSettingsSnapshot();
|
||||
const normalizeRelayModel = (value: unknown): string => {
|
||||
const raw = typeof value === 'string' && value.trim()
|
||||
? value.trim()
|
||||
: (current.openAiRelay.model || CLAWX_OPENAI_IMAGE_DEFAULT_MODEL);
|
||||
const slash = raw.indexOf('/');
|
||||
return (slash > 0 ? raw.slice(slash + 1) : raw).trim() || CLAWX_OPENAI_IMAGE_DEFAULT_MODEL;
|
||||
};
|
||||
const relayModel = normalizeRelayModel(body.openAiRelayModel);
|
||||
let nextPrimary = current.config.primary;
|
||||
if (body.openAiRelayEnabled === true) {
|
||||
nextPrimary = `${CLAWX_OPENAI_IMAGE_PROVIDER_KEY}/${relayModel}`;
|
||||
} else if (body.openAiRelayEnabled === false) {
|
||||
nextPrimary = null;
|
||||
}
|
||||
const next: ImageGenerationModelConfig = {
|
||||
primary: nextPrimary,
|
||||
fallbacks: [],
|
||||
timeoutMs: body.timeoutMs !== undefined
|
||||
? (typeof body.timeoutMs === 'number' && body.timeoutMs > 0 ? Math.floor(body.timeoutMs) : null)
|
||||
: current.config.timeoutMs,
|
||||
};
|
||||
|
||||
if (typeof body.openAiRelayEnabled === 'boolean') {
|
||||
await applyOpenAiImageRelaySettings({
|
||||
enabled: body.openAiRelayEnabled,
|
||||
baseUrl: typeof body.openAiRelayBaseUrl === 'string' ? body.openAiRelayBaseUrl : null,
|
||||
apiKey: typeof body.openAiRelayApiKey === 'string' ? body.openAiRelayApiKey : undefined,
|
||||
model: relayModel,
|
||||
});
|
||||
}
|
||||
|
||||
const config = await setImageGenerationConfig(next);
|
||||
return {
|
||||
success: true,
|
||||
...(await getImageGenerationSettingsSnapshot()),
|
||||
config,
|
||||
};
|
||||
},
|
||||
imageGenerationProviders: async () => ({
|
||||
success: true,
|
||||
providers: await listImageGenerationProvidersFromRuntime(),
|
||||
}),
|
||||
testImageGeneration: async (payload) => runImageGenerationTest(isRecord(payload) ? payload : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import { getOpenClawCliCommand } from '../utils/openclaw-cli';
|
||||
import { ensureDir, getOpenClawSkillsDir, getOpenClawStatus } from '../utils/paths';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
export function createOpenClawApi(): CompleteHostServiceRegistry['openclaw'] {
|
||||
return {
|
||||
status: () => getOpenClawStatus(),
|
||||
getSkillsDir: () => {
|
||||
const dir = getOpenClawSkillsDir();
|
||||
ensureDir(dir);
|
||||
return dir;
|
||||
},
|
||||
getCliCommand: () => {
|
||||
const status = getOpenClawStatus();
|
||||
if (!status.packageExists) {
|
||||
return { success: false, error: `OpenClaw package not found at: ${status.dir}` };
|
||||
}
|
||||
if (!existsSync(status.entryPath)) {
|
||||
return { success: false, error: `OpenClaw entry script not found at: ${status.entryPath}` };
|
||||
}
|
||||
return { success: true, command: getOpenClawCliCommand() };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
export function isRecord(value: unknown): value is UnknownRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { HostApiContract } from '@shared/host-api/contract';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { ProviderConfig } from '../utils/secure-storage';
|
||||
import { browserOAuthManager, type BrowserOAuthProviderType } from '../utils/browser-oauth';
|
||||
import { deviceOAuthManager, type OAuthProviderType } from '../utils/device-oauth';
|
||||
import { removeProviderFromOpenClaw, saveProviderKeyToOpenClaw } from '../utils/openclaw-auth';
|
||||
import { getProviderConfig } from '../utils/provider-registry';
|
||||
import { logger } from '../utils/logger';
|
||||
import { getProviderService } from './providers/provider-service';
|
||||
import { providerAccountToConfig } from './providers/provider-store';
|
||||
import {
|
||||
getOpenClawProviderKey,
|
||||
syncDefaultProviderToRuntime,
|
||||
syncDeletedProviderApiKeyToRuntime,
|
||||
syncDeletedProviderToRuntime,
|
||||
syncProviderApiKeyToRuntime,
|
||||
syncSavedProviderToRuntime,
|
||||
syncUpdatedProviderToRuntime,
|
||||
} from './providers/provider-runtime-sync';
|
||||
import { validateApiKeyWithProvider } from './providers/provider-validation';
|
||||
import type { ProviderAccount } from '../shared/providers/types';
|
||||
import { isRecord } from './payload-utils';
|
||||
|
||||
type ProvidersApiContext = {
|
||||
gatewayManager: GatewayManager;
|
||||
mainWindow: BrowserWindow;
|
||||
};
|
||||
|
||||
type ProviderPayload<Action extends keyof HostApiContract['providers']> =
|
||||
Parameters<HostApiContract['providers'][Action]>[0];
|
||||
|
||||
type ValidationOptions = {
|
||||
baseUrl?: string;
|
||||
apiProtocol?: string;
|
||||
modelId?: string;
|
||||
};
|
||||
|
||||
function hasObjectChanges<T extends Record<string, unknown>>(
|
||||
existing: T,
|
||||
patch: Partial<T> | undefined,
|
||||
): boolean {
|
||||
if (!patch) return false;
|
||||
const keys = Object.keys(patch) as Array<keyof T>;
|
||||
if (keys.length === 0) return false;
|
||||
return keys.some((key) => JSON.stringify(existing[key]) !== JSON.stringify(patch[key]));
|
||||
}
|
||||
|
||||
function selectReplacementDefaultAccount(
|
||||
accounts: ProviderAccount[],
|
||||
deletedAccountId: string,
|
||||
): ProviderAccount | undefined {
|
||||
return accounts
|
||||
.filter((account) => account.id !== deletedAccountId)
|
||||
.sort((left, right) => {
|
||||
if (left.enabled !== right.enabled) {
|
||||
return left.enabled ? -1 : 1;
|
||||
}
|
||||
const updatedAtOrder = right.updatedAt.localeCompare(left.updatedAt);
|
||||
return updatedAtOrder !== 0 ? updatedAtOrder : left.id.localeCompare(right.id);
|
||||
})[0];
|
||||
}
|
||||
|
||||
function payloadString(payload: unknown, key: string): string | undefined {
|
||||
if (typeof payload === 'string') return payload;
|
||||
if (!isRecord(payload)) return undefined;
|
||||
const value = payload[key];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function requireString(payload: unknown, key: string, action: string): string {
|
||||
const value = payloadString(payload, key);
|
||||
if (!value) {
|
||||
throw new Error(`Invalid providers.${action} payload`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getPayloadRecord(payload: unknown, action: string): Record<string, unknown> {
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error(`Invalid providers.${action} payload`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function getProviderId(payload: unknown, action: string): string {
|
||||
if (Array.isArray(payload)) {
|
||||
const [providerId] = payload;
|
||||
if (typeof providerId === 'string' && providerId.trim()) return providerId.trim();
|
||||
}
|
||||
return requireString(payload, 'providerId', action);
|
||||
}
|
||||
|
||||
function getAccountId(payload: unknown, action: string): string {
|
||||
return requireString(payload, 'accountId', action);
|
||||
}
|
||||
|
||||
function getApiKeyPayload(payload: unknown, action: string): { providerId: string; apiKey: string } {
|
||||
if (Array.isArray(payload)) {
|
||||
const [providerId, apiKey] = payload;
|
||||
if (typeof providerId === 'string' && providerId.trim() && typeof apiKey === 'string') {
|
||||
return { providerId: providerId.trim(), apiKey };
|
||||
}
|
||||
}
|
||||
const record = getPayloadRecord(payload, action);
|
||||
const providerId = typeof record.providerId === 'string' ? record.providerId.trim() : '';
|
||||
if (!providerId || typeof record.apiKey !== 'string') {
|
||||
throw new Error(`Invalid providers.${action} payload`);
|
||||
}
|
||||
return { providerId, apiKey: record.apiKey };
|
||||
}
|
||||
|
||||
function getProviderUpdatePayload(payload: unknown): {
|
||||
providerId: string;
|
||||
updates: Partial<ProviderConfig>;
|
||||
apiKey?: string;
|
||||
} {
|
||||
if (Array.isArray(payload)) {
|
||||
const [providerId, updates, apiKey] = payload;
|
||||
if (typeof providerId === 'string' && providerId.trim() && isRecord(updates)) {
|
||||
return { providerId: providerId.trim(), updates: updates as Partial<ProviderConfig>, apiKey: typeof apiKey === 'string' ? apiKey : undefined };
|
||||
}
|
||||
}
|
||||
const record = getPayloadRecord(payload, 'updateWithKey');
|
||||
const providerId = typeof record.providerId === 'string' ? record.providerId.trim() : '';
|
||||
if (!providerId || !isRecord(record.updates)) {
|
||||
throw new Error('Invalid providers.updateWithKey payload');
|
||||
}
|
||||
return {
|
||||
providerId,
|
||||
updates: record.updates as Partial<ProviderConfig>,
|
||||
apiKey: typeof record.apiKey === 'string' ? record.apiKey : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function getSavePayload(payload: unknown): { config: ProviderConfig; apiKey?: string } {
|
||||
if (Array.isArray(payload)) {
|
||||
const [config, apiKey] = payload;
|
||||
if (isRecord(config)) {
|
||||
return { config: config as unknown as ProviderConfig, apiKey: typeof apiKey === 'string' ? apiKey : undefined };
|
||||
}
|
||||
}
|
||||
const record = getPayloadRecord(payload, 'save');
|
||||
if (!isRecord(record.config)) {
|
||||
throw new Error('Invalid providers.save payload');
|
||||
}
|
||||
return {
|
||||
config: record.config as unknown as ProviderConfig,
|
||||
apiKey: typeof record.apiKey === 'string' ? record.apiKey : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function validateKey(payload: ProviderPayload<'validateKey'>): Promise<{ valid: boolean; error?: string }> {
|
||||
try {
|
||||
const body = getPayloadRecord(payload, 'validateKey');
|
||||
const accountId = typeof body.accountId === 'string' && body.accountId.trim()
|
||||
? body.accountId.trim()
|
||||
: undefined;
|
||||
const vendorId = typeof body.vendorId === 'string' && body.vendorId.trim()
|
||||
? body.vendorId.trim()
|
||||
: undefined;
|
||||
const providerId = typeof body.providerId === 'string' && body.providerId.trim()
|
||||
? body.providerId.trim()
|
||||
: undefined;
|
||||
const apiKey = typeof body.apiKey === 'string' ? body.apiKey : undefined;
|
||||
if (!apiKey) {
|
||||
return { valid: false, error: 'Invalid providers.validateKey payload' };
|
||||
}
|
||||
|
||||
const providerService = getProviderService();
|
||||
const lookupId = accountId || vendorId || providerId || '';
|
||||
const account = lookupId ? await providerService.getAccount(lookupId) : null;
|
||||
const legacyProvider = !account && providerId ? await providerService._getProviderInternal(providerId) : null;
|
||||
const providerType = account?.vendorId || legacyProvider?.type || vendorId || providerId || lookupId;
|
||||
if (!providerType) {
|
||||
return { valid: false, error: 'Invalid providers.validateKey payload' };
|
||||
}
|
||||
|
||||
const options = isRecord(body.options) ? body.options as ValidationOptions : undefined;
|
||||
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
|
||||
const resolvedBaseUrl = options?.baseUrl || account?.baseUrl || legacyProvider?.baseUrl || registryBaseUrl;
|
||||
const resolvedProtocol = options?.apiProtocol || account?.apiProtocol || legacyProvider?.apiProtocol;
|
||||
const resolvedModelId = options?.modelId || account?.model || legacyProvider?.model;
|
||||
return await validateApiKeyWithProvider(providerType, apiKey, {
|
||||
baseUrl: resolvedBaseUrl,
|
||||
apiProtocol: resolvedProtocol,
|
||||
modelId: resolvedModelId,
|
||||
});
|
||||
} catch (error) {
|
||||
return { valid: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProvider(payload: ProviderPayload<'save'>, gatewayManager?: GatewayManager) {
|
||||
const providerService = getProviderService();
|
||||
const { config, apiKey } = getSavePayload(payload);
|
||||
try {
|
||||
await providerService._saveProviderInternal(config);
|
||||
if (apiKey !== undefined) {
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await providerService._setProviderApiKeyInternal(config.id, trimmedKey);
|
||||
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
|
||||
}
|
||||
}
|
||||
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProvider(payload: ProviderPayload<'delete'>, gatewayManager?: GatewayManager) {
|
||||
const providerService = getProviderService();
|
||||
const providerId = getProviderId(payload, 'delete');
|
||||
try {
|
||||
const existing = await providerService._getProviderInternal(providerId);
|
||||
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
|
||||
await providerService._deleteProviderInternal(providerId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function setProviderApiKey(payload: ProviderPayload<'setApiKey'>) {
|
||||
const providerService = getProviderService();
|
||||
const { providerId, apiKey } = getApiKeyPayload(payload, 'setApiKey');
|
||||
try {
|
||||
await providerService._setProviderApiKeyInternal(providerId, apiKey);
|
||||
const provider = await providerService._getProviderInternal(providerId);
|
||||
const providerType = provider?.type || providerId;
|
||||
await syncProviderApiKeyToRuntime(providerType, providerId, apiKey);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProviderWithKey(payload: ProviderPayload<'updateWithKey'>, gatewayManager?: GatewayManager) {
|
||||
const providerService = getProviderService();
|
||||
const { providerId, updates, apiKey } = getProviderUpdatePayload(payload);
|
||||
const existing = await providerService._getProviderInternal(providerId);
|
||||
if (!existing) {
|
||||
return { success: false, error: 'Provider not found' };
|
||||
}
|
||||
|
||||
const previousKey = await providerService._getProviderApiKeyInternal(providerId);
|
||||
const previousOck = getOpenClawProviderKey(existing.type, providerId);
|
||||
|
||||
try {
|
||||
const nextConfig: ProviderConfig = {
|
||||
...existing,
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const ock = getOpenClawProviderKey(nextConfig.type, providerId);
|
||||
await providerService._saveProviderInternal(nextConfig);
|
||||
|
||||
if (apiKey !== undefined) {
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await providerService._setProviderApiKeyInternal(providerId, trimmedKey);
|
||||
await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey);
|
||||
} else {
|
||||
await providerService._deleteProviderApiKeyInternal(providerId);
|
||||
await removeProviderFromOpenClaw(ock);
|
||||
}
|
||||
}
|
||||
|
||||
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
try {
|
||||
await providerService._saveProviderInternal(existing);
|
||||
if (previousKey) {
|
||||
await providerService._setProviderApiKeyInternal(providerId, previousKey);
|
||||
await saveProviderKeyToOpenClaw(previousOck, previousKey);
|
||||
} else {
|
||||
await providerService._deleteProviderApiKeyInternal(providerId);
|
||||
await removeProviderFromOpenClaw(previousOck);
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
logger.warn('Failed to rollback provider updateWithKey:', rollbackError);
|
||||
}
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProviderApiKey(payload: ProviderPayload<'deleteApiKey'>) {
|
||||
const providerService = getProviderService();
|
||||
const providerId = getProviderId(payload, 'deleteApiKey');
|
||||
try {
|
||||
await providerService._deleteProviderApiKeyInternal(providerId);
|
||||
const provider = await providerService._getProviderInternal(providerId);
|
||||
await syncDeletedProviderApiKeyToRuntime(provider, providerId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefaultProvider(payload: ProviderPayload<'setDefault'>, gatewayManager?: GatewayManager) {
|
||||
const providerService = getProviderService();
|
||||
const providerId = getProviderId(payload, 'setDefault');
|
||||
try {
|
||||
await providerService._setDefaultProviderInternal(providerId);
|
||||
await syncDefaultProviderToRuntime(providerId, gatewayManager);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function createAccount(payload: ProviderPayload<'createAccount'>, gatewayManager?: GatewayManager) {
|
||||
const providerService = getProviderService();
|
||||
const body = getPayloadRecord(payload, 'createAccount');
|
||||
if (!isRecord(body.account)) {
|
||||
throw new Error('Invalid providers.createAccount payload');
|
||||
}
|
||||
const apiKey = typeof body.apiKey === 'string' ? body.apiKey : undefined;
|
||||
try {
|
||||
const account = await providerService.createAccount(body.account as unknown as ProviderAccount, apiKey);
|
||||
await syncSavedProviderToRuntime(providerAccountToConfig(account), apiKey, gatewayManager);
|
||||
return { success: true, account };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAccount(payload: ProviderPayload<'updateAccount'>, gatewayManager?: GatewayManager) {
|
||||
const providerService = getProviderService();
|
||||
const body = getPayloadRecord(payload, 'updateAccount');
|
||||
const accountId = typeof body.accountId === 'string' ? body.accountId.trim() : '';
|
||||
const updates = isRecord(body.updates) ? body.updates as Partial<ProviderAccount> : undefined;
|
||||
const apiKey = typeof body.apiKey === 'string' ? body.apiKey : undefined;
|
||||
if (!accountId || !updates) {
|
||||
throw new Error('Invalid providers.updateAccount payload');
|
||||
}
|
||||
try {
|
||||
const existing = await providerService.getAccount(accountId);
|
||||
if (!existing) {
|
||||
return { success: false, error: 'Provider account not found' };
|
||||
}
|
||||
const hasPatchChanges = hasObjectChanges(existing as unknown as Record<string, unknown>, updates as Record<string, unknown>);
|
||||
if (!hasPatchChanges && apiKey === undefined) {
|
||||
return { success: true, noChange: true, account: existing };
|
||||
}
|
||||
const account = await providerService.updateAccount(accountId, updates, apiKey);
|
||||
await syncUpdatedProviderToRuntime(providerAccountToConfig(account), apiKey, gatewayManager);
|
||||
return { success: true, account };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAccount(
|
||||
payload: ProviderPayload<'deleteAccount'> & { apiKeyOnly?: boolean },
|
||||
gatewayManager?: GatewayManager,
|
||||
) {
|
||||
const providerService = getProviderService();
|
||||
const body = getPayloadRecord(payload, 'deleteAccount');
|
||||
const accountId = typeof body.accountId === 'string' ? body.accountId.trim() : '';
|
||||
const apiKeyOnly = body.apiKeyOnly === true;
|
||||
if (!accountId) {
|
||||
throw new Error('Invalid providers.deleteAccount payload');
|
||||
}
|
||||
try {
|
||||
const existing = await providerService.getAccount(accountId);
|
||||
const runtimeProviderKey = existing?.authMode === 'oauth_browser' && existing.vendorId === 'openai'
|
||||
? 'openai'
|
||||
: undefined;
|
||||
if (apiKeyOnly) {
|
||||
await syncDeletedProviderApiKeyToRuntime(
|
||||
existing ? providerAccountToConfig(existing) : null,
|
||||
accountId,
|
||||
runtimeProviderKey,
|
||||
);
|
||||
await providerService._deleteProviderApiKeyInternal(accountId);
|
||||
return { success: true };
|
||||
}
|
||||
const currentDefaultAccountId = await providerService.getDefaultAccountId();
|
||||
const replacementDefault = currentDefaultAccountId === accountId
|
||||
? selectReplacementDefaultAccount(await providerService.listAccounts(), accountId)
|
||||
: undefined;
|
||||
|
||||
if (replacementDefault) {
|
||||
await syncDefaultProviderToRuntime(replacementDefault.id);
|
||||
await providerService.setDefaultAccount(replacementDefault.id);
|
||||
}
|
||||
await syncDeletedProviderToRuntime(
|
||||
existing ? providerAccountToConfig(existing) : null,
|
||||
accountId,
|
||||
gatewayManager,
|
||||
runtimeProviderKey,
|
||||
);
|
||||
await providerService.deleteAccount(accountId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefaultAccount(payload: ProviderPayload<'setDefaultAccount'>, gatewayManager?: GatewayManager) {
|
||||
const providerService = getProviderService();
|
||||
const accountId = getAccountId(payload, 'setDefaultAccount');
|
||||
try {
|
||||
const currentDefault = await providerService.getDefaultAccountId();
|
||||
if (currentDefault === accountId) {
|
||||
return { success: true, noChange: true };
|
||||
}
|
||||
await providerService.setDefaultAccount(accountId);
|
||||
await syncDefaultProviderToRuntime(accountId, gatewayManager);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function requestOAuth(payload: ProviderPayload<'requestOAuth'>) {
|
||||
const body = getPayloadRecord(payload, 'requestOAuth');
|
||||
const provider = typeof body.provider === 'string' ? body.provider : undefined;
|
||||
if (!provider) {
|
||||
return { success: false, error: 'Invalid providers.requestOAuth payload' };
|
||||
}
|
||||
const region = body.region === 'global' || body.region === 'cn' ? body.region : undefined;
|
||||
const options = {
|
||||
accountId: typeof body.accountId === 'string' ? body.accountId : undefined,
|
||||
label: typeof body.label === 'string' ? body.label : undefined,
|
||||
};
|
||||
try {
|
||||
if (provider === 'openai') {
|
||||
await browserOAuthManager.startFlow(provider as BrowserOAuthProviderType, options);
|
||||
} else {
|
||||
await deviceOAuthManager.startFlow(provider as OAuthProviderType, region, options);
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('providers.requestOAuth failed', error);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelOAuth() {
|
||||
try {
|
||||
await deviceOAuthManager.stopFlow();
|
||||
await browserOAuthManager.stopFlow();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('providers.cancelOAuth failed', error);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOAuth(payload: ProviderPayload<'submitOAuth'>) {
|
||||
const body = getPayloadRecord(payload, 'submitOAuth');
|
||||
const code = typeof body.code === 'string' ? body.code : '';
|
||||
try {
|
||||
const accepted = browserOAuthManager.submitManualCode(code);
|
||||
if (!accepted) {
|
||||
return { success: false, error: 'No active manual OAuth input pending' };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export function createProvidersApi(ctx: ProvidersApiContext): CompleteHostServiceRegistry['providers'] {
|
||||
const providerService = getProviderService();
|
||||
deviceOAuthManager.setWindow(ctx.mainWindow);
|
||||
browserOAuthManager.setWindow(ctx.mainWindow);
|
||||
|
||||
return {
|
||||
list: async () => providerService._listProvidersWithKeyInfoInternal(),
|
||||
get: async (payload) => providerService._getProviderInternal(getProviderId(payload, 'get')),
|
||||
getDefault: async () => providerService._getDefaultProviderInternal(),
|
||||
hasApiKey: async (payload) => providerService._hasProviderApiKeyInternal(getProviderId(payload, 'hasApiKey')),
|
||||
getApiKey: async (payload) => providerService._getProviderApiKeyInternal(getProviderId(payload, 'getApiKey')),
|
||||
validateKey,
|
||||
save: async (payload) => saveProvider(payload, ctx.gatewayManager),
|
||||
delete: async (payload) => deleteProvider(payload, ctx.gatewayManager),
|
||||
setApiKey: setProviderApiKey,
|
||||
updateWithKey: async (payload) => updateProviderWithKey(payload, ctx.gatewayManager),
|
||||
deleteApiKey: deleteProviderApiKey,
|
||||
setDefault: async (payload) => setDefaultProvider(payload, ctx.gatewayManager),
|
||||
accounts: async () => providerService.listAccounts(),
|
||||
vendors: async () => providerService.listVendors(),
|
||||
accountKeyInfo: async () => providerService.listAccountsKeyInfo(),
|
||||
getDefaultAccount: async () => ({ accountId: await providerService.getDefaultAccountId() ?? null }),
|
||||
getAccount: async (payload) => providerService.getAccount(getAccountId(payload, 'getAccount')),
|
||||
getAccountApiKey: async (payload) => providerService.getAccountApiKey(getAccountId(payload, 'getAccountApiKey')),
|
||||
hasAccountApiKey: async (payload) => providerService.hasAccountApiKey(getAccountId(payload, 'hasAccountApiKey')),
|
||||
createAccount: async (payload) => createAccount(payload, ctx.gatewayManager),
|
||||
updateAccount: async (payload) => updateAccount(payload, ctx.gatewayManager),
|
||||
deleteAccount: async (payload) => deleteAccount(payload, ctx.gatewayManager),
|
||||
deleteAccountApiKey: async (payload) => deleteAccount({ accountId: getAccountId(payload, 'deleteAccountApiKey'), apiKeyOnly: true }, ctx.gatewayManager),
|
||||
setDefaultAccount: async (payload) => setDefaultAccount(payload, ctx.gatewayManager),
|
||||
requestOAuth,
|
||||
cancelOAuth,
|
||||
submitOAuth,
|
||||
};
|
||||
}
|
||||
@@ -5,22 +5,40 @@ import type { ProviderConfig } from '../../utils/secure-storage';
|
||||
import { getAllProviders, getApiKey, getDefaultProvider, getProvider } from '../../utils/secure-storage';
|
||||
import { getProviderConfig, getProviderDefaultModel } from '../../utils/provider-registry';
|
||||
import {
|
||||
ensureAnthropicMessagesModelMaxTokens,
|
||||
ensureOpenClawProviderAgentRuntimePins,
|
||||
migrateAllAgentAuthProfilesToSqlite,
|
||||
pruneInvalidApiProviderEntries,
|
||||
removeProviderFromOpenClaw,
|
||||
removeProviderKeyFromOpenClaw,
|
||||
saveOAuthTokenToOpenClaw,
|
||||
saveProviderKeyToOpenClaw,
|
||||
OPENAI_CODEX_OAUTH_PROVIDER_CONFIG,
|
||||
setOpenClawDefaultModel,
|
||||
setOpenClawDefaultModelWithOverride,
|
||||
syncProviderConfigToOpenClaw,
|
||||
updateAgentModelProvider,
|
||||
updateSingleAgentModelProvider,
|
||||
getProviderApiKeyFromOpenClaw,
|
||||
} from '../../utils/openclaw-auth';
|
||||
import {
|
||||
piAiModelsJsonModelEntry,
|
||||
type PiAiModelCostRates,
|
||||
} from '../../shared/pi-ai-model-cost';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { listAgentsSnapshot } from '../../utils/agent-config';
|
||||
|
||||
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`;
|
||||
/** OpenClaw Codex OAuth hooks only apply to the canonical `openai` provider id. */
|
||||
const OPENAI_OAUTH_RUNTIME_PROVIDER = 'openai';
|
||||
const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.6-sol`;
|
||||
|
||||
/**
|
||||
* Provider types that are not in the built-in provider registry (no `providerConfig.api`).
|
||||
* They require explicit api-protocol defaulting to `openai-completions`.
|
||||
*/
|
||||
function isUnregisteredProviderType(type: string): boolean {
|
||||
return type === 'custom' || type === 'ollama';
|
||||
}
|
||||
|
||||
type RuntimeProviderSyncContext = {
|
||||
runtimeProviderKey: string;
|
||||
@@ -43,7 +61,7 @@ function normalizeProviderBaseUrl(
|
||||
return normalized.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
|
||||
}
|
||||
|
||||
if (config.type === 'custom' || config.type === 'ollama') {
|
||||
if (isUnregisteredProviderType(config.type)) {
|
||||
const protocol = apiProtocol || config.apiProtocol || 'openai-completions';
|
||||
if (protocol === 'openai-responses') {
|
||||
return normalized.replace(/\/responses?$/i, '');
|
||||
@@ -64,25 +82,33 @@ function shouldUseExplicitDefaultOverride(config: ProviderConfig, runtimeProvide
|
||||
}
|
||||
|
||||
export function getOpenClawProviderKey(type: string, providerId: string): string {
|
||||
if (type === 'custom' || type === 'ollama') {
|
||||
if (isUnregisteredProviderType(type)) {
|
||||
// If the providerId is already a runtime key (e.g. re-seeded from openclaw.json
|
||||
// as "custom-XXXXXXXX"), return it directly to avoid double-hashing.
|
||||
const prefix = `${type}-`;
|
||||
if (providerId.startsWith(prefix)) {
|
||||
const tail = providerId.slice(prefix.length);
|
||||
if (tail.length === 8 && !tail.includes('-')) {
|
||||
return providerId;
|
||||
}
|
||||
}
|
||||
const suffix = providerId.replace(/-/g, '').slice(0, 8);
|
||||
return `${type}-${suffix}`;
|
||||
}
|
||||
if (type === 'minimax-portal-cn') {
|
||||
return 'minimax-portal';
|
||||
}
|
||||
// OpenClaw Z.AI provider key is always `zai` (Global UI vendor aliases here).
|
||||
if (type === 'zai-global') {
|
||||
return 'zai';
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (account?.authMode === 'oauth_browser' && config.type === 'openai') {
|
||||
return OPENAI_OAUTH_RUNTIME_PROVIDER;
|
||||
}
|
||||
return getOpenClawProviderKey(config.type, config.id);
|
||||
}
|
||||
@@ -98,9 +124,6 @@ async function getBrowserOAuthRuntimeProvider(config: ProviderConfig): Promise<s
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config.type === 'google') {
|
||||
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
|
||||
}
|
||||
if (config.type === 'openai') {
|
||||
return OPENAI_OAUTH_RUNTIME_PROVIDER;
|
||||
}
|
||||
@@ -162,29 +185,6 @@ export async function getProviderFallbackModelRefs(config: ProviderConfig): Prom
|
||||
return results;
|
||||
}
|
||||
|
||||
type GatewayRefreshMode = 'reload' | 'restart';
|
||||
|
||||
function scheduleGatewayRefresh(
|
||||
gatewayManager: GatewayManager | undefined,
|
||||
message: string,
|
||||
options?: { delayMs?: number; onlyIfRunning?: boolean; mode?: GatewayRefreshMode },
|
||||
): void {
|
||||
if (!gatewayManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.onlyIfRunning && gatewayManager.getStatus().state === 'stopped') {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(message);
|
||||
if (options?.mode === 'restart') {
|
||||
gatewayManager.debouncedRestart(options?.delayMs);
|
||||
return;
|
||||
}
|
||||
gatewayManager.debouncedReload(options?.delayMs);
|
||||
}
|
||||
|
||||
export async function syncProviderApiKeyToRuntime(
|
||||
providerType: string,
|
||||
providerId: string,
|
||||
@@ -195,8 +195,8 @@ export async function syncProviderApiKeyToRuntime(
|
||||
}
|
||||
|
||||
export async function syncAllProviderAuthToRuntime(): Promise<void> {
|
||||
await migrateAllAgentAuthProfilesToSqlite();
|
||||
const accounts = await listProviderAccounts();
|
||||
|
||||
for (const account of accounts) {
|
||||
const runtimeProviderKey = await resolveRuntimeProviderKey({
|
||||
id: account.id,
|
||||
@@ -248,6 +248,12 @@ async function syncProviderSecretToRuntime(
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await saveProviderKeyToOpenClaw(runtimeProviderKey, trimmedKey);
|
||||
} else {
|
||||
// An explicit empty string means the caller wants to clear the key.
|
||||
// Mirror that intent into OpenClaw auth-profiles so the gateway no
|
||||
// longer authenticates with the stale value (matches the explicit
|
||||
// delete branch in the legacy /api/providers/:id PUT handler).
|
||||
await removeProviderKeyFromOpenClaw(runtimeProviderKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -276,7 +282,7 @@ async function syncProviderSecretToRuntime(
|
||||
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);
|
||||
const api = config.apiProtocol || (isUnregisteredProviderType(config.type) ? 'openai-completions' : meta?.api);
|
||||
if (!api) {
|
||||
return null;
|
||||
}
|
||||
@@ -292,7 +298,8 @@ async function syncRuntimeProviderConfig(
|
||||
config: ProviderConfig,
|
||||
context: RuntimeProviderSyncContext,
|
||||
): Promise<void> {
|
||||
await syncProviderConfigToOpenClaw(context.runtimeProviderKey, config.model, {
|
||||
const modelId = normalizeRuntimeModelId(context.runtimeProviderKey, config.model);
|
||||
await syncProviderConfigToOpenClaw(context.runtimeProviderKey, modelId, {
|
||||
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl, context.api),
|
||||
api: context.api,
|
||||
apiKeyEnv: context.meta?.apiKeyEnv,
|
||||
@@ -305,7 +312,7 @@ async function syncCustomProviderAgentModel(
|
||||
runtimeProviderKey: string,
|
||||
apiKey: string | undefined,
|
||||
): Promise<void> {
|
||||
if (config.type !== 'custom') {
|
||||
if (!isUnregisteredProviderType(config.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -314,11 +321,11 @@ async function syncCustomProviderAgentModel(
|
||||
return;
|
||||
}
|
||||
|
||||
const modelId = config.model;
|
||||
const modelId = normalizeRuntimeModelId(runtimeProviderKey, config.model);
|
||||
await updateAgentModelProvider(runtimeProviderKey, {
|
||||
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl, config.apiProtocol || 'openai-completions'),
|
||||
api: config.apiProtocol || 'openai-completions',
|
||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||
models: modelId ? [piAiModelsJsonModelEntry(modelId)] : [],
|
||||
apiKey: resolvedKey,
|
||||
});
|
||||
}
|
||||
@@ -338,6 +345,44 @@ async function syncProviderToRuntime(
|
||||
return context;
|
||||
}
|
||||
|
||||
async function removeDeletedProviderFromOpenClaw(
|
||||
provider: ProviderConfig,
|
||||
providerId: string,
|
||||
runtimeProviderKey?: string,
|
||||
): Promise<void> {
|
||||
const keys = new Set<string>();
|
||||
if (runtimeProviderKey) {
|
||||
keys.add(runtimeProviderKey);
|
||||
} else {
|
||||
keys.add(await resolveRuntimeProviderKey({ ...provider, id: providerId }));
|
||||
}
|
||||
keys.add(providerId);
|
||||
|
||||
for (const key of keys) {
|
||||
await removeProviderFromOpenClaw(key);
|
||||
}
|
||||
|
||||
// Legacy Codex OAuth used runtime key openai-codex; cleanup may leave a bare
|
||||
// models.providers.openai entry behind. Drop that slot when no API key credentials remain.
|
||||
if (runtimeProviderKey === OPENAI_OAUTH_RUNTIME_PROVIDER || runtimeProviderKey === 'openai-codex') {
|
||||
const openClawKey = await getProviderApiKeyFromOpenClaw('openai');
|
||||
if (openClawKey) {
|
||||
return;
|
||||
}
|
||||
const storeAccounts = await listProviderAccounts();
|
||||
for (const account of storeAccounts) {
|
||||
if (account.vendorId !== 'openai' || account.authMode === 'oauth_browser') {
|
||||
continue;
|
||||
}
|
||||
const apiKey = await getApiKey(account.id);
|
||||
if (apiKey) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await removeProviderFromOpenClaw('openai');
|
||||
}
|
||||
}
|
||||
|
||||
function parseModelRef(modelRef: string): { providerKey: string; modelId: string } | null {
|
||||
const trimmed = modelRef.trim();
|
||||
const separatorIndex = trimmed.indexOf('/');
|
||||
@@ -351,6 +396,16 @@ function parseModelRef(modelRef: string): { providerKey: string; modelId: string
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRuntimeModelId(
|
||||
runtimeProviderKey: string,
|
||||
modelId: string | undefined,
|
||||
): string | undefined {
|
||||
const value = modelId?.trim();
|
||||
if (!value) return undefined;
|
||||
const prefix = `${runtimeProviderKey}/`;
|
||||
return value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
||||
}
|
||||
|
||||
async function buildRuntimeProviderConfigMap(): Promise<Map<string, ProviderConfig>> {
|
||||
const configs = await getAllProviders();
|
||||
const runtimeMap = new Map<string, ProviderConfig>();
|
||||
@@ -369,12 +424,12 @@ async function buildAgentModelProviderEntry(
|
||||
): Promise<{
|
||||
baseUrl?: string;
|
||||
api?: string;
|
||||
models?: Array<{ id: string; name: string }>;
|
||||
models?: Array<{ id: string; name: string; cost: PiAiModelCostRates }>;
|
||||
apiKey?: string;
|
||||
authHeader?: boolean;
|
||||
} | null> {
|
||||
const meta = getProviderConfig(config.type);
|
||||
const api = config.apiProtocol || (config.type === 'custom' ? 'openai-completions' : meta?.api);
|
||||
const api = config.apiProtocol || (isUnregisteredProviderType(config.type) ? 'openai-completions' : meta?.api);
|
||||
const baseUrl = normalizeProviderBaseUrl(config, config.baseUrl || meta?.baseUrl, api);
|
||||
if (!api || !baseUrl) {
|
||||
return null;
|
||||
@@ -383,7 +438,7 @@ async function buildAgentModelProviderEntry(
|
||||
let apiKey: string | undefined;
|
||||
let authHeader: boolean | undefined;
|
||||
|
||||
if (config.type === 'custom') {
|
||||
if (isUnregisteredProviderType(config.type)) {
|
||||
apiKey = (await getApiKey(config.id)) || undefined;
|
||||
} else if (config.type === 'minimax-portal' || config.type === 'minimax-portal-cn') {
|
||||
const accountApiKey = await getApiKey(config.id);
|
||||
@@ -393,19 +448,12 @@ async function buildAgentModelProviderEntry(
|
||||
authHeader = true;
|
||||
apiKey = 'minimax-oauth';
|
||||
}
|
||||
} else if (config.type === 'qwen-portal') {
|
||||
const accountApiKey = await getApiKey(config.id);
|
||||
if (accountApiKey) {
|
||||
apiKey = accountApiKey;
|
||||
} else {
|
||||
apiKey = 'qwen-oauth';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
api,
|
||||
models: [{ id: modelId, name: modelId }],
|
||||
models: [piAiModelsJsonModelEntry(modelId)],
|
||||
apiKey,
|
||||
authHeader,
|
||||
};
|
||||
@@ -451,29 +499,21 @@ export async function syncAgentModelOverrideToRuntime(agentId: string): Promise<
|
||||
export async function syncSavedProviderToRuntime(
|
||||
config: ProviderConfig,
|
||||
apiKey: string | undefined,
|
||||
gatewayManager?: GatewayManager,
|
||||
_gatewayManager?: GatewayManager,
|
||||
): Promise<void> {
|
||||
const context = await syncProviderToRuntime(config, apiKey);
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await syncAgentModelsToRuntime();
|
||||
} catch (err) {
|
||||
logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider save:', err);
|
||||
}
|
||||
await syncAgentModelsToRuntime();
|
||||
|
||||
scheduleGatewayRefresh(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway reload after saving provider "${context.runtimeProviderKey}" config`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncUpdatedProviderToRuntime(
|
||||
config: ProviderConfig,
|
||||
apiKey: string | undefined,
|
||||
gatewayManager?: GatewayManager,
|
||||
_gatewayManager?: GatewayManager,
|
||||
): Promise<void> {
|
||||
const context = await syncProviderToRuntime(config, apiKey);
|
||||
if (!context) {
|
||||
@@ -484,9 +524,11 @@ export async function syncUpdatedProviderToRuntime(
|
||||
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') {
|
||||
const isDefaultProvider = defaultProviderId === config.id;
|
||||
if (isDefaultProvider) {
|
||||
const selectedModelId = normalizeRuntimeModelId(ock, config.model);
|
||||
const modelOverride = selectedModelId ? `${ock}/${selectedModelId}` : undefined;
|
||||
if (!isUnregisteredProviderType(config.type)) {
|
||||
if (shouldUseExplicitDefaultOverride(config, ock)) {
|
||||
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl, context.api),
|
||||
@@ -506,22 +548,14 @@ export async function syncUpdatedProviderToRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await syncAgentModelsToRuntime();
|
||||
} catch (err) {
|
||||
logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider update:', err);
|
||||
}
|
||||
await syncAgentModelsToRuntime();
|
||||
|
||||
scheduleGatewayRefresh(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway reload after updating provider "${ock}" config`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncDeletedProviderToRuntime(
|
||||
provider: ProviderConfig | null,
|
||||
providerId: string,
|
||||
gatewayManager?: GatewayManager,
|
||||
_gatewayManager?: GatewayManager,
|
||||
runtimeProviderKey?: string,
|
||||
): Promise<void> {
|
||||
if (!provider?.type) {
|
||||
@@ -529,13 +563,8 @@ export async function syncDeletedProviderToRuntime(
|
||||
}
|
||||
|
||||
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
|
||||
await removeProviderFromOpenClaw(ock);
|
||||
await removeDeletedProviderFromOpenClaw(provider, providerId, ock);
|
||||
|
||||
scheduleGatewayRefresh(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway restart after deleting provider "${ock}"`,
|
||||
{ mode: 'restart' },
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncDeletedProviderApiKeyToRuntime(
|
||||
@@ -548,22 +577,64 @@ export async function syncDeletedProviderApiKeyToRuntime(
|
||||
}
|
||||
|
||||
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
|
||||
await removeProviderFromOpenClaw(ock);
|
||||
await removeProviderKeyFromOpenClaw(ock);
|
||||
}
|
||||
|
||||
export async function syncDefaultProviderToRuntime(
|
||||
providerId: string,
|
||||
gatewayManager?: GatewayManager,
|
||||
_gatewayManager?: GatewayManager,
|
||||
): Promise<void> {
|
||||
const provider = await getProvider(providerId);
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Self-heal: opportunistically remove any pre-existing models.providers
|
||||
// entries with an invalid `api` field so a switch to a healthy provider
|
||||
// can rescue the user from a previously broken config (e.g. the historical
|
||||
// openrouter `api: 'openrouter'` bug). Covers both OAuth and non-OAuth
|
||||
// branches below.
|
||||
try {
|
||||
const removed = await pruneInvalidApiProviderEntries();
|
||||
if (removed.length > 0) {
|
||||
logger.warn(
|
||||
`[provider-runtime] Pruned invalid models.providers entries before switch: ${removed.join(', ')}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[provider-runtime] Failed to prune invalid provider entries before switch:', err);
|
||||
}
|
||||
|
||||
// Self-heal: pin the embedded agent runtime for legacy OpenAI provider entries
|
||||
// (`openai`, `openai-codex`) that would otherwise be auto-routed to the
|
||||
// unbundled `codex` harness. Running this before every default-provider switch
|
||||
// repairs on-disk config written by earlier ClawX builds.
|
||||
try {
|
||||
const pinned = await ensureOpenClawProviderAgentRuntimePins();
|
||||
if (pinned.length > 0) {
|
||||
logger.warn(
|
||||
`[provider-runtime] Pinned embedded agent runtime for models.providers entries before switch: ${pinned.join(', ')}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[provider-runtime] Failed to pin embedded agent runtime for provider entries before switch:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
const healed = await ensureAnthropicMessagesModelMaxTokens();
|
||||
if (healed.length > 0) {
|
||||
logger.warn(
|
||||
`[provider-runtime] Ensured anthropic-messages maxTokens for models.providers entries before switch: ${healed.join(', ')}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[provider-runtime] Failed to ensure anthropic-messages maxTokens before switch:', err);
|
||||
}
|
||||
|
||||
const ock = await resolveRuntimeProviderKey(provider);
|
||||
const providerKey = await getApiKey(providerId);
|
||||
const fallbackModels = await getProviderFallbackModelRefs(provider);
|
||||
const oauthTypes = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
|
||||
const oauthTypes = ['minimax-portal', 'minimax-portal-cn'];
|
||||
const browserOAuthRuntimeProvider = await getBrowserOAuthRuntimeProvider(provider);
|
||||
const isOAuthProvider = (oauthTypes.includes(provider.type) && !providerKey) || Boolean(browserOAuthRuntimeProvider);
|
||||
|
||||
@@ -572,7 +643,7 @@ export async function syncDefaultProviderToRuntime(
|
||||
? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`)
|
||||
: undefined;
|
||||
|
||||
if (provider.type === 'custom') {
|
||||
if (isUnregisteredProviderType(provider.type)) {
|
||||
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||
baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'),
|
||||
api: provider.apiProtocol || 'openai-completions',
|
||||
@@ -606,48 +677,42 @@ export async function syncDefaultProviderToRuntime(
|
||||
expires: secret.expiresAt,
|
||||
email: secret.email,
|
||||
projectId: secret.subject,
|
||||
accountId: secret.subject,
|
||||
});
|
||||
}
|
||||
|
||||
const defaultModelRef = browserOAuthRuntimeProvider === GOOGLE_OAUTH_RUNTIME_PROVIDER
|
||||
? GOOGLE_OAUTH_DEFAULT_MODEL_REF
|
||||
: OPENAI_OAUTH_DEFAULT_MODEL_REF;
|
||||
const defaultModelRef = OPENAI_OAUTH_DEFAULT_MODEL_REF;
|
||||
const modelOverride = provider.model
|
||||
? (provider.model.startsWith(`${browserOAuthRuntimeProvider}/`)
|
||||
? provider.model
|
||||
? provider.model.replace(/^openai-codex\//, `${browserOAuthRuntimeProvider}/`)
|
||||
: `${browserOAuthRuntimeProvider}/${provider.model}`)
|
||||
: defaultModelRef;
|
||||
|
||||
await setOpenClawDefaultModel(browserOAuthRuntimeProvider, modelOverride, fallbackModels);
|
||||
logger.info(`Configured openclaw.json for browser OAuth provider "${provider.id}"`);
|
||||
try {
|
||||
await syncAgentModelsToRuntime();
|
||||
} catch (err) {
|
||||
logger.warn('[provider-runtime] Failed to sync per-agent model registries after browser OAuth switch:', err);
|
||||
}
|
||||
scheduleGatewayRefresh(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway reload after provider switch to "${browserOAuthRuntimeProvider}"`,
|
||||
await setOpenClawDefaultModelWithOverride(
|
||||
browserOAuthRuntimeProvider,
|
||||
modelOverride,
|
||||
{
|
||||
baseUrl: OPENAI_CODEX_OAUTH_PROVIDER_CONFIG.baseUrl,
|
||||
api: OPENAI_CODEX_OAUTH_PROVIDER_CONFIG.api,
|
||||
},
|
||||
fallbackModels.map((fallback) => fallback.replace(/^openai-codex\//, `${browserOAuthRuntimeProvider}/`)),
|
||||
);
|
||||
logger.info(`Configured openclaw.json for browser OAuth provider "${provider.id}"`);
|
||||
await syncAgentModelsToRuntime();
|
||||
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';
|
||||
: 'https://api.minimaxi.com/anthropic';
|
||||
const api = 'anthropic-messages' as const;
|
||||
|
||||
let baseUrl = provider.baseUrl || defaultBaseUrl;
|
||||
if ((provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') && baseUrl) {
|
||||
if (baseUrl) {
|
||||
baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
|
||||
}
|
||||
|
||||
const targetProviderKey = (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn')
|
||||
? 'minimax-portal'
|
||||
: provider.type;
|
||||
const targetProviderKey = 'minimax-portal';
|
||||
|
||||
await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), {
|
||||
baseUrl,
|
||||
@@ -658,22 +723,18 @@ export async function syncDefaultProviderToRuntime(
|
||||
|
||||
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);
|
||||
}
|
||||
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 ? [piAiModelsJsonModelEntry(defaultModelId)] : [],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
provider.type === 'custom' &&
|
||||
isUnregisteredProviderType(provider.type) &&
|
||||
providerKey &&
|
||||
provider.baseUrl
|
||||
) {
|
||||
@@ -681,20 +742,11 @@ export async function syncDefaultProviderToRuntime(
|
||||
await updateAgentModelProvider(ock, {
|
||||
baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'),
|
||||
api: provider.apiProtocol || 'openai-completions',
|
||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||
models: modelId ? [piAiModelsJsonModelEntry(modelId)] : [],
|
||||
apiKey: providerKey,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await syncAgentModelsToRuntime();
|
||||
} catch (err) {
|
||||
logger.warn('[provider-runtime] Failed to sync per-agent model registries after default provider switch:', err);
|
||||
}
|
||||
await syncAgentModelsToRuntime();
|
||||
|
||||
scheduleGatewayRefresh(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway reload after provider switch to "${ock}"`,
|
||||
{ onlyIfRunning: true },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,8 +28,17 @@ import {
|
||||
setDefaultProvider,
|
||||
storeApiKey,
|
||||
} from '../../utils/secure-storage';
|
||||
import { getActiveOpenClawProviders, getOpenClawProvidersConfig } from '../../utils/openclaw-auth';
|
||||
import { getAliasSourceTypes, getOpenClawProviderKeyForType } from '../../utils/provider-keys';
|
||||
import {
|
||||
getActiveOpenClawProviders,
|
||||
getOpenClawProvidersConfig,
|
||||
getProviderApiKeyFromOpenClaw,
|
||||
} from '../../utils/openclaw-auth';
|
||||
import {
|
||||
filterActiveProviderKeysForUi,
|
||||
getAliasSourceTypes,
|
||||
OPENAI_CODEX_RUNTIME_PROVIDER_KEY,
|
||||
resolveOpenClawProviderKey,
|
||||
} from '../../utils/provider-keys';
|
||||
import type { ProviderWithKeyInfo } from '../../shared/providers/types';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
@@ -53,6 +62,49 @@ function logLegacyProviderApiUsage(method: string, replacement: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
function inferProviderVendorIdFromOpenClawEntry(
|
||||
key: string,
|
||||
entry: Record<string, unknown>,
|
||||
): ProviderType | 'custom' {
|
||||
if (key === 'minimax-portal') {
|
||||
const baseUrl = typeof entry.baseUrl === 'string' ? entry.baseUrl.toLowerCase() : '';
|
||||
if (baseUrl.includes('api.minimaxi.com')) {
|
||||
return 'minimax-portal-cn';
|
||||
}
|
||||
}
|
||||
|
||||
// OpenClaw stores a single `zai` key; pick CN vs Global UI vendor from baseUrl.
|
||||
if (key === 'zai') {
|
||||
const baseUrl = typeof entry.baseUrl === 'string' ? entry.baseUrl.toLowerCase() : '';
|
||||
if (baseUrl.includes('api.z.ai')) {
|
||||
return 'zai-global';
|
||||
}
|
||||
return 'zai';
|
||||
}
|
||||
|
||||
return ((BUILTIN_PROVIDER_TYPES as readonly string[]).includes(key) ? key : 'custom') as ProviderType | 'custom';
|
||||
}
|
||||
|
||||
function providerMetadataEquals(
|
||||
left: ProviderAccount['metadata'] | undefined,
|
||||
right: ProviderAccount['metadata'] | undefined,
|
||||
): boolean {
|
||||
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
|
||||
}
|
||||
|
||||
function mergeSyncedProviderMetadata(
|
||||
existing: ProviderAccount['metadata'] | undefined,
|
||||
synced: ProviderAccount['metadata'] | undefined,
|
||||
): ProviderAccount['metadata'] | undefined {
|
||||
const next = { ...(existing ?? {}) };
|
||||
if (synced?.customModels && synced.customModels.length > 0) {
|
||||
next.customModels = synced.customModels;
|
||||
} else {
|
||||
delete next.customModels;
|
||||
}
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
export class ProviderService {
|
||||
async listVendors(): Promise<ProviderDefinition[]> {
|
||||
return PROVIDER_DEFINITIONS;
|
||||
@@ -78,7 +130,7 @@ export class ProviderService {
|
||||
// Index store accounts by their openclaw runtime key for fast lookup.
|
||||
const storeByKey = new Map<string, ProviderAccount[]>();
|
||||
for (const account of allStoreAccounts) {
|
||||
const ock = getOpenClawProviderKeyForType(account.vendorId, account.id);
|
||||
const ock = resolveOpenClawProviderKey(account);
|
||||
const group = storeByKey.get(ock) ?? [];
|
||||
group.push(account);
|
||||
storeByKey.set(ock, group);
|
||||
@@ -87,8 +139,31 @@ export class ProviderService {
|
||||
const result: ProviderAccount[] = [];
|
||||
const processedKeys = new Set<string>();
|
||||
|
||||
let hasConfiguredOpenAiApiKey = false;
|
||||
if (activeProviders.has('openai')) {
|
||||
const openClawKey = await getProviderApiKeyFromOpenClaw('openai');
|
||||
if (openClawKey) {
|
||||
hasConfiguredOpenAiApiKey = true;
|
||||
} else {
|
||||
for (const account of storeByKey.get('openai') ?? []) {
|
||||
if (account.authMode === 'oauth_browser') {
|
||||
continue;
|
||||
}
|
||||
const apiKey = await getApiKey(account.id);
|
||||
if (apiKey) {
|
||||
hasConfiguredOpenAiApiKey = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const activeKeysForUi = filterActiveProviderKeysForUi(activeProviders, {
|
||||
hasConfiguredOpenAiApiKey,
|
||||
});
|
||||
|
||||
// For each active provider in openclaw.json, produce exactly ONE account.
|
||||
for (const key of activeProviders) {
|
||||
for (const key of activeKeysForUi) {
|
||||
if (processedKeys.has(key)) continue;
|
||||
processedKeys.add(key);
|
||||
|
||||
@@ -101,10 +176,9 @@ export class ProviderService {
|
||||
const aliasAccounts = storeGroup.filter((a) => a.vendorId !== key);
|
||||
const candidates = aliasAccounts.length > 0 ? aliasAccounts : storeGroup;
|
||||
candidates.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
result.push(candidates[0]);
|
||||
|
||||
// Clean up orphaned duplicates from the store.
|
||||
const kept = candidates[0];
|
||||
let kept = candidates[0];
|
||||
for (const account of storeGroup) {
|
||||
if (account.id !== kept.id) {
|
||||
logger.info(
|
||||
@@ -113,6 +187,34 @@ export class ProviderService {
|
||||
await deleteProviderAccount(account.id);
|
||||
}
|
||||
}
|
||||
|
||||
const entry = openClawProviders[key];
|
||||
if (entry) {
|
||||
const [syncedAccount] = ProviderService.buildAccountsFromOpenClawEntries(
|
||||
{ [key]: entry },
|
||||
new Set(),
|
||||
new Set(),
|
||||
defaultModel,
|
||||
);
|
||||
if (syncedAccount) {
|
||||
const nextMetadata = mergeSyncedProviderMetadata(kept.metadata, syncedAccount.metadata);
|
||||
const shouldSyncSelectedModel = defaultModel?.startsWith(`${key}/`) ?? false;
|
||||
const nextModel = shouldSyncSelectedModel ? syncedAccount.model : kept.model;
|
||||
const shouldSyncModelState = kept.model !== nextModel
|
||||
|| !providerMetadataEquals(kept.metadata, nextMetadata);
|
||||
if (shouldSyncModelState) {
|
||||
kept = {
|
||||
...kept,
|
||||
model: nextModel,
|
||||
metadata: nextMetadata,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await saveProviderAccount(kept);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push(kept);
|
||||
} else {
|
||||
// No store account for this key — create a seed from openclaw.json.
|
||||
const entry = openClawProviders[key];
|
||||
@@ -132,6 +234,30 @@ export class ProviderService {
|
||||
}
|
||||
}
|
||||
|
||||
if (activeProviders.has(OPENAI_CODEX_RUNTIME_PROVIDER_KEY) || !hasConfiguredOpenAiApiKey) {
|
||||
const openaiStoreAccounts = storeByKey.get('openai') ?? [];
|
||||
for (const account of openaiStoreAccounts) {
|
||||
if (account.authMode !== 'api_key' && account.authMode !== undefined) {
|
||||
continue;
|
||||
}
|
||||
const apiKey = await getApiKey(account.id);
|
||||
const openClawKey = await getProviderApiKeyFromOpenClaw('openai');
|
||||
if (!apiKey && !openClawKey) {
|
||||
logger.info(
|
||||
`[provider-sync] Removing unconfigured OpenAI API key account "${account.id}"`
|
||||
+ (activeProviders.has(OPENAI_CODEX_RUNTIME_PROVIDER_KEY)
|
||||
? ` (OAuth uses ${OPENAI_CODEX_RUNTIME_PROVIDER_KEY})`
|
||||
: ' (Codex OAuth removed)'),
|
||||
);
|
||||
await deleteProviderAccount(account.id);
|
||||
const resultIndex = result.findIndex((entry) => entry.id === account.id);
|
||||
if (resultIndex >= 0) {
|
||||
result.splice(resultIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -157,9 +283,8 @@ export class ProviderService {
|
||||
for (const [key, entry] of Object.entries(providers)) {
|
||||
if (existingIds.has(key)) continue;
|
||||
|
||||
const definition = getProviderDefinition(key);
|
||||
const isBuiltin = (BUILTIN_PROVIDER_TYPES as readonly string[]).includes(key);
|
||||
const vendorId = isBuiltin ? key : 'custom';
|
||||
const vendorId = inferProviderVendorIdFromOpenClawEntry(key, entry);
|
||||
const definition = getProviderDefinition(vendorId === 'custom' ? key : vendorId);
|
||||
|
||||
// Skip if an account with this vendorId already exists (e.g. user already
|
||||
// created "openrouter-uuid" via UI — no need to import bare "openrouter").
|
||||
@@ -173,6 +298,15 @@ export class ProviderService {
|
||||
}
|
||||
|
||||
const baseUrl = typeof entry.baseUrl === 'string' ? entry.baseUrl : definition?.providerConfig?.baseUrl;
|
||||
const customModels = Array.isArray(entry.models)
|
||||
? Array.from(new Set(entry.models
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) return '';
|
||||
const raw = (item as Record<string, unknown>).id;
|
||||
return typeof raw === 'string' ? raw.trim() : '';
|
||||
})
|
||||
.filter(Boolean)))
|
||||
: undefined;
|
||||
|
||||
// Infer model from the default model if it belongs to this provider
|
||||
let model: string | undefined;
|
||||
@@ -193,6 +327,9 @@ export class ProviderService {
|
||||
? (entry.headers as Record<string, string>)
|
||||
: undefined),
|
||||
model,
|
||||
metadata: customModels && customModels.length > 0
|
||||
? { customModels }
|
||||
: undefined,
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
createdAt: now,
|
||||
@@ -263,21 +400,22 @@ export class ProviderService {
|
||||
return deleteProvider(accountId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use listAccounts() and map account data in callers.
|
||||
*/
|
||||
async listLegacyProviders(): Promise<ProviderConfig[]> {
|
||||
logLegacyProviderApiUsage('listLegacyProviders', 'listAccounts');
|
||||
// ── Internal silent variants ─────────────────────────────────────
|
||||
// These mirror the legacy public API but never emit deprecation
|
||||
// warnings, so internal callers (HTTP routes, IPC handlers, the new
|
||||
// /api/provider-accounts surface) can reuse the same logic without
|
||||
// contributing to the migration noise. Public legacy methods below
|
||||
// delegate here after logging exactly once per process.
|
||||
|
||||
/** Internal: list providers in the legacy ProviderConfig shape. */
|
||||
async _listProvidersFromAccountsInternal(): Promise<ProviderConfig[]> {
|
||||
const accounts = await this.listAccounts();
|
||||
return accounts.map(providerAccountToConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use listAccounts() + secret-store based key summary.
|
||||
*/
|
||||
async listLegacyProvidersWithKeyInfo(): Promise<ProviderWithKeyInfo[]> {
|
||||
logLegacyProviderApiUsage('listLegacyProvidersWithKeyInfo', 'listAccounts');
|
||||
const providers = await this.listLegacyProviders();
|
||||
/** Internal: list providers with hasKey/keyMasked metadata. */
|
||||
async _listProvidersWithKeyInfoInternal(): Promise<ProviderWithKeyInfo[]> {
|
||||
const providers = await this._listProvidersFromAccountsInternal();
|
||||
const results: ProviderWithKeyInfo[] = [];
|
||||
for (const provider of providers) {
|
||||
const apiKey = await getApiKey(provider.id);
|
||||
@@ -290,21 +428,15 @@ export class ProviderService {
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getAccount(accountId).
|
||||
*/
|
||||
async getLegacyProvider(providerId: string): Promise<ProviderConfig | null> {
|
||||
logLegacyProviderApiUsage('getLegacyProvider', 'getAccount');
|
||||
/** Internal: resolve a single provider in the legacy ProviderConfig shape. */
|
||||
async _getProviderInternal(providerId: string): Promise<ProviderConfig | null> {
|
||||
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');
|
||||
/** Internal: upsert a legacy provider config (creates or updates the account). */
|
||||
async _saveProviderInternal(config: ProviderConfig): Promise<void> {
|
||||
await ensureProviderStoreMigrated();
|
||||
const account = providerConfigToAccount(config);
|
||||
const existing = await getProviderAccount(config.id);
|
||||
@@ -315,14 +447,129 @@ export class ProviderService {
|
||||
await this.createAccount(account);
|
||||
}
|
||||
|
||||
/** Internal: delete a provider account by id. */
|
||||
async _deleteProviderInternal(providerId: string): Promise<boolean> {
|
||||
await ensureProviderStoreMigrated();
|
||||
await this.deleteAccount(providerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Internal: set default account without warning. */
|
||||
async _setDefaultProviderInternal(providerId: string): Promise<void> {
|
||||
await this.setDefaultAccount(providerId);
|
||||
}
|
||||
|
||||
/** Internal: read default account id without warning. */
|
||||
async _getDefaultProviderInternal(): Promise<string | undefined> {
|
||||
return this.getDefaultAccountId();
|
||||
}
|
||||
|
||||
/** Internal: store an account's api key without warning. */
|
||||
async _setProviderApiKeyInternal(providerId: string, apiKey: string): Promise<boolean> {
|
||||
return storeApiKey(providerId, apiKey);
|
||||
}
|
||||
|
||||
/** Internal: read an account's api key without warning. */
|
||||
async _getProviderApiKeyInternal(providerId: string): Promise<string | null> {
|
||||
return getApiKey(providerId);
|
||||
}
|
||||
|
||||
/** Internal: delete an account's api key without warning. */
|
||||
async _deleteProviderApiKeyInternal(providerId: string): Promise<boolean> {
|
||||
return deleteApiKey(providerId);
|
||||
}
|
||||
|
||||
/** Internal: check if an account has a stored api key. */
|
||||
async _hasProviderApiKeyInternal(providerId: string): Promise<boolean> {
|
||||
return hasApiKey(providerId);
|
||||
}
|
||||
|
||||
// ── New clean account-based public API ───────────────────────────
|
||||
// These never log deprecation warnings — they operate purely in
|
||||
// the account namespace and are the preferred surface for the
|
||||
// /api/provider-accounts/* HTTP routes and modern renderer code.
|
||||
|
||||
/** Return per-account API key status for the new account API surface. */
|
||||
async listAccountsKeyInfo(): Promise<Array<{ accountId: string; hasKey: boolean; keyMasked: string | null }>> {
|
||||
const accounts = await this.listAccounts();
|
||||
const results: Array<{ accountId: string; hasKey: boolean; keyMasked: string | null }> = [];
|
||||
for (const account of accounts) {
|
||||
const runtimeProviderKey = resolveOpenClawProviderKey(account);
|
||||
const apiKey = (await getProviderApiKeyFromOpenClaw(runtimeProviderKey))
|
||||
?? (await getApiKey(account.id))
|
||||
?? (runtimeProviderKey !== account.id ? await getApiKey(runtimeProviderKey) : null);
|
||||
results.push({
|
||||
accountId: account.id,
|
||||
hasKey: !!apiKey,
|
||||
keyMasked: maskApiKey(apiKey),
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Read an account's API key (clean alternative to getLegacyProviderApiKey). */
|
||||
async getAccountApiKey(accountId: string): Promise<string | null> {
|
||||
return this._getProviderApiKeyInternal(accountId);
|
||||
}
|
||||
|
||||
/** Check whether an account has an API key stored. */
|
||||
async hasAccountApiKey(accountId: string): Promise<boolean> {
|
||||
const account = await this.getAccount(accountId);
|
||||
const runtimeProviderKey = account
|
||||
? resolveOpenClawProviderKey(account)
|
||||
: accountId;
|
||||
if (await getProviderApiKeyFromOpenClaw(runtimeProviderKey)) {
|
||||
return true;
|
||||
}
|
||||
if (runtimeProviderKey !== accountId && (await hasApiKey(runtimeProviderKey))) {
|
||||
return true;
|
||||
}
|
||||
return this._hasProviderApiKeyInternal(accountId);
|
||||
}
|
||||
|
||||
// ── Legacy public API (logs deprecation warning once per method) ─
|
||||
// These exist solely for backward compatibility with external clients
|
||||
// (older Gateway code, third-party tooling, in-flight tests). Internal
|
||||
// ClawX callers should use the internal/clean methods above.
|
||||
|
||||
/**
|
||||
* @deprecated Use listAccounts() and map account data in callers.
|
||||
*/
|
||||
async listLegacyProviders(): Promise<ProviderConfig[]> {
|
||||
logLegacyProviderApiUsage('listLegacyProviders', 'listAccounts');
|
||||
return this._listProvidersFromAccountsInternal();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use listAccountsKeyInfo() + the account snapshot API.
|
||||
*/
|
||||
async listLegacyProvidersWithKeyInfo(): Promise<ProviderWithKeyInfo[]> {
|
||||
logLegacyProviderApiUsage('listLegacyProvidersWithKeyInfo', 'listAccountsKeyInfo');
|
||||
return this._listProvidersWithKeyInfoInternal();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getAccount(accountId).
|
||||
*/
|
||||
async getLegacyProvider(providerId: string): Promise<ProviderConfig | null> {
|
||||
logLegacyProviderApiUsage('getLegacyProvider', 'getAccount');
|
||||
return this._getProviderInternal(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use createAccount()/updateAccount().
|
||||
*/
|
||||
async saveLegacyProvider(config: ProviderConfig): Promise<void> {
|
||||
logLegacyProviderApiUsage('saveLegacyProvider', 'createAccount/updateAccount');
|
||||
return this._saveProviderInternal(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use deleteAccount(accountId).
|
||||
*/
|
||||
async deleteLegacyProvider(providerId: string): Promise<boolean> {
|
||||
logLegacyProviderApiUsage('deleteLegacyProvider', 'deleteAccount');
|
||||
await ensureProviderStoreMigrated();
|
||||
await this.deleteAccount(providerId);
|
||||
return true;
|
||||
return this._deleteProviderInternal(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,7 +577,7 @@ export class ProviderService {
|
||||
*/
|
||||
async setDefaultLegacyProvider(providerId: string): Promise<void> {
|
||||
logLegacyProviderApiUsage('setDefaultLegacyProvider', 'setDefaultAccount');
|
||||
await this.setDefaultAccount(providerId);
|
||||
return this._setDefaultProviderInternal(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -338,7 +585,7 @@ export class ProviderService {
|
||||
*/
|
||||
async getDefaultLegacyProvider(): Promise<string | undefined> {
|
||||
logLegacyProviderApiUsage('getDefaultLegacyProvider', 'getDefaultAccountId');
|
||||
return this.getDefaultAccountId();
|
||||
return this._getDefaultProviderInternal();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -346,15 +593,15 @@ export class ProviderService {
|
||||
*/
|
||||
async setLegacyProviderApiKey(providerId: string, apiKey: string): Promise<boolean> {
|
||||
logLegacyProviderApiUsage('setLegacyProviderApiKey', 'setProviderSecret(accountId, api_key)');
|
||||
return storeApiKey(providerId, apiKey);
|
||||
return this._setProviderApiKeyInternal(providerId, apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use secret-store APIs by accountId.
|
||||
* @deprecated Use getAccountApiKey(accountId).
|
||||
*/
|
||||
async getLegacyProviderApiKey(providerId: string): Promise<string | null> {
|
||||
logLegacyProviderApiUsage('getLegacyProviderApiKey', 'getProviderSecret(accountId)');
|
||||
return getApiKey(providerId);
|
||||
logLegacyProviderApiUsage('getLegacyProviderApiKey', 'getAccountApiKey');
|
||||
return this._getProviderApiKeyInternal(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -362,15 +609,15 @@ export class ProviderService {
|
||||
*/
|
||||
async deleteLegacyProviderApiKey(providerId: string): Promise<boolean> {
|
||||
logLegacyProviderApiUsage('deleteLegacyProviderApiKey', 'deleteProviderSecret(accountId)');
|
||||
return deleteApiKey(providerId);
|
||||
return this._deleteProviderApiKeyInternal(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use secret-store APIs by accountId.
|
||||
* @deprecated Use hasAccountApiKey(accountId).
|
||||
*/
|
||||
async hasLegacyProviderApiKey(providerId: string): Promise<boolean> {
|
||||
logLegacyProviderApiUsage('hasLegacyProviderApiKey', 'getProviderSecret(accountId)');
|
||||
return hasApiKey(providerId);
|
||||
logLegacyProviderApiUsage('hasLegacyProviderApiKey', 'hasAccountApiKey');
|
||||
return this._hasProviderApiKeyInternal(providerId);
|
||||
}
|
||||
|
||||
async setDefaultAccount(accountId: string): Promise<void> {
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { ProviderAccount, ProviderConfig, ProviderType } from '../../shared
|
||||
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') {
|
||||
@@ -75,7 +74,6 @@ export async function saveProviderAccount(account: ProviderAccount): Promise<voi
|
||||
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> {
|
||||
|
||||
@@ -10,6 +10,10 @@ type ValidationProfile =
|
||||
| 'none';
|
||||
|
||||
type ValidationResult = { valid: boolean; error?: string; status?: number };
|
||||
type ClassifiedValidationResult = ValidationResult & { authFailure?: boolean };
|
||||
|
||||
const AUTH_ERROR_PATTERN = /\b(unauthorized|forbidden|access denied|invalid api key|api key invalid|incorrect api key|api key incorrect|authentication failed|auth failed|invalid credential|credential invalid|invalid signature|signature invalid|invalid access token|access token invalid|invalid bearer token|bearer token invalid|access token expired)\b|鉴权失败|認証失敗|认证失败|無效密鑰|无效密钥|密钥无效|密鑰無效|憑證無效|凭证无效/i;
|
||||
const AUTH_ERROR_CODE_PATTERN = /\b(unauthorized|forbidden|access[_-]?denied|invalid[_-]?api[_-]?key|api[_-]?key[_-]?invalid|incorrect[_-]?api[_-]?key|api[_-]?key[_-]?incorrect|authentication[_-]?failed|auth[_-]?failed|invalid[_-]?credential|credential[_-]?invalid|invalid[_-]?signature|signature[_-]?invalid|invalid[_-]?access[_-]?token|access[_-]?token[_-]?invalid|invalid[_-]?bearer[_-]?token|bearer[_-]?token[_-]?invalid|access[_-]?token[_-]?expired|invalid[_-]?token|token[_-]?invalid|token[_-]?expired)\b/i;
|
||||
|
||||
function logValidationStatus(provider: string, status: number): void {
|
||||
console.log(`[clawx-validate] ${provider} HTTP ${status}`);
|
||||
@@ -118,7 +122,7 @@ async function performProviderValidationRequest(
|
||||
providerLabel: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<ValidationResult> {
|
||||
): Promise<ClassifiedValidationResult> {
|
||||
try {
|
||||
logValidationRequest(providerLabel, 'GET', url, headers);
|
||||
const response = await proxyAwareFetch(url, { headers });
|
||||
@@ -137,21 +141,62 @@ async function performProviderValidationRequest(
|
||||
function classifyAuthResponse(
|
||||
status: number,
|
||||
data: unknown,
|
||||
): { valid: boolean; error?: string } {
|
||||
) : ClassifiedValidationResult {
|
||||
const obj = data as {
|
||||
error?: { message?: string; code?: string };
|
||||
message?: string;
|
||||
code?: string;
|
||||
} | null;
|
||||
const msg = obj?.error?.message || obj?.message || `API error: ${status}`;
|
||||
const code = obj?.error?.code || obj?.code;
|
||||
const hasAuthCode = typeof code === 'string' && AUTH_ERROR_CODE_PATTERN.test(code);
|
||||
|
||||
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' };
|
||||
if (status === 401 || status === 403) {
|
||||
return { valid: false, error: 'Invalid API key', authFailure: true };
|
||||
}
|
||||
if (status === 400 && (AUTH_ERROR_PATTERN.test(msg) || hasAuthCode)) {
|
||||
const error = hasAuthCode && msg === `API error: ${status}`
|
||||
? `Invalid API key (${code})`
|
||||
: msg || 'Invalid API key';
|
||||
return { valid: false, error, authFailure: true };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function shouldFallbackFromModelsProbe(result: ClassifiedValidationResult): boolean {
|
||||
if (result.valid || result.status === undefined) return false;
|
||||
if (result.status === 401 || result.status === 403) return false;
|
||||
if (result.authFailure) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function classifyProbeResponse(
|
||||
status: number,
|
||||
data: unknown,
|
||||
): ClassifiedValidationResult {
|
||||
const classified = classifyAuthResponse(status, data);
|
||||
|
||||
if (status >= 200 && status < 300) {
|
||||
return { valid: true, status };
|
||||
}
|
||||
if (status === 429) {
|
||||
return { valid: true, status };
|
||||
}
|
||||
if (status === 400 && !classified.authFailure) {
|
||||
return { valid: true, status };
|
||||
}
|
||||
return { ...classified, status };
|
||||
}
|
||||
|
||||
async function validateOpenAiCompatibleKey(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
apiProtocol: 'openai-completions' | 'openai-responses',
|
||||
baseUrl?: string,
|
||||
modelId?: string,
|
||||
): Promise<ValidationResult> {
|
||||
const trimmedBaseUrl = baseUrl?.trim();
|
||||
if (!trimmedBaseUrl) {
|
||||
@@ -159,17 +204,18 @@ async function validateOpenAiCompatibleKey(
|
||||
}
|
||||
|
||||
const headers = { Authorization: `Bearer ${apiKey}` };
|
||||
const probeModel = modelId?.trim() || 'validation-probe';
|
||||
const { modelsUrl, probeUrl } = resolveOpenAiProbeUrls(trimmedBaseUrl, apiProtocol);
|
||||
const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers);
|
||||
|
||||
if (modelsResult.status === 404) {
|
||||
if (shouldFallbackFromModelsProbe(modelsResult)) {
|
||||
console.log(
|
||||
`[clawx-validate] ${providerType} /models returned 404, falling back to ${apiProtocol} probe`,
|
||||
`[clawx-validate] ${providerType} /models returned ${modelsResult.status}, falling back to ${apiProtocol} probe`,
|
||||
);
|
||||
if (apiProtocol === 'openai-responses') {
|
||||
return await performResponsesProbe(providerType, probeUrl, headers);
|
||||
return await performResponsesProbe(providerType, probeUrl, headers, probeModel);
|
||||
}
|
||||
return await performChatCompletionsProbe(providerType, probeUrl, headers);
|
||||
return await performChatCompletionsProbe(providerType, probeUrl, headers, probeModel);
|
||||
}
|
||||
|
||||
return modelsResult;
|
||||
@@ -179,6 +225,7 @@ async function performResponsesProbe(
|
||||
providerLabel: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
modelId: string,
|
||||
): Promise<ValidationResult> {
|
||||
try {
|
||||
logValidationRequest(providerLabel, 'POST', url, headers);
|
||||
@@ -186,24 +233,13 @@ async function performResponsesProbe(
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'validation-probe',
|
||||
model: modelId,
|
||||
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);
|
||||
return classifyProbeResponse(response.status, data);
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
@@ -216,6 +252,7 @@ async function performChatCompletionsProbe(
|
||||
providerLabel: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
modelId: string,
|
||||
): Promise<ValidationResult> {
|
||||
try {
|
||||
logValidationRequest(providerLabel, 'POST', url, headers);
|
||||
@@ -223,25 +260,14 @@ async function performChatCompletionsProbe(
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'validation-probe',
|
||||
model: modelId,
|
||||
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);
|
||||
return classifyProbeResponse(response.status, data);
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
@@ -268,18 +294,7 @@ async function performAnthropicMessagesProbe(
|
||||
});
|
||||
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);
|
||||
return classifyProbeResponse(response.status, data);
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
@@ -342,7 +357,7 @@ async function validateOpenRouterKey(
|
||||
export async function validateApiKeyWithProvider(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
options?: { baseUrl?: string; apiProtocol?: string },
|
||||
options?: { baseUrl?: string; apiProtocol?: string; modelId?: string },
|
||||
): Promise<ValidationResult> {
|
||||
const profile = getValidationProfile(providerType, options);
|
||||
const resolvedBaseUrl = options?.baseUrl || getProviderConfig(providerType)?.baseUrl;
|
||||
@@ -364,6 +379,7 @@ export async function validateApiKeyWithProvider(
|
||||
trimmedKey,
|
||||
'openai-completions',
|
||||
resolvedBaseUrl,
|
||||
options?.modelId,
|
||||
);
|
||||
case 'openai-responses':
|
||||
return await validateOpenAiCompatibleKey(
|
||||
@@ -371,6 +387,7 @@ export async function validateApiKeyWithProvider(
|
||||
trimmedKey,
|
||||
'openai-responses',
|
||||
resolvedBaseUrl,
|
||||
options?.modelId,
|
||||
);
|
||||
case 'google-query-key':
|
||||
return await validateGoogleQueryKey(providerType, trimmedKey, resolvedBaseUrl);
|
||||
|
||||
@@ -0,0 +1,688 @@
|
||||
import { openSync, closeSync, fstatSync, readSync } from 'node:fs';
|
||||
import { access } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import { stripAcpWorkingDirectoryPrefix } from '@shared/chat/session-title';
|
||||
import { isOpenClawHeartbeatPollText } from '@shared/chat/openclaw-internal';
|
||||
import type { RawMessage } from '@shared/chat/types';
|
||||
import type { SessionTurnTimingCandidate } from '@shared/host-api/contract';
|
||||
import { resolveOpenClawStateDir } from '../utils/paths';
|
||||
import { logger } from '../utils/logger';
|
||||
import {
|
||||
removeSessionEntry,
|
||||
resolveSessionTranscriptPath,
|
||||
sweepSessionArtefacts,
|
||||
} from '../utils/session-files';
|
||||
import { isRecord } from './payload-utils';
|
||||
|
||||
const SAFE_SESSION_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
||||
const RECENT_TRANSCRIPT_INITIAL_READ_BYTES = 256 * 1024;
|
||||
const RECENT_TRANSCRIPT_MAX_READ_BYTES = 8 * 1024 * 1024;
|
||||
const RECENT_TRANSCRIPT_MAX_SCAN_LINES = 5_000;
|
||||
|
||||
type SessionSummary = {
|
||||
sessionKey: string;
|
||||
firstUserText: string | null;
|
||||
lastTimestamp: number | null;
|
||||
workspacePath: string | null;
|
||||
heartbeatOnly?: boolean;
|
||||
};
|
||||
|
||||
type TranscriptMessage = RawMessage;
|
||||
|
||||
type ParsedTranscriptLine = {
|
||||
type?: string;
|
||||
id?: unknown;
|
||||
timestamp?: unknown;
|
||||
message?: TranscriptMessage;
|
||||
};
|
||||
|
||||
type TranscriptMessageRecord = {
|
||||
id?: string;
|
||||
timestamp?: unknown;
|
||||
message: TranscriptMessage;
|
||||
};
|
||||
|
||||
type SessionPayload = {
|
||||
id?: unknown;
|
||||
sessionKey?: unknown;
|
||||
label?: unknown;
|
||||
title?: unknown;
|
||||
agentId?: unknown;
|
||||
sessionId?: unknown;
|
||||
limit?: unknown;
|
||||
sessionKeys?: unknown;
|
||||
};
|
||||
|
||||
function extractMessageText(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
return (content as Array<{ type?: unknown; text?: unknown }>)
|
||||
.filter((block) => block?.type === 'text' && typeof block.text === 'string' && block.text.trim())
|
||||
.map((block) => String(block.text))
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function cleanSummaryUserText(text: string): string {
|
||||
const textAfterInitialPrefix = stripAcpWorkingDirectoryPrefix(text);
|
||||
const cleaned = textAfterInitialPrefix
|
||||
.replace(/^Sender\s*\([^)]*\)\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '')
|
||||
.replace(/^Sender\s*\([^)]*\)\s*:\s*\{[\s\S]*?\}\s*/i, '')
|
||||
.replace(/^Sender\s*\([^)]*\)\s*:[^\n]*(?:\n\s*)*/i, '')
|
||||
.replace(/^Sender\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '')
|
||||
.replace(/^Sender\s*:\s*\{[\s\S]*?\}\s*/i, '')
|
||||
.replace(/^Sender\s*:[^\n]*(?:\n\s*)*/i, '')
|
||||
.replace(/^```json\n[\s\S]*?```\s*/i, '')
|
||||
.replace(/^\{[\s\S]*?\}\s*/i, '')
|
||||
.replace(/\s*\[media attached:[^\]]*\]/g, '')
|
||||
.replace(/\s*\[message_id:\s*[^\]]+\]/g, '')
|
||||
.replace(/^Conversation info\s*\([^)]*\):\s*```[a-z]*\n[\s\S]*?```\s*/i, '')
|
||||
.replace(/^Conversation info\s*\([^)]*\):\s*\{[\s\S]*?\}\s*/i, '')
|
||||
.replace(/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, '')
|
||||
.trim();
|
||||
const stripCwdExposedByCleanup = !textAfterInitialPrefix.startsWith('[Working directory: ')
|
||||
&& cleaned.startsWith('[Working directory: ');
|
||||
return (stripCwdExposedByCleanup ? stripAcpWorkingDirectoryPrefix(cleaned) : cleaned).trim();
|
||||
}
|
||||
|
||||
function isInternalSummaryText(text: string): boolean {
|
||||
if (!text) return true;
|
||||
if (isOpenClawHeartbeatPollText(text)) return true;
|
||||
if (/^\s*System\s*\(untrusted\)\s*:/i.test(text)) return true;
|
||||
if (
|
||||
/An async command you ran earlier has completed/i.test(text)
|
||||
&& /Do not relay it to the user unless explicitly requested/i.test(text)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
/^\s*Current time\s*:/i.test(text)
|
||||
&& /^\s*Current time\s*:[^\n]*\/\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+UTC\s*$/i.test(text)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||||
return value < 1e12 ? value * 1000 : value;
|
||||
}
|
||||
|
||||
function normalizeTranscriptTimestamp(value: unknown): number | null {
|
||||
if (typeof value === 'number') return normalizeTimestamp(value);
|
||||
if (typeof value !== 'string' || !value.trim()) return null;
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
function transcriptRecordTimestamp(record: TranscriptMessageRecord): number | null {
|
||||
return normalizeTranscriptTimestamp(record.timestamp)
|
||||
?? normalizeTranscriptTimestamp(record.message.timestamp);
|
||||
}
|
||||
|
||||
function normalizeTurnUserText(message: TranscriptMessage): string {
|
||||
return stripAcpWorkingDirectoryPrefix(extractMessageText(message.content))
|
||||
.replace(/\r\n/g, '\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isInternalInterSessionUser(message: TranscriptMessage): boolean {
|
||||
const provenance = (message as TranscriptMessage & { provenance?: unknown }).provenance;
|
||||
if (provenance && typeof provenance === 'object' && !Array.isArray(provenance)) {
|
||||
const kind = (provenance as Record<string, unknown>).kind;
|
||||
if (typeof kind === 'string' && kind.toLowerCase() === 'inter_session') return true;
|
||||
}
|
||||
return /^\[Inter-session message\]\s/.test(extractMessageText(message.content));
|
||||
}
|
||||
|
||||
function extractTranscriptTurnTimings(records: TranscriptMessageRecord[]): SessionTurnTimingCandidate[] {
|
||||
const turns: Array<{
|
||||
normalizedUserText: string;
|
||||
startedAt: number | null;
|
||||
completedAt: number | null;
|
||||
}> = [];
|
||||
let current: (typeof turns)[number] | null = null;
|
||||
|
||||
for (const record of records) {
|
||||
const role = typeof record.message.role === 'string' ? record.message.role.toLowerCase() : '';
|
||||
if (role === 'user') {
|
||||
if (isInternalInterSessionUser(record.message)) continue;
|
||||
current = {
|
||||
normalizedUserText: normalizeTurnUserText(record.message),
|
||||
startedAt: transcriptRecordTimestamp(record),
|
||||
completedAt: null,
|
||||
};
|
||||
turns.push(current);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!current || (role !== 'assistant' && role !== 'toolresult' && role !== 'tool_result')) continue;
|
||||
const timestamp = transcriptRecordTimestamp(record);
|
||||
if (timestamp != null && (current.completedAt == null || timestamp > current.completedAt)) {
|
||||
current.completedAt = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
const occurrences = new Map<string, number>();
|
||||
const candidates = new Array<SessionTurnTimingCandidate | null>(turns.length).fill(null);
|
||||
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
||||
const turn = turns[index]!;
|
||||
const userOccurrenceFromTail = (occurrences.get(turn.normalizedUserText) ?? 0) + 1;
|
||||
occurrences.set(turn.normalizedUserText, userOccurrenceFromTail);
|
||||
if (turn.startedAt == null || turn.completedAt == null || turn.completedAt < turn.startedAt) continue;
|
||||
candidates[index] = {
|
||||
normalizedUserText: turn.normalizedUserText,
|
||||
userOccurrenceFromTail,
|
||||
durationMs: turn.completedAt - turn.startedAt,
|
||||
};
|
||||
}
|
||||
return candidates.filter((candidate): candidate is SessionTurnTimingCandidate => candidate != null);
|
||||
}
|
||||
|
||||
type SqliteDatabaseLike = {
|
||||
prepare: (sql: string) => {
|
||||
get: (...params: unknown[]) => unknown;
|
||||
};
|
||||
};
|
||||
|
||||
function normalizeCwdValue(value: unknown): string | null {
|
||||
const cwd = typeof value === 'string' ? value.trim() : '';
|
||||
return cwd || null;
|
||||
}
|
||||
|
||||
function parseRuntimeOptionsCwd(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || !value.trim()) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
return normalizeCwdValue((parsed as Record<string, unknown>).cwd);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readAcpReplayCwd(db: SqliteDatabaseLike, sessionKey: string): string | null {
|
||||
try {
|
||||
const row = db.prepare(
|
||||
'SELECT cwd FROM acp_replay_sessions WHERE session_key = ? ORDER BY updated_at DESC, session_id ASC LIMIT 1',
|
||||
).get(sessionKey) as { cwd?: unknown } | undefined;
|
||||
return normalizeCwdValue(row?.cwd);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readAcpRuntimeMetaCwd(db: SqliteDatabaseLike, sessionKey: string): string | null {
|
||||
try {
|
||||
const row = db.prepare('SELECT * FROM acp_sessions WHERE session_key = ?').get(sessionKey) as {
|
||||
runtime_options_json?: unknown;
|
||||
cwd?: unknown;
|
||||
} | undefined;
|
||||
return parseRuntimeOptionsCwd(row?.runtime_options_json) ?? normalizeCwdValue(row?.cwd);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readOpenClawAcpSessionCwds(sessionKeys: string[]): Promise<Map<string, string>> {
|
||||
const normalizedKeys = Array.from(new Set(sessionKeys.map((sessionKey) => sessionKey.trim()).filter(Boolean)));
|
||||
const workspaceByKey = new Map<string, string>();
|
||||
if (normalizedKeys.length === 0) return workspaceByKey;
|
||||
|
||||
const databasePath = join(resolveOpenClawStateDir(), 'state', 'openclaw.sqlite');
|
||||
try {
|
||||
await access(databasePath);
|
||||
const sqliteSpecifier = 'node:sqlite';
|
||||
const { DatabaseSync } = await import(/* @vite-ignore */ sqliteSpecifier);
|
||||
const db = new DatabaseSync(databasePath, { readOnly: true });
|
||||
try {
|
||||
for (const sessionKey of normalizedKeys) {
|
||||
const cwd = readAcpReplayCwd(db, sessionKey) ?? readAcpRuntimeMetaCwd(db, sessionKey);
|
||||
if (cwd) workspaceByKey.set(sessionKey, cwd);
|
||||
}
|
||||
return workspaceByKey;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function parseMessageRecordLine(line: string): TranscriptMessageRecord | null {
|
||||
try {
|
||||
const entry = JSON.parse(line) as ParsedTranscriptLine;
|
||||
if (entry.type !== 'message' || !entry.message || typeof entry.message !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(typeof entry.id === 'string' ? { id: entry.id } : {}),
|
||||
timestamp: entry.timestamp,
|
||||
message: entry.message,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseMessageLine(line: string): TranscriptMessage | null {
|
||||
return parseMessageRecordLine(line)?.message ?? null;
|
||||
}
|
||||
|
||||
function parseRecentRecordsFromTailChunk(chunk: string, readStart: number, limit: number): TranscriptMessageRecord[] {
|
||||
const lines = chunk.split(/\r?\n/);
|
||||
if (readStart > 0) lines.shift();
|
||||
|
||||
const collected: TranscriptMessageRecord[] = [];
|
||||
let scanned = 0;
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const line = lines[index];
|
||||
if (!line?.trim()) continue;
|
||||
scanned += 1;
|
||||
if (scanned > RECENT_TRANSCRIPT_MAX_SCAN_LINES) break;
|
||||
const record = parseMessageRecordLine(line);
|
||||
if (record) {
|
||||
collected.push(record);
|
||||
if (collected.length >= limit) break;
|
||||
}
|
||||
}
|
||||
return collected.reverse();
|
||||
}
|
||||
|
||||
function readRecentTranscriptRecords(transcriptPath: string, limit: number): TranscriptMessageRecord[] {
|
||||
const boundedLimit = Math.max(1, Math.min(Math.floor(limit), 1000));
|
||||
let fd: number | null = null;
|
||||
try {
|
||||
fd = openSync(transcriptPath, 'r');
|
||||
const size = fstatSync(fd).size;
|
||||
if (size === 0) return [];
|
||||
|
||||
let readBytes = Math.min(size, Math.max(RECENT_TRANSCRIPT_INITIAL_READ_BYTES, boundedLimit * 2048));
|
||||
while (readBytes <= size) {
|
||||
const readStart = Math.max(0, size - readBytes);
|
||||
const readLen = size - readStart;
|
||||
const buffer = Buffer.allocUnsafe(readLen);
|
||||
readSync(fd, buffer, 0, readLen, readStart);
|
||||
const records = parseRecentRecordsFromTailChunk(buffer.toString('utf8'), readStart, boundedLimit);
|
||||
if (
|
||||
records.length >= boundedLimit
|
||||
|| readStart === 0
|
||||
|| readBytes >= RECENT_TRANSCRIPT_MAX_READ_BYTES
|
||||
) {
|
||||
return records;
|
||||
}
|
||||
readBytes = Math.min(size, readBytes * 2);
|
||||
}
|
||||
return [];
|
||||
} finally {
|
||||
if (fd !== null) closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function readRecentTranscriptMessages(transcriptPath: string, limit: number): TranscriptMessage[] {
|
||||
return readRecentTranscriptRecords(transcriptPath, limit).map((record) => record.message);
|
||||
}
|
||||
|
||||
async function readAllTranscriptMessages(transcriptPath: string): Promise<TranscriptMessage[]> {
|
||||
const fsP = await import('node:fs/promises');
|
||||
const raw = await fsP.readFile(transcriptPath, 'utf8');
|
||||
return raw.split(/\r?\n/).filter(Boolean).flatMap((line) => {
|
||||
const message = parseMessageLine(line);
|
||||
return message ? [message] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeTranscriptMessages(
|
||||
sessionKey: string,
|
||||
messages: TranscriptMessage[],
|
||||
workspacePath: string | null,
|
||||
): SessionSummary {
|
||||
let firstUserText: string | null = null;
|
||||
let lastTimestamp: number | null = null;
|
||||
let sawHeartbeatPollText = false;
|
||||
|
||||
for (const message of messages) {
|
||||
const normalizedTs = normalizeTimestamp(message.timestamp);
|
||||
if (normalizedTs != null) {
|
||||
lastTimestamp = normalizedTs;
|
||||
}
|
||||
if (firstUserText == null && message.role === 'user') {
|
||||
const text = cleanSummaryUserText(extractMessageText(message.content));
|
||||
if (text && isInternalSummaryText(text)) {
|
||||
if (isOpenClawHeartbeatPollText(text)) {
|
||||
sawHeartbeatPollText = true;
|
||||
}
|
||||
} else if (text) {
|
||||
firstUserText = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const heartbeatOnly = firstUserText == null && sawHeartbeatPollText;
|
||||
return {
|
||||
sessionKey,
|
||||
firstUserText,
|
||||
lastTimestamp,
|
||||
workspacePath,
|
||||
...(heartbeatOnly ? { heartbeatOnly: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSessionKey(sessionKey: string): { agentId: string; suffix: string } | null {
|
||||
if (!sessionKey.startsWith('agent:')) return null;
|
||||
const parts = sessionKey.split(':');
|
||||
if (parts.length < 3) return null;
|
||||
const agentId = parts[1] || '';
|
||||
const suffix = parts.slice(2).join(':');
|
||||
if (!SAFE_SESSION_SEGMENT.test(agentId) || !suffix) return null;
|
||||
return { agentId, suffix };
|
||||
}
|
||||
|
||||
function getSessionKey(payload: unknown): string {
|
||||
const body = isRecord(payload) ? payload as SessionPayload : {};
|
||||
const value = body.sessionKey ?? body.id ?? payload;
|
||||
if (typeof value !== 'string' || !value.startsWith('agent:')) {
|
||||
throw new Error(`Invalid sessionKey: ${String(value)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getLimit(payload: unknown, fallback = 200): number {
|
||||
const value = isRecord(payload) ? (payload as SessionPayload).limit : undefined;
|
||||
const limitRaw = typeof value === 'number' ? value : fallback;
|
||||
return Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 1000) : fallback;
|
||||
}
|
||||
|
||||
async function readSessionsJson(agentId: string): Promise<Record<string, unknown>> {
|
||||
const fsP = await import('node:fs/promises');
|
||||
const sessionsJsonPath = join(resolveOpenClawStateDir(), 'agents', agentId, 'sessions', 'sessions.json');
|
||||
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function resolveSessionTranscriptPathByKey(
|
||||
sessionKey: string,
|
||||
sessionsDir: string,
|
||||
sessionsJson: Record<string, unknown>,
|
||||
): string | null {
|
||||
let resolvedSrcPath: string | undefined;
|
||||
let fileName: string | undefined;
|
||||
|
||||
if (Array.isArray(sessionsJson.sessions)) {
|
||||
const entry = (sessionsJson.sessions as Array<Record<string, unknown>>)
|
||||
.find((session) => session.key === sessionKey || session.sessionKey === sessionKey);
|
||||
if (entry) {
|
||||
fileName = (entry.file ?? entry.fileName ?? entry.path) as string | undefined;
|
||||
if (!fileName && typeof entry.id === 'string') {
|
||||
fileName = `${entry.id}.jsonl`;
|
||||
}
|
||||
const absFile = (entry.sessionFile ?? entry.absolutePath) as string | undefined;
|
||||
if (absFile && (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/))) {
|
||||
resolvedSrcPath = absFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileName && !resolvedSrcPath && sessionsJson[sessionKey] != null) {
|
||||
const value = sessionsJson[sessionKey];
|
||||
if (typeof value === 'string') {
|
||||
fileName = value;
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
const entry = value as Record<string, unknown>;
|
||||
const absFile = (entry.sessionFile ?? entry.file ?? entry.fileName ?? entry.path) as string | undefined;
|
||||
if (absFile) {
|
||||
if (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/)) {
|
||||
resolvedSrcPath = absFile;
|
||||
} else {
|
||||
fileName = absFile;
|
||||
}
|
||||
} else {
|
||||
const id = (entry.id ?? entry.sessionId) as string | undefined;
|
||||
if (id) fileName = id.endsWith('.jsonl') ? id : `${id}.jsonl`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedSrcPath && fileName) {
|
||||
resolvedSrcPath = join(sessionsDir, fileName.endsWith('.jsonl') ? fileName : `${fileName}.jsonl`);
|
||||
}
|
||||
|
||||
return resolvedSrcPath ?? null;
|
||||
}
|
||||
|
||||
async function loadSessionSummary(sessionKey: string, workspacePath: string | null): Promise<SessionSummary> {
|
||||
const parsed = parseSessionKey(sessionKey);
|
||||
if (!parsed) {
|
||||
return { sessionKey, firstUserText: null, lastTimestamp: null, workspacePath };
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionsDir = join(resolveOpenClawStateDir(), 'agents', parsed.agentId, 'sessions');
|
||||
const sessionsJson = await readSessionsJson(parsed.agentId);
|
||||
const transcriptPath = resolveSessionTranscriptPathByKey(sessionKey, sessionsDir, sessionsJson);
|
||||
if (!transcriptPath) {
|
||||
return { sessionKey, firstUserText: null, lastTimestamp: null, workspacePath };
|
||||
}
|
||||
|
||||
const messages = await readAllTranscriptMessages(transcriptPath);
|
||||
return summarizeTranscriptMessages(sessionKey, messages, workspacePath);
|
||||
} catch {
|
||||
return { sessionKey, firstUserText: null, lastTimestamp: null, workspacePath };
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSessionTranscriptByKey(sessionKey: string, limit: number): Promise<RawMessage[] | null> {
|
||||
const parsed = parseSessionKey(sessionKey);
|
||||
if (!parsed) return null;
|
||||
|
||||
try {
|
||||
const sessionsDir = join(resolveOpenClawStateDir(), 'agents', parsed.agentId, 'sessions');
|
||||
const sessionsJson = await readSessionsJson(parsed.agentId);
|
||||
const transcriptPath = resolveSessionTranscriptPathByKey(sessionKey, sessionsDir, sessionsJson);
|
||||
if (!transcriptPath) return null;
|
||||
|
||||
return readRecentTranscriptMessages(transcriptPath, limit);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSessionTurnTimingsByKey(
|
||||
sessionKey: string,
|
||||
limit: number,
|
||||
): Promise<SessionTurnTimingCandidate[] | null> {
|
||||
const parsed = parseSessionKey(sessionKey);
|
||||
if (!parsed) return null;
|
||||
|
||||
try {
|
||||
const sessionsDir = join(resolveOpenClawStateDir(), 'agents', parsed.agentId, 'sessions');
|
||||
const sessionsJson = await readSessionsJson(parsed.agentId);
|
||||
const transcriptPath = resolveSessionTranscriptPathByKey(sessionKey, sessionsDir, sessionsJson);
|
||||
if (!transcriptPath) return null;
|
||||
|
||||
// ACP session/load is authoritative for history content, but its updates omit the
|
||||
// original timestamps needed to calculate a whole-turn duration. Read only bounded
|
||||
// transcript timing metadata here; this must never become a second history source.
|
||||
return extractTranscriptTurnTimings(readRecentTranscriptRecords(transcriptPath, limit));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(sessionKey: string): Promise<{ success: boolean; error?: string }> {
|
||||
if (!sessionKey || !sessionKey.startsWith('agent:')) {
|
||||
return { success: false, error: `Invalid sessionKey: ${sessionKey}` };
|
||||
}
|
||||
const parts = sessionKey.split(':');
|
||||
if (parts.length < 3) {
|
||||
return { success: false, error: `sessionKey has too few parts: ${sessionKey}` };
|
||||
}
|
||||
const agentId = parts[1];
|
||||
if (!SAFE_SESSION_SEGMENT.test(agentId)) {
|
||||
return { success: false, error: `Invalid agentId: ${agentId}` };
|
||||
}
|
||||
|
||||
const sessionsDir = join(resolveOpenClawStateDir(), 'agents', agentId, 'sessions');
|
||||
const sessionsJsonPath = join(sessionsDir, 'sessions.json');
|
||||
logger.info(`[session:delete] key=${sessionKey} agentId=${agentId}`);
|
||||
logger.info(`[session:delete] sessionsJson=${sessionsJsonPath}`);
|
||||
|
||||
const fsP = await import('node:fs/promises');
|
||||
let sessionsJson: Record<string, unknown>;
|
||||
try {
|
||||
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
|
||||
sessionsJson = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
logger.warn(`[session:delete] Could not read sessions.json: ${String(error)}`);
|
||||
return { success: false, error: `Could not read sessions.json: ${String(error)}` };
|
||||
}
|
||||
|
||||
const resolution = resolveSessionTranscriptPath(sessionsJson, sessionsDir, sessionKey);
|
||||
if (!resolution.ok) {
|
||||
if (resolution.failure.kind === 'not-found') {
|
||||
logger.warn(`[session:delete] Cannot resolve file for "${sessionKey}". Raw value: ${JSON.stringify(sessionsJson[sessionKey])}`);
|
||||
return { success: false, error: `Cannot resolve file for session: ${sessionKey}` };
|
||||
}
|
||||
logger.warn(`[session:delete] Refusing to delete out-of-scope path for "${sessionKey}": ${resolution.failure.resolvedPath}`);
|
||||
return {
|
||||
success: false,
|
||||
error: `Resolved session path is outside the agent sessions dir: ${resolution.failure.resolvedPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
const { resolvedSrcPath, sessionsDirAbs, baseId } = resolution;
|
||||
logger.info(`[session:delete] file: ${resolvedSrcPath}`);
|
||||
const sweep = await sweepSessionArtefacts(sessionsDirAbs, baseId);
|
||||
for (const removedPath of sweep.removed) {
|
||||
logger.info(`[session:delete] Unlinked ${removedPath}`);
|
||||
}
|
||||
for (const { path: failedPath, error } of sweep.errors) {
|
||||
logger.warn(`[session:delete] Failed to unlink ${failedPath}: ${String(error)}`);
|
||||
}
|
||||
logger.info(`[session:delete] Hard-deleted ${sweep.removed.length} file(s) for ${baseId}`);
|
||||
|
||||
try {
|
||||
const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8');
|
||||
const json2 = JSON.parse(raw2) as Record<string, unknown>;
|
||||
removeSessionEntry(json2, sessionKey);
|
||||
await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8');
|
||||
logger.info(`[session:delete] Removed "${sessionKey}" from sessions.json`);
|
||||
} catch (error) {
|
||||
logger.warn(`[session:delete] Could not update sessions.json: ${String(error)}`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function renameSession(sessionKey: string, label: string): Promise<{ success: boolean; error?: string }> {
|
||||
if (!sessionKey || !sessionKey.startsWith('agent:')) {
|
||||
return { success: false, error: `Invalid sessionKey: ${sessionKey}` };
|
||||
}
|
||||
if (!label || typeof label !== 'string' || !label.trim()) {
|
||||
return { success: false, error: 'Label cannot be empty' };
|
||||
}
|
||||
const parts = sessionKey.split(':');
|
||||
if (parts.length < 3) {
|
||||
return { success: false, error: `Malformed sessionKey: ${sessionKey}` };
|
||||
}
|
||||
const agentId = parts[1];
|
||||
if (!SAFE_SESSION_SEGMENT.test(agentId)) {
|
||||
return { success: false, error: `Invalid agentId in sessionKey: ${agentId}` };
|
||||
}
|
||||
|
||||
const sessionsJsonPath = join(resolveOpenClawStateDir(), 'agents', agentId, 'sessions', 'sessions.json');
|
||||
const fsP = await import('node:fs/promises');
|
||||
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
|
||||
const json = JSON.parse(raw) as Record<string, unknown>;
|
||||
const trimmedLabel = label.trim();
|
||||
|
||||
let found = false;
|
||||
if (json[sessionKey] && typeof json[sessionKey] === 'object') {
|
||||
(json[sessionKey] as Record<string, unknown>).label = trimmedLabel;
|
||||
found = true;
|
||||
}
|
||||
if (Array.isArray(json.sessions)) {
|
||||
for (const entry of json.sessions as Array<Record<string, unknown>>) {
|
||||
if (entry.key === sessionKey || entry.sessionKey === sessionKey) {
|
||||
entry.label = trimmedLabel;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
return { success: false, error: `Session not found in sessions.json: ${sessionKey}` };
|
||||
}
|
||||
|
||||
await fsP.writeFile(sessionsJsonPath, JSON.stringify(json, null, 2), 'utf8');
|
||||
logger.info(`[session:rename] key=${sessionKey} label=${trimmedLabel}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export function createSessionsApi(): CompleteHostServiceRegistry['sessions'] {
|
||||
return {
|
||||
delete: async (payload) => deleteSession(getSessionKey(payload)),
|
||||
rename: async (payload) => {
|
||||
const body = isRecord(payload) ? payload as SessionPayload : {};
|
||||
const sessionKey = getSessionKey(payload);
|
||||
const label = body.label ?? body.title;
|
||||
if (typeof label !== 'string') {
|
||||
throw new Error('Label cannot be empty');
|
||||
}
|
||||
return renameSession(sessionKey, label);
|
||||
},
|
||||
summaries: async (payload) => {
|
||||
const body = isRecord(payload) ? payload as SessionPayload : {};
|
||||
const sessionKeys = Array.isArray(body.sessionKeys)
|
||||
? body.sessionKeys.filter((value): value is string => typeof value === 'string' && value.startsWith('agent:'))
|
||||
: [];
|
||||
if (sessionKeys.length === 0) return { success: true, summaries: [] };
|
||||
const workspaceByKey = await readOpenClawAcpSessionCwds(sessionKeys);
|
||||
return {
|
||||
success: true,
|
||||
summaries: await Promise.all(sessionKeys.map((sessionKey) => (
|
||||
loadSessionSummary(sessionKey, workspaceByKey.get(sessionKey.trim()) ?? null)
|
||||
))),
|
||||
};
|
||||
},
|
||||
history: async (payload) => {
|
||||
const body = isRecord(payload) ? payload as SessionPayload : {};
|
||||
const limit = getLimit(payload);
|
||||
|
||||
if (typeof body.sessionKey === 'string' && body.sessionKey.trim()) {
|
||||
const messages = await loadSessionTranscriptByKey(body.sessionKey.trim(), limit);
|
||||
if (!messages) return { success: false, error: 'Transcript not found' };
|
||||
return { success: true, messages };
|
||||
}
|
||||
|
||||
const agentId = typeof body.agentId === 'string' ? body.agentId.trim() : '';
|
||||
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
||||
if (!agentId || !sessionId) {
|
||||
return { success: false, error: 'agentId and sessionId are required' };
|
||||
}
|
||||
if (!SAFE_SESSION_SEGMENT.test(agentId) || !SAFE_SESSION_SEGMENT.test(sessionId)) {
|
||||
return { success: false, error: 'Invalid transcript identifier' };
|
||||
}
|
||||
|
||||
try {
|
||||
const transcriptPath = join(resolveOpenClawStateDir(), 'agents', agentId, 'sessions', `${sessionId}.jsonl`);
|
||||
return { success: true, messages: readRecentTranscriptMessages(transcriptPath, limit) };
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
|
||||
return { success: false, error: 'Transcript not found' };
|
||||
}
|
||||
return { success: false, error: 'Failed to load transcript' };
|
||||
}
|
||||
},
|
||||
turnTimings: async (payload) => {
|
||||
const body = isRecord(payload) ? payload as SessionPayload : {};
|
||||
const sessionKey = typeof body.sessionKey === 'string' ? body.sessionKey.trim() : '';
|
||||
if (!sessionKey) return { success: false, error: 'sessionKey is required' };
|
||||
const timings = await loadSessionTurnTimingsByKey(sessionKey, getLimit(payload, 1000));
|
||||
if (!timings) return { success: false, error: 'Transcript not found' };
|
||||
return { success: true, timings };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import { syncLaunchAtStartupSettingFromStore } from '../main/launch-at-startup';
|
||||
import { createMenu } from '../main/menu';
|
||||
import { applyProxySettings } from '../main/proxy';
|
||||
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
|
||||
import {
|
||||
type AppSettings,
|
||||
getAllSettings,
|
||||
getSetting,
|
||||
resetSettings,
|
||||
setSetting,
|
||||
} from '../utils/store';
|
||||
import { isRecord } from './payload-utils';
|
||||
|
||||
type KeyPayload = {
|
||||
key?: unknown;
|
||||
};
|
||||
|
||||
type SetPayload = KeyPayload & {
|
||||
value?: unknown;
|
||||
};
|
||||
|
||||
type SetManyPayload = {
|
||||
patch?: unknown;
|
||||
};
|
||||
|
||||
const PROXY_SETTING_KEYS = new Set<keyof AppSettings>([
|
||||
'proxyEnabled',
|
||||
'proxyServer',
|
||||
'proxyHttpServer',
|
||||
'proxyHttpsServer',
|
||||
'proxyAllServer',
|
||||
'proxyBypassRules',
|
||||
]);
|
||||
|
||||
async function validateSettingKey(key: unknown): Promise<boolean> {
|
||||
if (typeof key !== 'string' || key.length === 0) return false;
|
||||
const settings = await getAllSettings();
|
||||
return Object.prototype.hasOwnProperty.call(settings, key);
|
||||
}
|
||||
|
||||
async function requireSettingKey(payload: unknown): Promise<keyof AppSettings> {
|
||||
const key = (payload as KeyPayload | undefined)?.key;
|
||||
if (!await validateSettingKey(key)) {
|
||||
throw new Error('Invalid settings key');
|
||||
}
|
||||
return key as keyof AppSettings;
|
||||
}
|
||||
|
||||
async function requireSettingsPatch(payload: unknown): Promise<Partial<AppSettings>> {
|
||||
const patch = (payload as SetManyPayload | undefined)?.patch;
|
||||
if (!isRecord(patch)) {
|
||||
throw new Error('Invalid settings patch');
|
||||
}
|
||||
const entries = Object.entries(patch);
|
||||
for (const [key] of entries) {
|
||||
if (!await validateSettingKey(key)) {
|
||||
throw new Error('Invalid settings key');
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(entries) as Partial<AppSettings>;
|
||||
}
|
||||
|
||||
function patchTouchesProxy(patch: Partial<AppSettings>): boolean {
|
||||
return Object.keys(patch).some((key) => PROXY_SETTING_KEYS.has(key as keyof AppSettings));
|
||||
}
|
||||
|
||||
function patchTouchesLaunchAtStartup(patch: Partial<AppSettings>): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(patch, 'launchAtStartup');
|
||||
}
|
||||
|
||||
function patchTouchesLanguage(patch: Partial<AppSettings>): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(patch, 'language');
|
||||
}
|
||||
|
||||
async function handleProxySettingsChange(gatewayManager: GatewayManager): Promise<void> {
|
||||
const settings = await getAllSettings();
|
||||
await syncProxyConfigToOpenClaw(settings, { preserveExistingWhenDisabled: false });
|
||||
await applyProxySettings(settings);
|
||||
if (gatewayManager.getStatus().state === 'running') {
|
||||
await gatewayManager.restart();
|
||||
}
|
||||
}
|
||||
|
||||
async function runSettingsSideEffects(
|
||||
gatewayManager: GatewayManager,
|
||||
patch: Partial<AppSettings>,
|
||||
): Promise<void> {
|
||||
if (patchTouchesProxy(patch)) {
|
||||
await handleProxySettingsChange(gatewayManager);
|
||||
}
|
||||
if (patchTouchesLaunchAtStartup(patch)) {
|
||||
await syncLaunchAtStartupSettingFromStore();
|
||||
}
|
||||
if (patchTouchesLanguage(patch)) {
|
||||
await createMenu(typeof patch.language === 'string' ? patch.language : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export function createSettingsApi(gatewayManager: GatewayManager): CompleteHostServiceRegistry['settings'] {
|
||||
return {
|
||||
getAll: () => getAllSettings(),
|
||||
get: async (payload) => {
|
||||
const key = await requireSettingKey(payload);
|
||||
return getSetting(key as never);
|
||||
},
|
||||
set: async (payload) => {
|
||||
const body = payload as SetPayload | undefined;
|
||||
const key = await requireSettingKey(body);
|
||||
await setSetting(key as never, body?.value as never);
|
||||
await runSettingsSideEffects(gatewayManager, { [key]: body?.value } as Partial<AppSettings>);
|
||||
return { success: true };
|
||||
},
|
||||
setMany: async (payload) => {
|
||||
const patch = await requireSettingsPatch(payload);
|
||||
const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>;
|
||||
for (const [key, value] of entries) {
|
||||
await setSetting(key, value as never);
|
||||
}
|
||||
await runSettingsSideEffects(gatewayManager, patch);
|
||||
return { success: true };
|
||||
},
|
||||
reset: async () => {
|
||||
await resetSettings();
|
||||
await handleProxySettingsChange(gatewayManager);
|
||||
await syncLaunchAtStartupSettingFromStore();
|
||||
const settings = await getAllSettings();
|
||||
await createMenu(settings.language);
|
||||
return { success: true, settings };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { shell } from 'electron';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, sep } from 'node:path';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
|
||||
function expandShellPath(input: string): string {
|
||||
if (input === '~') return homedir();
|
||||
if (input.startsWith(`~${sep}`) || input.startsWith('~/') || input.startsWith('~\\')) {
|
||||
return join(homedir(), input.slice(2));
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function requirePath(path: unknown): string {
|
||||
if (typeof path !== 'string' || !path.trim()) {
|
||||
throw new Error('path is required');
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function requireUrl(url: unknown): string {
|
||||
if (typeof url !== 'string' || !url.trim()) {
|
||||
throw new Error('url is required');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function createShellApi(): CompleteHostServiceRegistry['shell'] {
|
||||
return {
|
||||
openExternal: async (payload) => {
|
||||
await shell.openExternal(requireUrl(payload.url));
|
||||
},
|
||||
showItemInFolder: (payload) => {
|
||||
shell.showItemInFolder(expandShellPath(requirePath(payload.path)));
|
||||
},
|
||||
openPath: (payload) => shell.openPath(expandShellPath(requirePath(payload.path))),
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user