Compare commits

...
176 Commits
Author SHA1 Message Date
paisley 15a3faa996 fix(gateway): skip heartbeat ping on Windows to prevent reconnect cascade (#694) 2026-03-27 19:07:31 +08:00
paisley 77d6879b16 v0.3.2 (#693) 2026-03-27 18:29:25 +08:00
paisley 50bcfeccfc fix: ensure custom provider deletion targets correct runtime key by making key generation idempotent (#691) 2026-03-27 16:56:28 +08:00
paisley 1292e9f120 fix: force-clean stale instance lock when Electron lock guarantees exclusivity (#685) 2026-03-27 15:24:54 +08:00
paisley aa98e59317 fix(electron): work around Node.js cpSync Unicode crash on Windows (#686) 2026-03-27 15:05:09 +08:00
paisley 537a85c4d1 chore: fix mac ci (#678) 2026-03-26 19:38:44 +08:00
paisley 321a371677 chore: free disk space in macOS CI (#677) 2026-03-26 19:16:51 +08:00
paisley fcf5f7c566 fix: update MiniMax model display to M2.7 on OAuth re-login (#676) 2026-03-26 18:52:29 +08:00
paisley 19a6d2dd34 v0.3.1 (#674) 2026-03-26 17:39:46 +08:00
paisley bed62c64e8 fix: add @sinclair/typebox dep and fix delivery button text truncation (#673) 2026-03-26 17:31:55 +08:00
Octopusandximi 992eda8fe0 feat: upgrade MiniMax default model from M2.5 to M2.7 (#671)
Co-authored-by: ximi <ximi@minimaxi.com>
2026-03-26 17:03:13 +08:00
paisleyandFelix ba5947e2cb upgrade openclaw to 3.23 (#652)
Co-authored-by: Felix <24791380+vcfgv@users.noreply.github.com>
2026-03-26 16:58:04 +08:00
b786b773f1 fix(security): mitigate GHSA-9gf9-7xcc-xcq9 & GHSA-vf6c-fgmq-xm78 + bug fixes (#667)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 22:02:28 +08:00
83858fdf73 fix: prevent config overwrite, session history race, and streaming message loss (#663)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 21:11:20 +08:00
Felix 8c3a6a5f7a fix(installer): enhance installation logging and user feedback during setup (#661) 2026-03-25 15:34:47 +08:00
Felix ab8fe760ef feat(agent-model): add per-agent model override with default-reset UX and runtime sync (#651) 2026-03-25 10:13:11 +08:00
cedric 9d40e1fa05 feat: unify cron delivery account and target selection (#642) 2026-03-25 10:12:49 +08:00
paisley 9aea3c9441 v0.3.0 (#650) 2026-03-24 16:21:06 +08:00
paisley 05ae404dee fix: use openclaw.json as single source of truth for provider list (#649) 2026-03-24 15:21:37 +08:00
paisley 859e3fd6c5 Fix provider display (#641) 2026-03-23 19:11:53 +08:00
paisley c6021cedf4 feat(agents): add option to inherit main agent workspace when creating new agent (#639) 2026-03-23 18:00:35 +08:00
GASOT-GITandHaze 6b82c6ccb4 chore: normalize structure and split ipc handlers (#590)
Co-authored-by: Haze <709547807@qq.com>
2026-03-23 17:18:40 +08:00
884aa7c7f1 fix: persist provider display state across restarts (fixes #624) (#633)
Co-authored-by: Kagura Chen <daniyuu19@sjtu.edu.cn>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 16:59:41 +08:00
paisley 7643cb8a75 fix: clean up WhatsApp credentials on QR cancel to prevent ghost channel (#637) 2026-03-23 16:55:49 +08:00
Felix 4d75dc1e5f fix(provider): preserve custom headers and add custom-provider User-Agent setting (#635) 2026-03-23 16:45:57 +08:00
paisley 7b1e79ed7b Fix discord channel native (#634) 2026-03-23 14:56:41 +08:00
KaguraandKagura Chen f56cd5cc1d fix: persist theme setting to main process store (#628)
Co-authored-by: Kagura Chen <daniyuu19@sjtu.edu.cn>
2026-03-23 13:49:30 +08:00
arianachennn-design ae2aa97775 changed feishu group code (#630) 2026-03-23 10:46:22 +08:00
Haze 2c816cf6ea feat(release): 0.2.9 (#629) 2026-03-23 10:40:00 +08:00
Lingxuan Zuo c04e3b5464 chore(telemetry): stop sending gateway reconnect payloads to PostHog (#623) 2026-03-23 09:41:55 +08:00
Haze 1e7b40a486 feat(channel): support weichat channel (#620) 2026-03-22 17:08:02 +08:00
paisley f12f4a74df upgrade wecom plugin version to 2026.3.20 (#619) 2026-03-22 16:30:41 +08:00
Lingxuan Zuo d39982bad8 feat(ark): add Code Plan mode preset and guided setup (#617) 2026-03-22 16:26:12 +08:00
paisley f16e8062e1 fix: add fsPath prefix for Windows Unicode paths in config-sync plugin copy (#612) 2026-03-21 21:16:50 +08:00
Lingxuan Zuoandzuolingxuan 56701d823c fix: preserve telegram proxy on gateway restart after doctor (#546)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
2026-03-21 17:09:08 +08:00
KaguraandKagura Chen e10ff3a1fb fix: sanitize stale nested plugin paths in plugins.load.paths (#608)
Co-authored-by: Kagura Chen <daniyuu19@sjtu.edu.cn>
2026-03-21 15:45:43 +08:00
5836ba6b13 fix(providers): model list empty in settings panel (#581) (#591)
Co-authored-by: Kagura Chen <daniyuu19@sjtu.edu.cn>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 15:35:30 +08:00
paisley 5e519f9aa6 v0.2.8 (#601) 2026-03-20 18:45:34 +08:00
paisley b71982c406 fix(build): prevent node download script from deleting uv.exe on Windows (#600) 2026-03-20 18:42:44 +08:00
9b503b531b fix(processes): fix multiple clawx processes running concurently (#589)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
Co-authored-by: paisley <8197966+su8su@users.noreply.github.com>
Co-authored-by: Felix <24791380+vcfgv@users.noreply.github.com>
2026-03-20 18:34:20 +08:00
paisley 016ebb2b7b chore: upgrade dingding plugin to 3.3.0 (#599) 2026-03-20 17:55:31 +08:00
paisley fff02a8164 chore: upgrade qqbot plugin to 1.6.1 2026-03-20 17:25:20 +08:00
paisley 04f4f67f5a chore: upgrade wecom plugin to 1.0.13 (#598) 2026-03-20 17:16:03 +08:00
paisley b4fd4c85da chore: upgrade feishu plugin version to 3.17 (#596) 2026-03-20 12:06:00 +08:00
paisley a30f2238a4 v0.2.7 2026-03-20 10:37:14 +08:00
paisley 0ff5c90bb8 v0.2.7-beta.0 2026-03-19 18:28:52 +08:00
paisley 2471228cc2 fix gateway restart (#593) 2026-03-19 18:22:24 +08:00
Lingxuan Zuoandzuolingxuan 8029b507ba fix(gateway): harden heartbeat timeout recovery to avoid reconnect flapping (#588)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
2026-03-19 14:31:08 +08:00
Haze 8cca9af773 0.2.7-alpha.0 2026-03-19 13:29:43 +08:00
Felix 78a9eb755b fix(windows): run bundled openclaw CLI/TUI via node.exe to restore terminal input (#571) 2026-03-19 13:25:00 +08:00
Haze 2253fed5a1 0.2.6-beta.0 2026-03-19 11:48:50 +08:00
HazeandHaze 6b0400e3c3 fix(windows): Gateway process install extension failed (#587)
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-03-19 11:22:39 +08:00
paisley 73343d16ea fix(models): use useReducer for token usage fetch state to fix lint errors and reduce loading time (#586) 2026-03-19 11:22:06 +08:00
1b527d2f49 fix(linux): Can't change Chinese IMEs on Debian (#582)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-03-19 10:19:44 +08:00
Haze 7aec2febed 0.2.5 2026-03-18 19:25:26 +08:00
Haze d8d78ad690 0.2.5-beta.4 2026-03-18 18:40:34 +08:00
paisley f9164b3d15 fix token cost (#579) 2026-03-18 18:39:21 +08:00
paisley dbc9972fb8 Revert "fix: prevent orphaned gateway processes on Windows causing port conflcts and UI freeze (#570)"
This reverts commit 2dc1070213.
2026-03-18 18:28:34 +08:00
Haze b27815b394 0.2.5-beta.3 2026-03-18 17:01:58 +08:00
paisley 433240c43f feat(settings): add FAQ link to about section (#575) 2026-03-18 16:54:22 +08:00
paisley 58f19d4ddc fix: prevent chat sessions from disappearing on switch and add loading timeout safety net (#572) 2026-03-18 15:47:25 +08:00
Haze 4b4760bb12 0.2.5-beta.2 2026-03-18 14:42:40 +08:00
paisley 2dc1070213 fix: prevent orphaned gateway processes on Windows causing port conflcts and UI freeze (#570) 2026-03-18 14:34:03 +08:00
paisley 1eda50ef44 feat(gateway): add websocket pong timeout and channel auto-reconnect watchdog (#569) 2026-03-18 13:25:30 +08:00
Felix 554f894493 chore(github): add issue forms and pr template (#566) 2026-03-18 11:31:11 +08:00
paisley 5a657130c9 feat: set default session retention to 7 days (#565) 2026-03-18 11:10:05 +08:00
paisley e94629e5c8 Fix auto upgrade (#557) 2026-03-18 11:01:20 +08:00
Haze 94f1404d01 0.2.4 2026-03-17 14:38:15 +08:00
Felix b9231d6431 fix: improve Feishu/Lark channel modal UX and refine default main agent naming (#551) 2026-03-17 14:14:47 +08:00
Haze 3b9ecb72b4 0.2.4-beta.2 2026-03-17 11:54:07 +08:00
paisley c86bacd240 fix discord depends (#549) 2026-03-17 11:53:44 +08:00
Felix 686bc2f235 fix(skills): update tavily-search repoPath in preinstalled manifest (#548) 2026-03-17 11:44:51 +08:00
Haze 64d3539956 0.2.4-beta.1 2026-03-17 11:30:32 +08:00
Haze d4367d3265 refactor(channels): integrate channel runtime status management and enhance account status handling (#547) 2026-03-17 11:29:17 +08:00
Felix 43fe7a4d1c fix(binding): allow one agent to bind multiple channels while preserving per-channel account replacement (#541) 2026-03-17 11:19:24 +08:00
Haze ca1a325993 refactor(tray): update 'Open Chat' action to navigate to the root instead of '/chat' and remove 'Open Dashboard' option (#539) 2026-03-17 10:24:11 +08:00
paisley 11e28a2cfa fix: prevent "cannot read properties of undefined (reading 'map')" crash (#532) 2026-03-16 19:52:01 +08:00
paisley 925fbab86d fix clawx win uninstall (#533) 2026-03-16 19:51:19 +08:00
Haze a414c8e8a8 0.2.4-beta.0 2026-03-16 18:25:53 +08:00
Felix 4be679ac56 Refactor channel account management: move binding/editing to Channels, align Agents display, and simplify UX (#523) 2026-03-16 18:20:11 +08:00
Haze db480dff17 fix(channels): update channel name assignment to use CHANNEL_NAMES for better clarity (#531) 2026-03-16 18:17:59 +08:00
paisley f1e2e9fa01 fix wecom channel (#530) 2026-03-16 18:08:50 +08:00
paisley 7e54aad9e6 fix(providers): clear provider list when OpenClaw JSON is deleted (#526) 2026-03-16 17:21:17 +08:00
Felix 4e3f3c83f6 build: unify preinstalled skills bundling across dev/package/release and harden SignPath validation (#524) 2026-03-16 16:55:56 +08:00
DigHuang f6128ed743 0.2.4-alpha.3 2026-03-16 15:38:57 +08:00
DigHuang 6051a39a56 chore(npmrc):rm Electron mirror and builder binaries mirror configurations (#527) 2026-03-16 15:38:05 +08:00
DigHuang 7fb9d6364d 0.2.4-alpha.2 2026-03-16 14:27:27 +08:00
DigHuang d35e8481b7 0.2.4-alpha.1 2026-03-16 14:24:52 +08:00
DigHuang af81700180 ci(artifact): artifact download pattern to release workflow (#525) 2026-03-16 14:23:59 +08:00
DigHuang 0b15df25c9 0.2.4-alpha.0 2026-03-16 13:51:36 +08:00
DigHuang 75789b9947 chore(ci): update SignPath configuration for Windows signing (#516) 2026-03-16 13:33:35 +08:00
paisley 9ec23174c0 feat(electron): auto-upgrade bundled OpenClaw plugins at app startup (#521) 2026-03-16 13:19:06 +08:00
arianachennn-design 02d38d15db Update feishu-qr.png (#520) 2026-03-16 12:08:00 +08:00
arianachennn-design e716c6a4ad Update feishu-qr.png (#519) 2026-03-16 11:52:50 +08:00
paisley dd0ce7740a fix(installer): preserve Windows shortcuts during update by removing early delete (#517) 2026-03-16 11:07:37 +08:00
paisley 158e84ce8f fix: prevent page crash when deleting an agent (#514) 2026-03-16 10:47:30 +08:00
paisley 9e10c12f67 fix: re-mirror default account credentials after channel account changes (#513) 2026-03-16 09:53:54 +08:00
Lingxuan Zuo 7f3408559d Stabilize gateway reload/restart behavior and remove doctor --json dependency (#504) 2026-03-16 09:47:04 +08:00
Lingxuan Zuo 89bda3c7af fix: bound doctor output buffer and normalize credential values (#497) 2026-03-15 20:37:22 +08:00
Lingxuan Zuo 1dbe4a8466 Optimize gateway comms reload behavior and strengthen regression coverage (#496) 2026-03-15 20:36:48 +08:00
Jack_lv 08960d700f fix: default setup language to system locale (#500) 2026-03-15 20:36:32 +08:00
cedric 04aa94f907 fix: harden Windows single-instance startup (#498) 2026-03-15 18:50:59 +08:00
Haze 0cdafde2df 0.2.3 2026-03-15 00:08:30 +08:00
Haze 315ff6b9de fix(openclaw controller): use buildOpenClawControlUiUrl for generating gateway URLs (#493) 2026-03-14 18:57:43 +08:00
paisley 61291ff83f Fix upgrade 3.13 (#488) 2026-03-14 17:18:20 +08:00
paisley 67ffe09dfc v0.2.3-beta.0 2026-03-14 14:48:54 +08:00
paisley 6fdcd35283 chore: upgrade openclaw to 3.13 (#486) 2026-03-14 14:44:51 +08:00
paisley 9f2bc3cf68 Fix agent to channel (#485) 2026-03-14 14:17:42 +08:00
paisley f6de56fa78 build: upgrade feishu plugin to 2026.3.12 (#482) 2026-03-14 14:16:21 +08:00
Haze 6988b2b5bf 0.2.2 2026-03-13 19:51:27 +08:00
Haze 7014a50886 0.2.2-beta.1 2026-03-13 18:16:44 +08:00
Haze f847982632 feat(model): opt model provider doc url (#475) 2026-03-13 18:14:54 +08:00
Felix 4485491913 fix: allow configuring SiliconFlow model ID like OpenRouter (#471) 2026-03-13 16:16:01 +08:00
paisley 897983432b docs(i18n): update feishu channel documentation links (#466) 2026-03-13 14:59:56 +08:00
Felix 4cfb552b1d fix(skills): distinguish external skill install paths and open real location (#463) 2026-03-13 14:40:46 +08:00
Lingxuan Zuoandzuolingxuan 995a7f070d Fix telemetry shutdown noise and improve token usage diagnostics (#444)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
2026-03-13 13:57:49 +08:00
Haze 01adc828b5 0.2.2-beta.0 2026-03-13 13:25:36 +08:00
Haze e7923d0120 fix(electron): improve error handling during app initialization and updater events (#461) 2026-03-13 13:24:50 +08:00
paisley abc0c6e7d5 fix: resolve channel-config overwrite bug by implementing reentrant config mutex (#462) 2026-03-13 12:13:57 +08:00
Felix 740116ae9d fix(win): prevent user PATH clobbering and normalize gateway PATH env (#459) 2026-03-13 12:12:30 +08:00
Felix 5e880221b2 ci: add manual windows packaging workflow (#460) 2026-03-13 11:02:45 +08:00
Haze 02c8e7534b 0.2.1 2026-03-12 18:40:47 +08:00
paisley 1d4081fde7 chore: update document (#442) 2026-03-12 18:37:05 +08:00
DigHuang 554ae2b01f feat(file): make FileCard clickable to open the attached file and hover styles (#441) 2026-03-12 18:09:17 +08:00
DigHuang c0a3903377 feat(scroll): implement instant scroll-to-bottom behavior for chat messages (#438) 2026-03-12 17:02:43 +08:00
Felix c0c8701cc3 fix(providers): complete custom openai-responses support (#436) 2026-03-12 16:19:05 +08:00
Felix 272432783a misc(docs): update readme screenshots and refine natural phrasing (#431) 2026-03-12 12:21:20 +08:00
Haze a48dacfe64 0.2.0 2026-03-12 11:36:18 +08:00
Haze 38391dd093 feat(cron): implement cron session management and logging features, including session key parsing and fallback message handling (#429) 2026-03-12 11:20:56 +08:00
Felix 882da7b904 feat: add install skill functionality and enhance marketplace dialog (#427) 2026-03-12 11:13:49 +08:00
paisley 5c07ad77fc fix(channels): ensure all default account keys are correctly mirrored to top-level config 2026-03-12 11:13:26 +08:00
Haze 706789cf4d 0.2.0-beta.7 2026-03-11 23:22:38 +08:00
Haze 945c3b39b4 feat(i18n): update chat localization strings to enhance user experience and clarity (#424) 2026-03-11 23:17:46 +08:00
paisley 2769c69a84 v0.2.0-beta.6 2026-03-11 21:53:41 +08:00
paisley 1f90a40972 fix(feishu): resolve feishu connect failures by fixing config mismatch and channel collision (#423) 2026-03-11 21:39:14 +08:00
paisley 1b266227e4 v0.2.0-beta.5 2026-03-11 20:58:35 +08:00
Haze e80f666441 0.2.0-beta.4 2026-03-11 18:45:23 +08:00
Haze 050ee10850 feat(channels): enhance channel configuration with account support and improve agent handling (#420) 2026-03-11 18:44:55 +08:00
53a51642ce feat(setttings): support auto launch config (#415)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-03-11 18:41:18 +08:00
Felix baa551b30c feat: prebundle and auto-enable document, self-improving, search skills (#413) 2026-03-11 18:40:46 +08:00
paisley ce7e890509 feat: support feishu official plugin (#418) 2026-03-11 18:18:46 +08:00
DigHuang f37d2ac112 0.2.0-beta.3 2026-03-11 17:19:19 +08:00
DigHuang 5b59c8a8e2 chore(dep): ms isbinaryfile dependency override in package.json (#417) 2026-03-11 17:17:42 +08:00
paisley 14646a69fc feat: add documentation links for custom AI model providers (#412) 2026-03-11 16:01:04 +08:00
Haze a853f01583 0.2.0-beta.2 2026-03-11 15:41:18 +08:00
Haze 4c786915c8 fix(js): update Electron build configuration and dependencies (#411) 2026-03-11 15:40:08 +08:00
Haze a575977e3c chore(apparence): opt clawx apparence and i18n settings (#410) 2026-03-11 15:37:16 +08:00
paisley da8ed3bb32 feat: add PostHog telemetry and privacy toggle in settings (#409) 2026-03-11 15:22:02 +08:00
paisley d11e266cbb chore:auto-inject tools.profile and sessions.visibility for OpenClaw 3.2+ compatibility (#404) 2026-03-11 15:14:00 +08:00
Haze 672888d9c1 0.2.0-beta.1 2026-03-11 12:05:52 +08:00
Haze 95e090ecb5 feat(agents): support chat to agent (#403) 2026-03-11 12:03:30 +08:00
paisley 34dcb48e27 chore: upgrade openclaw to 2026.3.8 (#402) 2026-03-11 11:52:57 +08:00
DigHuang 3a697c4daa 0.2.0-beta.0 2026-03-11 10:57:01 +08:00
DigHuang ce0e5fd8af fix(history): query chat session and history in sidebar (#400) 2026-03-11 10:27:54 +08:00
Lingxuan Zuoandzuolingxuan ed40a3b7f4 Fix Models token usage refresh after gateway restart (#391)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
2026-03-11 09:40:22 +08:00
Lingxuan Zuoandzuolingxuan 31e80f256b feat(provider): add OpenAI Codex browser OAuth flow (#398)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
2026-03-11 09:40:00 +08:00
paisley 880995af19 fix minimax cn api key 401 error (#396) 2026-03-10 19:22:33 +08:00
DigHuang 45d7ff61c3 feat(ui): unify provider settings styling and improve channel config UX (#395) 2026-03-10 19:04:23 +08:00
Haze 807f6b8adf chore(logo): update SVG assets for various channels with new designs and improved structure (#394) 2026-03-10 18:23:36 +08:00
DigHuang 9502d9b1c5 style: refine chat UI consistency and enhance dark mode (#393) 2026-03-10 18:09:46 +08:00
paisley 99681777a0 feat: support dual protocols (OpenAI/Anthropic) for custom providers (#389) 2026-03-10 17:35:51 +08:00
Lingxuan Zuoandzuolingxuan 80e89ddc5c Fix token usage history gaps and add dev detail popup (#386)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
2026-03-10 17:20:10 +08:00
Haze d3960a3d0f feat(agent): enhance agent management with runtime and workspace directory removal, and improve agent ID listing (#387) 2026-03-10 17:07:41 +08:00
Haze 36c0fcb5c7 feat(Agnet): support multi agents (#385) 2026-03-10 16:13:55 +08:00
DigHuang 17e6ab9149 feat(i18n): Implement internationalization for Channels, Skills, Setup, and Models pages 2026-03-10 15:51:45 +08:00
DigHuang d9ae0f3263 feat(ui): redesign settings page and update sidebar navigation (#383) 2026-03-10 14:48:54 +08:00
paisley 19b5b2d540 fix(ui): prevent render crashes on malformed backend arrays (#384) 2026-03-10 14:43:19 +08:00
taojianhangand陶建行 1bae8229af feat(channel): add qq bot (#363)
Co-authored-by: 陶建行 <189307154@qq.com>
2026-03-10 14:20:02 +08:00
paisley 65c2b73e23 fix(settings): display correct configuration status for OAuth providers (#382) 2026-03-10 14:08:45 +08:00
paisleyandDigHuang b86f47171b feat: support wecom (#372)
Co-authored-by: DigHuang <114602213+DigHuang@users.noreply.github.com>
2026-03-10 12:06:37 +08:00
DigHuang 905ce02b0b feat(ui): refactor style ui & add Models page with provider settings (#379) 2026-03-10 11:39:56 +08:00
3d664c017a refactor(new merge) (#369)
Co-authored-by: paisley <8197966+su8su@users.noreply.github.com>
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
2026-03-09 20:18:25 +08:00
e28eba01e1 refactor/channel & ipc (#349)
Co-authored-by: paisley <8197966+su8su@users.noreply.github.com>
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
2026-03-09 19:04:00 +08:00
paisley 8b45960662 Fix windows path (#361) 2026-03-09 19:03:06 +08:00
9bb684af68 Multilingual partner program readme (#366)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-03-09 17:04:21 +08:00
paisley d32d84f1a9 fix: packaged app gateway connection; default Google to API key (#359) 2026-03-09 13:24:59 +08:00
299 changed files with 36346 additions and 10663 deletions
+76
View File
@@ -0,0 +1,76 @@
name: Bug Report
description: Report a reproducible problem in ClawX.
title: "[Bug]: "
labels:
- bug
body:
- type: markdown
attributes:
value: |
Thanks for filing a bug report. Please provide enough detail to reproduce and verify a fix.
- type: textarea
id: summary
attributes:
label: Summary
description: Briefly describe the problem.
placeholder: What happened?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: List exact steps to reproduce the issue.
placeholder: |
1. Go to ...
2. Click ...
3. See error ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
placeholder: What did you expect to happen?
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
placeholder: What actually happened?
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: Share OS, app version/commit, and relevant runtime details.
placeholder: |
- OS:
- ClawX version/commit:
- Node/pnpm (if relevant):
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs or Screenshots
description: Paste relevant logs, stack traces, or screenshots.
render: shell
- type: checkboxes
id: checks
attributes:
label: Pre-Submission Checklist
options:
- label: I searched existing issues and did not find a duplicate.
required: true
- label: I can reproduce this issue on the latest main branch build.
required: false
+2
View File
@@ -0,0 +1,2 @@
blank_issues_enabled: false
contact_links: []
+38
View File
@@ -0,0 +1,38 @@
name: Documentation
description: Report missing, unclear, or outdated documentation.
title: "[Docs]: "
labels:
- documentation
body:
- type: textarea
id: issue
attributes:
label: Documentation Issue
description: What is missing, unclear, or incorrect?
placeholder: The README section ... is outdated because ...
validations:
required: true
- type: textarea
id: location
attributes:
label: Affected Location
description: Which docs are impacted?
placeholder: README.md / README.zh-CN.md / README.ja-JP.md / other
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Suggested Update
description: What should the docs say instead?
placeholder: Replace ... with ...
validations:
required: true
- type: textarea
id: references
attributes:
label: References
description: Related issue/PR/commit links.
@@ -0,0 +1,51 @@
name: Feature Request
description: Propose a new capability or improvement.
title: "[Feature]: "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
Thanks for the suggestion. Clear problem and success criteria help reviewers evaluate quickly.
- type: textarea
id: problem
attributes:
label: Problem Statement
description: What user problem are you trying to solve?
placeholder: The current behavior is ... and it causes ...
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed Solution
description: Describe your preferred solution.
placeholder: We could add ...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Optional alternatives or tradeoffs.
- type: textarea
id: success
attributes:
label: Success Criteria
description: How should we know this is done?
placeholder: |
- [ ] ...
- [ ] ...
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional Context
description: Mockups, references, related links, etc.
+25
View File
@@ -0,0 +1,25 @@
## Summary
<!-- What does this PR change and why? -->
## Related Issue(s)
<!-- e.g. Closes #123 -->
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Documentation
- [ ] Refactor
- [ ] Other
## Validation
<!-- How did you verify this change? -->
## Checklist
- [ ] I ran relevant checks/tests locally.
- [ ] I updated docs if behavior or interfaces changed.
- [ ] I verified there are no unrelated changes in this PR.
+52
View File
@@ -0,0 +1,52 @@
name: Comms Regression
on:
workflow_dispatch:
pull_request:
branches:
- main
paths:
- 'src/lib/api-client.ts'
- 'src/lib/host-api.ts'
- 'src/stores/gateway.ts'
- 'src/stores/chat.ts'
- 'electron/gateway/**'
- 'electron/main/ipc-handlers.ts'
- 'electron/utils/logger.ts'
- 'scripts/comms/**'
- 'tests/unit/gateway-events.test.ts'
- '.github/workflows/comms-regression.yml'
jobs:
comms-regression:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: '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: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run comms replay
run: pnpm run comms:replay
- name: Compare with baseline
run: pnpm run comms:compare
- name: Upload comms artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: comms-regression-artifacts
path: artifacts/comms
+89
View File
@@ -0,0 +1,89 @@
name: Package Windows (Manual)
on:
workflow_dispatch:
inputs:
ref:
description: 'Git ref to build (branch, tag, or SHA). Leave empty for current default ref.'
required: false
default: ''
permissions:
contents: read
jobs:
package-windows:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Download uv binaries for Windows
run: pnpm run uv:download:win
- name: Build Windows package (no publish)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm run package:win
- name: Upload Windows Installer (x64)
uses: actions/upload-artifact@v4
with:
name: windows-installer-x64
path: release/*-win-x64.exe
if-no-files-found: error
retention-days: 7
- name: Upload Windows Installer (arm64)
uses: actions/upload-artifact@v4
with:
name: windows-installer-arm64
path: release/*-win-arm64.exe
if-no-files-found: warn
retention-days: 7
- name: Upload Windows Blockmap Files
uses: actions/upload-artifact@v4
with:
name: windows-blockmap
path: release/*.blockmap
if-no-files-found: warn
retention-days: 7
- name: Upload Windows Update Manifests
uses: actions/upload-artifact@v4
with:
name: windows-update-manifests
path: |
release/*.yml
!release/builder-debug.yml
if-no-files-found: warn
retention-days: 7
+173 -32
View File
@@ -15,6 +15,7 @@ on:
permissions:
contents: write
actions: read
jobs:
release:
@@ -32,30 +33,18 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
node-version: '24'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
@@ -72,7 +61,25 @@ jobs:
if: matrix.platform == 'linux'
run: pnpm run uv:download:linux
# macOS specific steps
- name: Free disk space (macOS)
if: matrix.platform == 'mac'
run: |
echo "=== Disk usage before cleanup ==="
df -h /
# Remove large pre-installed toolchains not needed for Electron builds
sudo rm -rf /usr/local/lib/android || true
sudo rm -rf /usr/share/dotnet || true
sudo rm -rf /usr/local/share/powershell || true
sudo rm -rf /usr/local/share/chromium || true
sudo rm -rf /usr/local/lib/node_modules || true
rm -rf ~/Library/Caches/electron-builder/dmg-builder* || true
# Homebrew cleanup
brew cleanup --prune=all 2>/dev/null || true
echo "=== Disk usage after cleanup ==="
df -h /
# --publish never: prevent electron-builder from auto-publishing to GitHub.
# All artifacts are collected and published atomically in the publish job.
- name: Build macOS
@@ -87,21 +94,159 @@ jobs:
run: |
ulimit -n 65536
echo "File descriptor limit: $(ulimit -n)"
pnpm run build:vite && pnpm exec zx scripts/bundle-openclaw.mjs && pnpm exec electron-builder --mac --publish never
pnpm run package:mac
# Windows specific steps
- name: Build Windows
if: matrix.platform == 'win'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm run build:vite && pnpm exec zx scripts/bundle-openclaw.mjs && pnpm exec electron-builder --win --publish never
run: pnpm run package:win
# Detect release channel from tag to skip code signing for alpha/beta builds
- name: Detect Windows release channel
if: matrix.platform == 'win'
id: win-channel
shell: bash
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
TAG="${GITHUB_REF#refs/tags/v}"
else
TAG="${{ github.event.inputs.version }}"
fi
if [[ "$TAG" =~ (alpha|beta) ]]; then
echo "is_stable=false" >> $GITHUB_OUTPUT
echo "Channel: prerelease ($TAG) — skipping code signing"
else
echo "is_stable=true" >> $GITHUB_OUTPUT
echo "Channel: stable ($TAG) — will sign"
fi
- name: Validate unsigned Windows artifacts before SignPath
if: matrix.platform == 'win' && steps.win-channel.outputs.is_stable == 'true'
shell: pwsh
run: |
$unsignedExeFiles = Get-ChildItem -Path "release" -Filter *.exe -File
if (-not $unsignedExeFiles) {
throw "No unsigned .exe files found in release/ before SignPath upload"
}
$unsignedCount = $unsignedExeFiles.Count
"UNSIGNED_EXE_COUNT=$unsignedCount" | Out-File -FilePath $env:GITHUB_ENV -Append
Write-Host "Found $unsignedCount unsigned .exe file(s):"
$unsignedExeFiles | ForEach-Object { Write-Host " - $($_.Name)" }
- name: Upload unsigned Windows artifacts for SignPath
if: matrix.platform == 'win' && steps.win-channel.outputs.is_stable == 'true'
id: upload-unsigned-windows-artifact
uses: actions/upload-artifact@v4
with:
name: unsigned-win-exe-${{ github.run_id }}-${{ github.run_attempt }}
path: release/*.exe
retention-days: 1
- name: Sign Windows artifacts via SignPath
if: matrix.platform == 'win' && steps.win-channel.outputs.is_stable == 'true'
id: signpath-sign-windows
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: "78e37079-23df-4800-b41c-33312ad7c1e3"
project-slug: "ValueCell"
signing-policy-slug: "ValueCell-sign"
github-artifact-id: ${{ steps.upload-unsigned-windows-artifact.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: release/signed
- name: Replace unsigned executables with signed ones
if: matrix.platform == 'win' && steps.win-channel.outputs.is_stable == 'true'
shell: pwsh
run: |
Write-Host "SignPath GitHub artifact ID: ${{ steps.upload-unsigned-windows-artifact.outputs.artifact-id }}"
$signedExeFiles = Get-ChildItem -Path "release/signed" -Filter *.exe -File -Recurse
if (-not $signedExeFiles) {
throw "No signed .exe files found in release/signed"
}
$signedCount = $signedExeFiles.Count
if ($env:UNSIGNED_EXE_COUNT -and ($signedCount -ne [int]$env:UNSIGNED_EXE_COUNT)) {
throw "Signed .exe count ($signedCount) does not match unsigned count ($env:UNSIGNED_EXE_COUNT)"
}
foreach ($file in $signedExeFiles) {
Copy-Item -Path $file.FullName -Destination "release/$($file.Name)" -Force
}
$finalExeFiles = Get-ChildItem -Path "release" -Filter *.exe -File
if ($env:UNSIGNED_EXE_COUNT -and ($finalExeFiles.Count -ne [int]$env:UNSIGNED_EXE_COUNT)) {
throw "Final release .exe count ($($finalExeFiles.Count)) does not match unsigned count ($env:UNSIGNED_EXE_COUNT)"
}
Write-Host "Signed executables copied to release/ ($($finalExeFiles.Count) file(s))"
# Code signing changes the .exe binary, invalidating the sha512 hash that
# electron-builder wrote into latest.yml during the initial build.
# Recalculate the hash for each signed .exe and patch the yml files so
# electron-updater can verify the download successfully.
#
# Actual latest.yml structure (from electron-builder NSIS):
# files:
# - url: ClawX-0.2.4-win-x64.exe ← files[] entries have url/sha512/size
# sha512: <base64>
# size: 430775882
# path: ClawX-0.2.4-win-arm64.exe ← top-level has path/sha512 (no size!)
# sha512: <base64>
# releaseDate: '...'
- name: Update latest.yml sha512 after code signing
if: matrix.platform == 'win' && steps.win-channel.outputs.is_stable == 'true'
shell: pwsh
run: |
$ymlFiles = Get-ChildItem -Path "release" -Filter "*.yml" -File | Where-Object { $_.Name -ne "builder-debug.yml" }
$exeFiles = Get-ChildItem -Path "release" -Filter "*.exe" -File
foreach ($yml in $ymlFiles) {
$content = Get-Content $yml.FullName -Raw
$modified = $false
foreach ($exe in $exeFiles) {
# Compute new sha512 (base64) for the signed exe
$hash = Get-FileHash -Path $exe.FullName -Algorithm SHA512
$hashBytes = [byte[]]::new($hash.Hash.Length / 2)
for ($i = 0; $i -lt $hashBytes.Length; $i++) {
$hashBytes[$i] = [Convert]::ToByte($hash.Hash.Substring($i * 2, 2), 16)
}
$newSha512 = [Convert]::ToBase64String($hashBytes)
$newSize = (Get-Item $exe.FullName).Length
$escapedName = [Regex]::Escape($exe.Name)
# 1) files[] entries: url: <name>\n sha512: <hash>\n size: <n>
$urlPattern = "(?m)(url:\s*${escapedName}\s*\r?\n\s*sha512:\s*)(\S+)(\s*\r?\n\s*size:\s*)(\d+)"
if ($content -match $urlPattern) {
$content = $content -replace $urlPattern, "`${1}${newSha512}`${3}${newSize}"
$modified = $true
Write-Host "Updated $($yml.Name) files[]: $($exe.Name) sha512=$newSha512 size=$newSize"
}
# 2) Top-level entry: path: <name>\nsha512: <hash>\n (no size field)
$pathPattern = "(?m)(path:\s*${escapedName}\s*\r?\n)sha512:\s*\S+"
if ($content -match $pathPattern) {
$content = $content -replace $pathPattern, "`${1}sha512: ${newSha512}"
$modified = $true
Write-Host "Updated $($yml.Name) top-level: $($exe.Name) sha512=$newSha512"
}
}
if ($modified) {
Set-Content -Path $yml.FullName -Value $content -NoNewline
Write-Host "Saved updated $($yml.Name)"
}
}
Write-Host ""
Write-Host "=== Final yml contents ==="
foreach ($yml in $ymlFiles) {
Write-Host "--- $($yml.Name) ---"
Get-Content $yml.FullName
Write-Host ""
}
# Linux specific steps
- name: Build Linux
if: matrix.platform == 'linux'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm run build:vite && pnpm exec zx scripts/bundle-openclaw.mjs && pnpm exec electron-builder --linux --publish never
run: pnpm run package:linux
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -127,13 +272,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download all artifacts
- name: Download release artifacts only
uses: actions/download-artifact@v4
with:
path: release-artifacts
pattern: release-*
- name: List all downloaded artifacts
run: |
@@ -222,13 +365,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download all artifacts
- name: Download release artifacts only
uses: actions/download-artifact@v4
with:
path: release-artifacts
pattern: release-*
- name: Extract version and channel
id: version
+104
View File
@@ -0,0 +1,104 @@
name: Windows Build Test
on:
workflow_dispatch:
inputs:
version:
description: "Version for SignPath parameter (e.g., 1.0.0-test.1)"
required: false
default: "dev"
permissions:
contents: read
actions: read
jobs:
windows-build-sign:
runs-on: windows-latest
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: Install dependencies
run: pnpm install
- name: Download uv binaries for Windows
run: pnpm run uv:download:win
- name: Build Windows
run: pnpm run package:win
- name: Validate unsigned Windows artifacts before SignPath
shell: pwsh
run: |
$unsignedExeFiles = Get-ChildItem -Path "release" -Filter *.exe -File
if (-not $unsignedExeFiles) {
throw "No unsigned .exe files found in release/ before SignPath upload"
}
$unsignedCount = $unsignedExeFiles.Count
"UNSIGNED_EXE_COUNT=$unsignedCount" | Out-File -FilePath $env:GITHUB_ENV -Append
Write-Host "Found $unsignedCount unsigned .exe file(s):"
$unsignedExeFiles | ForEach-Object { Write-Host " - $($_.Name)" }
# Required by SignPath Trusted Build: artifact must exist on GitHub first.
- name: Upload unsigned Windows artifacts for SignPath
id: upload-unsigned-windows-artifact
uses: actions/upload-artifact@v4
with:
name: unsigned-win-exe-${{ github.run_id }}-${{ github.run_attempt }}
path: release/*.exe
retention-days: 1
- name: Sign Windows artifacts via SignPath
id: signpath-sign-windows
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: "78e37079-23df-4800-b41c-33312ad7c1e3"
project-slug: "ValueCell"
signing-policy-slug: "ValueCell-sign"
github-artifact-id: ${{ steps.upload-unsigned-windows-artifact.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: release/signed
- name: Replace unsigned executables with signed ones
shell: pwsh
run: |
Write-Host "SignPath GitHub artifact ID: ${{ steps.upload-unsigned-windows-artifact.outputs.artifact-id }}"
$signedExeFiles = Get-ChildItem -Path "release/signed" -Filter *.exe -File -Recurse
if (-not $signedExeFiles) {
throw "No signed .exe files found in release/signed"
}
$signedCount = $signedExeFiles.Count
if ($env:UNSIGNED_EXE_COUNT -and ($signedCount -ne [int]$env:UNSIGNED_EXE_COUNT)) {
throw "Signed .exe count ($signedCount) does not match unsigned count ($env:UNSIGNED_EXE_COUNT)"
}
foreach ($file in $signedExeFiles) {
Copy-Item -Path $file.FullName -Destination "release/$($file.Name)" -Force
}
$finalExeFiles = Get-ChildItem -Path "release" -Filter *.exe -File
if ($env:UNSIGNED_EXE_COUNT -and ($finalExeFiles.Count -ne [int]$env:UNSIGNED_EXE_COUNT)) {
throw "Final release .exe count ($($finalExeFiles.Count)) does not match unsigned count ($env:UNSIGNED_EXE_COUNT)"
}
Write-Host "Signed executables copied to release/ ($($finalExeFiles.Count) file(s))"
- name: Upload signed Windows artifacts
uses: actions/upload-artifact@v4
with:
name: signed-win-exe-${{ github.run_id }}-${{ github.run_attempt }}
path: |
release/*.exe
release/latest*.yml
retention-days: 7
+4 -1
View File
@@ -35,6 +35,7 @@ yarn-error.log*
# OS files
.DS_Store
Thumbs.db
desktop.ini
# Test coverage
coverage/
@@ -59,6 +60,8 @@ resources/bin
*.key
build/
artifacts/
docs/pr-session-notes-*.md
.cursor/
.pnpm-store/
.pnpm-store/
+3
View File
@@ -0,0 +1,3 @@
package-import-method=copy
shamefully-hoist=true
strict-peer-dependencies=false
+13 -1
View File
@@ -17,6 +17,9 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
| Lint (ESLint, auto-fix) | `pnpm run lint` |
| Type check | `pnpm run typecheck` |
| Unit tests (Vitest) | `pnpm test` |
| Comms replay metrics | `pnpm run comms:replay` |
| Comms baseline refresh | `pnpm run comms:baseline` |
| Comms regression compare | `pnpm run comms:compare` |
| E2E tests (Playwright) | `pnpm run test:e2e` |
| Build frontend only | `pnpm run build:vite` |
@@ -30,4 +33,13 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
- **Gateway startup**: When running `pnpm dev`, the OpenClaw Gateway process starts automatically on port 18789. It takes ~10-30 seconds to become ready. Gateway readiness is not required for UI development—the app functions without it (shows "connecting" state).
- **No database**: The app uses `electron-store` (JSON files) and OS keychain. No database setup is needed.
- **AI Provider keys**: Actual AI chat requires at least one provider API key configured via Settings > AI Providers. The app is fully navigable and testable without keys.
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, extracts assistant messages 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.
- **Models page aggregation**: The 7-day/30-day filters are relative rolling windows, not calendar-month buckets. When grouped by time, the chart should keep all day buckets in the selected window; only model grouping is intentionally capped to the top entries.
- **OpenClaw Doctor in UI**: In Settings > Advanced > Developer, the app exposes both `Run Doctor` (`openclaw doctor --json`) and `Run Doctor Fix` (`openclaw doctor --fix --yes --non-interactive`) through the host-api. Renderer code should call the host route, not spawn CLI processes directly.
- **Renderer/Main API boundary (important)**:
- Renderer must use `src/lib/host-api.ts` and `src/lib/api-client.ts` as the single entry for backend calls.
- Do not add new direct `window.electron.ipcRenderer.invoke(...)` calls in pages/components; expose them through host-api/api-client instead.
- Do not call Gateway HTTP endpoints directly from renderer (`fetch('http://127.0.0.1:18789/...')` etc.). Use Main-process proxy channels (`hostapi:fetch`, `gateway:httpProxy`) to avoid CORS/env drift.
- Transport policy is Main-owned and fixed as `WS -> HTTP -> IPC fallback`; renderer should not implement protocol switching UI/business logic.
- **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.
+117 -55
View File
@@ -47,27 +47,27 @@ ClawXはベストプラクティスのモデルプロバイダーが事前設定
## スクリーンショット
<p align="center">
<img src="resources/screenshot/chat.png" style="width: 100%; height: auto;">
<img src="resources/screenshot/jp/chat.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/cron_task.png" style="width: 100%; height: auto;">
<img src="resources/screenshot/jp/cron.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/skills.png" style="width: 100%; height: auto;">
</p>
<!-- <p align="center">
<img src="resources/screenshot/channels.png" style="width: 100%; height: auto;">
</p> -->
<p align="center">
<img src="resources/screenshot/dashboard.png" style="width: 100%; height: auto;">
<img src="resources/screenshot/jp/skills.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/settings.png" style="width: 100%; height: auto;">
<img src="resources/screenshot/jp/channels.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/jp/models.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/jp/settings.png" style="width: 100%; height: auto;">
</p>
---
@@ -98,23 +98,39 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
インストールから最初のAIインタラクションまで、すべてのセットアップを直感的なグラフィカルインターフェースで完了できます。ターミナルコマンド不要、YAMLファイル不要、環境変数の探索も不要です。
### 💬 インテリジェントチャットインターフェース
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングをサポートしています。
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングに加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
各 Agent は `provider/model` の実行時設定を個別に上書きできます。上書きしていない Agent は引き続きグローバルの既定モデルを継承します。
### 📡 マルチチャネル管理
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。
現在は各チャンネルで複数アカウントを扱え、Channels ページでアカウントの Agent 紐付けやデフォルトアカウント切替を直接管理できます。
ClawX には Tencent 公式の個人 WeChat チャンネルプラグインも同梱されており、Channels ページからアプリ内 QR フローで直接 WeChat を連携できます。
### ⏰ Cronベースの自動化
AIタスクを自動的に実行するようスケジュール設定できます。トリガーを定義し、間隔を設定することで、手動介入なしにAIエージェントを24時間稼働させることができます。
定期タスク画面では外部配信を「送信アカウント」と「受信先ターゲット」の 2 段階セレクターで設定できるようになりました。対応チャネルでは、受信先候補をチャネルのディレクトリ機能や既知セッション履歴から自動検出するため、`jobs.json` を手で編集する必要はありません。
既知の制限: WeChat は現在、定期タスク配信の対応チャネルから意図的に除外しています。`openclaw-weixin` プラグインの送信処理が、リアルタイム会話で得られる `contextToken` を必要とするため、cron のような能動配信をプラグイン自体がサポートしていません。
### 🧩 拡張可能なスキルシステム
事前構築されたスキルで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プロバイダー(OpenAI、Anthropicなど)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。
複数のAIプロバイダー(OpenAI、Anthropicなど)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuth(Codex サブスクリプション)の両方に対応しています。
OpenAI-compatible ゲートウェイを **Custom プロバイダー** で使う場合、**設定 → AI Providers → Provider 編集** でカスタム `User-Agent` を設定でき、互換性が必要なエンドポイントで有効です。
### 🌙 アダプティブテーマ
ライトモード、ダークモード、またはシステム同期テーマ。ClawXはあなたの好みに自動的に適応します。
### 🚀 自動起動設定
**設定 → 通用** から **システム起動時に自動起動** を有効化すると、ログイン後に ClawX が自動的に起動します。
---
## はじめに
@@ -144,16 +160,17 @@ pnpm run init
# 開発モードで起動
pnpm dev
```
### 初回起動
ClawXを初めて起動すると、**セットアップウィザード**が以下の手順をガイドします:
1. **言語と地域** 使用する言語・地域の設定
2. **AIプロバイダー** サポートされているプロバイダーのAPIキーを入力
2. **AIプロバイダー** APIキーまたは OAuth(ブラウザ/デバイスログイン対応プロバイダー)で追加
3. **スキルバンドル** – 一般的なユースケース向けの事前設定スキルを選択
4. **検証** – メインインターフェースに入る前に設定をテスト
サポート対象のシステム言語がある場合、ウィザードはその言語を初期選択し、未対応の場合は英語にフォールバックします。
### プロキシ設定
ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネルがローカルプロキシクライアントを介してインターネットにアクセスする必要がある環境向けに、組み込みのプロキシ設定が含まれています。
@@ -172,22 +189,24 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ
```text
プロキシサーバー: http://127.0.0.1:7890
```
注意事項:
- `host:port`のみの値はHTTPとして扱われます。
- 高度なプロキシフィールドが空の場合、ClawXは`プロキシサーバー`にフォールバックします。
- プロキシ設定を保存すると、Electronのネットワーク設定が即座に再適用され、ゲートウェイが自動的に再起動されます。
- ClawXはTelegramが有効な場合、プロキシをOpenClawのTelegramチャネル設定にも同期します。
- ClawXのプロキシが無効な状態では、Gatewayの通常再起動時に既存のTelegramチャネルプロキシ設定を保持します。
- OpenClaw設定のTelegramプロキシを明示的に消したい場合は、プロキシ無効の状態で一度「保存」を実行してください。
- **設定 → 詳細 → 開発者** では **OpenClaw Doctor** を実行でき、`openclaw doctor --json` の診断出力をアプリ内で確認できます。
- Windows のパッケージ版では、同梱された `openclaw` CLI/TUI は端末入力を安定させるため、同梱の `node.exe` エントリーポイント経由で実行されます。
---
## アーキテクチャ
ClawXは、UIの関心事とAIランタイム操作を分離する**デュアルプロセスアーキテクチャ**を採用しています:
ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を採用しています。Renderer は単一クライアント抽象を呼び出し、プロトコル選択とライフサイクルは Main が管理します:
```
┌─────────────────────────────────────────────────────────────────┐
```┌─────────────────────────────────────────────────────────────────┐
│ ClawX デスクトップアプリ │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
@@ -198,18 +217,29 @@ ClawXは、UIの関心事とAIランタイム操作を分離する**デュアル
│ │ • 自動アップデートオーケストレーション │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ IPC
│ │ IPC(権威ある制御プレーン)
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ React レンダラープロセス │ │
│ │ • モダンなコンポーネントベースUI(React 19) │ │
│ │ • Zustandによるステート管理 │ │
│ │ • リアルタイムWebSocket通信 │ │
│ │ • 統一 host-api/api-client 呼び出し │ │
│ │ • リッチなMarkdownレンダリング │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────────┘
WebSocket (JSON-RPC)
Main管理のトランスポート戦略
│(WS優先、HTTP次点、IPCフォールバック)
┌─────────────────────────────────────────────────────────────────┐
│ Host API と Main プロキシ層 │
│ │
│ • hostapi:fetchMainプロキシ、CORS回避) │
│ • gateway:httpProxyRendererはGateway HTTPに直アクセスしない) │
│ • 統一エラーマッピングとリトライ/バックオフ │
└──────────────────────────────┬──────────────────────────────────┘
│ WS / HTTP / IPC フォールバック
┌─────────────────────────────────────────────────────────────────┐
│ OpenClaw ゲートウェイ │
@@ -220,13 +250,25 @@ ClawXは、UIの関心事とAIランタイム操作を分離する**デュアル
│ • プロバイダー抽象化レイヤー │
└─────────────────────────────────────────────────────────────────┘
```
### 設計原則
- **プロセス分離**: AIランタイムは別プロセスで動作し、重い計算処理中でもUIの応答性を確保します
- **グレースフルリカバリ**: 指数バックオフ付きの再接続ロジックが、一時的な障害を自動的に処理します
- **フロントエンド呼び出しの単一入口**: Renderer は host-api/api-client を通じて呼び出し、下位プロトコルに依存しません
- **Mainによるトランスポート制御**: WS/HTTP の選択と IPC フォールバックを Main で一元管理します
- **グレースフルリカバリ**: 再接続・タイムアウト・バックオフで一時的障害を自動処理します
- **セキュアストレージ**: APIキーや機密データは、OSのネイティブセキュアストレージ機構を活用します
- **ホットリロード**: 開発モードでは、ゲートウェイを再起動せずにUIの即時更新をサポートします
- **CORSセーフ設計**: ローカルHTTPはMainプロキシ経由とし、Renderer側CORS問題を回避します
### プロセスモデルと Gateway トラブルシューティング
- ClawX は Electron アプリのため、**1つのアプリインスタンスでも複数プロセス(main/renderer/zygote/utility)が表示される**のが正常です。
- 単一起動保護は Electron のロックに加え、ローカルのプロセスロックファイルも併用し、デスクトップ IPC / セッションバスが不安定な環境でも重複起動を防ぎます。
- ローリングアップグレード中に旧版/新版が混在すると、単一起動保護の挙動が非対称になる場合があります。安定運用のため、デスクトップクライアントは可能な限り同一バージョンへ揃えてください。
- ただし OpenClaw Gateway の待受は常に**単一**であるべきです。`127.0.0.1:18789` を Listen しているプロセスは1つだけです。
- Listen プロセスの確認例:
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
- Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen`
- ウィンドウの閉じるボタン(`X`)は既定でトレイへ最小化する動作で、完全終了ではありません。完全終了する場合はトレイメニューの **Quit ClawX** を使用してください。
---
@@ -255,40 +297,37 @@ AI を開発ワークフローに統合できます。エージェントを使
### プロジェクト構成
```ClawX/
├── electron/ # Electron メインプロセス
│ ├── api/ # メイン側 API ルーターとハンドラー
│ │ └── routes/ # RPC/HTTP プロキシのルートモジュール
│ ├── services/ # Provider/Secrets/ランタイムサービス
│ │ ├── providers/ # provider/account モデル同期ロジック
│ │ └── secrets/ # OS キーチェーンと秘密情報管理
│ ├── shared/ # 共通 Provider スキーマ/定数
│ │ └── providers/
│ ├── main/ # アプリ入口、ウィンドウ、IPC 登録
│ ├── gateway/ # OpenClaw ゲートウェイプロセスマネージャー
│ ├── preload/ # セキュア IPC ブリッジ
│ └── utils/ # ユーティリティ(ストレージ、認証、パス)
├── src/ # React レンダラープロセス
│ ├── lib/ # フロントエンド統一 API とエラーモデル
│ ├── stores/ # Zustand ストア(settings/chat/gateway
│ ├── components/ # 再利用可能な UI コンポーネント
│ ├── pages/ # Setup/Dashboard/Chat/Channels/Skills/Cron/Settings
│ ├── i18n/ # ローカライズリソース
│ └── types/ # TypeScript 型定義
├── tests/
│ └── unit/ # Vitest ユニット/統合寄りテスト
├── resources/ # 静的アセット(アイコン、画像)
└── scripts/ # ビルド/ユーティリティスクリプト
```
ClawX/
├── electron/ # Electron メインプロセス
│ ├── main/ # アプリケーションエントリ、ウィンドウ管理
│ ├── gateway/ # OpenClaw ゲートウェイプロセスマネージャー
│ ├── preload/ # セキュアIPCブリッジスクリプト
│ └── utils/ # ユーティリティ(ストレージ、認証、パス)
├── src/ # React レンダラープロセス
│ ├── components/ # 再利用可能なUIコンポーネント
│ │ ├── ui/ # ベースコンポーネント(shadcn/ui)
│ │ ├── layout/ # レイアウトコンポーネント(サイドバー、ヘッダー)
│ │ └── common/ # 共通コンポーネント
│ ├── pages/ # アプリケーションページ
│ │ ├── Setup/ # 初期セットアップウィザード
│ │ ├── Dashboard/ # ホームダッシュボード
│ │ ├── Chat/ # AIチャットインターフェース
│ │ ├── Channels/ # チャネル管理
│ │ ├── Skills/ # スキルブラウザ&マネージャー
│ │ ├── Cron/ # スケジュールタスク
│ │ └── Settings/ # 設定パネル
│ ├── stores/ # Zustand ステートストア
│ ├── lib/ # フロントエンドユーティリティ
│ └── types/ # TypeScript 型定義
├── resources/ # 静的アセット(アイコン、画像)
├── scripts/ # ビルド&ユーティリティスクリプト
└── tests/ # テストスイート
```
### 利用可能なコマンド
```bash
# 開発
pnpm run init # 依存関係のインストール + uvのダウンロード
pnpm dev # ホットリロードで起動
pnpm dev # ホットリロードで起動(不足時は同梱スキルを自動準備)
# コード品質
pnpm lint # ESLintを実行
@@ -296,16 +335,29 @@ pnpm typecheck # TypeScriptの型チェック
# テスト
pnpm test # ユニットテストを実行
pnpm run comms:replay # 通信リプレイ指標を算出
pnpm run comms:baseline # 通信ベースラインを更新
pnpm run comms:compare # リプレイ指標をベースライン閾値と比較
# ビルド&パッケージ
pnpm run build:vite # フロントエンドのみビルド
pnpm build # フルプロダクションビルド(パッケージアセット含む)
pnpm package # 現在のプラットフォーム向けにパッケージ化
pnpm package # 現在のプラットフォーム向けにパッケージ化(同梱プリインストールスキルを含む)
pnpm package:mac # macOS向けにパッケージ化
pnpm package:win # Windows向けにパッケージ化
pnpm package:linux # Linux向けにパッケージ化
```
### 通信回帰チェック
PR が通信経路(Gateway イベント、Chat 送受信フロー、Channel 配信、トランスポートのフォールバック)に触れる場合は、次を実行してください。
```bash
pnpm run comms:replay
pnpm run comms:compare
```
CI の `comms-regression` が必須シナリオと閾値を検証します。
### 技術スタック
| レイヤー | 技術 |
@@ -362,6 +414,16 @@ ClawXは優れたオープンソースプロジェクトの上に構築されて
| :---: | :---: | :---: |
| <img src="src/assets/community/wecom-qr.png" width="150" alt="WeChat QRコード" /> | <img src="src/assets/community/feishu-qr.png" width="150" alt="Feishu QRコード" /> | <img src="src/assets/community/20260212-185822.png" width="150" alt="Discord QRコード" /> |
### ClawX パートナープログラム 🚀
ClawX パートナープログラムを開始します。特に、カスタム AI エージェントや自動化ニーズを持つより多くの顧客に ClawX を紹介してくださるパートナーを募集しています。
パートナーの皆さまには、見込みユーザーや案件との接点づくりを担っていただき、ClawX チームは技術サポート、カスタマイズ、統合を全面的に提供します。
AI ツールや自動化に関心のある顧客とお仕事をされている方は、ぜひご一緒できればうれしいです。
詳細は DM いただくか、[public@valuecell.ai](mailto:public@valuecell.ai) までメールでご連絡ください。
---
## スター履歴
+118 -55
View File
@@ -47,27 +47,27 @@ ClawX comes pre-configured with best-practice model providers and natively suppo
## Screenshot
<p align="center">
<img src="resources/screenshot/chat.png" style="width: 100%; height: auto;">
<img src="resources/screenshot/en/chat.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/cron_task.png" style="width: 100%; height: auto;">
<img src="resources/screenshot/en/cron.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/skills.png" style="width: 100%; height: auto;">
</p>
<!-- <p align="center">
<img src="resources/screenshot/channels.png" style="width: 100%; height: auto;">
</p> -->
<p align="center">
<img src="resources/screenshot/dashboard.png" style="width: 100%; height: auto;">
<img src="resources/screenshot/en/skills.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/settings.png" style="width: 100%; height: auto;">
<img src="resources/screenshot/en/channels.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/en/models.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/en/settings.png" style="width: 100%; height: auto;">
</p>
---
@@ -98,23 +98,40 @@ We are committed to maintaining strict alignment with the upstream OpenClaw proj
Complete the entire setup—from installation to your first AI interaction—through an intuitive graphical interface. No terminal commands, no YAML files, no environment variable hunting.
### 💬 Intelligent Chat Interface
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, and rich content rendering with Markdown.
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, rich content rendering with Markdown, and direct `@agent` routing in the main composer for multi-agent setups.
When you target another agent with `@agent`, ClawX switches into that agent's own conversation context directly instead of relaying through the default agent. Agent workspaces stay separate by default, and stronger isolation depends on OpenClaw sandbox settings.
Each agent can also override its own `provider/model` runtime setting; agents without overrides continue inheriting the global default model.
### 📡 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.
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.
### 🧩 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
### 🔐 Secure Provider Integration
Connect to multiple AI providers (OpenAI, Anthropic, and more) with credentials stored securely in your system's native keychain.
Connect to multiple AI providers (OpenAI, Anthropic, and more) with credentials stored securely in your system's native keychain. OpenAI supports both API key and browser OAuth (Codex subscription) sign-in.
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.
### 🌙 Adaptive Theming
Light mode, dark mode, or system-synchronized themes. ClawX adapts to your preferences automatically.
### 🚀 Startup Launch Control
In **Settings → General**, you can enable **Launch at system startup** so ClawX starts automatically after login.
---
## Getting Started
@@ -144,16 +161,17 @@ pnpm run init
# Start in development mode
pnpm dev
```
### First Launch
When you launch ClawX for the first time, the **Setup Wizard** will guide you through:
1. **Language & Region** Configure your preferred locale
2. **AI Provider** Enter your API keys for supported providers
2. **AI Provider** Add providers with API keys or OAuth (for providers that support browser/device login)
3. **Skill Bundles** Select pre-configured skills for common use cases
4. **Verification** Test your configuration before entering the main interface
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.
@@ -175,22 +193,24 @@ Recommended local examples:
```text
Proxy Server: http://127.0.0.1:7890
```
Notes:
- A bare `host:port` value is treated as HTTP.
- If advanced proxy fields are left empty, ClawX falls back to `Proxy Server`.
- Saving proxy settings reapplies Electron networking immediately and restarts the Gateway automatically.
- ClawX also syncs the proxy to OpenClaw's Telegram channel config when Telegram is enabled.
- Gateway restarts preserve an existing Telegram channel proxy if ClawX proxy is currently disabled.
- To explicitly clear Telegram channel proxy from OpenClaw config, save proxy settings with proxy disabled.
- In **Settings → Advanced → Developer**, you can run **OpenClaw Doctor** to execute `openclaw doctor --json` and inspect the diagnostic output without leaving the app.
- On packaged Windows builds, the bundled `openclaw` CLI/TUI runs via the shipped `node.exe` entrypoint to keep terminal input behavior stable.
---
## Architecture
ClawX employs a **dual-process architecture** that separates UI concerns from AI runtime operations:
ClawX employs a **dual-process architecture** with a unified host API layer. The renderer talks to a single client abstraction, while Electron Main owns protocol selection and process lifecycle:
```
┌─────────────────────────────────────────────────────────────────┐
```┌─────────────────────────────────────────────────────────────────┐
│ ClawX Desktop App │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
@@ -201,18 +221,29 @@ ClawX employs a **dual-process architecture** that separates UI concerns from AI
│ │ • Auto-update orchestration │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ IPC
│ │ IPC (authoritative control plane)
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ React Renderer Process │ │
│ │ • Modern component-based UI (React 19) │ │
│ │ • State management with Zustand │ │
│ │ • Real-time WebSocket communication │ │
│ │ • Unified host-api/api-client calls │ │
│ │ • Rich Markdown rendering │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────────┘
WebSocket (JSON-RPC)
Main-owned transport strategy
│ (WS first, HTTP then IPC fallback)
┌─────────────────────────────────────────────────────────────────┐
│ Host API & Main Process Proxies │
│ │
│ • hostapi:fetch (Main proxy, avoids CORS in dev/prod) │
│ • gateway:httpProxy (Renderer never calls Gateway HTTP direct) │
│ • Unified error mapping & retry/backoff │
└──────────────────────────────┬──────────────────────────────────┘
│ WS / HTTP / IPC fallback
┌─────────────────────────────────────────────────────────────────┐
│ OpenClaw Gateway │
@@ -223,13 +254,25 @@ ClawX employs a **dual-process architecture** that separates UI concerns from AI
│ • Provider abstraction layer │
└─────────────────────────────────────────────────────────────────┘
```
### Design Principles
- **Process Isolation**: The AI runtime operates in a separate process, ensuring UI responsiveness even during heavy computation
- **Graceful Recovery**: Built-in reconnection logic with exponential backoff handles transient failures automatically
- **Single Entry for Frontend Calls**: Renderer requests go through host-api/api-client; protocol details are hidden behind a stable interface
- **Main-Process Transport Ownership**: Electron Main controls WS/HTTP usage and fallback to IPC for reliability
- **Graceful Recovery**: Built-in reconnect, timeout, and backoff logic handles transient failures automatically
- **Secure Storage**: API keys and sensitive data leverage the operating system's native secure storage mechanisms
- **Hot Reload**: Development mode supports instant UI updates without restarting the gateway
- **CORS-Safe by Design**: Local HTTP access is proxied by Main, preventing renderer-side CORS issues
### Process Model & Gateway Troubleshooting
- ClawX is an Electron app, so **one app instance normally appears as multiple OS processes** (main/renderer/zygote/utility). This is expected.
- Single-instance protection uses Electron's lock plus a local process-file lock fallback, preventing duplicate app launch in environments where desktop IPC/session bus is unstable.
- 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`.
- To verify the active listener:
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
- Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen`
- Clicking the window close button (`X`) hides ClawX to tray; it does **not** fully quit the app. Use tray menu **Quit ClawX** for complete shutdown.
---
@@ -258,40 +301,37 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro
### Project Structure
```ClawX/
├── electron/ # Electron Main Process
│ ├── api/ # Main-side API router and handlers
│ │ └── routes/ # RPC/HTTP proxy route modules
│ ├── services/ # Provider, secrets and runtime services
│ │ ├── providers/ # Provider/account model sync logic
│ │ └── secrets/ # OS keychain and secret storage
│ ├── shared/ # Shared provider schemas/constants
│ │ └── providers/
│ ├── main/ # App entry, windows, IPC registration
│ ├── gateway/ # OpenClaw Gateway process manager
│ ├── preload/ # Secure IPC bridge
│ └── utils/ # Utilities (storage, auth, paths)
├── src/ # React Renderer Process
│ ├── lib/ # Unified frontend API + error model
│ ├── stores/ # Zustand stores (settings/chat/gateway)
│ ├── components/ # Reusable UI components
│ ├── pages/ # Setup/Dashboard/Chat/Channels/Skills/Cron/Settings
│ ├── i18n/ # Localization resources
│ └── types/ # TypeScript type definitions
├── tests/
│ └── unit/ # Vitest unit/integration-like tests
├── resources/ # Static assets (icons/images)
└── scripts/ # Build and utility scripts
```
ClawX/
├── electron/ # Electron Main Process
│ ├── main/ # Application entry, window management
│ ├── gateway/ # OpenClaw Gateway process manager
│ ├── preload/ # Secure IPC bridge scripts
│ └── utils/ # Utilities (storage, auth, paths)
├── src/ # React Renderer Process
│ ├── components/ # Reusable UI components
│ │ ├── ui/ # Base components (shadcn/ui)
│ │ ├── layout/ # Layout components (sidebar, header)
│ │ └── common/ # Shared components
│ ├── pages/ # Application pages
│ │ ├── Setup/ # Initial setup wizard
│ │ ├── Dashboard/ # Home dashboard
│ │ ├── Chat/ # AI chat interface
│ │ ├── Channels/ # Channel management
│ │ ├── Skills/ # Skill browser & manager
│ │ ├── Cron/ # Scheduled tasks
│ │ └── Settings/ # Configuration panels
│ ├── stores/ # Zustand state stores
│ ├── lib/ # Frontend utilities
│ └── types/ # TypeScript type definitions
├── resources/ # Static assets (icons, images)
├── scripts/ # Build & utility scripts
└── tests/ # Test suites
```
### Available Commands
```bash
# Development
pnpm run init # Install dependencies + download uv
pnpm dev # Start with hot reload
pnpm dev # Start with hot reload (auto-prepares bundled skills if missing)
# Quality
pnpm lint # Run ESLint
@@ -299,16 +339,29 @@ pnpm typecheck # TypeScript validation
# Testing
pnpm test # Run unit tests
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
# Build & Package
pnpm run build:vite # Build frontend only
pnpm build # Full production build (with packaging assets)
pnpm package # Package for current platform
pnpm package # Package for current platform (includes bundled preinstalled skills)
pnpm package:mac # Package for macOS
pnpm package:win # Package for Windows
pnpm package:linux # Package for Linux
```
### Communication Regression Checks
When a PR changes communication paths (gateway events, chat runtime send/receive flow, channel delivery, or transport fallback), run:
```bash
pnpm run comms:replay
pnpm run comms:compare
```
`comms-regression` in CI enforces required scenarios and threshold checks.
### Tech Stack
| Layer | Technology |
@@ -365,6 +418,16 @@ Join our community to connect with other users, get support, and share your expe
| :---: | :---: | :---: |
| <img src="src/assets/community/wecom-qr.png" width="150" alt="WeChat QR Code" /> | <img src="src/assets/community/feishu-qr.png" width="150" alt="Feishu QR Code" /> | <img src="src/assets/community/20260212-185822.png" width="150" alt="Discord QR Code" /> |
### ClawX Partner Program 🚀
We're launching the ClawX Partner Program and looking for partners who can help introduce ClawX to more clients, especially those with custom AI agent or automation needs.
Partners help connect us with potential users and projects, while the ClawX team provides full technical support, customization, and integration.
If you work with clients interested in AI tools or automation, we'd love to collaborate.
DM us or email [public@valuecell.ai](mailto:public@valuecell.ai) to learn more.
---
## Star History
+111 -49
View File
@@ -48,27 +48,27 @@ ClawX 预置了最佳实践的模型供应商配置,原生支持 Windows 平
## 截图预览
<p align="center">
<img src="resources/screenshot/zh/聊天.png" style="width: 100%; height: auto;">
<img src="resources/screenshot/zh/chat.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/zh/定时任务.png" style="width: 100%; height: auto;">
<img src="resources/screenshot/zh/cron.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/zh/技能.png" style="width: 100%; height: auto;">
<img src="resources/screenshot/zh/skills.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/zh/频道.png" style="width: 100%; height: auto;">
<img src="resources/screenshot/zh/channels.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/zh/仪表盘.png" style="width: 100%; height: auto;">
<img src="resources/screenshot/zh/models.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/zh/设置.png" style="width: 100%; height: auto;">
<img src="resources/screenshot/zh/settings.png" style="width: 100%; height: auto;">
</p>
---
@@ -99,23 +99,39 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
从安装到第一次 AI 对话,全程通过直观的图形界面完成。无需终端命令,无需 YAML 文件,无需到处寻找环境变量。
### 💬 智能聊天界面
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录以及 Markdown 富文本渲染。
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录Markdown 富文本渲染,以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体
当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。
### 📡 多频道管理
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。
现在每个频道支持多个账号,并可在 Channels 页面直接完成账号绑定到 Agent 与默认账号切换。
ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Channels 页面通过内置二维码流程完成微信连接。
### ⏰ 定时任务自动化
调度 AI 任务自动执行。定义触发器、设置时间间隔,让 AI 智能体 7×24 小时不间断工作。
现在定时任务页面已经可以直接配置外部投递,统一拆成“发送账号”和“接收目标”两个下拉选择。对于已支持的通道,接收目标会从通道目录能力或已知会话历史中自动发现,不需要再手动修改 `jobs.json`
已知限制:微信当前不在支持的定时任务投递通道列表内。原因是 `openclaw-weixin` 插件的出站发送依赖实时会话里的 `contextToken`,插件本身不支持 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 供应商(OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。
连接多个 AI 供应商(OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuthCodex 订阅)登录。
如果你通过 **自定义(CustomProvider** 对接 OpenAI-compatible 网关,可以在 **设置 → AI Providers → 编辑 Provider** 中配置自定义 `User-Agent`,以提高兼容性。
### 🌙 自适应主题
支持浅色模式、深色模式或跟随系统主题。ClawX 自动适应你的偏好设置。
### 🚀 开机启动控制
**设置 → 通用** 中,你可以开启 **开机自动启动**,让 ClawX 在系统登录后自动启动。
---
## 快速上手
@@ -145,16 +161,17 @@ pnpm run init
# 以开发模式启动
pnpm dev
```
### 首次启动
首次启动 ClawX 时,**设置向导** 将引导你完成以下步骤:
1. **语言与区域** 配置你的首选语言和地区
2. **AI 供应商** 输入所支持供应商的 API 密钥
2. **AI 供应商** 通过 API 密钥或 OAuth(支持浏览器/设备登录的供应商)添加账号
3. **技能包** 选择适用于常见场景的预配置技能
4. **验证** 在进入主界面前测试你的配置
如果系统语言在支持列表中,向导会默认选中该语言;否则回退到英文。
> MoonshotKimi)说明:ClawX 默认保持开启 Kimi 的 web search。
> 当配置 Moonshot 后,ClawX 也会将 OpenClaw 配置中的 Kimi web search 同步到中国区端点(`https://api.moonshot.cn/v1`)。
@@ -176,22 +193,24 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问
```text
代理服务器: http://127.0.0.1:7890
```
说明:
- 只填写 `host:port` 时,会按 HTTP 代理处理。
- 高级代理项留空时,会自动回退到“代理服务器”。
- 保存代理设置后,Electron 网络层会立即重新应用代理,并自动重启 Gateway。
- 如果启用了 TelegramClawX 还会把代理同步到 OpenClaw 的 Telegram 频道配置中。
- 当 ClawX 代理处于关闭状态时,Gateway 的常规重启会保留已有的 Telegram 频道代理配置。
- 如果你要明确清空 OpenClaw 中的 Telegram 代理,请在关闭代理后点一次“保存代理设置”。
-**设置 → 高级 → 开发者** 中,可以直接运行 **OpenClaw Doctor**,执行 `openclaw doctor --json` 并在应用内查看诊断输出。
- 在 Windows 打包版本中,内置的 `openclaw` CLI/TUI 会通过随包分发的 `node.exe` 入口运行,以保证终端输入行为稳定。
---
## 系统架构
ClawX 采用 **双进程架构**,将 UI 层与 AI 运行时操作分离
ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用统一客户端抽象,协议选择与进程生命周期由 Electron 主进程统一管理
```
┌─────────────────────────────────────────────────────────────────┐
```┌─────────────────────────────────────────────────────────────────┐
│ ClawX 桌面应用 │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
@@ -202,18 +221,29 @@ ClawX 采用 **双进程架构**,将 UI 层与 AI 运行时操作分离:
│ │ • 自动更新编排 │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ IPC
│ │ IPC(权威控制面)
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ React 渲染进程 │ │
│ │ • 现代组件化 UI(React 19 │ │
│ │ • Zustand 状态管理 │ │
│ │ • WebSocket 实时通信 │ │
│ │ • 统一 host-api/api-client 调用 │ │
│ │ • Markdown 富文本渲染 │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────────┘
WebSocket (JSON-RPC)
主进程统一传输策略
│(WS 优先,HTTP 次之,IPC 回退)
┌─────────────────────────────────────────────────────────────────┐
│ Host API 与主进程代理层 │
│ │
│ • hostapi:fetch(主进程代理,规避开发/生产 CORS) │
│ • gateway:httpProxy(渲染进程不直连 Gateway HTTP
│ • 统一错误映射与重试/退避策略 │
└──────────────────────────────┬──────────────────────────────────┘
│ WS / HTTP / IPC 回退
┌─────────────────────────────────────────────────────────────────┐
│ OpenClaw 网关 │
@@ -224,13 +254,25 @@ ClawX 采用 **双进程架构**,将 UI 层与 AI 运行时操作分离:
│ • 供应商抽象层 │
└─────────────────────────────────────────────────────────────────┘
```
### 设计原则
- **进程隔离**:AI 运行时在独立进程中运行,确保即使在高负载计算期间 UI 也能保持响应
- **优雅恢复**:内置带指数退避的重连逻辑,自动处理瞬时故障
- **前端调用单一入口**:渲染层统一走 host-api/api-client,不感知底层协议细节
- **主进程掌控传输策略**:WS/HTTP 选择与 IPC 回退在主进程集中处理,提升稳定性
- **优雅恢复**:内置重连、超时、退避逻辑,自动处理瞬时故障
- **安全存储**:API 密钥和敏感数据利用操作系统原生的安全存储机制
- **热重载**:开发模式支持即时 UI 更新,无需重启网关
- **CORS 安全**:本地 HTTP 请求由主进程代理,避免渲染进程跨域问题
### 进程模型与 Gateway 排障
- ClawX 基于 Electron,**单个应用实例出现多个系统进程是正常现象**main/renderer/zygote/utility)。
- 单实例保护同时使用 Electron 自带锁与本地进程文件锁回退机制,可在桌面会话总线异常时避免重复启动。
- 滚动升级期间若新旧版本混跑,单实例保护仍可能出现不对称行为。为保证稳定性,建议桌面客户端尽量统一升级到同一版本。
- 但 OpenClaw Gateway 监听应始终保持**单实例**:`127.0.0.1:18789` 只能有一个监听者。
- 可用以下命令确认监听进程:
- macOS/Linux`lsof -nP -iTCP:18789 -sTCP:LISTEN`
- WindowsPowerShell):`Get-NetTCPConnection -LocalPort 18789 -State Listen`
- 点击窗口关闭按钮(`X`)默认只是最小化到托盘,并不会完全退出应用。请在托盘菜单中选择 **Quit ClawX** 执行完整退出。
---
@@ -259,40 +301,37 @@ ClawX 采用 **双进程架构**,将 UI 层与 AI 运行时操作分离:
### 项目结构
```ClawX/
├── electron/ # Electron 主进程
│ ├── api/ # 主进程 API 路由与处理器
│ │ └── routes/ # RPC/HTTP 代理路由模块
│ ├── services/ # Provider、Secrets 与运行时服务
│ │ ├── providers/ # Provider/account 模型同步逻辑
│ │ └── secrets/ # 系统钥匙串与密钥存储
│ ├── shared/ # 共享 Provider schema/常量
│ │ └── providers/
│ ├── main/ # 应用入口、窗口、IPC 注册
│ ├── gateway/ # OpenClaw 网关进程管理
│ ├── preload/ # 安全 IPC 桥接
│ └── utils/ # 工具模块(存储、认证、路径)
├── src/ # React 渲染进程
│ ├── lib/ # 前端统一 API 与错误模型
│ ├── stores/ # Zustand 状态仓库(settings/chat/gateway
│ ├── components/ # 可复用 UI 组件
│ ├── pages/ # Setup/Dashboard/Chat/Channels/Skills/Cron/Settings
│ ├── i18n/ # 国际化资源
│ └── types/ # TypeScript 类型定义
├── tests/
│ └── unit/ # Vitest 单元/集成型测试
├── resources/ # 静态资源(图标、图片)
└── scripts/ # 构建与工具脚本
```
ClawX/
├── electron/ # Electron 主进程
│ ├── main/ # 应用入口、窗口管理
│ ├── gateway/ # OpenClaw 网关进程管理
│ ├── preload/ # 安全 IPC 桥接脚本
│ └── utils/ # 工具模块(存储、认证、路径)
├── src/ # React 渲染进程
│ ├── components/ # 可复用 UI 组件
│ │ ├── ui/ # 基础组件(shadcn/ui
│ │ ├── layout/ # 布局组件(侧边栏、顶栏)
│ │ └── common/ # 公共组件
│ ├── pages/ # 应用页面
│ │ ├── Setup/ # 初始设置向导
│ │ ├── Dashboard/ # 首页仪表盘
│ │ ├── Chat/ # AI 聊天界面
│ │ ├── Channels/ # 频道管理
│ │ ├── Skills/ # 技能浏览与管理
│ │ ├── Cron/ # 定时任务
│ │ └── Settings/ # 配置面板
│ ├── stores/ # Zustand 状态仓库
│ ├── lib/ # 前端工具库
│ └── types/ # TypeScript 类型定义
├── resources/ # 静态资源(图标、图片)
├── scripts/ # 构建与工具脚本
└── tests/ # 测试套件
```
### 常用命令
```bash
# 开发
pnpm run init # 安装依赖并下载 uv
pnpm dev # 以热重载模式启动
pnpm dev # 以热重载模式启动(若缺失会自动准备预装技能包)
# 代码质量
pnpm lint # 运行 ESLint 检查
@@ -300,16 +339,29 @@ pnpm typecheck # TypeScript 类型检查
# 测试
pnpm test # 运行单元测试
pnpm run comms:replay # 计算通信回放指标
pnpm run comms:baseline # 刷新通信基线快照
pnpm run comms:compare # 将回放指标与基线阈值对比
# 构建与打包
pnpm run build:vite # 仅构建前端
pnpm build # 完整生产构建(含打包资源)
pnpm package # 为当前平台打包
pnpm package # 为当前平台打包(包含预装技能资源)
pnpm package:mac # 为 macOS 打包
pnpm package:win # 为 Windows 打包
pnpm package:linux # 为 Linux 打包
```
### 通信回归检查
当 PR 涉及通信链路(Gateway 事件、Chat 收发流程、Channel 投递、传输回退)时,建议执行:
```bash
pnpm run comms:replay
pnpm run comms:compare
```
CI 中的 `comms-regression` 会校验必选场景与阈值。
### 技术栈
| 层级 | 技术 |
@@ -366,6 +418,16 @@ ClawX 构建于以下优秀的开源项目之上:
| :---: | :---: | :---: |
| <img src="src/assets/community/wecom-qr.png" width="150" alt="企业微信二维码" /> | <img src="src/assets/community/feishu-qr.png" width="150" alt="飞书二维码" /> | <img src="src/assets/community/20260212-185822.png" width="150" alt="Discord 二维码" /> |
### ClawX 合作伙伴计划 🚀
我们正在启动 ClawX 合作伙伴计划,寻找能够帮助我们将 ClawX 介绍给更多客户的合作伙伴,尤其是那些有定制化 AI 智能体或自动化需求的客户。
合作伙伴负责帮助我们连接潜在用户和项目,ClawX 团队则提供完整的技术支持、定制开发与集成服务。
如果你服务的客户对 AI 工具或自动化方案感兴趣,欢迎与我们合作。
欢迎私信我们,或发送邮件至 [public@valuecell.ai](mailto:public@valuecell.ai) 了解更多。
---
## Stars 历史
+14 -7
View File
@@ -26,6 +26,9 @@ extraResources:
# because electron-builder respects .gitignore which excludes node_modules/)
- from: build/openclaw/
to: openclaw/
# Pre-bundled third-party skills (full directories, not only SKILL.md)
- from: build/preinstalled-skills/
to: resources/preinstalled-skills/
# NOTE: OpenClaw plugin mirrors (dingtalk, etc.) are bundled by the
# afterPack hook (after-pack.cjs) directly from node_modules, so they
# don't need an extraResources entry here.
@@ -35,6 +38,10 @@ afterPack: ./scripts/after-pack.cjs
asar: true
asarUnpack:
- "**/*.node"
# lru-cache CJS/ESM interop: older CJS versions (v5, v6, v7) don't export
# `LRUCache` as a named property, breaking `import { LRUCache }` in Node.js
# 22+ (Electron 40+). Unpacking lets afterPack patch them in place.
- "**/node_modules/lru-cache/**"
# Disable native module rebuilding.
# The Electron renderer/main process has no native (.node) dependencies.
@@ -83,6 +90,10 @@ mac:
NSCameraUsageDescription: ClawX requires camera access for video features
dmg:
# Explicit volume size prevents dmg-builder@1.2.0 auto-calculation from
# underestimating (causes "No space left on device" for large app bundles).
# The final .dmg is bzip2-compressed, so this only affects the temp volume.
size: 2g
background: resources/dmg-background.png
icon: resources/icons/icon.icns
iconSize: 100
@@ -100,9 +111,9 @@ dmg:
# Windows Configuration
win:
forceCodeSigning: false
# Skip update signature verification: we ship via OSS + GitHub without a
# code-signing certificate, so verifying would always fail on the updater.
verifyUpdateCodeSignature: false
signAndEditExecutable: true
extraResources:
- from: resources/bin/win32-${arch}
to: bin
@@ -111,15 +122,11 @@ win:
icon: resources/icons/icon.ico
target:
- target: nsis
arch:
- x64
- arm64
arch: x64
nsis:
oneClick: false
perMachine: false
# Avoid NSIS build failure: warning 6010 (_ci_StrContains "not referenced") is emitted when
# building the uninstaller, because that function is only used in the install macro.
warningsAsErrors: false
allowToChangeInstallationDirectory: true
deleteAppDataOnUninstall: false
+40 -6
View File
@@ -1,4 +1,16 @@
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[] = [];
@@ -12,27 +24,49 @@ export async function parseJsonBody<T>(req: IncomingMessage): Promise<T> {
return JSON.parse(raw) as T;
}
export function setCorsHeaders(res: ServerResponse): void {
res.setHeader('Access-Control-Allow-Origin', '*');
/**
* 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');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
export function sendJson(res: ServerResponse, statusCode: number, payload: unknown): void {
setCorsHeaders(res);
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(payload));
}
export function sendNoContent(res: ServerResponse): void {
setCorsHeaders(res);
res.statusCode = 204;
res.end();
}
export function sendText(res: ServerResponse, statusCode: number, text: string): void {
setCorsHeaders(res);
res.statusCode = statusCode;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end(text);
+247
View File
@@ -0,0 +1,247 @@
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;
}
+9 -4
View File
@@ -1,6 +1,7 @@
import type { IncomingMessage, ServerResponse } from 'http';
import type { HostApiContext } from '../context';
import { setCorsHeaders, sendNoContent } from '../route-utils';
import { parseJsonBody, sendJson } from '../route-utils';
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../../utils/openclaw-doctor';
export async function handleAppRoutes(
req: IncomingMessage,
@@ -9,7 +10,7 @@ export async function handleAppRoutes(
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/events' && req.method === 'GET') {
setCorsHeaders(res);
// 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',
@@ -23,10 +24,14 @@ export async function handleAppRoutes(
return true;
}
if (req.method === 'OPTIONS') {
sendNoContent(res);
if (url.pathname === '/api/app/openclaw-doctor' && req.method === 'POST') {
const body = await parseJsonBody<{ mode?: 'diagnose' | 'fix' }>(req);
const mode = body.mode === 'fix' ? 'fix' : 'diagnose';
sendJson(res, 200, mode === 'fix' ? await runOpenClawDoctorFix() : await runOpenClawDoctor());
return true;
}
// OPTIONS is handled by the server middleware; no route-level handler needed.
return false;
}
File diff suppressed because it is too large Load Diff
+413 -13
View File
@@ -1,6 +1,10 @@
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;
@@ -11,10 +15,11 @@ interface GatewayCronJob {
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 };
delivery?: { mode: string; channel?: string; to?: string; accountId?: string };
sessionTarget?: string;
state: {
nextRunAtMs?: number;
runningAtMs?: number;
lastRunAtMs?: number;
lastStatus?: string;
lastError?: string;
@@ -22,11 +27,344 @@ interface GatewayCronJob {
};
}
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 channelType = job.delivery?.channel;
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: channelType, channelName: channelType }
? {
channelType,
channelId: delivery.accountId || gatewayDelivery.channel,
channelName: channelType,
recipient: delivery.to,
}
: undefined;
const lastRun = job.state?.lastRunAtMs
? {
@@ -45,6 +383,7 @@ function transformCronJob(job: GatewayCronJob) {
name: job.name,
message,
schedule: job.schedule,
delivery,
target,
enabled: job.enabled,
createdAt: new Date(job.createdAtMs).toISOString(),
@@ -60,6 +399,47 @@ export async function handleCronRoutes(
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 });
@@ -98,7 +478,19 @@ export async function handleCronRoutes(
if (url.pathname === '/api/cron/jobs' && req.method === 'POST') {
try {
const input = await parseJsonBody<{ name: string; message: string; schedule: string; enabled?: boolean }>(req);
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 },
@@ -106,7 +498,7 @@ export async function handleCronRoutes(
enabled: input.enabled ?? true,
wakeMode: 'next-heartbeat',
sessionTarget: 'isolated',
delivery: { mode: 'none' },
delivery,
});
sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result);
} catch (error) {
@@ -119,15 +511,23 @@ export async function handleCronRoutes(
try {
const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length));
const input = await parseJsonBody<Record<string, unknown>>(req);
const patch = { ...input };
if (typeof patch.schedule === 'string') {
patch.schedule = { kind: 'cron', expr: patch.schedule };
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;
}
if (typeof patch.message === 'string') {
patch.payload = { kind: 'agentTurn', message: patch.message };
delete patch.message;
}
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.update', { id, patch }));
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) });
}
+2 -1
View File
@@ -1,5 +1,6 @@
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';
@@ -68,7 +69,7 @@ export async function handleGatewayRoutes(
const status = ctx.gatewayManager.getStatus();
const token = await getSetting('gatewayToken');
const port = status.port || PORTS.OPENCLAW_GATEWAY;
const urlValue = `http://127.0.0.1:${port}/?token=${encodeURIComponent(token)}`;
const urlValue = buildOpenClawControlUiUrl(port, token);
sendJson(res, 200, { success: true, url: urlValue, token, port });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
+90 -31
View File
@@ -1,15 +1,5 @@
import type { IncomingMessage, ServerResponse } from 'http';
import {
deleteApiKey,
deleteProvider,
getAllProvidersWithKeyInfo,
getApiKey,
getDefaultProvider,
getProvider,
hasApiKey,
saveProvider,
setDefaultProvider,
storeApiKey,
type ProviderConfig,
} from '../../utils/secure-storage';
import {
@@ -31,6 +21,19 @@ import { validateApiKeyWithProvider } from '../../services/providers/provider-va
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,
@@ -39,6 +42,13 @@ export async function handleProviderRoutes(
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());
@@ -70,6 +80,11 @@ export async function handleProviderRoutes(
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 });
@@ -94,6 +109,11 @@ export async function handleProviderRoutes(
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 });
@@ -107,8 +127,10 @@ export async function handleProviderRoutes(
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
try {
const existing = await providerService.getAccount(accountId);
const runtimeProviderKey = existing?.vendorId === 'google' && existing.authMode === 'oauth_browser'
? 'google-gemini-cli'
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);
@@ -135,19 +157,27 @@ export async function handleProviderRoutes(
}
if (url.pathname === '/api/providers' && req.method === 'GET') {
sendJson(res, 200, await getAllProvidersWithKeyInfo());
logLegacyProviderRoute('GET /api/providers');
sendJson(res, 200, await providerService.listLegacyProvidersWithKeyInfo());
return true;
}
if (url.pathname === '/api/providers/default' && req.method === 'GET') {
sendJson(res, 200, { providerId: await getDefaultProvider() ?? null });
logLegacyProviderRoute('GET /api/providers/default');
sendJson(res, 200, { providerId: await providerService.getDefaultLegacyProvider() ?? null });
return true;
}
if (url.pathname === '/api/providers/default' && req.method === 'PUT') {
logLegacyProviderRoute('PUT /api/providers/default');
try {
const body = await parseJsonBody<{ providerId: string }>(req);
await setDefaultProvider(body.providerId);
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) {
@@ -157,13 +187,15 @@ export async function handleProviderRoutes(
}
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 } }>(req);
const provider = await getProvider(body.providerId);
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;
sendJson(res, 200, await validateApiKeyWithProvider(providerType, body.apiKey, { baseUrl: resolvedBaseUrl }));
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) });
}
@@ -171,6 +203,7 @@ export async function handleProviderRoutes(
}
if (url.pathname === '/api/providers/oauth/start' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers/oauth/start');
try {
const body = await parseJsonBody<{
provider: OAuthProviderType | BrowserOAuthProviderType;
@@ -178,7 +211,7 @@ export async function handleProviderRoutes(
accountId?: string;
label?: string;
}>(req);
if (body.provider === 'google') {
if (body.provider === 'google' || body.provider === 'openai') {
await browserOAuthManager.startFlow(body.provider, {
accountId: body.accountId,
label: body.label,
@@ -197,6 +230,7 @@ export async function handleProviderRoutes(
}
if (url.pathname === '/api/providers/oauth/cancel' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers/oauth/cancel');
try {
await deviceOAuthManager.stopFlow();
await browserOAuthManager.stopFlow();
@@ -207,15 +241,32 @@ export async function handleProviderRoutes(
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 saveProvider(config);
await providerService.saveLegacyProvider(config);
if (body.apiKey !== undefined) {
const trimmedKey = body.apiKey.trim();
if (trimmedKey) {
await storeApiKey(config.id, trimmedKey);
await providerService.setLegacyProviderApiKey(config.id, trimmedKey);
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
}
}
@@ -228,39 +279,46 @@ export async function handleProviderRoutes(
}
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 getApiKey(actualId) });
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 hasApiKey(actualId) });
sendJson(res, 200, { hasKey: await providerService.hasLegacyProviderApiKey(actualId) });
return true;
}
sendJson(res, 200, await getProvider(providerId));
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 getProvider(providerId);
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 saveProvider(nextConfig);
await providerService.saveLegacyProvider(nextConfig);
if (body.apiKey !== undefined) {
const trimmedKey = body.apiKey.trim();
if (trimmedKey) {
await storeApiKey(providerId, trimmedKey);
await providerService.setLegacyProviderApiKey(providerId, trimmedKey);
await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey);
} else {
await deleteApiKey(providerId);
await providerService.deleteLegacyProviderApiKey(providerId);
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
}
}
@@ -273,16 +331,17 @@ export async function handleProviderRoutes(
}
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 getProvider(providerId);
const existing = await providerService.getLegacyProvider(providerId);
if (url.searchParams.get('apiKeyOnly') === '1') {
await deleteApiKey(providerId);
await providerService.deleteLegacyProviderApiKey(providerId);
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
sendJson(res, 200, { success: true });
return true;
}
await deleteProvider(providerId);
await providerService.deleteLegacyProvider(providerId);
await syncDeletedProviderToRuntime(existing, providerId, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
+14
View File
@@ -1,11 +1,14 @@
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();
@@ -23,6 +26,10 @@ function patchTouchesProxy(patch: Partial<AppSettings>): boolean {
));
}
function patchTouchesLaunchAtStartup(patch: Partial<AppSettings>): boolean {
return Object.prototype.hasOwnProperty.call(patch, 'launchAtStartup');
}
export async function handleSettingsRoutes(
req: IncomingMessage,
res: ServerResponse,
@@ -44,6 +51,9 @@ export async function handleSettingsRoutes(
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) });
@@ -76,6 +86,9 @@ export async function handleSettingsRoutes(
) {
await handleProxySettingsChange(ctx);
}
if (key === 'launchAtStartup') {
await syncLaunchAtStartupSettingFromStore();
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
@@ -87,6 +100,7 @@ export async function handleSettingsRoutes(
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) });
+13 -2
View File
@@ -77,8 +77,19 @@ export async function handleSkillRoutes(
if (url.pathname === '/api/clawhub/open-readme' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ slug: string }>(req);
await ctx.clawHubService.openSkillReadme(body.slug);
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) });
+8 -2
View File
@@ -10,8 +10,14 @@ export async function handleUsageRoutes(
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/usage/recent-token-history' && req.method === 'GET') {
const parsedLimit = Number(url.searchParams.get('limit') || '');
const limit = Number.isFinite(parsedLimit) ? Math.max(Math.floor(parsedLimit), 1) : undefined;
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;
}
+55 -1
View File
@@ -1,3 +1,4 @@
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';
@@ -6,6 +7,7 @@ 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';
@@ -13,7 +15,7 @@ import { handleSkillRoutes } from './routes/skills';
import { handleFileRoutes } from './routes/files';
import { handleSessionRoutes } from './routes/sessions';
import { handleCronRoutes } from './routes/cron';
import { sendJson } from './route-utils';
import { sendJson, setCorsHeaders, requireJsonContentType } from './route-utils';
type RouteHandler = (
req: IncomingMessage,
@@ -27,6 +29,7 @@ const routeHandlers: RouteHandler[] = [
handleGatewayRoutes,
handleSettingsRoutes,
handleProviderRoutes,
handleAgentRoutes,
handleChannelRoutes,
handleSkillRoutes,
handleFileRoutes,
@@ -36,10 +39,61 @@ const routeHandlers: RouteHandler[] = [
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;
+101 -11
View File
@@ -33,6 +33,13 @@ export interface ClawHubSkillResult {
stars?: number;
}
export interface ClawHubInstalledSkillResult {
slug: string;
version: string;
source?: string;
baseDir?: string;
}
export class ClawHubService {
private workDir: string;
private cliPath: string;
@@ -67,6 +74,55 @@ export class ClawHubService {
return line.replace(this.ansiRegex, '').trim();
}
private extractFrontmatterName(skillManifestPath: string): string | null {
try {
const raw = fs.readFileSync(skillManifestPath, 'utf8');
// Match the first frontmatter block and read `name: ...`
const frontmatterMatch = raw.match(/^---\s*\n([\s\S]*?)\n---/);
if (!frontmatterMatch) return null;
const body = frontmatterMatch[1];
const nameMatch = body.match(/^\s*name\s*:\s*["']?([^"'\n]+)["']?\s*$/m);
if (!nameMatch) return null;
const name = nameMatch[1].trim();
return name || null;
} catch {
return null;
}
}
private resolveSkillDirByManifestName(candidates: string[]): string | null {
const skillsRoot = path.join(this.workDir, 'skills');
if (!fs.existsSync(skillsRoot)) return null;
const wanted = new Set(
candidates
.map((v) => v.trim().toLowerCase())
.filter((v) => v.length > 0),
);
if (wanted.size === 0) return null;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(skillsRoot, { withFileTypes: true });
} catch {
return null;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillDir = path.join(skillsRoot, entry.name);
const skillManifestPath = path.join(skillDir, 'SKILL.md');
if (!fs.existsSync(skillManifestPath)) continue;
const frontmatterName = this.extractFrontmatterName(skillManifestPath);
if (!frontmatterName) continue;
if (wanted.has(frontmatterName.toLowerCase())) {
return skillDir;
}
}
return null;
}
/**
* Run a ClawHub CLI command
*/
@@ -290,7 +346,7 @@ export class ClawHubService {
/**
* List installed skills
*/
async listInstalled(): Promise<Array<{ slug: string; version: string }>> {
async listInstalled(): Promise<ClawHubInstalledSkillResult[]> {
try {
const output = await this.runCommand(['list']);
if (!output || output.includes('No installed skills')) {
@@ -302,40 +358,59 @@ export class ClawHubService {
const cleanLine = this.stripAnsi(line);
const match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)/);
if (match) {
const slug = match[1];
return {
slug: match[1],
slug,
version: match[2],
source: 'openclaw-managed',
baseDir: path.join(this.workDir, 'skills', slug),
};
}
return null;
}).filter((s): s is { slug: string; version: string } => s !== null);
}).filter((s): s is ClawHubInstalledSkillResult => s !== null);
} catch (error) {
console.error('ClawHub list error:', error);
return [];
}
}
private resolveSkillDir(skillKeyOrSlug: string, fallbackSlug?: string, preferredBaseDir?: string): string | null {
const candidates = [skillKeyOrSlug, fallbackSlug]
.filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
.map(v => v.trim());
const uniqueCandidates = [...new Set(candidates)];
if (preferredBaseDir && preferredBaseDir.trim() && fs.existsSync(preferredBaseDir.trim())) {
return preferredBaseDir.trim();
}
const directSkillDir = uniqueCandidates
.map((id) => path.join(this.workDir, 'skills', id))
.find((dir) => fs.existsSync(dir));
return directSkillDir || this.resolveSkillDirByManifestName(uniqueCandidates);
}
/**
* Open skill README/manual in default editor
*/
async openSkillReadme(slug: string): Promise<boolean> {
const skillDir = path.join(this.workDir, 'skills', slug);
async openSkillReadme(skillKeyOrSlug: string, fallbackSlug?: string, preferredBaseDir?: string): Promise<boolean> {
const skillDir = this.resolveSkillDir(skillKeyOrSlug, fallbackSlug, preferredBaseDir);
// Try to find documentation file
const possibleFiles = ['SKILL.md', 'README.md', 'skill.md', 'readme.md'];
let targetFile = '';
for (const file of possibleFiles) {
const filePath = path.join(skillDir, file);
if (fs.existsSync(filePath)) {
targetFile = filePath;
break;
if (skillDir) {
for (const file of possibleFiles) {
const filePath = path.join(skillDir, file);
if (fs.existsSync(filePath)) {
targetFile = filePath;
break;
}
}
}
if (!targetFile) {
// If no md file, just open the directory
if (fs.existsSync(skillDir)) {
if (skillDir) {
targetFile = skillDir;
} else {
throw new Error('Skill directory not found');
@@ -351,4 +426,19 @@ export class ClawHubService {
throw error;
}
}
/**
* Open skill path in file explorer
*/
async openSkillPath(skillKeyOrSlug: string, fallbackSlug?: string, preferredBaseDir?: string): Promise<boolean> {
const skillDir = this.resolveSkillDir(skillKeyOrSlug, fallbackSlug, preferredBaseDir);
if (!skillDir) {
throw new Error('Skill directory not found');
}
const openResult = await shell.openPath(skillDir);
if (openResult) {
throw new Error(openResult);
}
return true;
}
}
+169 -9
View File
@@ -1,16 +1,32 @@
import { app } from 'electron';
import path from 'path';
import { existsSync } from 'fs';
import { existsSync, readFileSync, mkdirSync, rmSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
function fsPath(filePath: string): string {
if (process.platform !== 'win32') return filePath;
if (!filePath) return filePath;
if (filePath.startsWith('\\\\?\\')) return filePath;
const windowsPath = filePath.replace(/\//g, '\\');
if (!path.win32.isAbsolute(windowsPath)) return windowsPath;
if (windowsPath.startsWith('\\\\')) {
return `\\\\?\\UNC\\${windowsPath.slice(2)}`;
}
return `\\\\?\\${windowsPath}`;
}
import { getAllSettings } from '../utils/store';
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry';
import { getOpenClawDir, getOpenClawEntryPath, isOpenClawPresent } from '../utils/paths';
import { getUvMirrorEnv } from '../utils/uv-env';
import { listConfiguredChannels } from '../utils/channel-config';
import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, sanitizeOpenClawConfig } from '../utils/openclaw-auth';
import { cleanupDanglingWeChatPluginState, listConfiguredChannels } from '../utils/channel-config';
import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, syncSessionIdleMinutesToOpenClaw, sanitizeOpenClawConfig } from '../utils/openclaw-auth';
import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
import { logger } from '../utils/logger';
import { prependPathEntry } from '../utils/env-path';
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe } from '../utils/plugin-install';
export interface GatewayLaunchContext {
appSettings: Awaited<ReturnType<typeof getAllSettings>>;
@@ -25,10 +41,125 @@ export interface GatewayLaunchContext {
channelStartupSummary: 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' },
'openclaw-weixin': { dirName: 'openclaw-weixin', npmName: '@tencent-weixin/openclaw-weixin' },
};
/**
* 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.
*/
const BUILTIN_CHANNEL_EXTENSIONS = ['discord', 'telegram'];
function cleanupStaleBuiltInExtensions(): void {
for (const ext of BUILTIN_CHANNEL_EXTENSIONS) {
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 });
} catch (err) {
logger.warn(`[plugin] Failed to remove stale extension ${ext}:`, err);
}
}
}
}
function readPluginVersion(pkgJsonPath: string): string | null {
try {
const raw = readFileSync(fsPath(pkgJsonPath), 'utf-8');
const parsed = JSON.parse(raw) as { version?: string };
return parsed.version ?? null;
} catch {
return 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),
];
}
/**
* Auto-upgrade all configured channel plugins before Gateway start.
* - 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 {
for (const channelType of configuredChannels) {
const pluginInfo = CHANNEL_PLUGIN_MAP[channelType];
if (!pluginInfo) continue;
const { dirName, npmName } = pluginInfo;
const targetDir = join(homedir(), '.openclaw', 'extensions', dirName);
const targetManifest = join(targetDir, 'openclaw.plugin.json');
const isInstalled = existsSync(fsPath(targetManifest));
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 bundledDir = bundledSources.find((dir) => existsSync(fsPath(join(dir, 'openclaw.plugin.json'))));
if (bundledDir) {
const sourceVersion = readPluginVersion(join(bundledDir, 'package.json'));
// Install or upgrade if version differs or plugin not installed
if (!isInstalled || (sourceVersion && installedVersion && sourceVersion !== installedVersion)) {
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 });
cpSyncSafe(bundledDir, targetDir);
fixupPluginManifest(targetDir);
} catch (err) {
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin:`, err);
}
}
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;
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);
}
}
}
}
// ── Pre-launch sync ──────────────────────────────────────────────
export async function syncGatewayConfigBeforeLaunch(
appSettings: Awaited<ReturnType<typeof getAllSettings>>,
): Promise<void> {
await syncProxyConfigToOpenClaw(appSettings);
await syncProxyConfigToOpenClaw(appSettings, { preserveExistingWhenDisabled: true });
try {
await sanitizeOpenClawConfig();
@@ -36,6 +167,29 @@ export async function syncGatewayConfigBeforeLaunch(
logger.warn('Failed to sanitize openclaw.json:', err);
}
try {
await cleanupDanglingWeChatPluginState();
} catch (err) {
logger.warn('Failed to clean dangling WeChat plugin state before launch:', err);
}
// Remove stale copies of built-in extensions (Discord, Telegram) that
// override OpenClaw's working built-in plugins and break channel loading.
try {
cleanupStaleBuiltInExtensions();
} catch (err) {
logger.warn('Failed to clean stale built-in extensions:', err);
}
// Auto-upgrade installed plugins before Gateway starts so that
// the plugin manifest ID matches what sanitize wrote to the config.
try {
const configuredChannels = await listConfiguredChannels();
ensureConfiguredPluginsUpgraded(configuredChannels);
} catch (err) {
logger.warn('Failed to auto-upgrade plugins:', err);
}
try {
await syncGatewayTokenToConfig(appSettings.gatewayToken);
} catch (err) {
@@ -47,6 +201,12 @@ export async function syncGatewayConfigBeforeLaunch(
} 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);
}
}
async function loadProviderEnv(): Promise<{ providerEnv: Record<string, string>; loadedProviderKeyCount: number }> {
@@ -141,9 +301,6 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
? path.join(process.resourcesPath, 'bin')
: path.join(process.cwd(), 'resources', 'bin', target);
const binPathExists = existsSync(binPath);
const finalPath = binPathExists
? `${binPath}${path.delimiter}${process.env.PATH || ''}`
: process.env.PATH || '';
const { providerEnv, loadedProviderKeyCount } = await loadProviderEnv();
const { skipChannels, channelStartupSummary } = await resolveChannelStartupPolicy();
@@ -155,9 +312,12 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
: 'disabled';
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
const baseEnvRecord = baseEnv as Record<string, string | undefined>;
const baseEnvPatched = binPathExists
? prependPathEntry(baseEnvRecord, binPath).env
: baseEnvRecord;
const forkEnv: Record<string, string | undefined> = {
...baseEnv,
PATH: finalPath,
...baseEnvPatched,
...providerEnv,
...uvEnv,
...proxyEnv,
+67 -2
View File
@@ -1,21 +1,78 @@
import { logger } from '../utils/logger';
type HealthResult = { ok: boolean; error?: string };
type HeartbeatAliveReason = 'pong' | 'message';
type PingOptions = {
sendPing: () => void;
onHeartbeatTimeout: (context: { consecutiveMisses: number; timeoutMs: number }) => void;
intervalMs?: number;
timeoutMs?: number;
maxConsecutiveMisses?: number;
};
export class GatewayConnectionMonitor {
private pingInterval: NodeJS.Timeout | null = null;
private healthCheckInterval: NodeJS.Timeout | null = null;
private lastPingAt = 0;
private waitingForAlive = false;
private consecutiveMisses = 0;
private timeoutTriggered = false;
startPing(options: PingOptions): void {
const intervalMs = options.intervalMs ?? 30000;
const timeoutMs = options.timeoutMs ?? 10000;
const maxConsecutiveMisses = Math.max(1, options.maxConsecutiveMisses ?? 3);
this.resetHeartbeatState();
startPing(sendPing: () => void, intervalMs = 30000): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
}
this.pingInterval = setInterval(() => {
sendPing();
const now = Date.now();
if (this.waitingForAlive && now - this.lastPingAt >= timeoutMs) {
this.waitingForAlive = false;
this.consecutiveMisses += 1;
logger.warn(
`Gateway heartbeat missed (${this.consecutiveMisses}/${maxConsecutiveMisses}, timeout=${timeoutMs}ms)`,
);
if (this.consecutiveMisses >= maxConsecutiveMisses && !this.timeoutTriggered) {
this.timeoutTriggered = true;
options.onHeartbeatTimeout({
consecutiveMisses: this.consecutiveMisses,
timeoutMs,
});
return;
}
}
options.sendPing();
this.waitingForAlive = true;
this.lastPingAt = now;
}, intervalMs);
}
markAlive(reason: HeartbeatAliveReason): void {
// Only log true recovery cases to avoid steady-state heartbeat log spam.
if (this.consecutiveMisses > 0) {
logger.debug(`Gateway heartbeat recovered via ${reason} (misses=${this.consecutiveMisses})`);
}
this.waitingForAlive = false;
this.consecutiveMisses = 0;
this.timeoutTriggered = false;
}
// Backward-compatible alias for old callers.
handlePong(): void {
this.markAlive('pong');
}
getConsecutiveMisses(): number {
return this.consecutiveMisses;
}
startHealthCheck(options: {
shouldCheck: () => boolean;
checkHealth: () => Promise<HealthResult>;
@@ -55,5 +112,13 @@ export class GatewayConnectionMonitor {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
this.resetHeartbeatState();
}
private resetHeartbeatState(): void {
this.lastPingAt = 0;
this.waitingForAlive = false;
this.consecutiveMisses = 0;
this.timeoutTriggered = false;
}
}
+2 -12
View File
@@ -17,18 +17,8 @@ export function dispatchProtocolEvent(
emitter.emit('chat:message', { message: payload });
break;
case 'agent': {
const p = payload as Record<string, unknown>;
const data = (p.data && typeof p.data === 'object') ? p.data as Record<string, unknown> : {};
const chatEvent: Record<string, unknown> = {
...data,
runId: p.runId ?? data.runId,
sessionKey: p.sessionKey ?? data.sessionKey,
state: p.state ?? data.state,
message: p.message ?? data.message,
};
if (chatEvent.state || chatEvent.message) {
emitter.emit('chat:message', { message: chatEvent });
}
// Keep "agent" on the canonical notification path to avoid double
// handling in renderer when both notification and chat-message are wired.
emitter.emit('notification', { method: event, params: payload });
break;
}
+238 -14
View File
@@ -9,6 +9,7 @@ import WebSocket from 'ws';
import { PORTS } from '../utils/config';
import { JsonRpcNotification, isNotification, isResponse } from './protocol';
import { logger } from '../utils/logger';
import { captureTelemetryEvent, trackMetric } from '../utils/telemetry';
import {
loadOrCreateDeviceIdentity,
type DeviceIdentity,
@@ -42,6 +43,12 @@ import { GatewayConnectionMonitor } from './connection-monitor';
import { GatewayLifecycleController, LifecycleSupersededError } from './lifecycle-controller';
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';
import { runGatewayStartupSequence } from './startup-orchestrator';
@@ -93,8 +100,20 @@ export class GatewayManager extends EventEmitter {
private readonly connectionMonitor = new GatewayConnectionMonitor();
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 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;
public static readonly RESTART_COOLDOWN_MS = 5_000;
private lastRestartAt = 0;
constructor(config?: Partial<ReconnectConfig>) {
super();
@@ -104,6 +123,9 @@ export class GatewayManager extends EventEmitter {
this.emit('status', status);
},
onTransition: (previousState, nextState) => {
if (nextState === 'running') {
this.restartGovernor.onRunning();
}
this.restartController.flushDeferredRestart(
`status:${previousState}->${nextState}`,
{
@@ -181,6 +203,7 @@ 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.
@@ -219,8 +242,16 @@ export class GatewayManager extends EventEmitter {
await this.connect(port, externalToken);
},
onConnectedToExistingGateway: () => {
this.ownsProcess = false;
this.setStatus({ pid: undefined });
// 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.
const isOwnProcess = this.process?.pid != null && this.ownsProcess;
if (!isOwnProcess) {
this.ownsProcess = false;
this.setStatus({ pid: undefined });
}
this.startHealthCheck();
},
waitForPortFree: async (port) => {
@@ -327,6 +358,25 @@ export class GatewayManager extends EventEmitter {
this.setStatus({ state: 'stopped', error: undefined, pid: undefined, connectedAt: undefined, uptime: undefined });
}
/**
* Best-effort emergency cleanup for app-quit timeout paths.
* Only terminates a process this manager still owns.
*/
async forceTerminateOwnedProcessForQuit(): Promise<boolean> {
if (!this.process || !this.ownsProcess) {
return false;
}
const child = this.process;
await terminateOwnedGatewayProcess(child);
if (this.process === child) {
this.process = null;
}
this.ownsProcess = false;
this.setStatus({ pid: undefined });
return true;
}
/**
* Restart Gateway process
*/
@@ -348,7 +398,27 @@ export class GatewayManager extends EventEmitter {
return;
}
logger.debug('Gateway restart requested');
const decision = this.restartGovernor.decide();
if (!decision.allow) {
const observability = this.restartGovernor.getObservability();
logger.warn(
`[gateway-restart-governor] restart suppressed reason=${decision.reason} retryAfterMs=${decision.retryAfterMs} ` +
`suppressed=${observability.suppressed_total} executed=${observability.executed_total} circuitOpenUntil=${observability.circuit_open_until}`,
);
const props = {
reason: decision.reason,
retry_after_ms: decision.retryAfterMs,
gateway_restart_suppressed_total: observability.suppressed_total,
gateway_restart_executed_total: observability.executed_total,
gateway_restart_circuit_open_until: observability.circuit_open_until,
};
trackMetric('gateway.restart.suppressed', props);
captureTelemetryEvent('gateway_restart_suppressed', props);
return;
}
const pidBefore = this.status.pid;
logger.info(`[gateway-refresh] mode=restart requested pidBefore=${pidBefore ?? 'n/a'}`);
this.restartInFlight = (async () => {
await this.stop();
await this.start();
@@ -356,6 +426,19 @@ export class GatewayManager extends EventEmitter {
try {
await this.restartInFlight;
this.restartGovernor.recordExecuted();
const observability = this.restartGovernor.getObservability();
const props = {
gateway_restart_executed_total: observability.executed_total,
gateway_restart_suppressed_total: observability.suppressed_total,
gateway_restart_circuit_open_until: observability.circuit_open_until,
};
trackMetric('gateway.restart.executed', props);
captureTelemetryEvent('gateway_restart_executed', props);
logger.info(
`[gateway-refresh] mode=restart result=applied pidBefore=${pidBefore ?? 'n/a'} pidAfter=${this.status.pid ?? 'n/a'} ` +
`suppressed=${observability.suppressed_total} executed=${observability.executed_total} circuitOpenUntil=${observability.circuit_open_until}`,
);
} finally {
this.restartInFlight = null;
this.restartController.flushDeferredRestart(
@@ -394,6 +477,16 @@ export class GatewayManager extends EventEmitter {
* 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,
@@ -405,13 +498,18 @@ export class GatewayManager extends EventEmitter {
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;
@@ -423,6 +521,9 @@ export class GatewayManager extends EventEmitter {
// 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;
}
@@ -434,10 +535,17 @@ export class GatewayManager extends EventEmitter {
// 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();
}
@@ -447,17 +555,51 @@ export class GatewayManager extends EventEmitter {
* Debounced reload — coalesces multiple rapid config-change events into one
* in-process reload when possible.
*/
debouncedReload(delayMs = 1200): void {
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 ${delayMs}ms)`);
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);
});
}, delayMs);
}, 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;
}
}
/**
@@ -582,6 +724,7 @@ export class GatewayManager extends EventEmitter {
onExit: (exitedChild, code) => {
this.processExitCode = code;
this.ownsProcess = false;
this.connectionMonitor.clear();
if (this.process === exitedChild) {
this.process = null;
}
@@ -602,6 +745,7 @@ export class GatewayManager extends EventEmitter {
this.process = child;
this.ownsProcess = true;
logger.debug(`Gateway manager now owns process pid=${child.pid ?? 'unknown'}`);
this.lastSpawnSummary = lastSpawnSummary;
}
@@ -617,17 +761,28 @@ export class GatewayManager extends EventEmitter {
getToken: async () => await import('../utils/store').then(({ getSetting }) => getSetting('gatewayToken')),
onHandshakeComplete: (ws) => {
this.ws = ws;
ws.on('pong', () => {
this.connectionMonitor.markAlive('pong');
});
this.setStatus({
state: 'running',
port,
connectedAt: Date.now(),
});
this.startPing();
// On Windows, skip WebSocket heartbeat ping to avoid cascading failures:
// heartbeat timeout → terminate socket → reconnect → port conflict
// (old process holds port due to TCP TIME_WAIT) → ~2 min downtime.
// Gateway is a local child process; actual crashes are caught by the
// process exit handler, and graceful restarts use code=1012 close frames.
if (process.platform !== 'win32') {
this.startPing();
}
},
onMessage: (message) => {
this.handleMessage(message);
},
onCloseAfterHandshake: () => {
this.connectionMonitor.clear();
if (this.status.state === 'running') {
this.setStatus({ state: 'stopped' });
this.scheduleReconnect();
@@ -640,6 +795,8 @@ export class GatewayManager extends EventEmitter {
* Handle incoming WebSocket message
*/
private handleMessage(message: unknown): void {
this.connectionMonitor.markAlive('message');
if (typeof message !== 'object' || message === null) {
logger.debug('Received non-object Gateway message');
return;
@@ -692,10 +849,33 @@ export class GatewayManager extends EventEmitter {
* Start ping interval to keep connection alive
*/
private startPing(): void {
this.connectionMonitor.startPing(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.ping();
}
this.connectionMonitor.startPing({
intervalMs: GatewayManager.HEARTBEAT_INTERVAL_MS,
timeoutMs: GatewayManager.HEARTBEAT_TIMEOUT_MS,
maxConsecutiveMisses: GatewayManager.HEARTBEAT_MAX_MISSES,
sendPing: () => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.ping();
}
},
onHeartbeatTimeout: ({ consecutiveMisses, timeoutMs }) => {
if (this.status.state !== 'running' || !this.shouldReconnect) {
return;
}
const ws = this.ws;
if (!ws || ws.readyState !== WebSocket.OPEN) {
return;
}
logger.warn(
`Gateway heartbeat timed out after ${consecutiveMisses} consecutive misses (timeout=${timeoutMs}ms); terminating stale socket`,
);
try {
ws.terminate();
} catch (error) {
logger.warn('Failed to terminate stale Gateway socket after heartbeat timeout:', error);
}
},
});
}
@@ -731,9 +911,11 @@ export class GatewayManager extends EventEmitter {
return;
}
const cooldownRemaining = Math.max(0, GatewayManager.RESTART_COOLDOWN_MS - (Date.now() - this.lastRestartAt));
const { delay, nextAttempt, maxAttempts } = decision;
const effectiveDelay = Math.max(delay, cooldownRemaining);
this.reconnectAttempts = nextAttempt;
logger.warn(`Scheduling Gateway reconnect attempt ${nextAttempt}/${maxAttempts} in ${delay}ms`);
logger.warn(`Scheduling Gateway reconnect attempt ${nextAttempt}/${maxAttempts} in ${effectiveDelay}ms`);
this.setStatus({
state: 'reconnecting',
@@ -752,16 +934,58 @@ export class GatewayManager extends EventEmitter {
logger.debug(`Skipping reconnect attempt: ${skipReason}`);
return;
}
const attemptNo = this.reconnectAttempts;
this.reconnectAttemptsTotal += 1;
try {
// Use the guarded start() flow so reconnect attempts cannot bypass
// lifecycle locking and accidentally start duplicate Gateway processes.
await this.start();
this.reconnectSuccessTotal += 1;
this.emitReconnectMetric('success', {
attemptNo,
maxAttempts,
delayMs: effectiveDelay,
});
this.reconnectAttempts = 0;
} catch (error) {
logger.error('Gateway reconnection attempt failed:', error);
this.emitReconnectMetric('failure', {
attemptNo,
maxAttempts,
delayMs: effectiveDelay,
error: error instanceof Error ? error.message : String(error),
});
this.scheduleReconnect();
}
}, delay);
}, effectiveDelay);
}
private emitReconnectMetric(
outcome: 'success' | 'failure',
payload: {
attemptNo: number;
maxAttempts: number;
delayMs: number;
error?: string;
},
): void {
const successRate = this.reconnectAttemptsTotal > 0
? this.reconnectSuccessTotal / this.reconnectAttemptsTotal
: 0;
const properties = {
outcome,
attemptNo: payload.attemptNo,
maxAttempts: payload.maxAttempts,
delayMs: payload.delayMs,
gateway_reconnect_success_count: this.reconnectSuccessTotal,
gateway_reconnect_attempt_count: this.reconnectAttemptsTotal,
gateway_reconnect_success_rate: Number(successRate.toFixed(4)),
...(payload.error ? { error: payload.error } : {}),
};
trackMetric('gateway.reconnect', properties);
// Keep local metrics only; do not upload reconnect details to PostHog.
}
/**
@@ -770,4 +994,4 @@ export class GatewayManager extends EventEmitter {
private setStatus(update: Partial<GatewayStatus>): void {
this.stateController.setStatus(update);
}
}
}
+63
View File
@@ -0,0 +1,63 @@
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 };
}
}
+145
View File
@@ -0,0 +1,145 @@
export type RestartDecision =
| { allow: true }
| {
allow: false;
reason: 'circuit_open' | 'budget_exceeded' | 'cooldown_active';
retryAfterMs: number;
};
type RestartGovernorOptions = {
maxRestartsPerWindow: number;
windowMs: number;
baseCooldownMs: number;
maxCooldownMs: number;
circuitOpenMs: number;
stableResetMs: 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,
};
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;
}
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);
return {
allow: false,
reason: 'cooldown_active',
retryAfterMs: requiredCooldown - sinceLast,
};
}
}
return { allow: true };
}
recordExecuted(now = Date.now()): void {
this.executedTotal = this.incrementCounter(this.executedTotal);
this.lastRestartAt = now;
this.consecutiveRestarts += 1;
this.restartTimestamps.push(now);
this.pruneOld(now);
}
getCounters(): { executedTotal: number; suppressedTotal: number } {
return {
executedTotal: this.executedTotal,
suppressedTotal: this.suppressedTotal,
};
}
getObservability(): {
suppressed_total: number;
executed_total: number;
circuit_open_until: number;
} {
return {
suppressed_total: this.suppressedTotal,
executed_total: this.executedTotal,
circuit_open_until: this.circuitOpenUntil,
};
}
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;
return current + 1;
}
}
+6
View File
@@ -18,6 +18,12 @@ export function classifyGatewayStderrMessage(message: string): GatewayStderrClas
if (msg.includes('closed before connect') && msg.includes('token mismatch')) {
return { level: 'drop', normalized: msg };
}
if (msg.includes('[ws] closed before connect') && msg.includes('code=1005')) {
return { level: 'debug', normalized: msg };
}
if (msg.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 };
+41 -18
View File
@@ -6,6 +6,7 @@ import { getOpenClawDir, getOpenClawEntryPath } from '../utils/paths';
import { getUvMirrorEnv } from '../utils/uv-env';
import { isPythonReady, setupManagedPython } from '../utils/uv-setup';
import { logger } from '../utils/logger';
import { prependPathEntry } from '../utils/env-path';
export function warmupManagedPythonReadiness(): void {
void isPythonReady().then((pythonReady) => {
@@ -21,39 +22,58 @@ export function warmupManagedPythonReadiness(): void {
}
export async function terminateOwnedGatewayProcess(child: Electron.UtilityProcess): Promise<void> {
let exited = false;
const terminateWindowsProcessTree = async (pid: number): Promise<void> => {
const cp = await import('child_process');
await new Promise<void>((resolve) => {
cp.exec(`taskkill /F /PID ${pid} /T`, { timeout: 5000, windowsHide: true }, () => resolve());
});
};
await new Promise<void>((resolve) => {
let exited = false;
// Register a single exit listener before any kill attempt to avoid
// the race where exit fires between two separate `once('exit')` calls.
child.once('exit', () => {
exited = true;
clearTimeout(timeout);
resolve();
});
const pid = child.pid;
logger.info(`Sending kill to Gateway process (pid=${pid ?? 'unknown'})`);
try {
child.kill();
} catch {
// ignore if already exited
if (process.platform === 'win32' && pid) {
void terminateWindowsProcessTree(pid).catch((err) => {
logger.warn(`Windows process-tree kill failed for Gateway pid=${pid}:`, err);
});
} else {
try {
child.kill();
} catch {
// ignore if already exited
}
}
const timeout = setTimeout(() => {
if (!exited) {
logger.warn(`Gateway did not exit in time, force-killing (pid=${pid ?? 'unknown'})`);
if (pid) {
try {
process.kill(pid, 'SIGKILL');
} catch {
// ignore
if (process.platform === 'win32') {
void terminateWindowsProcessTree(pid).catch((err) => {
logger.warn(`Forced Windows process-tree kill failed for Gateway pid=${pid}:`, err);
});
} else {
try {
process.kill(pid, 'SIGKILL');
} catch {
// ignore
}
}
}
}
resolve();
}, 5000);
child.once('exit', () => {
clearTimeout(timeout);
});
});
}
@@ -225,6 +245,9 @@ export async function findExistingGatewayProcess(options: {
const pids = await getListeningProcessIds(port);
if (pids.length > 0 && (!ownedPid || !pids.includes(String(ownedPid)))) {
await terminateOrphanedProcessIds(port, pids);
if (process.platform === 'win32') {
await waitForPortFree(port, 10000);
}
return null;
}
} catch (err) {
@@ -269,9 +292,10 @@ export async function runOpenClawDoctorRepair(): Promise<boolean> {
? path.join(process.resourcesPath, 'bin')
: path.join(process.cwd(), 'resources', 'bin', target);
const binPathExists = existsSync(binPath);
const finalPath = binPathExists
? `${binPath}${path.delimiter}${process.env.PATH || ''}`
: process.env.PATH || '';
const baseProcessEnv = process.env as Record<string, string | undefined>;
const baseEnvPatched = binPathExists
? prependPathEntry(baseProcessEnv, binPath).env
: baseProcessEnv;
const uvEnv = await getUvMirrorEnv();
const doctorArgs = ['doctor', '--fix', '--yes', '--non-interactive'];
@@ -281,8 +305,7 @@ export async function runOpenClawDoctorRepair(): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const forkEnv: Record<string, string | undefined> = {
...process.env,
PATH: finalPath,
...baseEnvPatched,
...uvEnv,
OPENCLAW_NO_RESPAWN: '1',
};
+277 -68
View File
@@ -13,14 +13,30 @@ import { createMenu } from './menu';
import { appUpdater, registerUpdateHandlers } from './updater';
import { logger } from '../utils/logger';
import { warmupNetworkOptimization } from '../utils/uv-env';
import { initTelemetry } from '../utils/telemetry';
import { ClawHubService } from '../gateway/clawhub';
import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace';
import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToProfile } from '../utils/openclaw-cli';
import { isQuitting, setQuitting } from './app-state';
import { applyProxySettings } from './proxy';
import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup';
import {
clearPendingSecondInstanceFocus,
consumeMainWindowReady,
createMainWindowFocusState,
requestSecondInstanceFocus,
} from './main-window-focus';
import {
createQuitLifecycleState,
markQuitCleanupCompleted,
requestQuitLifecycleAction,
} from './quit-lifecycle';
import { createSignalQuitHandler } from './signal-quit';
import { acquireProcessInstanceFileLock } from './process-instance-lock';
import { getSetting } from '../utils/store';
import { ensureBuiltinSkillsInstalled } from '../utils/skill-config';
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 { deviceOAuthManager } from '../utils/device-oauth';
@@ -28,6 +44,8 @@ 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';
// Disable GPU hardware acceleration globally for maximum stability across
// all GPU configurations (no GPU, integrated, discrete).
//
@@ -56,17 +74,48 @@ if (process.platform === 'linux') {
// Without this, two instances each spawn their own gateway process on the
// same port, then each treats the other's gateway as "orphaned" and kills
// it — creating an infinite kill/restart loop on Windows.
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
// The losing process must exit immediately so it never reaches Gateway startup.
const gotElectronLock = 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) {
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;
if (!fileLock.acquired) {
const ownerDescriptor = fileLock.ownerPid
? `${fileLock.ownerFormat ?? 'legacy'} pid=${fileLock.ownerPid}`
: fileLock.ownerFormat === 'unknown'
? 'unknown lock format/content'
: 'unknown owner';
console.info(
`[ClawX] Another instance already holds process lock (${fileLock.lockPath}, ${ownerDescriptor}); exiting duplicate process`,
);
app.exit(0);
}
} catch (error) {
console.warn('[ClawX] Failed to acquire process instance file lock; continuing with Electron single-instance lock only', error);
}
}
const gotTheLock = gotElectronLock && gotFileLock;
// Global references
let mainWindow: BrowserWindow | null = null;
const gatewayManager = new GatewayManager();
const clawHubService = new ClawHubService();
const hostEventBus = new HostEventBus();
let gatewayManager!: GatewayManager;
let clawHubService!: ClawHubService;
let hostEventBus!: HostEventBus;
let hostApiServer: Server | null = null;
const mainWindowFocusState = createMainWindowFocusState();
const quitLifecycleState = createQuitLifecycleState();
/**
* Resolve the icons directory path (works in both dev and packaged mode)
@@ -100,6 +149,8 @@ function getAppIcon(): Electron.NativeImage | undefined {
*/
function createWindow(): BrowserWindow {
const isMac = process.platform === 'darwin';
const isWindows = process.platform === 'win32';
const useCustomTitleBar = isWindows;
const win = new BrowserWindow({
width: 1280,
@@ -114,20 +165,25 @@ function createWindow(): BrowserWindow {
sandbox: false,
webviewTag: true, // Enable <webview> for embedding OpenClaw Control UI
},
titleBarStyle: isMac ? 'hiddenInset' : 'hidden',
titleBarStyle: isMac ? 'hiddenInset' : useCustomTitleBar ? 'hidden' : 'default',
trafficLightPosition: isMac ? { x: 16, y: 16 } : undefined,
frame: isMac,
frame: isMac || !useCustomTitleBar,
show: false,
});
// Show window when ready to prevent visual flash
win.once('ready-to-show', () => {
win.show();
});
// Handle external links
// 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 }) => {
shell.openExternal(url);
try {
const parsed = new URL(url);
if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
shell.openExternal(url);
} else {
logger.warn(`Blocked openExternal for disallowed protocol: ${parsed.protocol}`);
}
} catch {
logger.warn(`Blocked openExternal for malformed URL: ${url}`);
}
return { action: 'deny' };
});
@@ -142,6 +198,62 @@ function createWindow(): BrowserWindow {
return win;
}
function focusWindow(win: BrowserWindow): void {
if (win.isDestroyed()) {
return;
}
if (win.isMinimized()) {
win.restore();
}
win.show();
win.focus();
}
function focusMainWindow(): void {
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
clearPendingSecondInstanceFocus(mainWindowFocusState);
focusWindow(mainWindow);
}
function createMainWindow(): BrowserWindow {
const win = createWindow();
win.once('ready-to-show', () => {
if (mainWindow !== win) {
return;
}
const action = consumeMainWindowReady(mainWindowFocusState);
if (action === 'focus') {
focusWindow(win);
return;
}
win.show();
});
win.on('close', (event) => {
if (!isQuitting()) {
event.preventDefault();
win.hide();
}
});
win.on('closed', () => {
if (mainWindow === win) {
mainWindow = null;
}
});
mainWindow = win;
return win;
}
/**
* Initialize the application
*/
@@ -150,23 +262,27 @@ async function initialize(): Promise<void> {
logger.init();
logger.info('=== ClawX Application Starting ===');
logger.debug(
`Runtime: platform=${process.platform}/${process.arch}, electron=${process.versions.electron}, node=${process.versions.node}, packaged=${app.isPackaged}`
`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();
// Initialize Telemetry early
await initTelemetry();
// Apply persisted proxy settings before creating windows or network requests.
await applyProxySettings();
await syncLaunchAtStartupSettingFromStore();
// Set application menu
createMenu();
// Create the main window
mainWindow = createWindow();
const window = createMainWindow();
// Create system tray
createTray(mainWindow);
createTray(window);
// Override security headers ONLY for the OpenClaw Gateway Control UI.
// The URL filter ensures this callback only fires for gateway requests,
@@ -192,33 +308,21 @@ async function initialize(): Promise<void> {
);
// Register IPC handlers
registerIpcHandlers(gatewayManager, clawHubService, mainWindow);
registerIpcHandlers(gatewayManager, clawHubService, window);
hostApiServer = startHostApiServer({
gatewayManager,
clawHubService,
eventBus: hostEventBus,
mainWindow,
mainWindow: window,
});
// Register update handlers
registerUpdateHandlers(appUpdater, mainWindow);
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.
// Minimize to tray on close instead of quitting (macOS & Windows)
mainWindow.on('close', (event) => {
if (!isQuitting()) {
event.preventDefault();
mainWindow?.hide();
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
// 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.
@@ -232,6 +336,19 @@ async function initialize(): Promise<void> {
logger.warn('Failed to install built-in skills:', error);
});
// Pre-deploy bundled third-party skills from resources/preinstalled-skills.
// This installs full skill directories (not only SKILL.md) in an idempotent,
// non-destructive way and never blocks startup.
void ensurePreinstalledSkillsInstalled().catch((error) => {
logger.warn('Failed to install preinstalled skills:', error);
});
// 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);
});
// 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 }) => {
@@ -283,6 +400,10 @@ async function initialize(): Promise<void> {
hostEventBus.emit('oauth:start', payload);
});
browserOAuthManager.on('oauth:code', (payload) => {
hostEventBus.emit('oauth:code', payload);
});
browserOAuthManager.on('oauth:success', (payload) => {
hostEventBus.emit('oauth:success', { ...payload, success: true });
});
@@ -337,48 +458,136 @@ async function initialize(): Promise<void> {
});
}
// When a second instance is launched, focus the existing window instead.
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
if (gotTheLock) {
const requestQuitOnSignal = createSignalQuitHandler({
logInfo: (message) => logger.info(message),
requestQuit: () => app.quit(),
});
process.on('exit', () => {
releaseProcessInstanceFileLock();
});
process.once('SIGINT', () => requestQuitOnSignal('SIGINT'));
process.once('SIGTERM', () => requestQuitOnSignal('SIGTERM'));
app.on('will-quit', () => {
releaseProcessInstanceFileLock();
});
if (process.platform === 'win32') {
app.setAppUserModelId(WINDOWS_APP_USER_MODEL_ID);
}
});
// Application lifecycle
app.whenReady().then(() => {
initialize();
gatewayManager = new GatewayManager();
clawHubService = new ClawHubService();
hostEventBus = new HostEventBus();
// Register activate handler AFTER app is ready to prevent
// "Cannot create BrowserWindow before app is ready" on macOS.
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow();
} else if (mainWindow && !mainWindow.isDestroyed()) {
// On macOS, clicking the dock icon should show the window if it's hidden
mainWindow.show();
mainWindow.focus();
// When a second instance is launched, focus the existing window instead.
app.on('second-instance', () => {
logger.info('Second ClawX instance detected; redirecting to the existing window');
const focusRequest = requestSecondInstanceFocus(
mainWindowFocusState,
Boolean(mainWindow && !mainWindow.isDestroyed()),
);
if (focusRequest === 'focus-now') {
focusMainWindow();
return;
}
logger.debug('Main window is not ready yet; deferring second-instance focus until ready-to-show');
});
// Application lifecycle
app.whenReady().then(() => {
void initialize().catch((error) => {
logger.error('Application initialization failed:', error);
});
// Register activate handler AFTER app is ready to prevent
// "Cannot create BrowserWindow before app is ready" on macOS.
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
} else {
focusMainWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('before-quit', (event) => {
setQuitting();
const action = requestQuitLifecycleAction(quitLifecycleState);
app.on('before-quit', () => {
setQuitting();
hostEventBus.closeAll();
hostApiServer?.close();
// Fire-and-forget: do not await gatewayManager.stop() here.
// Awaiting inside before-quit can stall Electron's quit sequence.
void gatewayManager.stop().catch((err) => {
logger.warn('gatewayManager.stop() error during quit:', err);
if (action === 'allow-quit') {
return;
}
event.preventDefault();
if (action === 'cleanup-in-progress') {
logger.debug('Quit requested while cleanup already in progress; waiting for shutdown task to finish');
return;
}
hostEventBus.closeAll();
hostApiServer?.close();
const stopPromise = gatewayManager.stop().catch((err) => {
logger.warn('gatewayManager.stop() error during quit:', err);
});
const timeoutPromise = new Promise<'timeout'>((resolve) => {
setTimeout(() => resolve('timeout'), 5000);
});
void Promise.race([stopPromise.then(() => 'stopped' as const), timeoutPromise]).then((result) => {
if (result === 'timeout') {
logger.warn('Gateway shutdown timed out during app quit; proceeding with forced quit');
void gatewayManager.forceTerminateOwnedProcessForQuit().then((terminated) => {
if (terminated) {
logger.warn('Forced gateway process termination completed after quit timeout');
}
}).catch((err) => {
logger.warn('Forced gateway termination failed after quit timeout:', err);
});
}
markQuitCleanupCompleted(quitLifecycleState);
app.quit();
});
});
});
// Best-effort Gateway cleanup on unexpected crashes.
// These handlers attempt to terminate the Gateway child process within a
// short timeout before force-exiting, preventing orphaned processes.
const emergencyGatewayCleanup = (reason: string, error: unknown): void => {
logger.error(`${reason}:`, error);
try {
void gatewayManager?.stop().catch(() => { /* ignore */ });
} catch {
// ignore — stop() may not be callable if state is corrupted
}
// Give Gateway stop a brief window, then force-exit.
setTimeout(() => {
process.exit(1);
}, 3000).unref();
};
process.on('uncaughtException', (error) => {
emergencyGatewayCleanup('Uncaught exception in main process', error);
});
process.on('unhandledRejection', (reason) => {
emergencyGatewayCleanup('Unhandled promise rejection in main process', reason);
});
}
// Export for testing
export { mainWindow, gatewayManager };
+341 -179
View File
@@ -3,7 +3,7 @@
* Registers all IPC handlers for main-renderer communication
*/
import { ipcMain, BrowserWindow, shell, dialog, app, nativeImage } from 'electron';
import { existsSync, cpSync, mkdirSync, rmSync } from 'node:fs';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, extname, basename } from 'node:path';
import crypto from 'node:crypto';
@@ -19,6 +19,8 @@ import {
saveProviderKeyToOpenClaw,
removeProviderFromOpenClaw,
} from '../utils/openclaw-auth';
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
import { buildOpenClawControlUiUrl } from '../utils/openclaw-control-ui';
import { logger } from '../utils/logger';
import {
saveChannelConfig,
@@ -30,13 +32,22 @@ import {
validateChannelConfig,
validateChannelCredentials,
} from '../utils/channel-config';
import { toOpenClawChannelType, toUiChannelType } from '../utils/channel-alias';
import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-setup';
import {
ensureDingTalkPluginInstalled,
ensureFeishuPluginInstalled,
ensureQQBotPluginInstalled,
ensureWeComPluginInstalled,
} from '../utils/plugin-install';
import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config';
import { whatsAppLoginManager } from '../utils/whatsapp-login';
import { getProviderConfig } from '../utils/provider-registry';
import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth';
import { browserOAuthManager, type BrowserOAuthProviderType } from '../utils/browser-oauth';
import { applyProxySettings } from './proxy';
import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup';
import { proxyAwareFetch } from '../utils/proxy-fetch';
import { getRecentTokenUsageHistory } from '../utils/token-usage';
import { getProviderService } from '../services/providers/provider-service';
import {
@@ -50,24 +61,14 @@ import {
} from '../services/providers/provider-runtime-sync';
import { validateApiKeyWithProvider } from '../services/providers/provider-validation';
import { appUpdater } from './updater';
type AppRequest = {
id?: string;
module: string;
action: string;
payload?: unknown;
};
type AppResponse = {
id?: string;
ok: boolean;
data?: unknown;
error?: {
code: 'VALIDATION' | 'PERMISSION' | 'TIMEOUT' | 'GATEWAY' | 'INTERNAL' | 'UNSUPPORTED';
message: string;
details?: unknown;
};
};
import { registerHostApiProxyHandlers } from './ipc/host-api-proxy';
import {
isLaunchAtStartupKey,
isProxyKey,
mapAppErrorCode,
type AppRequest,
type AppResponse,
} from './ipc/request-helpers';
/**
* Register all IPC handlers
@@ -80,6 +81,9 @@ export function registerIpcHandlers(
// Unified request protocol (non-breaking: legacy channels remain available)
registerUnifiedRequestHandlers(gatewayManager);
// Host API proxy handlers
registerHostApiProxyHandlers();
// Gateway handlers
registerGatewayHandlers(gatewayManager, mainWindow);
@@ -135,29 +139,11 @@ export function registerIpcHandlers(
registerFileHandlers();
}
function mapAppErrorCode(error: unknown): AppResponse['error']['code'] {
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
if (msg.includes('timeout')) return 'TIMEOUT';
if (msg.includes('permission') || msg.includes('denied') || msg.includes('forbidden')) return 'PERMISSION';
if (msg.includes('gateway')) return 'GATEWAY';
if (msg.includes('invalid') || msg.includes('required')) return 'VALIDATION';
return 'INTERNAL';
}
function isProxyKey(key: keyof AppSettings): boolean {
return (
key === 'proxyEnabled' ||
key === 'proxyServer' ||
key === 'proxyHttpServer' ||
key === 'proxyHttpsServer' ||
key === 'proxyAllServer' ||
key === 'proxyBypassRules'
);
}
function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
const providerService = getProviderService();
const handleProxySettingsChange = async () => {
const settings = await getAllSettings();
await syncProxyConfigToOpenClaw(settings, { preserveExistingWhenDisabled: false });
await applyProxySettings(settings);
if (gatewayManager.getStatus().state === 'running') {
await gatewayManager.restart();
@@ -194,38 +180,38 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
}
case 'provider': {
if (request.action === 'list') {
data = await getAllProvidersWithKeyInfo();
data = await providerService.listLegacyProvidersWithKeyInfo();
break;
}
if (request.action === 'get') {
const payload = request.payload as { providerId?: string } | string | undefined;
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
if (!providerId) throw new Error('Invalid provider.get payload');
data = await getProvider(providerId);
data = await providerService.getLegacyProvider(providerId);
break;
}
if (request.action === 'getDefault') {
data = await getDefaultProvider();
data = await providerService.getDefaultLegacyProvider();
break;
}
if (request.action === 'hasApiKey') {
const payload = request.payload as { providerId?: string } | string | undefined;
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
if (!providerId) throw new Error('Invalid provider.hasApiKey payload');
data = await hasApiKey(providerId);
data = await providerService.hasLegacyProviderApiKey(providerId);
break;
}
if (request.action === 'getApiKey') {
const payload = request.payload as { providerId?: string } | string | undefined;
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
if (!providerId) throw new Error('Invalid provider.getApiKey payload');
data = await getApiKey(providerId);
data = await providerService.getLegacyProviderApiKey(providerId);
break;
}
if (request.action === 'validateKey') {
const payload = request.payload as
| { providerId?: string; apiKey?: string; options?: { baseUrl?: string } }
| [string, string, { baseUrl?: string }?]
| { providerId?: string; apiKey?: string; options?: { baseUrl?: string; apiProtocol?: string } }
| [string, string, { baseUrl?: string; apiProtocol?: string }?]
| undefined;
const providerId = Array.isArray(payload) ? payload[0] : payload?.providerId;
const apiKey = Array.isArray(payload) ? payload[1] : payload?.apiKey;
@@ -234,11 +220,15 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
throw new Error('Invalid provider.validateKey payload');
}
const provider = await getProvider(providerId);
const provider = await providerService.getLegacyProvider(providerId);
const providerType = provider?.type || providerId;
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
const resolvedBaseUrl = options?.baseUrl || provider?.baseUrl || registryBaseUrl;
data = await validateApiKeyWithProvider(providerType, apiKey, { baseUrl: resolvedBaseUrl });
const resolvedProtocol = options?.apiProtocol || provider?.apiProtocol;
data = await validateApiKeyWithProvider(providerType, apiKey, {
baseUrl: resolvedBaseUrl,
apiProtocol: resolvedProtocol,
});
break;
}
if (request.action === 'save') {
@@ -251,12 +241,12 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
if (!config) throw new Error('Invalid provider.save payload');
try {
await saveProvider(config);
await providerService.saveLegacyProvider(config);
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await storeApiKey(config.id, trimmedKey);
await providerService.setLegacyProviderApiKey(config.id, trimmedKey);
}
}
@@ -278,8 +268,8 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
if (!providerId) throw new Error('Invalid provider.delete payload');
try {
const existing = await getProvider(providerId);
await deleteProvider(providerId);
const existing = await providerService.getLegacyProvider(providerId);
await providerService.deleteLegacyProvider(providerId);
if (existing?.type) {
try {
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
@@ -303,8 +293,8 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
if (!providerId || typeof apiKey !== 'string') throw new Error('Invalid provider.setApiKey payload');
try {
await storeApiKey(providerId, apiKey);
const provider = await getProvider(providerId);
await providerService.setLegacyProviderApiKey(providerId, apiKey);
const provider = await providerService.getLegacyProvider(providerId);
const providerType = provider?.type || providerId;
const ock = getOpenClawProviderKey(providerType, providerId);
try {
@@ -328,13 +318,13 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
const apiKey = Array.isArray(payload) ? payload[2] : payload?.apiKey;
if (!providerId || !updates) throw new Error('Invalid provider.updateWithKey payload');
const existing = await getProvider(providerId);
const existing = await providerService.getLegacyProvider(providerId);
if (!existing) {
data = { success: false, error: 'Provider not found' };
break;
}
const previousKey = await getApiKey(providerId);
const previousKey = await providerService.getLegacyProviderApiKey(providerId);
const previousOck = getOpenClawProviderKey(existing.type, providerId);
try {
@@ -344,15 +334,15 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
updatedAt: new Date().toISOString(),
};
const ock = getOpenClawProviderKey(nextConfig.type, providerId);
await saveProvider(nextConfig);
await providerService.saveLegacyProvider(nextConfig);
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await storeApiKey(providerId, trimmedKey);
await providerService.setLegacyProviderApiKey(providerId, trimmedKey);
await saveProviderKeyToOpenClaw(ock, trimmedKey);
} else {
await deleteApiKey(providerId);
await providerService.deleteLegacyProviderApiKey(providerId);
await removeProviderFromOpenClaw(ock);
}
}
@@ -366,12 +356,12 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
data = { success: true };
} catch (error) {
try {
await saveProvider(existing);
await providerService.saveLegacyProvider(existing);
if (previousKey) {
await storeApiKey(providerId, previousKey);
await providerService.setLegacyProviderApiKey(providerId, previousKey);
await saveProviderKeyToOpenClaw(previousOck, previousKey);
} else {
await deleteApiKey(providerId);
await providerService.deleteLegacyProviderApiKey(providerId);
await removeProviderFromOpenClaw(previousOck);
}
} catch (rollbackError) {
@@ -387,8 +377,8 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
if (!providerId) throw new Error('Invalid provider.deleteApiKey payload');
try {
await deleteApiKey(providerId);
const provider = await getProvider(providerId);
await providerService.deleteLegacyProviderApiKey(providerId);
const provider = await providerService.getLegacyProvider(providerId);
const providerType = provider?.type || providerId;
const ock = getOpenClawProviderKey(providerType, providerId);
try {
@@ -410,8 +400,8 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
if (!providerId) throw new Error('Invalid provider.setDefault payload');
try {
await setDefaultProvider(providerId);
const provider = await getProvider(providerId);
await providerService.setDefaultLegacyProvider(providerId);
const provider = await providerService.getLegacyProvider(providerId);
if (provider) {
try {
await syncDefaultProviderToRuntime(providerId, gatewayManager);
@@ -505,14 +495,26 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
break;
}
if (request.action === 'create') {
type CronCreateInput = {
name: string;
message: string;
schedule: string;
delivery?: { mode: string; channel?: string; to?: string };
enabled?: boolean;
};
const payload = request.payload as
| { input?: { name: string; message: string; schedule: string; enabled?: boolean } }
| [{ name: string; message: string; schedule: string; enabled?: boolean }]
| { name: string; message: string; schedule: string; enabled?: boolean }
| { input?: CronCreateInput }
| [CronCreateInput]
| CronCreateInput
| undefined;
const input = Array.isArray(payload)
? payload[0]
: ('input' in (payload ?? {}) ? (payload as { input: { name: string; message: string; schedule: string; enabled?: boolean } }).input : payload);
let input: CronCreateInput | undefined;
if (Array.isArray(payload)) {
input = payload[0];
} else if (payload && typeof payload === 'object' && 'input' in payload) {
input = payload.input;
} else {
input = payload as CronCreateInput | undefined;
}
if (!input) throw new Error('Invalid cron.create payload');
const gatewayInput = {
name: input.name,
@@ -521,8 +523,12 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
enabled: input.enabled ?? true,
wakeMode: 'next-heartbeat',
sessionTarget: 'isolated',
delivery: { mode: 'none' },
delivery: normalizeCronDelivery(input.delivery),
};
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(gatewayInput.delivery.channel);
if (gatewayInput.delivery.mode === 'announce' && unsupportedDeliveryError) {
throw new Error(unsupportedDeliveryError);
}
const created = await gatewayManager.rpc('cron.add', gatewayInput);
data = created && typeof created === 'object' ? transformCronJob(created as GatewayCronJob) : created;
break;
@@ -535,11 +541,19 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
const id = Array.isArray(payload) ? payload[0] : payload?.id;
const input = Array.isArray(payload) ? payload[1] : payload?.input;
if (!id || !input) throw new Error('Invalid cron.update payload');
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;
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') {
throw new Error(unsupportedDeliveryError);
}
data = await gatewayManager.rpc('cron.update', { id, patch });
break;
@@ -618,6 +632,9 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
if (isProxyKey(key)) {
await handleProxySettingsChange();
}
if (isLaunchAtStartupKey(key)) {
await syncLaunchAtStartupSettingFromStore();
}
data = { success: true };
break;
}
@@ -630,6 +647,9 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
if (entries.some(([key]) => isProxyKey(key))) {
await handleProxySettingsChange();
}
if (entries.some(([key]) => isLaunchAtStartupKey(key))) {
await syncLaunchAtStartupSettingFromStore();
}
data = { success: true };
break;
}
@@ -637,6 +657,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
await resetSettings();
const settings = await getAllSettings();
await handleProxySettingsChange();
await syncLaunchAtStartupSettingFromStore();
data = { success: true, settings };
break;
}
@@ -714,7 +735,7 @@ interface GatewayCronJob {
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 };
delivery?: { mode: string; channel?: string; to?: string; accountId?: string };
sessionTarget?: string;
state: {
nextRunAtMs?: number;
@@ -725,17 +746,109 @@ interface GatewayCronJob {
};
}
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 Record<string, unknown>;
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 Record<string, unknown>;
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;
}
/**
* Transform a Gateway CronJob to the frontend CronJob format
*/
function transformCronJob(job: GatewayCronJob) {
// Extract message from payload
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;
// Build target from delivery info — only if a delivery channel is specified
const channelType = job.delivery?.channel;
const target = channelType
? { channelType, channelId: channelType, channelName: channelType }
? { channelType, channelId: delivery.accountId || gatewayDelivery.channel, channelName: channelType, recipient: delivery.to }
: undefined;
// Build lastRun from state
@@ -758,6 +871,7 @@ function transformCronJob(job: GatewayCronJob) {
name: job.name,
message,
schedule: job.schedule, // Pass the object through; frontend parseCronSchedule handles it
delivery,
target,
enabled: job.enabled,
createdAt: new Date(job.createdAtMs).toISOString(),
@@ -829,6 +943,7 @@ function registerCronHandlers(gatewayManager: GatewayManager): void {
name: string;
message: string;
schedule: string;
delivery?: GatewayCronDelivery;
enabled?: boolean;
}) => {
try {
@@ -843,8 +958,12 @@ function registerCronHandlers(gatewayManager: GatewayManager): void {
// not external messaging channels. Setting mode='none' prevents
// the Gateway from attempting channel delivery (which would fail
// with "Channel is required" when no channels are configured).
delivery: { mode: 'none' },
delivery: normalizeCronDelivery(input.delivery),
};
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(gatewayInput.delivery.channel);
if (gatewayInput.delivery.mode === 'announce' && unsupportedDeliveryError) {
throw new Error(unsupportedDeliveryError);
}
const result = await gatewayManager.rpc('cron.add', gatewayInput);
// Transform the returned job to frontend format
if (result && typeof result === 'object') {
@@ -860,18 +979,22 @@ function registerCronHandlers(gatewayManager: GatewayManager): void {
// Update an existing cron job
ipcMain.handle('cron:update', async (_, id: string, input: Record<string, unknown>) => {
try {
// Transform schedule string to CronSchedule object if present
const patch = { ...input };
if (typeof patch.schedule === 'string') {
patch.schedule = { kind: 'cron', expr: patch.schedule };
}
// Transform message to payload format if present
if (typeof patch.message === 'string') {
patch.payload = { kind: 'agentTurn', message: patch.message };
delete patch.message;
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') {
throw new Error(unsupportedDeliveryError);
}
const result = await gatewayManager.rpc('cron.update', { id, patch });
return result;
return result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result;
} catch (error) {
console.error('Failed to update cron job:', error);
throw error;
@@ -1066,13 +1189,18 @@ function registerGatewayHandlers(
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const response = await proxyAwareFetch(`http://127.0.0.1:${port}${path}`, {
method,
headers,
body,
signal: controller.signal,
});
clearTimeout(timer);
const response = await (async () => {
try {
return await proxyAwareFetch(`http://127.0.0.1:${port}${path}`, {
method,
headers,
body,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
})();
const contentType = (response.headers.get('content-type') || '').toLowerCase();
if (contentType.includes('application/json')) {
@@ -1191,8 +1319,7 @@ function registerGatewayHandlers(
const status = gatewayManager.getStatus();
const token = await getSetting('gatewayToken');
const port = status.port || 18789;
// Pass token as query param - Control UI will store it in localStorage
const url = `http://127.0.0.1:${port}/?token=${encodeURIComponent(token)}`;
const url = buildOpenClawControlUiUrl(port, token);
return { success: true, url, port, token };
} catch (error) {
return { success: false, error: String(error) };
@@ -1258,55 +1385,33 @@ function registerGatewayHandlers(
* For checking package status and channel configuration
*/
function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk');
const targetManifest = join(targetDir, 'openclaw.plugin.json');
// Plugin-based channels require a full Gateway process restart to properly
// initialize / tear-down plugin connections. SIGUSR1 in-process reload is
// not sufficient for channel plugins (see restartGatewayForAgentDeletion).
const forceRestartChannels = new Set(['dingtalk', 'wecom', 'whatsapp', 'feishu', 'qqbot']);
if (existsSync(targetManifest)) {
logger.info('DingTalk plugin already installed from local mirror');
return { installed: true };
const scheduleGatewayChannelRestart = (reason: string): void => {
if (gatewayManager.getStatus().state !== 'stopped') {
logger.info(`Scheduling Gateway restart after ${reason}`);
gatewayManager.debouncedRestart();
} else {
logger.info(`Gateway is stopped; skip immediate restart after ${reason}`);
}
};
const candidateSources = app.isPackaged
? [
join(process.resourcesPath, 'openclaw-plugins', 'dingtalk'),
join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', 'dingtalk'),
join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', 'dingtalk')
]
: [
join(app.getAppPath(), 'build', 'openclaw-plugins', 'dingtalk'),
join(process.cwd(), 'build', 'openclaw-plugins', 'dingtalk'),
join(__dirname, '../../build/openclaw-plugins/dingtalk'),
];
const sourceDir = candidateSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json')));
if (!sourceDir) {
logger.warn('Bundled DingTalk plugin mirror not found in candidate paths', { candidateSources });
return {
installed: false,
warning: `Bundled DingTalk plugin mirror not found. Checked: ${candidateSources.join(' | ')}`,
};
const scheduleGatewayChannelSaveRefresh = (channelType: string, reason: string): void => {
if (gatewayManager.getStatus().state === 'stopped') {
logger.info(`Gateway is stopped; skip immediate refresh after ${reason}`);
return;
}
try {
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
rmSync(targetDir, { recursive: true, force: true });
cpSync(sourceDir, targetDir, { recursive: true, dereference: true });
if (!existsSync(targetManifest)) {
return { installed: false, warning: 'Failed to install DingTalk plugin mirror (manifest missing).' };
}
logger.info(`Installed DingTalk plugin from bundled mirror: ${sourceDir}`);
return { installed: true };
} catch (error) {
logger.warn('Failed to install DingTalk plugin from bundled mirror:', error);
return {
installed: false,
warning: 'Failed to install bundled DingTalk plugin mirror',
};
if (forceRestartChannels.has(channelType)) {
logger.info(`Scheduling Gateway restart after ${reason}`);
gatewayManager.debouncedRestart();
return;
}
}
logger.info(`Scheduling Gateway reload after ${reason}`);
gatewayManager.debouncedReload();
};
// Get OpenClaw package status
ipcMain.handle('openclaw:status', () => {
@@ -1370,12 +1475,55 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
};
}
await saveChannelConfig(channelType, config);
if (gatewayManager.getStatus().state !== 'stopped') {
logger.info(`Scheduling Gateway reload after channel:saveConfig (${channelType})`);
gatewayManager.debouncedReload();
} else {
logger.info(`Gateway is stopped; skip immediate reload after channel:saveConfig (${channelType})`);
scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`);
return {
success: true,
pluginInstalled: installResult.installed,
warning: installResult.warning,
};
}
if (channelType === 'wecom') {
const installResult = await ensureWeComPluginInstalled();
if (!installResult.installed) {
return {
success: false,
error: installResult.warning || 'WeCom plugin install failed',
};
}
await saveChannelConfig(channelType, config);
scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`);
return {
success: true,
pluginInstalled: installResult.installed,
warning: installResult.warning,
};
}
if (channelType === 'qqbot') {
const installResult = await ensureQQBotPluginInstalled();
if (!installResult.installed) {
return {
success: false,
error: installResult.warning || 'QQ Bot plugin install failed',
};
}
await saveChannelConfig(channelType, config);
scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`);
return {
success: true,
pluginInstalled: installResult.installed,
warning: installResult.warning,
};
}
if (channelType === 'feishu') {
const installResult = await ensureFeishuPluginInstalled();
if (!installResult.installed) {
return {
success: false,
error: installResult.warning || 'Feishu plugin install failed',
};
}
await saveChannelConfig(channelType, config);
scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`);
return {
success: true,
pluginInstalled: installResult.installed,
@@ -1383,12 +1531,7 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
};
}
await saveChannelConfig(channelType, config);
if (gatewayManager.getStatus().state !== 'stopped') {
logger.info(`Scheduling Gateway reload after channel:saveConfig (${channelType})`);
gatewayManager.debouncedReload();
} else {
logger.info(`Gateway is stopped; skip immediate reload after channel:saveConfig (${channelType})`);
}
scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`);
return { success: true };
} catch (error) {
console.error('Failed to save channel config:', error);
@@ -1422,12 +1565,7 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
ipcMain.handle('channel:deleteConfig', async (_, channelType: string) => {
try {
await deleteChannelConfig(channelType);
if (gatewayManager.getStatus().state !== 'stopped') {
logger.info(`Scheduling Gateway reload after channel:deleteConfig (${channelType})`);
gatewayManager.debouncedReload();
} else {
logger.info(`Gateway is stopped; skip immediate reload after channel:deleteConfig (${channelType})`);
}
scheduleGatewayChannelRestart(`channel:deleteConfig (${channelType})`);
return { success: true };
} catch (error) {
console.error('Failed to delete channel config:', error);
@@ -1450,12 +1588,7 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
ipcMain.handle('channel:setEnabled', async (_, channelType: string, enabled: boolean) => {
try {
await setChannelEnabled(channelType, enabled);
if (gatewayManager.getStatus().state !== 'stopped') {
logger.info(`Scheduling Gateway reload after channel:setEnabled (${channelType}, enabled=${enabled})`);
gatewayManager.debouncedReload();
} else {
logger.info(`Gateway is stopped; skip immediate reload after channel:setEnabled (${channelType})`);
}
scheduleGatewayChannelRestart(`channel:setEnabled (${channelType}, enabled=${enabled})`);
return { success: true };
} catch (error) {
console.error('Failed to set channel enabled:', error);
@@ -1556,7 +1689,7 @@ function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void {
) => {
try {
logger.info(`provider:requestOAuth for ${provider}`);
if (provider === 'google') {
if (provider === 'google' || provider === 'openai') {
await browserOAuthManager.startFlow(provider, options);
} else {
await deviceOAuthManager.startFlow(provider, region, options);
@@ -1587,12 +1720,18 @@ function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void {
*/
function registerProviderHandlers(gatewayManager: GatewayManager): void {
const providerService = getProviderService();
const legacyProviderChannelsWarned = new Set<string>();
const logLegacyProviderChannel = (channel: string): void => {
if (legacyProviderChannelsWarned.has(channel)) return;
legacyProviderChannelsWarned.add(channel);
logger.warn(
`[provider-migration] Legacy IPC channel "${channel}" is deprecated. Prefer app:request provider actions and account APIs.`,
);
};
// Listen for OAuth success to automatically restart the Gateway with new tokens/configs.
// Use a longer debounce (8s) so that provider:setDefault — which writes the full config
// and then calls debouncedRestart(2s) — has time to fire and coalesce into a single
// restart. Without this, the OAuth restart fires first with stale config, and the
// subsequent provider:setDefault restart is deferred and dropped.
// Keep a longer debounce (8s) so provider config writes and OAuth token persistence
// can settle before applying the process-level refresh.
deviceOAuthManager.on('oauth:success', ({ provider, accountId }) => {
logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`);
gatewayManager.debouncedRestart(8000);
@@ -1604,6 +1743,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
// Get all providers with key info
ipcMain.handle('provider:list', async () => {
logLegacyProviderChannel('provider:list');
return await providerService.listLegacyProvidersWithKeyInfo();
});
@@ -1622,11 +1762,13 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
// Get a specific provider
ipcMain.handle('provider:get', async (_, providerId: string) => {
logLegacyProviderChannel('provider:get');
return await providerService.getLegacyProvider(providerId);
});
// Save a provider configuration
ipcMain.handle('provider:save', async (_, config: ProviderConfig, apiKey?: string) => {
logLegacyProviderChannel('provider:save');
try {
// Save the provider config
await providerService.saveLegacyProvider(config);
@@ -1661,6 +1803,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
// Delete a provider
ipcMain.handle('provider:delete', async (_, providerId: string) => {
logLegacyProviderChannel('provider:delete');
try {
const existing = await providerService.getLegacyProvider(providerId);
await providerService.deleteLegacyProvider(providerId);
@@ -1682,6 +1825,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
// Update API key for a provider
ipcMain.handle('provider:setApiKey', async (_, providerId: string, apiKey: string) => {
logLegacyProviderChannel('provider:setApiKey');
try {
await providerService.setLegacyProviderApiKey(providerId, apiKey);
@@ -1709,6 +1853,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
updates: Partial<ProviderConfig>,
apiKey?: string
) => {
logLegacyProviderChannel('provider:updateWithKey');
const existing = await providerService.getLegacyProvider(providerId);
if (!existing) {
return { success: false, error: 'Provider not found' };
@@ -1769,6 +1914,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
// Delete API key for a provider
ipcMain.handle('provider:deleteApiKey', async (_, providerId: string) => {
logLegacyProviderChannel('provider:deleteApiKey');
try {
await providerService.deleteLegacyProviderApiKey(providerId);
@@ -1788,16 +1934,19 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
// Check if a provider has an API key
ipcMain.handle('provider:hasApiKey', async (_, providerId: string) => {
logLegacyProviderChannel('provider:hasApiKey');
return await providerService.hasLegacyProviderApiKey(providerId);
});
// Get the actual API key (for internal use only - be careful!)
ipcMain.handle('provider:getApiKey', async (_, providerId: string) => {
logLegacyProviderChannel('provider:getApiKey');
return await providerService.getLegacyProviderApiKey(providerId);
});
// Set default provider and update OpenClaw default model
ipcMain.handle('provider:setDefault', async (_, providerId: string) => {
logLegacyProviderChannel('provider:setDefault');
try {
await providerService.setDefaultLegacyProvider(providerId);
@@ -1818,6 +1967,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
// Get default provider
ipcMain.handle('provider:getDefault', async () => {
logLegacyProviderChannel('provider:getDefault');
return await providerService.getDefaultLegacyProvider();
});
@@ -1829,8 +1979,9 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
_,
providerId: string,
apiKey: string,
options?: { baseUrl?: string }
options?: { baseUrl?: string; apiProtocol?: string }
) => {
logLegacyProviderChannel('provider:validateKey');
try {
// First try to get existing provider
const provider = await providerService.getLegacyProvider(providerId);
@@ -1842,9 +1993,13 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
// Prefer caller-supplied baseUrl (live form value) over persisted config.
// This ensures Setup/Settings validation reflects unsaved edits immediately.
const resolvedBaseUrl = options?.baseUrl || provider?.baseUrl || registryBaseUrl;
const resolvedProtocol = options?.apiProtocol || provider?.apiProtocol;
console.log(`[clawx-validate] validating provider type: ${providerType}`);
return await validateApiKeyWithProvider(providerType, apiKey, { baseUrl: resolvedBaseUrl });
return await validateApiKeyWithProvider(providerType, apiKey, {
baseUrl: resolvedBaseUrl,
apiProtocol: resolvedProtocol,
});
} catch (error) {
console.error('Validation error:', error);
return { valid: false, error: String(error) };
@@ -1990,6 +2145,7 @@ function registerAppHandlers(): void {
function registerSettingsHandlers(gatewayManager: GatewayManager): void {
const handleProxySettingsChange = async () => {
const settings = await getAllSettings();
await syncProxyConfigToOpenClaw(settings, { preserveExistingWhenDisabled: false });
await applyProxySettings(settings);
if (gatewayManager.getStatus().state === 'running') {
await gatewayManager.restart();
@@ -2017,6 +2173,9 @@ function registerSettingsHandlers(gatewayManager: GatewayManager): void {
) {
await handleProxySettingsChange();
}
if (key === 'launchAtStartup') {
await syncLaunchAtStartupSettingFromStore();
}
return { success: true };
});
@@ -2037,6 +2196,9 @@ function registerSettingsHandlers(gatewayManager: GatewayManager): void {
)) {
await handleProxySettingsChange();
}
if (entries.some(([key]) => key === 'launchAtStartup')) {
await syncLaunchAtStartupSettingFromStore();
}
return { success: true };
});
@@ -2045,6 +2207,7 @@ function registerSettingsHandlers(gatewayManager: GatewayManager): void {
await resetSettings();
const settings = await getAllSettings();
await handleProxySettingsChange();
await syncLaunchAtStartupSettingFromStore();
return { success: true, settings };
});
}
@@ -2057,7 +2220,7 @@ function registerUsageHandlers(): void {
});
}
/**
* Window control handlers (for custom title bar on Windows/Linux)
* Window control handlers (for custom title bar on Windows)
*/
function registerWindowHandlers(mainWindow: BrowserWindow): void {
ipcMain.handle('window:minimize', () => {
@@ -2291,8 +2454,7 @@ function registerFileHandlers(): void {
* Performs a soft-delete of a session's JSONL transcript on disk.
* sessionKey format: "agent:<agentId>:<suffix>" — e.g. "agent:main:session-1234567890".
* The JSONL file lives at: ~/.openclaw/agents/<agentId>/sessions/<suffix>.jsonl
* Renaming to <suffix>.deleted.jsonl hides it from sessions.list and token-usage
* (both already filter out filenames containing ".deleted.").
* Renaming to <suffix>.deleted.jsonl hides it from sessions.list.
*/
function registerSessionHandlers(): void {
ipcMain.handle('session:delete', async (_, sessionKey: string) => {
@@ -2422,4 +2584,4 @@ function registerSessionHandlers(): void {
return { success: false, error: String(err) };
}
});
}
}
+74
View File
@@ -0,0 +1,74 @@
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),
},
};
}
});
}
+45
View File
@@ -0,0 +1,45 @@
import type { AppSettings } from '../../utils/store';
export type AppRequest = {
id?: string;
module: string;
action: string;
payload?: unknown;
};
export type AppErrorCode = 'VALIDATION' | 'PERMISSION' | 'TIMEOUT' | 'GATEWAY' | 'INTERNAL' | 'UNSUPPORTED';
export type AppResponse = {
id?: string;
ok: boolean;
data?: unknown;
error?: {
code: AppErrorCode;
message: string;
details?: unknown;
};
};
export function mapAppErrorCode(error: unknown): AppErrorCode {
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
if (msg.includes('timeout')) return 'TIMEOUT';
if (msg.includes('permission') || msg.includes('denied') || msg.includes('forbidden')) return 'PERMISSION';
if (msg.includes('gateway')) return 'GATEWAY';
if (msg.includes('invalid') || msg.includes('required')) return 'VALIDATION';
return 'INTERNAL';
}
export function isProxyKey(key: keyof AppSettings): boolean {
return (
key === 'proxyEnabled' ||
key === 'proxyServer' ||
key === 'proxyHttpServer' ||
key === 'proxyHttpsServer' ||
key === 'proxyAllServer' ||
key === 'proxyBypassRules'
);
}
export function isLaunchAtStartupKey(key: keyof AppSettings): boolean {
return key === 'launchAtStartup';
}
+85
View File
@@ -0,0 +1,85 @@
import { app } from 'electron';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { logger } from '../utils/logger';
import { getSetting } from '../utils/store';
const LINUX_AUTOSTART_FILE = join('.config', 'autostart', 'clawx.desktop');
function quoteDesktopArg(value: string): string {
if (!value) return '""';
const escaped = value.replace(/(["\\`$])/g, '\\$1');
if (/[\s"'\\`$]/.test(value)) {
return `"${escaped}"`;
}
return value;
}
function getLinuxExecCommand(): string {
if (app.isPackaged) {
return quoteDesktopArg(process.execPath);
}
const launchArgs = process.argv.slice(1).filter(Boolean);
const cmdParts = [process.execPath, ...launchArgs].map(quoteDesktopArg);
return cmdParts.join(' ');
}
function getLinuxDesktopEntry(): string {
return [
'[Desktop Entry]',
'Type=Application',
'Version=1.0',
'Name=ClawX',
'Comment=ClawX - AI Assistant',
`Exec=${getLinuxExecCommand()}`,
'Terminal=false',
'Categories=Utility;',
'X-GNOME-Autostart-enabled=true',
'',
].join('\n');
}
async function applyLinuxLaunchAtStartup(enabled: boolean): Promise<void> {
const targetPath = join(app.getPath('home'), LINUX_AUTOSTART_FILE);
if (enabled) {
await mkdir(dirname(targetPath), { recursive: true });
await writeFile(targetPath, getLinuxDesktopEntry(), 'utf8');
logger.info(`Launch-at-startup enabled via desktop entry: ${targetPath}`);
return;
}
await rm(targetPath, { force: true });
logger.info(`Launch-at-startup disabled and desktop entry removed: ${targetPath}`);
}
function applyWindowsOrMacLaunchAtStartup(enabled: boolean): void {
app.setLoginItemSettings({
openAtLogin: enabled,
openAsHidden: false,
});
logger.info(`Launch-at-startup ${enabled ? 'enabled' : 'disabled'} via login items`);
}
export async function applyLaunchAtStartupSetting(enabled: boolean): Promise<void> {
try {
if (process.platform === 'linux') {
await applyLinuxLaunchAtStartup(enabled);
return;
}
if (process.platform === 'win32' || process.platform === 'darwin') {
applyWindowsOrMacLaunchAtStartup(enabled);
return;
}
logger.warn(`Launch-at-startup unsupported on platform: ${process.platform}`);
} catch (error) {
logger.error(`Failed to apply launch-at-startup=${enabled}:`, error);
}
}
export async function syncLaunchAtStartupSettingFromStore(): Promise<void> {
const launchAtStartup = await getSetting('launchAtStartup');
await applyLaunchAtStartupSetting(Boolean(launchAtStartup));
}
+38
View File
@@ -0,0 +1,38 @@
export interface MainWindowFocusState {
pendingSecondInstanceFocus: boolean;
}
export type SecondInstanceFocusRequest = 'focus-now' | 'defer';
export type MainWindowReadyAction = 'show' | 'focus';
export function createMainWindowFocusState(): MainWindowFocusState {
return {
pendingSecondInstanceFocus: false,
};
}
export function requestSecondInstanceFocus(
state: MainWindowFocusState,
hasFocusableMainWindow: boolean,
): SecondInstanceFocusRequest {
if (hasFocusableMainWindow) {
state.pendingSecondInstanceFocus = false;
return 'focus-now';
}
state.pendingSecondInstanceFocus = true;
return 'defer';
}
export function consumeMainWindowReady(state: MainWindowFocusState): MainWindowReadyAction {
if (state.pendingSecondInstanceFocus) {
state.pendingSecondInstanceFocus = false;
return 'focus';
}
return 'show';
}
export function clearPendingSecondInstanceFocus(state: MainWindowFocusState): void {
state.pendingSecondInstanceFocus = false;
}
+206
View File
@@ -0,0 +1,206 @@
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const LOCK_SCHEMA = 'clawx-instance-lock';
const LOCK_VERSION = 1;
export interface ProcessInstanceFileLock {
acquired: boolean;
lockPath: string;
ownerPid?: number;
ownerFormat?: 'legacy' | 'structured' | 'unknown';
release: () => void;
}
export interface ProcessInstanceFileLockOptions {
userDataDir: string;
lockName: string;
pid?: number;
isPidAlive?: (pid: number) => boolean;
/**
* When true, unconditionally remove any existing lock file before attempting
* to acquire. Use this when an external mechanism (e.g. Electron's
* `requestSingleInstanceLock`) already guarantees that no other real instance
* is running, so a surviving lock file can only be stale (orphan child
* process, PID recycling on Windows, etc.).
*/
force?: boolean;
}
function defaultPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
const errno = (error as NodeJS.ErrnoException).code;
return errno !== 'ESRCH';
}
}
type ParsedLockOwner =
| { kind: 'legacy'; pid: number }
| { kind: 'structured'; pid: number }
| { kind: 'unknown' };
interface StructuredLockContent {
schema: string;
version: number;
pid: number;
}
function parsePositivePid(raw: string): number | undefined {
if (!/^\d+$/.test(raw)) {
return undefined;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return undefined;
}
return parsed;
}
function parseStructuredLockContent(raw: string): StructuredLockContent | undefined {
try {
const parsed = JSON.parse(raw) as Partial<StructuredLockContent>;
if (
parsed?.schema === LOCK_SCHEMA
&& parsed?.version === LOCK_VERSION
&& typeof parsed?.pid === 'number'
&& Number.isFinite(parsed.pid)
&& parsed.pid > 0
) {
return {
schema: parsed.schema,
version: parsed.version,
pid: parsed.pid,
};
}
} catch {
// ignore parse errors
}
return undefined;
}
function readLockOwner(lockPath: string): ParsedLockOwner {
try {
const raw = readFileSync(lockPath, 'utf8').trim();
const legacyPid = parsePositivePid(raw);
if (legacyPid !== undefined) {
return { kind: 'legacy', pid: legacyPid };
}
const structured = parseStructuredLockContent(raw);
if (structured) {
return { kind: 'structured', pid: structured.pid };
}
} catch {
// ignore read errors
}
return { kind: 'unknown' };
}
export function acquireProcessInstanceFileLock(
options: ProcessInstanceFileLockOptions,
): ProcessInstanceFileLock {
const pid = options.pid ?? process.pid;
const isPidAlive = options.isPidAlive ?? defaultPidAlive;
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';
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const fd = openSync(lockPath, 'wx');
try {
// Keep writing legacy numeric format for broad backward compatibility.
// Parser accepts both legacy numeric and structured JSON formats.
writeFileSync(fd, String(pid), 'utf8');
} finally {
closeSync(fd);
}
let released = false;
return {
acquired: true,
lockPath,
release: () => {
if (released) return;
released = true;
try {
const currentOwner = readLockOwner(lockPath);
if (
(currentOwner.kind === 'legacy' || currentOwner.kind === 'structured')
&& currentOwner.pid !== pid
) {
return;
}
if (currentOwner.kind === 'unknown') {
return;
}
rmSync(lockPath, { force: true });
} catch {
// best-effort
}
},
};
} catch (error) {
const errno = (error as NodeJS.ErrnoException).code;
if (errno !== 'EEXIST') {
break;
}
const owner = readLockOwner(lockPath);
if (owner.kind === 'legacy' || owner.kind === 'structured') {
ownerPid = owner.pid;
ownerFormat = owner.kind;
} else {
ownerPid = undefined;
ownerFormat = 'unknown';
}
const shouldTreatAsStale =
(owner.kind === 'legacy' || owner.kind === 'structured')
&& !isPidAlive(owner.pid);
if (shouldTreatAsStale && existsSync(lockPath)) {
try {
rmSync(lockPath, { force: true });
continue;
} catch {
// If deletion fails, treat as held lock.
}
}
break;
}
}
return {
acquired: false,
lockPath,
ownerPid,
ownerFormat,
release: () => {
// no-op when lock wasn't acquired
},
};
}
+48
View File
@@ -0,0 +1,48 @@
import { getProviderConfig } from '../utils/provider-registry';
import { getOpenClawProviderKeyForType, isOAuthProviderType } from '../utils/provider-keys';
import type { ProviderConfig } from '../utils/secure-storage';
export interface AgentProviderUpdatePayload {
providerKey: string;
entry: {
baseUrl: string;
api: string;
apiKey: string | undefined;
models: Array<{ id: string; name: string }>;
};
}
export function getModelIdFromRef(modelRef: string | undefined, providerKey: string): string | undefined {
if (!modelRef) return undefined;
if (modelRef.startsWith(`${providerKey}/`)) {
return modelRef.slice(providerKey.length + 1);
}
return modelRef;
}
export function buildNonOAuthAgentProviderUpdate(
provider: ProviderConfig,
providerId: string,
modelRef: string | undefined
): AgentProviderUpdatePayload | null {
if (provider.type === 'custom' || provider.type === 'ollama' || isOAuthProviderType(provider.type)) {
return null;
}
const providerKey = getOpenClawProviderKeyForType(provider.type, providerId);
const meta = getProviderConfig(provider.type);
const baseUrl = provider.baseUrl || meta?.baseUrl;
const api = meta?.api;
if (!baseUrl || !api) return null;
const modelId = getModelIdFromRef(modelRef, providerKey);
return {
providerKey,
entry: {
baseUrl,
api,
apiKey: meta?.apiKeyEnv,
models: modelId ? [{ id: modelId, name: modelId }] : [],
},
};
}
+30
View File
@@ -0,0 +1,30 @@
export interface QuitLifecycleState {
cleanupStarted: boolean;
cleanupCompleted: boolean;
}
export type QuitLifecycleAction = 'start-cleanup' | 'cleanup-in-progress' | 'allow-quit';
export function createQuitLifecycleState(): QuitLifecycleState {
return {
cleanupStarted: false,
cleanupCompleted: false,
};
}
export function requestQuitLifecycleAction(state: QuitLifecycleState): QuitLifecycleAction {
if (state.cleanupCompleted) {
return 'allow-quit';
}
if (state.cleanupStarted) {
return 'cleanup-in-progress';
}
state.cleanupStarted = true;
return 'start-cleanup';
}
export function markQuitCleanupCompleted(state: QuitLifecycleState): void {
state.cleanupCompleted = true;
}
+11
View File
@@ -0,0 +1,11 @@
export interface SignalQuitHandlerHooks {
logInfo: (message: string) => void;
requestQuit: () => void;
}
export function createSignalQuitHandler(hooks: SignalQuitHandlerHooks): (signal: NodeJS.Signals) => void {
return (signal: NodeJS.Signals) => {
hooks.logInfo(`Received ${signal}; requesting app quit`);
hooks.requestQuit();
};
}
+1 -9
View File
@@ -90,20 +90,12 @@ export function createTray(mainWindow: BrowserWindow): Tray {
{
label: 'Quick Actions',
submenu: [
{
label: 'Open Dashboard',
click: () => {
if (mainWindow.isDestroyed()) return;
mainWindow.show();
mainWindow.webContents.send('navigate', '/');
},
},
{
label: 'Open Chat',
click: () => {
if (mainWindow.isDestroyed()) return;
mainWindow.show();
mainWindow.webContents.send('navigate', '/chat');
mainWindow.webContents.send('navigate', '/');
},
},
{
+6
View File
@@ -52,6 +52,12 @@ export class AppUpdater extends EventEmitter {
constructor() {
super();
// EventEmitter treats an unhandled 'error' event as fatal. Keep a default
// listener so updater failures surface in logs/UI without terminating main.
this.on('error', (error: Error) => {
logger.error('[Updater] AppUpdater emitted error:', error);
});
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
+11
View File
@@ -22,6 +22,8 @@ const electronAPI = {
'gateway:restart',
'gateway:rpc',
'gateway:httpProxy',
'hostapi:fetch',
'hostapi:token',
'gateway:health',
'gateway:getControlUiUrl',
// OpenClaw
@@ -158,6 +160,9 @@ const electronAPI = {
'channel:whatsapp-qr',
'channel:whatsapp-success',
'channel:whatsapp-error',
'channel:wechat-qr',
'channel:wechat-success',
'channel:wechat-error',
'gateway:exit',
'gateway:error',
'navigate',
@@ -202,6 +207,12 @@ const electronAPI = {
'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',
@@ -6,7 +6,7 @@ import {
} from './provider-store';
import { getClawXProviderStore } from './store-instance';
const PROVIDER_STORE_SCHEMA_VERSION = 1;
const PROVIDER_STORE_SCHEMA_VERSION = 2;
export async function ensureProviderStoreMigrated(): Promise<void> {
const store = await getClawXProviderStore();
@@ -16,19 +16,31 @@ export async function ensureProviderStoreMigrated(): Promise<void> {
return;
}
const legacyProviders = (store.get('providers') ?? {}) as Record<string, ProviderConfig>;
const defaultProviderId = (store.get('defaultProvider') ?? null) as string | null;
const existingDefaultAccountId = await getDefaultProviderAccountId();
// v0 → v1: migrate legacy `providers` entries to `providerAccounts`.
if (schemaVersion < 1) {
const legacyProviders = (store.get('providers') ?? {}) as Record<string, ProviderConfig>;
const defaultProviderId = (store.get('defaultProvider') ?? null) as string | null;
const existingDefaultAccountId = await getDefaultProviderAccountId();
for (const provider of Object.values(legacyProviders)) {
const account = providerConfigToAccount(provider, {
isDefault: provider.id === defaultProviderId,
});
await saveProviderAccount(account);
for (const provider of Object.values(legacyProviders)) {
const account = providerConfigToAccount(provider, {
isDefault: provider.id === defaultProviderId,
});
await saveProviderAccount(account);
}
if (!existingDefaultAccountId && defaultProviderId) {
store.set('defaultProviderAccountId', defaultProviderId);
}
}
if (!existingDefaultAccountId && defaultProviderId) {
store.set('defaultProviderAccountId', defaultProviderId);
// v1 → v2: clear the legacy `providers` store.
// The old `saveProvider()` was duplicating entries into this store, causing
// phantom and duplicate accounts when the migration above re-runs.
// Now that createAccount/updateAccount no longer write to `providers`,
// we clear it to prevent stale entries from causing issues.
if (schemaVersion < 2) {
store.set('providers', {});
}
store.set('schemaVersion', PROVIDER_STORE_SCHEMA_VERSION);
@@ -12,14 +12,68 @@ import {
setOpenClawDefaultModelWithOverride,
syncProviderConfigToOpenClaw,
updateAgentModelProvider,
updateSingleAgentModelProvider,
} from '../../utils/openclaw-auth';
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`;
type RuntimeProviderSyncContext = {
runtimeProviderKey: string;
meta: ReturnType<typeof getProviderConfig>;
api: string;
};
function normalizeProviderBaseUrl(
config: ProviderConfig,
baseUrl?: string,
apiProtocol?: string,
): string | undefined {
if (!baseUrl) {
return undefined;
}
const normalized = baseUrl.trim().replace(/\/+$/, '');
if (config.type === 'minimax-portal' || config.type === 'minimax-portal-cn') {
return normalized.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
}
if (config.type === 'custom' || config.type === 'ollama') {
const protocol = apiProtocol || config.apiProtocol || 'openai-completions';
if (protocol === 'openai-responses') {
return normalized.replace(/\/responses?$/i, '');
}
if (protocol === 'openai-completions') {
return normalized.replace(/\/chat\/completions$/i, '');
}
if (protocol === 'anthropic-messages') {
return normalized.replace(/\/v1\/messages$/i, '').replace(/\/messages$/i, '');
}
}
return normalized;
}
function shouldUseExplicitDefaultOverride(config: ProviderConfig, runtimeProviderKey: string): boolean {
return Boolean(config.baseUrl || config.apiProtocol || runtimeProviderKey !== config.type);
}
export function getOpenClawProviderKey(type: string, providerId: string): string {
if (type === 'custom' || type === 'ollama') {
// 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}`;
}
@@ -31,20 +85,35 @@ export function getOpenClawProviderKey(type: string, providerId: string): string
async function resolveRuntimeProviderKey(config: ProviderConfig): Promise<string> {
const account = await getProviderAccount(config.id);
if (config.type === 'google' && account?.authMode === 'oauth_browser') {
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
if (account?.authMode === 'oauth_browser') {
if (config.type === 'google') {
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
}
if (config.type === 'openai') {
return OPENAI_OAUTH_RUNTIME_PROVIDER;
}
}
return getOpenClawProviderKey(config.type, config.id);
}
async function isGoogleBrowserOAuthProvider(config: ProviderConfig): Promise<boolean> {
async function getBrowserOAuthRuntimeProvider(config: ProviderConfig): Promise<string | null> {
const account = await getProviderAccount(config.id);
if (config.type !== 'google' || account?.authMode !== 'oauth_browser') {
return false;
if (account?.authMode !== 'oauth_browser') {
return null;
}
const secret = await getProviderSecret(config.id);
return secret?.type === 'oauth';
if (secret?.type !== 'oauth') {
return null;
}
if (config.type === 'google') {
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
}
if (config.type === 'openai') {
return OPENAI_OAUTH_RUNTIME_PROVIDER;
}
return null;
}
export function getProviderModelRef(config: ProviderConfig): string | undefined {
@@ -102,10 +171,12 @@ export async function getProviderFallbackModelRefs(config: ProviderConfig): Prom
return results;
}
function scheduleGatewayRestart(
type GatewayRefreshMode = 'reload' | 'restart';
function scheduleGatewayRefresh(
gatewayManager: GatewayManager | undefined,
message: string,
options?: { delayMs?: number; onlyIfRunning?: boolean },
options?: { delayMs?: number; onlyIfRunning?: boolean; mode?: GatewayRefreshMode },
): void {
if (!gatewayManager) {
return;
@@ -116,7 +187,11 @@ function scheduleGatewayRestart(
}
logger.info(message);
gatewayManager.debouncedRestart(options?.delayMs);
if (options?.mode === 'restart') {
gatewayManager.debouncedRestart(options?.delayMs);
return;
}
gatewayManager.debouncedReload(options?.delayMs);
}
export async function syncProviderApiKeyToRuntime(
@@ -172,63 +247,235 @@ export async function syncAllProviderAuthToRuntime(): Promise<void> {
}
}
export async function syncSavedProviderToRuntime(
async function syncProviderSecretToRuntime(
config: ProviderConfig,
runtimeProviderKey: string,
apiKey: string | undefined,
gatewayManager?: GatewayManager,
): Promise<void> {
const ock = await resolveRuntimeProviderKey(config);
const secret = await getProviderSecret(config.id);
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await saveProviderKeyToOpenClaw(ock, trimmedKey);
await saveProviderKeyToOpenClaw(runtimeProviderKey, trimmedKey);
}
} else if (secret?.type === 'api_key') {
await saveProviderKeyToOpenClaw(ock, secret.apiKey);
} else if (secret?.type === 'oauth') {
await saveOAuthTokenToOpenClaw(ock, {
return;
}
if (secret?.type === 'api_key') {
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
return;
}
if (secret?.type === 'oauth') {
await saveOAuthTokenToOpenClaw(runtimeProviderKey, {
access: secret.accessToken,
refresh: secret.refreshToken,
expires: secret.expiresAt,
email: secret.email,
projectId: secret.subject,
});
} else if (secret?.type === 'local' && secret.apiKey) {
await saveProviderKeyToOpenClaw(ock, secret.apiKey);
}
const meta = getProviderConfig(config.type);
const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api;
if (!api) {
return;
}
await syncProviderConfigToOpenClaw(ock, config.model, {
baseUrl: config.baseUrl || meta?.baseUrl,
api,
apiKeyEnv: meta?.apiKeyEnv,
headers: meta?.headers,
});
if (secret?.type === 'local' && secret.apiKey) {
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
}
}
if (config.type === 'custom' || config.type === 'ollama') {
const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id);
if (resolvedKey && config.baseUrl) {
const modelId = config.model;
await updateAgentModelProvider(ock, {
baseUrl: config.baseUrl,
api: 'openai-completions',
models: modelId ? [{ id: modelId, name: modelId }] : [],
apiKey: resolvedKey,
});
async function resolveRuntimeSyncContext(config: ProviderConfig): Promise<RuntimeProviderSyncContext | null> {
const runtimeProviderKey = await resolveRuntimeProviderKey(config);
const meta = getProviderConfig(config.type);
const api = config.apiProtocol || (config.type === 'custom' ? 'openai-completions' : meta?.api);
if (!api) {
return null;
}
return {
runtimeProviderKey,
meta,
api,
};
}
async function syncRuntimeProviderConfig(
config: ProviderConfig,
context: RuntimeProviderSyncContext,
): Promise<void> {
await syncProviderConfigToOpenClaw(context.runtimeProviderKey, config.model, {
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl, context.api),
api: context.api,
apiKeyEnv: context.meta?.apiKeyEnv,
headers: config.headers ?? context.meta?.headers,
});
}
async function syncCustomProviderAgentModel(
config: ProviderConfig,
runtimeProviderKey: string,
apiKey: string | undefined,
): Promise<void> {
if (config.type !== 'custom') {
return;
}
const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id);
if (!resolvedKey || !config.baseUrl) {
return;
}
const modelId = config.model;
await updateAgentModelProvider(runtimeProviderKey, {
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl, config.apiProtocol || 'openai-completions'),
api: config.apiProtocol || 'openai-completions',
models: modelId ? [{ id: modelId, name: modelId }] : [],
apiKey: resolvedKey,
});
}
async function syncProviderToRuntime(
config: ProviderConfig,
apiKey: string | undefined,
): Promise<RuntimeProviderSyncContext | null> {
const context = await resolveRuntimeSyncContext(config);
if (!context) {
return null;
}
await syncProviderSecretToRuntime(config, context.runtimeProviderKey, apiKey);
await syncRuntimeProviderConfig(config, context);
await syncCustomProviderAgentModel(config, context.runtimeProviderKey, apiKey);
return context;
}
function parseModelRef(modelRef: string): { providerKey: string; modelId: string } | null {
const trimmed = modelRef.trim();
const separatorIndex = trimmed.indexOf('/');
if (separatorIndex <= 0 || separatorIndex >= trimmed.length - 1) {
return null;
}
return {
providerKey: trimmed.slice(0, separatorIndex),
modelId: trimmed.slice(separatorIndex + 1),
};
}
async function buildRuntimeProviderConfigMap(): Promise<Map<string, ProviderConfig>> {
const configs = await getAllProviders();
const runtimeMap = new Map<string, ProviderConfig>();
for (const config of configs) {
const runtimeKey = await resolveRuntimeProviderKey(config);
runtimeMap.set(runtimeKey, config);
}
return runtimeMap;
}
async function buildAgentModelProviderEntry(
config: ProviderConfig,
modelId: string,
): Promise<{
baseUrl?: string;
api?: string;
models?: Array<{ id: string; name: string }>;
apiKey?: string;
authHeader?: boolean;
} | null> {
const meta = getProviderConfig(config.type);
const api = config.apiProtocol || (config.type === 'custom' ? 'openai-completions' : meta?.api);
const baseUrl = normalizeProviderBaseUrl(config, config.baseUrl || meta?.baseUrl, api);
if (!api || !baseUrl) {
return null;
}
let apiKey: string | undefined;
let authHeader: boolean | undefined;
if (config.type === 'custom') {
apiKey = (await getApiKey(config.id)) || undefined;
} else if (config.type === 'minimax-portal' || config.type === 'minimax-portal-cn') {
const accountApiKey = await getApiKey(config.id);
if (accountApiKey) {
apiKey = accountApiKey;
} else {
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';
}
}
scheduleGatewayRestart(
return {
baseUrl,
api,
models: [{ id: modelId, name: modelId }],
apiKey,
authHeader,
};
}
async function syncAgentModelsToRuntime(agentIds?: Set<string>): Promise<void> {
const snapshot = await listAgentsSnapshot();
const runtimeProviderConfigs = await buildRuntimeProviderConfigMap();
const targets = snapshot.agents.filter((agent) => {
if (!agent.modelRef) return false;
if (!agentIds) return true;
return agentIds.has(agent.id);
});
for (const agent of targets) {
const parsed = parseModelRef(agent.modelRef || '');
if (!parsed) {
continue;
}
const providerConfig = runtimeProviderConfigs.get(parsed.providerKey);
if (!providerConfig) {
logger.warn(
`[provider-runtime] No provider account mapped to runtime key "${parsed.providerKey}" for agent "${agent.id}"`,
);
continue;
}
const entry = await buildAgentModelProviderEntry(providerConfig, parsed.modelId);
if (!entry) {
continue;
}
await updateSingleAgentModelProvider(agent.id, parsed.providerKey, entry);
}
}
export async function syncAgentModelOverrideToRuntime(agentId: string): Promise<void> {
await syncAgentModelsToRuntime(new Set([agentId]));
}
export async function syncSavedProviderToRuntime(
config: ProviderConfig,
apiKey: string | undefined,
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);
}
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway restart after saving provider "${ock}" config`,
`Scheduling Gateway reload after saving provider "${context.runtimeProviderKey}" config`,
);
}
@@ -237,71 +484,46 @@ export async function syncUpdatedProviderToRuntime(
apiKey: string | undefined,
gatewayManager?: GatewayManager,
): Promise<void> {
const ock = await resolveRuntimeProviderKey(config);
const fallbackModels = await getProviderFallbackModelRefs(config);
const meta = getProviderConfig(config.type);
const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api;
const secret = await getProviderSecret(config.id);
if (!api) {
const context = await syncProviderToRuntime(config, apiKey);
if (!context) {
return;
}
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await saveProviderKeyToOpenClaw(ock, trimmedKey);
}
} else if (secret?.type === 'api_key') {
await saveProviderKeyToOpenClaw(ock, secret.apiKey);
} else if (secret?.type === 'oauth') {
await saveOAuthTokenToOpenClaw(ock, {
access: secret.accessToken,
refresh: secret.refreshToken,
expires: secret.expiresAt,
email: secret.email,
projectId: secret.subject,
});
} else if (secret?.type === 'local' && secret.apiKey) {
await saveProviderKeyToOpenClaw(ock, secret.apiKey);
}
await syncProviderConfigToOpenClaw(ock, config.model, {
baseUrl: config.baseUrl || meta?.baseUrl,
api,
apiKeyEnv: meta?.apiKeyEnv,
headers: meta?.headers,
});
if (config.type === 'custom' || config.type === 'ollama') {
const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id);
if (resolvedKey && config.baseUrl) {
const modelId = config.model;
await updateAgentModelProvider(ock, {
baseUrl: config.baseUrl,
api: 'openai-completions',
models: modelId ? [{ id: modelId, name: modelId }] : [],
apiKey: resolvedKey,
});
}
}
const ock = context.runtimeProviderKey;
const fallbackModels = await getProviderFallbackModelRefs(config);
const defaultProviderId = await getDefaultProvider();
if (defaultProviderId === config.id) {
const modelOverride = config.model ? `${ock}/${config.model}` : undefined;
if (config.type !== 'custom' && config.type !== 'ollama') {
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
if (config.type !== 'custom') {
if (shouldUseExplicitDefaultOverride(config, ock)) {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl, context.api),
api: context.api,
apiKeyEnv: context.meta?.apiKeyEnv,
headers: config.headers ?? context.meta?.headers,
}, fallbackModels);
} else {
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
}
} else {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: config.baseUrl,
api: 'openai-completions',
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl, config.apiProtocol || 'openai-completions'),
api: config.apiProtocol || 'openai-completions',
headers: config.headers,
}, fallbackModels);
}
}
scheduleGatewayRestart(
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider update:', err);
}
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway restart after updating provider "${ock}" config`,
`Scheduling Gateway reload after updating provider "${ock}" config`,
);
}
@@ -318,9 +540,10 @@ export async function syncDeletedProviderToRuntime(
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
await removeProviderFromOpenClaw(ock);
scheduleGatewayRestart(
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway restart after deleting provider "${ock}"`,
{ mode: 'restart' },
);
}
@@ -350,18 +573,30 @@ export async function syncDefaultProviderToRuntime(
const providerKey = await getApiKey(providerId);
const fallbackModels = await getProviderFallbackModelRefs(provider);
const oauthTypes = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
const isGoogleOAuthProvider = await isGoogleBrowserOAuthProvider(provider);
const isOAuthProvider = (oauthTypes.includes(provider.type) && !providerKey) || isGoogleOAuthProvider;
const browserOAuthRuntimeProvider = await getBrowserOAuthRuntimeProvider(provider);
const isOAuthProvider = (oauthTypes.includes(provider.type) && !providerKey) || Boolean(browserOAuthRuntimeProvider);
if (!isOAuthProvider) {
const modelOverride = provider.model
? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`)
: undefined;
if (provider.type === 'custom' || provider.type === 'ollama') {
if (provider.type === 'custom') {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: provider.baseUrl,
api: 'openai-completions',
baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'),
api: provider.apiProtocol || 'openai-completions',
headers: provider.headers,
}, fallbackModels);
} else if (shouldUseExplicitDefaultOverride(provider, ock)) {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: normalizeProviderBaseUrl(
provider,
provider.baseUrl || getProviderConfig(provider.type)?.baseUrl,
provider.apiProtocol || getProviderConfig(provider.type)?.api,
),
api: provider.apiProtocol || getProviderConfig(provider.type)?.api,
apiKeyEnv: getProviderConfig(provider.type)?.apiKeyEnv,
headers: provider.headers ?? getProviderConfig(provider.type)?.headers,
}, fallbackModels);
} else {
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
@@ -371,10 +606,10 @@ export async function syncDefaultProviderToRuntime(
await saveProviderKeyToOpenClaw(ock, providerKey);
}
} else {
if (isGoogleOAuthProvider) {
if (browserOAuthRuntimeProvider) {
const secret = await getProviderSecret(provider.id);
if (secret?.type === 'oauth') {
await saveOAuthTokenToOpenClaw(GOOGLE_OAUTH_RUNTIME_PROVIDER, {
await saveOAuthTokenToOpenClaw(browserOAuthRuntimeProvider, {
access: secret.accessToken,
refresh: secret.refreshToken,
expires: secret.expiresAt,
@@ -383,17 +618,25 @@ export async function syncDefaultProviderToRuntime(
});
}
const defaultModelRef = browserOAuthRuntimeProvider === GOOGLE_OAUTH_RUNTIME_PROVIDER
? GOOGLE_OAUTH_DEFAULT_MODEL_REF
: OPENAI_OAUTH_DEFAULT_MODEL_REF;
const modelOverride = provider.model
? (provider.model.startsWith(`${GOOGLE_OAUTH_RUNTIME_PROVIDER}/`)
? (provider.model.startsWith(`${browserOAuthRuntimeProvider}/`)
? provider.model
: `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/${provider.model}`)
: GOOGLE_OAUTH_DEFAULT_MODEL_REF;
: `${browserOAuthRuntimeProvider}/${provider.model}`)
: defaultModelRef;
await setOpenClawDefaultModel(GOOGLE_OAUTH_RUNTIME_PROVIDER, modelOverride, fallbackModels);
logger.info(`Configured openclaw.json for Google browser OAuth provider "${provider.id}"`);
scheduleGatewayRestart(
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 restart after provider switch to "${GOOGLE_OAUTH_RUNTIME_PROVIDER}"`,
`Scheduling Gateway reload after provider switch to "${browserOAuthRuntimeProvider}"`,
);
return;
}
@@ -439,22 +682,28 @@ export async function syncDefaultProviderToRuntime(
}
if (
(provider.type === 'custom' || provider.type === 'ollama') &&
provider.type === 'custom' &&
providerKey &&
provider.baseUrl
) {
const modelId = provider.model;
await updateAgentModelProvider(ock, {
baseUrl: provider.baseUrl,
api: 'openai-completions',
baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'),
api: provider.apiProtocol || 'openai-completions',
models: modelId ? [{ id: modelId, name: modelId }] : [],
apiKey: providerKey,
});
}
scheduleGatewayRestart(
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after default provider switch:', err);
}
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway restart after provider switch to "${ock}"`,
`Scheduling Gateway reload after provider switch to "${ock}"`,
{ onlyIfRunning: true },
);
}
+246 -23
View File
@@ -6,9 +6,12 @@ import type {
ProviderAccount,
ProviderConfig,
ProviderDefinition,
ProviderType,
} from '../../shared/providers/types';
import { BUILTIN_PROVIDER_TYPES } from '../../shared/providers/types';
import { ensureProviderStoreMigrated } from './provider-migration';
import {
deleteProviderAccount,
getDefaultProviderAccountId,
getProviderAccount,
listProviderAccounts,
@@ -20,17 +23,35 @@ import {
import {
deleteApiKey,
deleteProvider,
getAllProviders,
getAllProvidersWithKeyInfo,
getApiKey,
getDefaultProvider,
getProvider,
hasApiKey,
saveProvider,
setDefaultProvider,
storeApiKey,
} from '../../utils/secure-storage';
import { getActiveOpenClawProviders, getOpenClawProvidersConfig } from '../../utils/openclaw-auth';
import { getAliasSourceTypes, getOpenClawProviderKeyForType } from '../../utils/provider-keys';
import type { ProviderWithKeyInfo } from '../../shared/providers/types';
import { logger } from '../../utils/logger';
function maskApiKey(apiKey: string | null): string | null {
if (!apiKey) return null;
if (apiKey.length > 12) {
return `${apiKey.substring(0, 4)}${'*'.repeat(apiKey.length - 8)}${apiKey.substring(apiKey.length - 4)}`;
}
return '*'.repeat(apiKey.length);
}
const legacyProviderApiWarned = new Set<string>();
function logLegacyProviderApiUsage(method: string, replacement: string): void {
if (legacyProviderApiWarned.has(method)) {
return;
}
legacyProviderApiWarned.add(method);
logger.warn(
`[provider-migration] Legacy provider API "${method}" is deprecated. Migrate to "${replacement}".`,
);
}
export class ProviderService {
async listVendors(): Promise<ProviderDefinition[]> {
@@ -39,7 +60,149 @@ export class ProviderService {
async listAccounts(): Promise<ProviderAccount[]> {
await ensureProviderStoreMigrated();
return listProviderAccounts();
// ── openclaw.json is the ONLY source of truth ──
// The provider list is derived entirely from openclaw.json.
// The electron-store is only used as a metadata cache (label, authMode, etc.).
const { providers: openClawProviders, defaultModel } = await getOpenClawProvidersConfig();
const activeProviders = await getActiveOpenClawProviders();
if (activeProviders.size === 0) {
return [];
}
// Read store accounts as a lookup cache (NOT as the source of what to display).
const allStoreAccounts = await listProviderAccounts();
// 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 group = storeByKey.get(ock) ?? [];
group.push(account);
storeByKey.set(ock, group);
}
const result: ProviderAccount[] = [];
const processedKeys = new Set<string>();
// For each active provider in openclaw.json, produce exactly ONE account.
for (const key of activeProviders) {
if (processedKeys.has(key)) continue;
processedKeys.add(key);
const storeGroup = storeByKey.get(key) ?? [];
if (storeGroup.length > 0) {
// Pick the best store account for this key:
// 1. Prefer alias variants (e.g. minimax-portal-cn over minimax-portal)
// 2. Among equal variants, prefer the most recently updated
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];
for (const account of storeGroup) {
if (account.id !== kept.id) {
logger.info(
`[provider-sync] Removing orphaned account "${account.id}" for key "${key}" (keeping "${kept.id}")`,
);
await deleteProviderAccount(account.id);
}
}
} else {
// No store account for this key — create a seed from openclaw.json.
const entry = openClawProviders[key];
if (entry) {
const seeded = ProviderService.buildAccountsFromOpenClawEntries(
{ [key]: entry },
new Set(),
new Set(),
defaultModel,
);
for (const account of seeded) {
await saveProviderAccount(account);
result.push(account);
logger.info(`[provider-sync] Seeded provider account "${account.id}" from openclaw.json`);
}
}
}
}
return result;
}
/**
* Build ProviderAccount objects from OpenClaw config entries, skipping any
* whose id or vendorId is already represented by an existing account.
*/
static buildAccountsFromOpenClawEntries(
providers: Record<string, Record<string, unknown>>,
existingIds: Set<string>,
existingVendorIds: Set<string>,
defaultModel: string | undefined,
): ProviderAccount[] {
const defaultModelProvider = defaultModel?.includes('/')
? defaultModel.split('/')[0]
: undefined;
const now = new Date().toISOString();
const built: ProviderAccount[] = [];
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';
// Skip if an account with this vendorId already exists (e.g. user already
// created "openrouter-uuid" via UI — no need to import bare "openrouter").
if (existingVendorIds.has(vendorId)) continue;
// Skip if an alias source type already exists.
// e.g. openclaw.json has "minimax-portal" but account vendorId is "minimax-portal-cn"
const aliasSources = getAliasSourceTypes(key);
if (aliasSources.some((source) => existingVendorIds.has(source))) {
continue;
}
const baseUrl = typeof entry.baseUrl === 'string' ? entry.baseUrl : definition?.providerConfig?.baseUrl;
// Infer model from the default model if it belongs to this provider
let model: string | undefined;
if (defaultModelProvider === key && defaultModel) {
model = defaultModel;
} else if (definition?.defaultModelId) {
model = definition.defaultModelId;
}
const account: ProviderAccount = {
id: key,
vendorId: (vendorId as ProviderAccount['vendorId'] as ProviderType),
label: definition?.name ?? key.charAt(0).toUpperCase() + key.slice(1),
authMode: definition?.defaultAuthMode ?? 'api_key',
baseUrl,
apiProtocol: definition?.providerConfig?.api,
headers: (entry.headers && typeof entry.headers === 'object'
? (entry.headers as Record<string, string>)
: undefined),
model,
enabled: true,
isDefault: false,
createdAt: now,
updatedAt: now,
};
built.push(account);
}
return built;
}
async getAccount(accountId: string): Promise<ProviderAccount | null> {
@@ -49,12 +212,13 @@ export class ProviderService {
async getDefaultAccountId(): Promise<string | undefined> {
await ensureProviderStoreMigrated();
return (await getDefaultProvider()) ?? getDefaultProviderAccountId();
return getDefaultProviderAccountId();
}
async createAccount(account: ProviderAccount, apiKey?: string): Promise<ProviderAccount> {
await ensureProviderStoreMigrated();
await saveProvider(providerAccountToConfig(account));
// Only save to providerAccounts store — do NOT call saveProvider() which
// writes to the legacy `providers` store and causes phantom/duplicate issues.
await saveProviderAccount(account);
if (apiKey !== undefined && apiKey.trim()) {
await storeApiKey(account.id, apiKey.trim());
@@ -80,7 +244,7 @@ export class ProviderService {
updatedAt: patch.updatedAt ?? new Date().toISOString(),
};
await saveProvider(providerAccountToConfig(nextAccount));
// Only save to providerAccounts store — skip legacy saveProvider().
await saveProviderAccount(nextAccount);
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
@@ -99,54 +263,113 @@ export class ProviderService {
return deleteProvider(accountId);
}
async syncLegacyProvider(config: ProviderConfig, options?: { isDefault?: boolean }): Promise<ProviderAccount> {
await ensureProviderStoreMigrated();
const account = providerConfigToAccount(config, options);
await saveProviderAccount(account);
return account;
}
/**
* @deprecated Use listAccounts() and map account data in callers.
*/
async listLegacyProviders(): Promise<ProviderConfig[]> {
return getAllProviders();
logLegacyProviderApiUsage('listLegacyProviders', 'listAccounts');
const accounts = await this.listAccounts();
return accounts.map(providerAccountToConfig);
}
/**
* @deprecated Use listAccounts() + secret-store based key summary.
*/
async listLegacyProvidersWithKeyInfo(): Promise<ProviderWithKeyInfo[]> {
return getAllProvidersWithKeyInfo();
logLegacyProviderApiUsage('listLegacyProvidersWithKeyInfo', 'listAccounts');
const providers = await this.listLegacyProviders();
const results: ProviderWithKeyInfo[] = [];
for (const provider of providers) {
const apiKey = await getApiKey(provider.id);
results.push({
...provider,
hasKey: !!apiKey,
keyMasked: maskApiKey(apiKey),
});
}
return results;
}
/**
* @deprecated Use getAccount(accountId).
*/
async getLegacyProvider(providerId: string): Promise<ProviderConfig | null> {
return getProvider(providerId);
logLegacyProviderApiUsage('getLegacyProvider', 'getAccount');
await ensureProviderStoreMigrated();
const account = await getProviderAccount(providerId);
return account ? providerAccountToConfig(account) : null;
}
/**
* @deprecated Use createAccount()/updateAccount().
*/
async saveLegacyProvider(config: ProviderConfig): Promise<void> {
await saveProvider(config);
logLegacyProviderApiUsage('saveLegacyProvider', 'createAccount/updateAccount');
await ensureProviderStoreMigrated();
const account = providerConfigToAccount(config);
const existing = await getProviderAccount(config.id);
if (existing) {
await this.updateAccount(config.id, account);
return;
}
await this.createAccount(account);
}
/**
* @deprecated Use deleteAccount(accountId).
*/
async deleteLegacyProvider(providerId: string): Promise<boolean> {
return deleteProvider(providerId);
logLegacyProviderApiUsage('deleteLegacyProvider', 'deleteAccount');
await ensureProviderStoreMigrated();
await this.deleteAccount(providerId);
return true;
}
/**
* @deprecated Use setDefaultAccount(accountId).
*/
async setDefaultLegacyProvider(providerId: string): Promise<void> {
await setDefaultProvider(providerId);
logLegacyProviderApiUsage('setDefaultLegacyProvider', 'setDefaultAccount');
await this.setDefaultAccount(providerId);
}
/**
* @deprecated Use getDefaultAccountId().
*/
async getDefaultLegacyProvider(): Promise<string | undefined> {
return getDefaultProvider();
logLegacyProviderApiUsage('getDefaultLegacyProvider', 'getDefaultAccountId');
return this.getDefaultAccountId();
}
/**
* @deprecated Use secret-store APIs by accountId.
*/
async setLegacyProviderApiKey(providerId: string, apiKey: string): Promise<boolean> {
logLegacyProviderApiUsage('setLegacyProviderApiKey', 'setProviderSecret(accountId, api_key)');
return storeApiKey(providerId, apiKey);
}
/**
* @deprecated Use secret-store APIs by accountId.
*/
async getLegacyProviderApiKey(providerId: string): Promise<string | null> {
logLegacyProviderApiUsage('getLegacyProviderApiKey', 'getProviderSecret(accountId)');
return getApiKey(providerId);
}
/**
* @deprecated Use secret-store APIs by accountId.
*/
async deleteLegacyProviderApiKey(providerId: string): Promise<boolean> {
logLegacyProviderApiUsage('deleteLegacyProviderApiKey', 'deleteProviderSecret(accountId)');
return deleteApiKey(providerId);
}
/**
* @deprecated Use secret-store APIs by accountId.
*/
async hasLegacyProviderApiKey(providerId: string): Promise<boolean> {
logLegacyProviderApiUsage('hasLegacyProviderApiKey', 'getProviderSecret(accountId)');
return hasApiKey(providerId);
}
@@ -27,9 +27,10 @@ export function providerConfigToAccount(
label: config.name,
authMode: inferAuthMode(config.type),
baseUrl: config.baseUrl,
apiProtocol: config.type === 'custom' || config.type === 'ollama'
apiProtocol: config.apiProtocol || (config.type === 'custom' || config.type === 'ollama'
? 'openai-completions'
: getProviderDefinition(config.type)?.providerConfig?.api,
: getProviderDefinition(config.type)?.providerConfig?.api),
headers: config.headers,
model: config.model,
fallbackModels: config.fallbackModels,
fallbackAccountIds: config.fallbackProviderIds,
@@ -46,6 +47,8 @@ export function providerAccountToConfig(account: ProviderAccount): ProviderConfi
name: account.label,
type: account.vendorId,
baseUrl: account.baseUrl,
apiProtocol: account.apiProtocol,
headers: account.headers,
model: account.model,
fallbackModels: account.fallbackModels,
fallbackProviderIds: account.fallbackAccountIds,
@@ -1,12 +1,16 @@
import { proxyAwareFetch } from '../../utils/proxy-fetch';
import { getProviderConfig } from '../../utils/provider-registry';
type ValidationProfile =
| 'openai-compatible'
| 'openai-completions'
| 'openai-responses'
| 'google-query-key'
| 'anthropic-header'
| 'openrouter'
| 'none';
type ValidationResult = { valid: boolean; error?: string; status?: number };
function logValidationStatus(provider: string, status: number): void {
console.log(`[clawx-validate] ${provider} HTTP ${status}`);
}
@@ -48,6 +52,28 @@ function buildOpenAiModelsUrl(baseUrl: string): string {
return `${normalizeBaseUrl(baseUrl)}/models?limit=1`;
}
function resolveOpenAiProbeUrls(
baseUrl: string,
apiProtocol: 'openai-completions' | 'openai-responses',
): { modelsUrl: string; probeUrl: string } {
const normalizedBase = normalizeBaseUrl(baseUrl);
const endpointSuffixPattern = /(\/responses?|\/chat\/completions)$/;
const rootBase = normalizedBase.replace(endpointSuffixPattern, '');
const modelsUrl = buildOpenAiModelsUrl(rootBase);
if (apiProtocol === 'openai-responses') {
const probeUrl = /(\/responses?)$/.test(normalizedBase)
? normalizedBase
: `${rootBase}/responses`;
return { modelsUrl, probeUrl };
}
const probeUrl = /\/chat\/completions$/.test(normalizedBase)
? normalizedBase
: `${rootBase}/chat/completions`;
return { modelsUrl, probeUrl };
}
function logValidationRequest(
provider: string,
method: string,
@@ -59,7 +85,21 @@ function logValidationRequest(
);
}
function getValidationProfile(providerType: string): ValidationProfile {
function getValidationProfile(
providerType: string,
options?: { apiProtocol?: string }
): ValidationProfile {
const providerApi = options?.apiProtocol || getProviderConfig(providerType)?.api;
if (providerApi === 'anthropic-messages') {
return 'anthropic-header';
}
if (providerApi === 'openai-responses') {
return 'openai-responses';
}
if (providerApi === 'openai-completions') {
return 'openai-completions';
}
switch (providerType) {
case 'anthropic':
return 'anthropic-header';
@@ -70,7 +110,7 @@ function getValidationProfile(providerType: string): ValidationProfile {
case 'ollama':
return 'none';
default:
return 'openai-compatible';
return 'openai-completions';
}
}
@@ -78,13 +118,14 @@ async function performProviderValidationRequest(
providerLabel: string,
url: string,
headers: Record<string, string>,
): Promise<{ valid: boolean; error?: string }> {
): Promise<ValidationResult> {
try {
logValidationRequest(providerLabel, 'GET', url, headers);
const response = await proxyAwareFetch(url, { headers });
logValidationStatus(providerLabel, response.status);
const data = await response.json().catch(() => ({}));
return classifyAuthResponse(response.status, data);
const result = classifyAuthResponse(response.status, data);
return { ...result, status: response.status };
} catch (error) {
return {
valid: false,
@@ -109,34 +150,111 @@ function classifyAuthResponse(
async function validateOpenAiCompatibleKey(
providerType: string,
apiKey: string,
apiProtocol: 'openai-completions' | 'openai-responses',
baseUrl?: string,
): Promise<{ valid: boolean; error?: string }> {
): Promise<ValidationResult> {
const trimmedBaseUrl = baseUrl?.trim();
if (!trimmedBaseUrl) {
return { valid: false, error: `Base URL is required for provider "${providerType}" validation` };
}
const headers = { Authorization: `Bearer ${apiKey}` };
const modelsUrl = buildOpenAiModelsUrl(trimmedBaseUrl);
const { modelsUrl, probeUrl } = resolveOpenAiProbeUrls(trimmedBaseUrl, apiProtocol);
const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers);
if (modelsResult.error?.includes('API error: 404')) {
if (modelsResult.status === 404) {
console.log(
`[clawx-validate] ${providerType} /models returned 404, falling back to /chat/completions probe`,
`[clawx-validate] ${providerType} /models returned 404, falling back to ${apiProtocol} probe`,
);
const base = normalizeBaseUrl(trimmedBaseUrl);
const chatUrl = `${base}/chat/completions`;
return await performChatCompletionsProbe(providerType, chatUrl, headers);
if (apiProtocol === 'openai-responses') {
return await performResponsesProbe(providerType, probeUrl, headers);
}
return await performChatCompletionsProbe(providerType, probeUrl, headers);
}
return modelsResult;
}
async function performResponsesProbe(
providerLabel: string,
url: string,
headers: Record<string, string>,
): Promise<ValidationResult> {
try {
logValidationRequest(providerLabel, 'POST', url, headers);
const response = await proxyAwareFetch(url, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'validation-probe',
input: 'hi',
}),
});
logValidationStatus(providerLabel, response.status);
const data = await response.json().catch(() => ({}));
if (response.status === 401 || response.status === 403) {
return { valid: false, error: 'Invalid API key' };
}
if (
(response.status >= 200 && response.status < 300) ||
response.status === 400 ||
response.status === 429
) {
return { valid: true };
}
return classifyAuthResponse(response.status, data);
} catch (error) {
return {
valid: false,
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
async function performChatCompletionsProbe(
providerLabel: string,
url: string,
headers: Record<string, string>,
): Promise<{ valid: boolean; error?: string }> {
): Promise<ValidationResult> {
try {
logValidationRequest(providerLabel, 'POST', url, headers);
const response = await proxyAwareFetch(url, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'validation-probe',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 1,
}),
});
logValidationStatus(providerLabel, response.status);
const data = await response.json().catch(() => ({}));
if (response.status === 401 || response.status === 403) {
return { valid: false, error: 'Invalid API key' };
}
if (
(response.status >= 200 && response.status < 300) ||
response.status === 400 ||
response.status === 429
) {
return { valid: true };
}
return classifyAuthResponse(response.status, data);
} catch (error) {
return {
valid: false,
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
async function performAnthropicMessagesProbe(
providerLabel: string,
url: string,
headers: Record<string, string>,
): Promise<ValidationResult> {
try {
logValidationRequest(providerLabel, 'POST', url, headers);
const response = await proxyAwareFetch(url, {
@@ -174,7 +292,7 @@ async function validateGoogleQueryKey(
providerType: string,
apiKey: string,
baseUrl?: string,
): Promise<{ valid: boolean; error?: string }> {
): Promise<ValidationResult> {
const base = normalizeBaseUrl(baseUrl || 'https://generativelanguage.googleapis.com/v1beta');
const url = `${base}/models?pageSize=1&key=${encodeURIComponent(apiKey)}`;
return await performProviderValidationRequest(providerType, url, {});
@@ -184,20 +302,38 @@ async function validateAnthropicHeaderKey(
providerType: string,
apiKey: string,
baseUrl?: string,
): Promise<{ valid: boolean; error?: string }> {
const base = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1');
): Promise<ValidationResult> {
const rawBase = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1');
const base = rawBase.endsWith('/v1') ? rawBase : `${rawBase}/v1`;
const url = `${base}/models?limit=1`;
const headers = {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
};
return await performProviderValidationRequest(providerType, url, headers);
const modelsResult = await performProviderValidationRequest(providerType, url, headers);
// If the endpoint doesn't implement /models (like Minimax Anthropic compatibility), fallback to a /messages probe.
if (
modelsResult.status === 404 ||
modelsResult.status === 400 ||
modelsResult.error?.includes('API error: 404') ||
modelsResult.error?.includes('API error: 400')
) {
console.log(
`[clawx-validate] ${providerType} /models returned error, falling back to /messages probe`,
);
const messagesUrl = `${base}/messages`;
return await performAnthropicMessagesProbe(providerType, messagesUrl, headers);
}
return modelsResult;
}
async function validateOpenRouterKey(
providerType: string,
apiKey: string,
): Promise<{ valid: boolean; error?: string }> {
): Promise<ValidationResult> {
const url = 'https://openrouter.ai/api/v1/auth/key';
const headers = { Authorization: `Bearer ${apiKey}` };
return await performProviderValidationRequest(providerType, url, headers);
@@ -206,9 +342,11 @@ async function validateOpenRouterKey(
export async function validateApiKeyWithProvider(
providerType: string,
apiKey: string,
options?: { baseUrl?: string },
): Promise<{ valid: boolean; error?: string }> {
const profile = getValidationProfile(providerType);
options?: { baseUrl?: string; apiProtocol?: string },
): Promise<ValidationResult> {
const profile = getValidationProfile(providerType, options);
const resolvedBaseUrl = options?.baseUrl || getProviderConfig(providerType)?.baseUrl;
if (profile === 'none') {
return { valid: true };
}
@@ -220,12 +358,24 @@ export async function validateApiKeyWithProvider(
try {
switch (profile) {
case 'openai-compatible':
return await validateOpenAiCompatibleKey(providerType, trimmedKey, options?.baseUrl);
case 'openai-completions':
return await validateOpenAiCompatibleKey(
providerType,
trimmedKey,
'openai-completions',
resolvedBaseUrl,
);
case 'openai-responses':
return await validateOpenAiCompatibleKey(
providerType,
trimmedKey,
'openai-responses',
resolvedBaseUrl,
);
case 'google-query-key':
return await validateGoogleQueryKey(providerType, trimmedKey, options?.baseUrl);
return await validateGoogleQueryKey(providerType, trimmedKey, resolvedBaseUrl);
case 'anthropic-header':
return await validateAnthropicHeaderKey(providerType, trimmedKey, options?.baseUrl);
return await validateAnthropicHeaderKey(providerType, trimmedKey, resolvedBaseUrl);
case 'openrouter':
return await validateOpenRouterKey(providerType, trimmedKey);
default:
+9 -4
View File
@@ -30,7 +30,9 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
category: 'official',
envVar: 'OPENAI_API_KEY',
defaultModelId: 'gpt-5.2',
supportedAuthModes: ['api_key'],
isOAuth: true,
supportsApiKey: true,
supportedAuthModes: ['api_key', 'oauth_browser'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
providerConfig: {
@@ -93,6 +95,9 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
modelIdPlaceholder: 'ep-20260228000000-xxxxx',
category: 'official',
envVar: 'ARK_API_KEY',
codePlanPresetBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3',
codePlanPresetModelId: 'ark-code-latest',
codePlanDocsUrl: 'https://www.volcengine.com/docs/82379/1928261?lang=zh',
supportedAuthModes: ['api_key'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
@@ -165,8 +170,8 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
requiresApiKey: false,
isOAuth: true,
supportsApiKey: true,
defaultModelId: 'MiniMax-M2.5',
apiKeyUrl: 'https://intl.minimaxi.com/',
defaultModelId: 'MiniMax-M2.7',
apiKeyUrl: 'https://platform.minimax.io',
category: 'official',
envVar: 'MINIMAX_API_KEY',
supportedAuthModes: ['oauth_device', 'api_key'],
@@ -187,7 +192,7 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
requiresApiKey: false,
isOAuth: true,
supportsApiKey: true,
defaultModelId: 'MiniMax-M2.5',
defaultModelId: 'MiniMax-M2.7',
apiKeyUrl: 'https://platform.minimaxi.com/',
category: 'official',
envVar: 'MINIMAX_CN_API_KEY',
+23 -17
View File
@@ -54,6 +54,8 @@ export interface ProviderConfig {
name: string;
type: ProviderType;
baseUrl?: string;
apiProtocol?: ProviderProtocol;
headers?: Record<string, string>;
model?: string;
fallbackModels?: string[];
fallbackProviderIds?: string[];
@@ -83,6 +85,9 @@ export interface ProviderTypeInfo {
isOAuth?: boolean;
supportsApiKey?: boolean;
apiKeyUrl?: string;
codePlanPresetBaseUrl?: string;
codePlanPresetModelId?: string;
codePlanDocsUrl?: string;
}
export interface ProviderModelEntry extends Record<string, unknown> {
@@ -114,6 +119,7 @@ export interface ProviderAccount {
authMode: ProviderAuthMode;
baseUrl?: string;
apiProtocol?: ProviderProtocol;
headers?: Record<string, string>;
model?: string;
fallbackModels?: string[];
fallbackAccountIds?: string[];
@@ -131,25 +137,25 @@ export interface ProviderAccount {
export type ProviderSecret =
| {
type: 'api_key';
accountId: string;
apiKey: string;
}
type: 'api_key';
accountId: string;
apiKey: string;
}
| {
type: 'oauth';
accountId: string;
accessToken: string;
refreshToken: string;
expiresAt: number;
scopes?: string[];
email?: string;
subject?: string;
}
type: 'oauth';
accountId: string;
accessToken: string;
refreshToken: string;
expiresAt: number;
scopes?: string[];
email?: string;
subject?: string;
}
| {
type: 'local';
accountId: string;
apiKey?: string;
};
type: 'local';
accountId: string;
apiKey?: string;
};
export interface ModelSummary {
id: string;
+774
View File
@@ -0,0 +1,774 @@
import { access, copyFile, mkdir, readdir, rm } from 'fs/promises';
import { constants } from 'fs';
import { join, normalize } from 'path';
import { deleteAgentChannelAccounts, listConfiguredChannels, readOpenClawConfig, writeOpenClawConfig } from './channel-config';
import { withConfigLock } from './config-mutex';
import { expandPath, getOpenClawConfigDir } from './paths';
import * as logger from './logger';
import { toUiChannelType } from './channel-alias';
const MAIN_AGENT_ID = 'main';
const MAIN_AGENT_NAME = 'Main Agent';
const DEFAULT_ACCOUNT_ID = 'default';
const DEFAULT_WORKSPACE_PATH = '~/.openclaw/workspace';
const AGENT_BOOTSTRAP_FILES = [
'AGENTS.md',
'SOUL.md',
'TOOLS.md',
'USER.md',
'IDENTITY.md',
'HEARTBEAT.md',
'BOOT.md',
];
const AGENT_RUNTIME_FILES = [
'auth-profiles.json',
'models.json',
];
interface AgentModelConfig {
primary?: string;
[key: string]: unknown;
}
interface AgentDefaultsConfig {
workspace?: string;
model?: string | AgentModelConfig;
[key: string]: unknown;
}
interface AgentListEntry extends Record<string, unknown> {
id: string;
name?: string;
default?: boolean;
workspace?: string;
agentDir?: string;
model?: string | AgentModelConfig;
}
interface AgentsConfig extends Record<string, unknown> {
defaults?: AgentDefaultsConfig;
list?: AgentListEntry[];
}
interface BindingMatch extends Record<string, unknown> {
channel?: string;
accountId?: string;
}
interface BindingConfig extends Record<string, unknown> {
agentId?: string;
match?: BindingMatch;
}
interface ChannelSectionConfig extends Record<string, unknown> {
accounts?: Record<string, Record<string, unknown>>;
defaultAccount?: string;
enabled?: boolean;
}
interface AgentConfigDocument extends Record<string, unknown> {
agents?: AgentsConfig;
bindings?: BindingConfig[];
channels?: Record<string, ChannelSectionConfig>;
session?: {
mainKey?: string;
[key: string]: unknown;
};
}
export interface AgentSummary {
id: string;
name: string;
isDefault: boolean;
modelDisplay: string;
modelRef: string | null;
overrideModelRef: string | null;
inheritedModel: boolean;
workspace: string;
agentDir: string;
mainSessionKey: string;
channelTypes: string[];
}
export interface AgentsSnapshot {
agents: AgentSummary[];
defaultAgentId: string;
defaultModelRef: string | null;
configuredChannelTypes: string[];
channelOwners: Record<string, string>;
channelAccountOwners: Record<string, string>;
}
function resolveModelRef(model: unknown): string | null {
if (typeof model === 'string' && model.trim()) {
return model.trim();
}
if (model && typeof model === 'object') {
const primary = (model as AgentModelConfig).primary;
if (typeof primary === 'string' && primary.trim()) {
return primary.trim();
}
}
return null;
}
function formatModelLabel(model: unknown): string | null {
const modelRef = resolveModelRef(model);
if (modelRef) {
const trimmed = modelRef;
const parts = trimmed.split('/');
return parts[parts.length - 1] || trimmed;
}
return null;
}
function normalizeAgentName(name: string): string {
return name.trim() || 'Agent';
}
function slugifyAgentId(name: string): string {
const normalized = name
.normalize('NFKD')
.replace(/[^\w\s-]/g, '')
.toLowerCase()
.replace(/[_\s]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
if (!normalized) return 'agent';
if (normalized === MAIN_AGENT_ID) return 'agent';
return normalized;
}
async function fileExists(path: string): Promise<boolean> {
try {
await access(path, constants.F_OK);
return true;
} catch {
return false;
}
}
async function ensureDir(path: string): Promise<void> {
if (!(await fileExists(path))) {
await mkdir(path, { recursive: true });
}
}
function getDefaultWorkspacePath(config: AgentConfigDocument): string {
const defaults = (config.agents && typeof config.agents === 'object'
? (config.agents as AgentsConfig).defaults
: undefined);
return typeof defaults?.workspace === 'string' && defaults.workspace.trim()
? defaults.workspace
: DEFAULT_WORKSPACE_PATH;
}
function getDefaultAgentDirPath(agentId: string): string {
return `~/.openclaw/agents/${agentId}/agent`;
}
function createImplicitMainEntry(config: AgentConfigDocument): AgentListEntry {
return {
id: MAIN_AGENT_ID,
name: MAIN_AGENT_NAME,
default: true,
workspace: getDefaultWorkspacePath(config),
agentDir: getDefaultAgentDirPath(MAIN_AGENT_ID),
};
}
function normalizeAgentsConfig(config: AgentConfigDocument): {
agentsConfig: AgentsConfig;
entries: AgentListEntry[];
defaultAgentId: string;
syntheticMain: boolean;
} {
const agentsConfig = (config.agents && typeof config.agents === 'object'
? { ...(config.agents as AgentsConfig) }
: {}) as AgentsConfig;
const rawEntries = Array.isArray(agentsConfig.list)
? agentsConfig.list.filter((entry): entry is AgentListEntry => (
Boolean(entry) && typeof entry === 'object' && typeof entry.id === 'string' && entry.id.trim().length > 0
))
: [];
if (rawEntries.length === 0) {
const main = createImplicitMainEntry(config);
return {
agentsConfig,
entries: [main],
defaultAgentId: MAIN_AGENT_ID,
syntheticMain: true,
};
}
const defaultEntry = rawEntries.find((entry) => entry.default) ?? rawEntries[0];
return {
agentsConfig,
entries: rawEntries.map((entry) => ({ ...entry })),
defaultAgentId: defaultEntry.id,
syntheticMain: false,
};
}
function isChannelBinding(binding: unknown): binding is BindingConfig {
if (!binding || typeof binding !== 'object') return false;
const candidate = binding as BindingConfig;
if (typeof candidate.agentId !== 'string' || !candidate.agentId) return false;
if (!candidate.match || typeof candidate.match !== 'object' || Array.isArray(candidate.match)) return false;
if (typeof candidate.match.channel !== 'string' || !candidate.match.channel) return false;
const keys = Object.keys(candidate.match);
// Accept bindings with just {channel} or {channel, accountId}
if (keys.length === 1 && keys[0] === 'channel') return true;
if (keys.length === 2 && keys.includes('channel') && keys.includes('accountId')) return true;
return false;
}
/** Normalize agent ID for consistent comparison (bindings vs entries). */
function normalizeAgentIdForBinding(id: string): string {
return (id ?? '').trim().toLowerCase() || '';
}
function normalizeMainKey(value: unknown): string {
if (typeof value !== 'string') return 'main';
const trimmed = value.trim().toLowerCase();
return trimmed || 'main';
}
function buildAgentMainSessionKey(config: AgentConfigDocument, agentId: string): string {
return `agent:${normalizeAgentIdForBinding(agentId) || MAIN_AGENT_ID}:${normalizeMainKey(config.session?.mainKey)}`;
}
/**
* Returns a map of channelType -> agentId from bindings.
* Account-scoped bindings are preferred; channel-wide bindings serve as fallback.
* Multiple agents can own the same channel type (different accounts).
*/
function getChannelBindingMap(bindings: unknown): {
channelToAgent: Map<string, string>;
accountToAgent: Map<string, string>;
} {
const channelToAgent = new Map<string, string>();
const accountToAgent = new Map<string, string>();
if (!Array.isArray(bindings)) return { channelToAgent, accountToAgent };
for (const binding of bindings) {
if (!isChannelBinding(binding)) continue;
const agentId = normalizeAgentIdForBinding(binding.agentId!);
const channel = binding.match?.channel;
if (!agentId || !channel) continue;
const accountId = binding.match?.accountId;
if (accountId) {
accountToAgent.set(`${channel}:${accountId}`, agentId);
} else {
channelToAgent.set(channel, agentId);
}
}
return { channelToAgent, accountToAgent };
}
function upsertBindingsForChannel(
bindings: unknown,
channelType: string,
agentId: string | null,
accountId?: string,
): BindingConfig[] | undefined {
const normalizedAgentId = agentId ? normalizeAgentIdForBinding(agentId) : '';
const nextBindings = Array.isArray(bindings)
? [...bindings as BindingConfig[]].filter((binding) => {
if (!isChannelBinding(binding)) return true;
if (binding.match?.channel !== channelType) return true;
// Keep a single account binding per (agent, channelType). Rebinding to
// another account should replace the previous one.
if (normalizedAgentId && normalizeAgentIdForBinding(binding.agentId || '') === normalizedAgentId) {
return false;
}
// Only remove binding that matches the exact accountId scope
if (accountId) {
return binding.match?.accountId !== accountId;
}
// No accountId: remove channel-wide binding (legacy)
return Boolean(binding.match?.accountId);
})
: [];
if (agentId) {
const match: BindingMatch = { channel: channelType };
if (accountId) {
match.accountId = accountId;
}
nextBindings.push({ agentId, match });
}
return nextBindings.length > 0 ? nextBindings : undefined;
}
async function listExistingAgentIdsOnDisk(): Promise<Set<string>> {
const ids = new Set<string>();
const agentsDir = join(getOpenClawConfigDir(), 'agents');
try {
if (!(await fileExists(agentsDir))) return ids;
const entries = await readdir(agentsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) ids.add(entry.name);
}
} catch {
// ignore discovery failures
}
return ids;
}
async function removeAgentRuntimeDirectory(agentId: string): Promise<void> {
const runtimeDir = join(getOpenClawConfigDir(), 'agents', agentId);
try {
await rm(runtimeDir, { recursive: true, force: true });
} catch (error) {
logger.warn('Failed to remove agent runtime directory', {
agentId,
runtimeDir,
error: String(error),
});
}
}
function trimTrailingSeparators(path: string): string {
return path.replace(/[\\/]+$/, '');
}
function getManagedWorkspaceDirectory(agent: AgentListEntry): string | null {
if (agent.id === MAIN_AGENT_ID) return null;
const configuredWorkspace = expandPath(agent.workspace || `~/.openclaw/workspace-${agent.id}`);
const managedWorkspace = join(getOpenClawConfigDir(), `workspace-${agent.id}`);
const normalizedConfigured = trimTrailingSeparators(normalize(configuredWorkspace));
const normalizedManaged = trimTrailingSeparators(normalize(managedWorkspace));
return normalizedConfigured === normalizedManaged ? configuredWorkspace : null;
}
export async function removeAgentWorkspaceDirectory(agent: { id: string; workspace?: string }): Promise<void> {
const workspaceDir = getManagedWorkspaceDirectory(agent as AgentListEntry);
if (!workspaceDir) {
logger.warn('Skipping agent workspace deletion for unmanaged path', {
agentId: agent.id,
workspace: agent.workspace,
});
return;
}
try {
await rm(workspaceDir, { recursive: true, force: true });
} catch (error) {
logger.warn('Failed to remove agent workspace directory', {
agentId: agent.id,
workspaceDir,
error: String(error),
});
}
}
async function copyBootstrapFiles(sourceWorkspace: string, targetWorkspace: string): Promise<void> {
await ensureDir(targetWorkspace);
for (const fileName of AGENT_BOOTSTRAP_FILES) {
const source = join(sourceWorkspace, fileName);
const target = join(targetWorkspace, fileName);
if (!(await fileExists(source)) || (await fileExists(target))) continue;
await copyFile(source, target);
}
}
async function copyRuntimeFiles(sourceAgentDir: string, targetAgentDir: string): Promise<void> {
await ensureDir(targetAgentDir);
for (const fileName of AGENT_RUNTIME_FILES) {
const source = join(sourceAgentDir, fileName);
const target = join(targetAgentDir, fileName);
if (!(await fileExists(source)) || (await fileExists(target))) continue;
await copyFile(source, target);
}
}
async function provisionAgentFilesystem(
config: AgentConfigDocument,
agent: AgentListEntry,
options?: { inheritWorkspace?: boolean },
): Promise<void> {
const { entries } = normalizeAgentsConfig(config);
const mainEntry = entries.find((entry) => entry.id === MAIN_AGENT_ID) ?? createImplicitMainEntry(config);
const sourceWorkspace = expandPath(mainEntry.workspace || getDefaultWorkspacePath(config));
const targetWorkspace = expandPath(agent.workspace || `~/.openclaw/workspace-${agent.id}`);
const sourceAgentDir = expandPath(mainEntry.agentDir || getDefaultAgentDirPath(MAIN_AGENT_ID));
const targetAgentDir = expandPath(agent.agentDir || getDefaultAgentDirPath(agent.id));
const targetSessionsDir = join(getOpenClawConfigDir(), 'agents', agent.id, 'sessions');
await ensureDir(targetWorkspace);
await ensureDir(targetAgentDir);
await ensureDir(targetSessionsDir);
// When inheritWorkspace is true, copy the main agent's workspace bootstrap
// files (SOUL.md, AGENTS.md, etc.) so the new agent inherits the same
// personality / instructions. When false (default), leave the workspace
// empty and let OpenClaw Gateway seed the default bootstrap files on startup.
if (options?.inheritWorkspace && targetWorkspace !== sourceWorkspace) {
await copyBootstrapFiles(sourceWorkspace, targetWorkspace);
}
if (targetAgentDir !== sourceAgentDir) {
await copyRuntimeFiles(sourceAgentDir, targetAgentDir);
}
}
export function resolveAccountIdForAgent(agentId: string): string {
return agentId === MAIN_AGENT_ID ? DEFAULT_ACCOUNT_ID : agentId;
}
function listConfiguredAccountIdsForChannel(config: AgentConfigDocument, channelType: string): string[] {
const channelSection = config.channels?.[channelType];
if (!channelSection || channelSection.enabled === false) {
return [];
}
const accounts = channelSection.accounts;
if (!accounts || typeof accounts !== 'object' || Object.keys(accounts).length === 0) {
return [DEFAULT_ACCOUNT_ID];
}
return Object.keys(accounts)
.filter(Boolean)
.sort((a, b) => {
if (a === DEFAULT_ACCOUNT_ID) return -1;
if (b === DEFAULT_ACCOUNT_ID) return 1;
return a.localeCompare(b);
});
}
async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<AgentsSnapshot> {
const { entries, defaultAgentId } = normalizeAgentsConfig(config);
const configuredChannels = await listConfiguredChannels();
const { channelToAgent, accountToAgent } = getChannelBindingMap(config.bindings);
const defaultAgentIdNorm = normalizeAgentIdForBinding(defaultAgentId);
const channelOwners: Record<string, string> = {};
const channelAccountOwners: Record<string, string> = {};
// Build per-agent channel lists from account-scoped bindings
const agentChannelSets = new Map<string, Set<string>>();
for (const channelType of configuredChannels) {
const accountIds = listConfiguredAccountIdsForChannel(config, channelType);
let primaryOwner: string | undefined;
const hasExplicitAccountBindingForChannel = accountIds.some((accountId) =>
accountToAgent.has(`${channelType}:${accountId}`),
);
for (const accountId of accountIds) {
const owner =
accountToAgent.get(`${channelType}:${accountId}`)
|| (
accountId === DEFAULT_ACCOUNT_ID && !hasExplicitAccountBindingForChannel
? channelToAgent.get(channelType)
: undefined
);
if (!owner) {
continue;
}
channelAccountOwners[`${channelType}:${accountId}`] = owner;
primaryOwner ??= owner;
const existing = agentChannelSets.get(owner) ?? new Set();
existing.add(channelType);
agentChannelSets.set(owner, existing);
}
if (!primaryOwner) {
primaryOwner = channelToAgent.get(channelType) || defaultAgentIdNorm;
const existing = agentChannelSets.get(primaryOwner) ?? new Set();
existing.add(channelType);
agentChannelSets.set(primaryOwner, existing);
}
channelOwners[channelType] = primaryOwner;
}
const defaultModelConfig = (config.agents as AgentsConfig | undefined)?.defaults?.model;
const defaultModelLabel = formatModelLabel(defaultModelConfig);
const defaultModelRef = resolveModelRef(defaultModelConfig);
const agents: AgentSummary[] = entries.map((entry) => {
const explicitModelRef = resolveModelRef(entry.model);
const modelLabel = formatModelLabel(entry.model) || defaultModelLabel || 'Not configured';
const inheritedModel = !explicitModelRef && Boolean(defaultModelLabel);
const entryIdNorm = normalizeAgentIdForBinding(entry.id);
const ownedChannels = agentChannelSets.get(entryIdNorm) ?? new Set<string>();
return {
id: entry.id,
name: entry.name || (entry.id === MAIN_AGENT_ID ? MAIN_AGENT_NAME : entry.id),
isDefault: entry.id === defaultAgentId,
modelDisplay: modelLabel,
modelRef: explicitModelRef || defaultModelRef || null,
overrideModelRef: explicitModelRef,
inheritedModel,
workspace: entry.workspace || (entry.id === MAIN_AGENT_ID ? getDefaultWorkspacePath(config) : `~/.openclaw/workspace-${entry.id}`),
agentDir: entry.agentDir || getDefaultAgentDirPath(entry.id),
mainSessionKey: buildAgentMainSessionKey(config, entry.id),
channelTypes: configuredChannels
.filter((ct) => ownedChannels.has(ct))
.map((channelType) => toUiChannelType(channelType)),
};
});
return {
agents,
defaultAgentId,
defaultModelRef,
configuredChannelTypes: configuredChannels.map((channelType) => toUiChannelType(channelType)),
channelOwners,
channelAccountOwners,
};
}
export async function listAgentsSnapshot(): Promise<AgentsSnapshot> {
const config = await readOpenClawConfig() as AgentConfigDocument;
return buildSnapshotFromConfig(config);
}
export async function listConfiguredAgentIds(): Promise<string[]> {
const config = await readOpenClawConfig() as AgentConfigDocument;
const { entries } = normalizeAgentsConfig(config);
const ids = [...new Set(entries.map((entry) => entry.id.trim()).filter(Boolean))];
return ids.length > 0 ? ids : [MAIN_AGENT_ID];
}
export async function createAgent(
name: string,
options?: { inheritWorkspace?: boolean },
): Promise<AgentsSnapshot> {
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
const { agentsConfig, entries, syntheticMain } = normalizeAgentsConfig(config);
const normalizedName = normalizeAgentName(name);
const existingIds = new Set(entries.map((entry) => entry.id));
const diskIds = await listExistingAgentIdsOnDisk();
let nextId = slugifyAgentId(normalizedName);
let suffix = 2;
while (existingIds.has(nextId) || diskIds.has(nextId)) {
nextId = `${slugifyAgentId(normalizedName)}-${suffix}`;
suffix += 1;
}
const nextEntries = syntheticMain ? [createImplicitMainEntry(config), ...entries.filter((_, index) => index > 0)] : [...entries];
const newAgent: AgentListEntry = {
id: nextId,
name: normalizedName,
workspace: `~/.openclaw/workspace-${nextId}`,
agentDir: getDefaultAgentDirPath(nextId),
};
if (!nextEntries.some((entry) => entry.id === MAIN_AGENT_ID) && syntheticMain) {
nextEntries.unshift(createImplicitMainEntry(config));
}
nextEntries.push(newAgent);
config.agents = {
...agentsConfig,
list: nextEntries,
};
await provisionAgentFilesystem(config, newAgent, { inheritWorkspace: options?.inheritWorkspace });
await writeOpenClawConfig(config);
logger.info('Created agent config entry', { agentId: nextId, inheritWorkspace: !!options?.inheritWorkspace });
return buildSnapshotFromConfig(config);
});
}
export async function updateAgentName(agentId: string, name: string): Promise<AgentsSnapshot> {
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
const { agentsConfig, entries } = normalizeAgentsConfig(config);
const normalizedName = normalizeAgentName(name);
const index = entries.findIndex((entry) => entry.id === agentId);
if (index === -1) {
throw new Error(`Agent "${agentId}" not found`);
}
entries[index] = {
...entries[index],
name: normalizedName,
};
config.agents = {
...agentsConfig,
list: entries,
};
await writeOpenClawConfig(config);
logger.info('Updated agent name', { agentId, name: normalizedName });
return buildSnapshotFromConfig(config);
});
}
function isValidModelRef(modelRef: string): boolean {
const firstSlash = modelRef.indexOf('/');
return firstSlash > 0 && firstSlash < modelRef.length - 1;
}
export async function updateAgentModel(agentId: string, modelRef: string | null): Promise<AgentsSnapshot> {
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
const { agentsConfig, entries } = normalizeAgentsConfig(config);
const index = entries.findIndex((entry) => entry.id === agentId);
if (index === -1) {
throw new Error(`Agent "${agentId}" not found`);
}
const normalizedModelRef = typeof modelRef === 'string' ? modelRef.trim() : '';
const nextEntry: AgentListEntry = { ...entries[index] };
if (!normalizedModelRef) {
delete nextEntry.model;
} else {
if (!isValidModelRef(normalizedModelRef)) {
throw new Error('modelRef must be in "provider/model" format');
}
nextEntry.model = { primary: normalizedModelRef };
}
entries[index] = nextEntry;
config.agents = {
...agentsConfig,
list: entries,
};
await writeOpenClawConfig(config);
logger.info('Updated agent model', { agentId, modelRef: normalizedModelRef || null });
return buildSnapshotFromConfig(config);
});
}
export async function deleteAgentConfig(agentId: string): Promise<{ snapshot: AgentsSnapshot; removedEntry: AgentListEntry }> {
return withConfigLock(async () => {
if (agentId === MAIN_AGENT_ID) {
throw new Error('The main agent cannot be deleted');
}
const config = await readOpenClawConfig() as AgentConfigDocument;
const { agentsConfig, entries, defaultAgentId } = normalizeAgentsConfig(config);
const snapshotBeforeDeletion = await buildSnapshotFromConfig(config);
const removedEntry = entries.find((entry) => entry.id === agentId);
const nextEntries = entries.filter((entry) => entry.id !== agentId);
if (!removedEntry || nextEntries.length === entries.length) {
throw new Error(`Agent "${agentId}" not found`);
}
config.agents = {
...agentsConfig,
list: nextEntries,
};
config.bindings = Array.isArray(config.bindings)
? config.bindings.filter((binding) => !(isChannelBinding(binding) && binding.agentId === agentId))
: undefined;
if (defaultAgentId === agentId && nextEntries.length > 0) {
nextEntries[0] = {
...nextEntries[0],
default: true,
};
}
const normalizedAgentId = normalizeAgentIdForBinding(agentId);
const legacyAccountId = resolveAccountIdForAgent(agentId);
const ownedLegacyAccounts = new Set(
Object.entries(snapshotBeforeDeletion.channelAccountOwners)
.filter(([channelAccountKey, owner]) => {
if (owner !== normalizedAgentId) return false;
const accountId = channelAccountKey.slice(channelAccountKey.indexOf(':') + 1);
return accountId === legacyAccountId;
})
.map(([channelAccountKey]) => channelAccountKey),
);
await writeOpenClawConfig(config);
await deleteAgentChannelAccounts(agentId, ownedLegacyAccounts);
await removeAgentRuntimeDirectory(agentId);
// NOTE: workspace directory is NOT deleted here intentionally.
// The caller (route handler) defers workspace removal until after
// the Gateway process has fully restarted, so that any in-flight
// process.chdir(workspace) calls complete before the directory
// disappears (otherwise process.cwd() throws ENOENT for the rest
// of the Gateway's lifetime).
logger.info('Deleted agent config entry', { agentId });
return { snapshot: await buildSnapshotFromConfig(config), removedEntry };
});
}
export async function assignChannelToAgent(agentId: string, channelType: string): Promise<AgentsSnapshot> {
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
const { entries } = normalizeAgentsConfig(config);
if (!entries.some((entry) => entry.id === agentId)) {
throw new Error(`Agent "${agentId}" not found`);
}
const accountId = resolveAccountIdForAgent(agentId);
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId);
await writeOpenClawConfig(config);
logger.info('Assigned channel to agent', { agentId, channelType, accountId });
return buildSnapshotFromConfig(config);
});
}
export async function assignChannelAccountToAgent(
agentId: string,
channelType: string,
accountId: string,
): Promise<AgentsSnapshot> {
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
const { entries } = normalizeAgentsConfig(config);
if (!entries.some((entry) => entry.id === agentId)) {
throw new Error(`Agent "${agentId}" not found`);
}
if (!accountId.trim()) {
throw new Error('accountId is required');
}
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId.trim());
await writeOpenClawConfig(config);
logger.info('Assigned channel account to agent', { agentId, channelType, accountId: accountId.trim() });
return buildSnapshotFromConfig(config);
});
}
export async function clearChannelBinding(channelType: string, accountId?: string): Promise<AgentsSnapshot> {
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
config.bindings = upsertBindingsForChannel(config.bindings, channelType, null, accountId);
await writeOpenClawConfig(config);
logger.info('Cleared channel binding', { channelType, accountId });
return buildSnapshotFromConfig(config);
});
}
export async function clearAllBindingsForChannel(channelType: string): Promise<void> {
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
if (!Array.isArray(config.bindings)) return;
const nextBindings = config.bindings.filter((binding) => {
if (!isChannelBinding(binding)) return true;
return binding.match?.channel !== channelType;
});
config.bindings = nextBindings.length > 0 ? nextBindings : undefined;
await writeOpenClawConfig(config);
logger.info('Cleared all bindings for channel', { channelType });
});
}
+116 -37
View File
@@ -2,14 +2,17 @@ import { EventEmitter } from 'events';
import { BrowserWindow, shell } from 'electron';
import { logger } from './logger';
import { loginGeminiCliOAuth, type GeminiCliOAuthCredentials } from './gemini-cli-oauth';
import { loginOpenAICodexOAuth, type OpenAICodexOAuthCredentials } from './openai-codex-oauth';
import { getProviderService } from '../services/providers/provider-service';
import { getSecretStore } from '../services/secrets/secret-store';
import { saveOAuthTokenToOpenClaw } from './openclaw-auth';
export type BrowserOAuthProviderType = 'google';
export type BrowserOAuthProviderType = 'google' | 'openai';
const GOOGLE_RUNTIME_PROVIDER_ID = 'google-gemini-cli';
const GOOGLE_OAUTH_DEFAULT_MODEL = 'gemini-3-pro-preview';
const OPENAI_RUNTIME_PROVIDER_ID = 'openai-codex';
const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.3-codex';
class BrowserOAuthManager extends EventEmitter {
private activeProvider: BrowserOAuthProviderType | null = null;
@@ -17,6 +20,8 @@ class BrowserOAuthManager extends EventEmitter {
private activeLabel: string | null = null;
private active = false;
private mainWindow: BrowserWindow | null = null;
private pendingManualCodeResolve: ((value: string) => void) | null = null;
private pendingManualCodeReject: ((reason?: unknown) => void) | null = null;
setWindow(window: BrowserWindow) {
this.mainWindow = window;
@@ -36,38 +41,72 @@ class BrowserOAuthManager extends EventEmitter {
this.activeLabel = options?.label || null;
this.emit('oauth:start', { provider, accountId: this.activeAccountId });
try {
if (provider !== 'google') {
throw new Error(`Unsupported browser OAuth provider type: ${provider}`);
}
if (provider === 'openai') {
// OpenAI flow may switch to manual callback mode; keep start API non-blocking.
void this.executeFlow(provider);
return true;
}
const token = await loginGeminiCliOAuth({
isRemote: false,
openUrl: async (url) => {
await shell.openExternal(url);
},
log: (message) => logger.info(`[BrowserOAuth] ${message}`),
note: async (message, title) => {
logger.info(`[BrowserOAuth] ${title || 'OAuth note'}: ${message}`);
},
prompt: async () => {
throw new Error('Manual browser OAuth fallback is not implemented in ClawX yet.');
},
progress: {
update: (message) => logger.info(`[BrowserOAuth] ${message}`),
stop: (message) => {
if (message) {
logger.info(`[BrowserOAuth] ${message}`);
await this.executeFlow(provider);
return true;
}
private async executeFlow(provider: BrowserOAuthProviderType): Promise<void> {
try {
const token = provider === 'google'
? await loginGeminiCliOAuth({
isRemote: false,
openUrl: async (url) => {
await shell.openExternal(url);
},
log: (message) => logger.info(`[BrowserOAuth] ${message}`),
note: async (message, title) => {
logger.info(`[BrowserOAuth] ${title || 'OAuth note'}: ${message}`);
},
prompt: async () => {
throw new Error('Manual browser OAuth fallback is not implemented in ClawX yet.');
},
progress: {
update: (message) => logger.info(`[BrowserOAuth] ${message}`),
stop: (message) => {
if (message) {
logger.info(`[BrowserOAuth] ${message}`);
}
},
},
})
: await loginOpenAICodexOAuth({
openUrl: async (url) => {
await shell.openExternal(url);
},
onProgress: (message) => logger.info(`[BrowserOAuth] ${message}`),
onManualCodeRequired: ({ authorizationUrl, reason }) => {
const message = reason === 'port_in_use'
? 'OpenAI OAuth callback port 1455 is in use. Complete sign-in, then paste the final callback URL or code.'
: 'OpenAI OAuth callback timed out. Paste the final callback URL or code to continue.';
const payload = {
provider,
mode: 'manual' as const,
authorizationUrl,
message,
};
this.emit('oauth:code', payload);
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:code', payload);
}
},
},
});
onManualCodeInput: async () => {
return await new Promise<string>((resolve, reject) => {
this.pendingManualCodeResolve = resolve;
this.pendingManualCodeReject = reject;
});
},
});
await this.onSuccess(provider, token);
return true;
} catch (error) {
if (!this.active) {
return false;
return;
}
logger.error(`[BrowserOAuth] Flow error for ${provider}:`, error);
this.emitError(error instanceof Error ? error.message : String(error));
@@ -75,7 +114,8 @@ class BrowserOAuthManager extends EventEmitter {
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
return false;
this.pendingManualCodeResolve = null;
this.pendingManualCodeReject = null;
}
}
@@ -84,12 +124,28 @@ class BrowserOAuthManager extends EventEmitter {
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
if (this.pendingManualCodeReject) {
this.pendingManualCodeReject(new Error('OAuth flow cancelled'));
}
this.pendingManualCodeResolve = null;
this.pendingManualCodeReject = null;
logger.info('[BrowserOAuth] Flow explicitly stopped');
}
submitManualCode(code: string): boolean {
const value = code.trim();
if (!value || !this.pendingManualCodeResolve) {
return false;
}
this.pendingManualCodeResolve(value);
this.pendingManualCodeResolve = null;
this.pendingManualCodeReject = null;
return true;
}
private async onSuccess(
providerType: BrowserOAuthProviderType,
token: GeminiCliOAuthCredentials,
token: GeminiCliOAuthCredentials | OpenAICodexOAuthCredentials,
) {
const accountId = this.activeAccountId || providerType;
const accountLabel = this.activeLabel;
@@ -97,26 +153,49 @@ class BrowserOAuthManager extends EventEmitter {
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
this.pendingManualCodeResolve = null;
this.pendingManualCodeReject = null;
logger.info(`[BrowserOAuth] Successfully completed OAuth for ${providerType}`);
const providerService = getProviderService();
const existing = await providerService.getAccount(accountId);
const isGoogle = providerType === 'google';
const runtimeProviderId = isGoogle ? GOOGLE_RUNTIME_PROVIDER_ID : OPENAI_RUNTIME_PROVIDER_ID;
const defaultModel = isGoogle ? GOOGLE_OAUTH_DEFAULT_MODEL : OPENAI_OAUTH_DEFAULT_MODEL;
const accountLabelDefault = isGoogle ? 'Google Gemini' : 'OpenAI Codex';
const oauthTokenEmail = 'email' in token && typeof token.email === 'string' ? token.email : undefined;
const oauthTokenSubject = 'projectId' in token && typeof token.projectId === 'string'
? token.projectId
: ('accountId' in token && typeof token.accountId === 'string' ? token.accountId : undefined);
const normalizedExistingModel = (() => {
const value = existing?.model?.trim();
if (!value) return undefined;
if (isGoogle) {
return value.includes('/') ? value.split('/').pop() : value;
}
// OpenAI OAuth uses openai-codex/* runtime; existing openai/* refs are incompatible.
if (value.startsWith('openai/')) return undefined;
if (value.startsWith('openai-codex/')) return value.split('/').pop();
return value.includes('/') ? value.split('/').pop() : value;
})();
const nextAccount = await providerService.createAccount({
id: accountId,
vendorId: providerType,
label: accountLabel || existing?.label || 'Google Gemini',
label: accountLabel || existing?.label || accountLabelDefault,
authMode: 'oauth_browser',
baseUrl: existing?.baseUrl,
apiProtocol: existing?.apiProtocol,
model: existing?.model || GOOGLE_OAUTH_DEFAULT_MODEL,
model: normalizedExistingModel || defaultModel,
fallbackModels: existing?.fallbackModels,
fallbackAccountIds: existing?.fallbackAccountIds,
enabled: existing?.enabled ?? true,
isDefault: existing?.isDefault ?? false,
metadata: {
...existing?.metadata,
email: token.email,
resourceUrl: GOOGLE_RUNTIME_PROVIDER_ID,
email: oauthTokenEmail,
resourceUrl: runtimeProviderId,
},
createdAt: existing?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -128,16 +207,16 @@ class BrowserOAuthManager extends EventEmitter {
accessToken: token.access,
refreshToken: token.refresh,
expiresAt: token.expires,
email: token.email,
subject: token.projectId,
email: oauthTokenEmail,
subject: oauthTokenSubject,
});
await saveOAuthTokenToOpenClaw(GOOGLE_RUNTIME_PROVIDER_ID, {
await saveOAuthTokenToOpenClaw(runtimeProviderId, {
access: token.access,
refresh: token.refresh,
expires: token.expires,
email: token.email,
projectId: token.projectId,
email: oauthTokenEmail,
projectId: oauthTokenSubject,
});
this.emit('oauth:success', { provider: providerType, accountId: nextAccount.id });
+46
View File
@@ -0,0 +1,46 @@
const BLOCKED_OBJECT_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
const INVALID_CHARS_RE = /[^a-z0-9_-]+/g;
const LEADING_DASH_RE = /^-+/;
const TRAILING_DASH_RE = /-+$/;
export const UI_WECHAT_CHANNEL_TYPE = 'wechat';
export const OPENCLAW_WECHAT_CHANNEL_TYPE = 'openclaw-weixin';
export type QrChannelEvent = 'qr' | 'success' | 'error';
export function toOpenClawChannelType(channelType: string): string {
return channelType === UI_WECHAT_CHANNEL_TYPE ? OPENCLAW_WECHAT_CHANNEL_TYPE : channelType;
}
export function toUiChannelType(channelType: string): string {
return channelType === OPENCLAW_WECHAT_CHANNEL_TYPE ? UI_WECHAT_CHANNEL_TYPE : channelType;
}
export function isWechatChannelType(channelType: string | null | undefined): boolean {
return channelType === UI_WECHAT_CHANNEL_TYPE || channelType === OPENCLAW_WECHAT_CHANNEL_TYPE;
}
export function buildQrChannelEventName(channelType: string, event: QrChannelEvent): string {
return `channel:${toUiChannelType(channelType)}-${event}`;
}
function canonicalizeAccountId(value: string): string {
if (VALID_ID_RE.test(value)) return value.toLowerCase();
return value
.toLowerCase()
.replace(INVALID_CHARS_RE, '-')
.replace(LEADING_DASH_RE, '')
.replace(TRAILING_DASH_RE, '')
.slice(0, 64);
}
export function normalizeOpenClawAccountId(value: string | null | undefined, fallback = 'default'): string {
const trimmed = (value ?? '').trim();
if (!trimmed) return fallback;
const normalized = canonicalizeAccountId(trimmed);
if (!normalized || BLOCKED_OBJECT_KEYS.has(normalized)) {
return fallback;
}
return normalized;
}
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +1,101 @@
export type ChannelConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
export interface ChannelRuntimeAccountSnapshot {
connected?: boolean;
linked?: boolean;
running?: boolean;
lastError?: string | null;
lastConnectedAt?: number | null;
lastInboundAt?: number | null;
lastOutboundAt?: number | null;
lastProbeAt?: number | null;
probe?: {
ok?: boolean | null;
} | null;
}
export interface ChannelRuntimeSummarySnapshot {
error?: string | null;
lastError?: string | null;
}
const RECENT_ACTIVITY_MS = 10 * 60 * 1000;
function hasNonEmptyError(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0;
}
export function hasRecentChannelActivity(
account: Pick<ChannelRuntimeAccountSnapshot, 'lastConnectedAt' | 'lastInboundAt' | 'lastOutboundAt'>,
now = Date.now(),
recentMs = RECENT_ACTIVITY_MS,
): boolean {
return (
(typeof account.lastInboundAt === 'number' && now - account.lastInboundAt < recentMs) ||
(typeof account.lastOutboundAt === 'number' && now - account.lastOutboundAt < recentMs) ||
(typeof account.lastConnectedAt === 'number' && now - account.lastConnectedAt < recentMs)
);
}
export function hasSuccessfulChannelProbe(
account: Pick<ChannelRuntimeAccountSnapshot, 'probe'>,
): boolean {
return account.probe?.ok === true;
}
export function hasChannelRuntimeError(
account: Pick<ChannelRuntimeAccountSnapshot, 'lastError'>,
): boolean {
return hasNonEmptyError(account.lastError);
}
export function hasSummaryRuntimeError(
summary: ChannelRuntimeSummarySnapshot | undefined,
): boolean {
if (!summary) return false;
return hasNonEmptyError(summary.error) || hasNonEmptyError(summary.lastError);
}
export function isChannelRuntimeConnected(
account: ChannelRuntimeAccountSnapshot,
): boolean {
if (account.connected === true || account.linked === true) {
return true;
}
if (hasRecentChannelActivity(account) || hasSuccessfulChannelProbe(account)) {
return true;
}
// OpenClaw integrations such as Feishu/WeCom may stay "running" without ever
// setting a durable connected=true flag. Treat healthy running as connected.
return account.running === true && !hasChannelRuntimeError(account);
}
export function computeChannelRuntimeStatus(
account: ChannelRuntimeAccountSnapshot,
): ChannelConnectionStatus {
if (isChannelRuntimeConnected(account)) return 'connected';
if (hasChannelRuntimeError(account)) return 'error';
if (account.running === true) return 'connecting';
return 'disconnected';
}
export function pickChannelRuntimeStatus(
accounts: ChannelRuntimeAccountSnapshot[],
summary?: ChannelRuntimeSummarySnapshot,
): ChannelConnectionStatus {
if (accounts.some((account) => isChannelRuntimeConnected(account))) {
return 'connected';
}
if (accounts.some((account) => hasChannelRuntimeError(account)) || hasSummaryRuntimeError(summary)) {
return 'error';
}
if (accounts.some((account) => account.running === true)) {
return 'connecting';
}
return 'disconnected';
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Async mutex for serializing read-modify-write operations on
* ~/.openclaw/openclaw.json.
*
* Multiple code paths (channel-config, openclaw-auth, openclaw-proxy,
* skill-config, agent-config) perform async read → modify → write against
* the same JSON file. Without coordination, Node's event-loop can
* interleave two I/O sequences so that the second writer reads stale data
* and overwrites the first writer's changes (classic TOCTOU race).
*
* The mutex is **reentrant**: if a function already holding the lock calls
* another function that also calls `withConfigLock`, the inner call will
* pass through without blocking. This prevents deadlocks when e.g.
* `deleteAgentConfig` (locked) calls `deleteAgentChannelAccounts` (also locked).
*
* Usage:
* import { withConfigLock } from './config-mutex';
*
* await withConfigLock(async () => {
* const cfg = await readConfig();
* cfg.foo = 'bar';
* await writeConfig(cfg);
* });
*/
import { AsyncLocalStorage } from 'async_hooks';
/** Tracks whether the current async context already holds the config lock. */
const lockContext = new AsyncLocalStorage<boolean>();
class ConfigMutex {
private queue: Array<() => void> = [];
private locked = false;
async acquire(): Promise<() => void> {
if (!this.locked) {
this.locked = true;
return this.createRelease();
}
return new Promise<() => void>((resolve) => {
this.queue.push(() => resolve(this.createRelease()));
});
}
private createRelease(): () => void {
let released = false;
return () => {
if (released) return;
released = true;
const next = this.queue.shift();
if (next) {
next();
} else {
this.locked = false;
}
};
}
}
/** Singleton mutex shared across all openclaw.json writers. */
const configMutex = new ConfigMutex();
/**
* Execute `fn` while holding the config mutex.
* Ensures only one read-modify-write cycle on openclaw.json runs at a time.
*
* **Reentrant**: if the current async context already holds the lock
* (i.e. an outer `withConfigLock` is on the call stack), `fn` runs
* immediately without re-acquiring the lock.
*/
export async function withConfigLock<T>(fn: () => Promise<T>): Promise<T> {
// If we're already inside a withConfigLock call, skip re-acquiring
if (lockContext.getStore()) {
return fn();
}
const release = await configMutex.acquire();
try {
return await lockContext.run(true, fn);
} finally {
release();
}
}
+40 -40
View File
@@ -1,18 +1,18 @@
/**
* Device OAuth Manager
*
* Delegates MiniMax and Qwen OAuth to the OpenClaw extension oauth.ts functions
* imported directly from the bundled openclaw package at build time.
* Manages Device Code OAuth flows for MiniMax and Qwen providers.
*
* The OAuth protocol implementations are fully self-contained in:
* - ./minimax-oauth.ts (MiniMax Device Code + PKCE)
* - ./qwen-oauth.ts (Qwen Device Code + PKCE)
*
* This approach:
* - Avoids hardcoding client_id (lives in openclaw extension)
* - Avoids duplicating HTTP OAuth logic
* - Avoids spawning CLI process (which requires interactive TTY)
* - Hardcodes client_id and endpoints (same as openai-codex-oauth.ts)
* - Implements OAuth flows locally with zero openclaw dependency
* - Survives openclaw package upgrades without breakage
* - Works identically on macOS, Windows, and Linux
*
* The extension oauth.ts files only use `node:crypto` and global `fetch` —
* they are pure Node.js HTTP functions, no TTY, no prompter needed.
*
* We provide our own callbacks (openUrl/note/progress) that hook into
* the Electron IPC system to display UI in the ClawX frontend.
*/
@@ -21,20 +21,15 @@ import { BrowserWindow, shell } from 'electron';
import { logger } from './logger';
import { saveProvider, getProvider, ProviderConfig } from './secure-storage';
import { getProviderDefaultModel } from './provider-registry';
import { isOpenClawPresent } from './paths';
import {
loginMiniMaxPortalOAuth,
type MiniMaxOAuthToken,
type MiniMaxRegion,
} from '../../node_modules/openclaw/extensions/minimax-portal-auth/oauth';
import {
loginQwenPortalOAuth,
type QwenOAuthToken,
} from '../../node_modules/openclaw/extensions/qwen-portal-auth/oauth';
import { proxyAwareFetch } from './proxy-fetch';
import { saveOAuthTokenToOpenClaw, setOpenClawDefaultModelWithOverride } from './openclaw-auth';
import { loginMiniMaxPortalOAuth, type MiniMaxOAuthToken, type MiniMaxRegion } from './minimax-oauth';
import { loginQwenPortalOAuth, type QwenOAuthToken } from './qwen-oauth';
export type OAuthProviderType = 'minimax-portal' | 'minimax-portal-cn' | 'qwen-portal';
export type { MiniMaxRegion };
// Re-export types for consumers
export type { MiniMaxRegion, MiniMaxOAuthToken, QwenOAuthToken };
// ─────────────────────────────────────────────────────────────
// DeviceOAuthManager
@@ -47,6 +42,17 @@ class DeviceOAuthManager extends EventEmitter {
private active: boolean = false;
private mainWindow: BrowserWindow | null = null;
private async runWithProxyAwareFetch<T>(task: () => Promise<T>): Promise<T> {
const originalFetch = globalThis.fetch;
globalThis.fetch = ((input: string | URL, init?: RequestInit) =>
proxyAwareFetch(input, init)) as typeof fetch;
try {
return await task();
} finally {
globalThis.fetch = originalFetch;
}
}
setWindow(window: BrowserWindow) {
this.mainWindow = window;
}
@@ -104,21 +110,18 @@ class DeviceOAuthManager extends EventEmitter {
// ─────────────────────────────────────────────────────────
private async runMiniMaxFlow(region?: MiniMaxRegion, providerType: OAuthProviderType = 'minimax-portal'): Promise<void> {
if (!isOpenClawPresent()) {
throw new Error('OpenClaw package not found');
}
const provider = this.activeProvider!;
const token: MiniMaxOAuthToken = await loginMiniMaxPortalOAuth({
const token: MiniMaxOAuthToken = await this.runWithProxyAwareFetch(() => loginMiniMaxPortalOAuth({
region,
openUrl: async (url) => {
openUrl: async (url: string) => {
logger.info(`[DeviceOAuth] MiniMax opening browser: ${url}`);
// Open the authorization URL in the system browser
shell.openExternal(url).catch((err) =>
shell.openExternal(url).catch((err: unknown) =>
logger.warn(`[DeviceOAuth] Failed to open browser:`, err)
);
},
note: async (message, _title) => {
note: async (message: string, _title?: string) => {
if (!this.active) return;
// The extension calls note() with a message containing
// the user_code and verification_uri — parse them for the UI
@@ -130,10 +133,10 @@ class DeviceOAuthManager extends EventEmitter {
}
},
progress: {
update: (msg) => logger.info(`[DeviceOAuth] MiniMax progress: ${msg}`),
stop: (msg) => logger.info(`[DeviceOAuth] MiniMax progress done: ${msg ?? ''}`),
update: (msg: string) => logger.info(`[DeviceOAuth] MiniMax progress: ${msg}`),
stop: (msg?: string) => logger.info(`[DeviceOAuth] MiniMax progress done: ${msg ?? ''}`),
},
});
}));
if (!this.active) return;
@@ -154,19 +157,16 @@ class DeviceOAuthManager extends EventEmitter {
// ─────────────────────────────────────────────────────────
private async runQwenFlow(): Promise<void> {
if (!isOpenClawPresent()) {
throw new Error('OpenClaw package not found');
}
const provider = this.activeProvider!;
const token: QwenOAuthToken = await loginQwenPortalOAuth({
openUrl: async (url) => {
const token: QwenOAuthToken = await this.runWithProxyAwareFetch(() => loginQwenPortalOAuth({
openUrl: async (url: string) => {
logger.info(`[DeviceOAuth] Qwen opening browser: ${url}`);
shell.openExternal(url).catch((err) =>
shell.openExternal(url).catch((err: unknown) =>
logger.warn(`[DeviceOAuth] Failed to open browser:`, err)
);
},
note: async (message, _title) => {
note: async (message: string, _title?: string) => {
if (!this.active) return;
const { verificationUri, userCode } = this.parseNote(message);
if (verificationUri && userCode) {
@@ -176,10 +176,10 @@ class DeviceOAuthManager extends EventEmitter {
}
},
progress: {
update: (msg) => logger.info(`[DeviceOAuth] Qwen progress: ${msg}`),
stop: (msg) => logger.info(`[DeviceOAuth] Qwen progress done: ${msg ?? ''}`),
update: (msg: string) => logger.info(`[DeviceOAuth] Qwen progress: ${msg}`),
stop: (msg?: string) => logger.info(`[DeviceOAuth] Qwen progress done: ${msg ?? ''}`),
},
});
}));
if (!this.active) return;
@@ -283,7 +283,7 @@ class DeviceOAuthManager extends EventEmitter {
enabled: existing?.enabled ?? true,
baseUrl, // Save the dynamically resolved URL (Global vs CN)
model: existing?.model || getProviderDefaultModel(providerType),
model: getProviderDefaultModel(providerType) || existing?.model,
createdAt: existing?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
+59
View File
@@ -0,0 +1,59 @@
type EnvMap = Record<string, string | undefined>;
function isPathKey(key: string): boolean {
return key.toLowerCase() === 'path';
}
function preferredPathKey(): string {
return process.platform === 'win32' ? 'Path' : 'PATH';
}
function pathDelimiter(): string {
return process.platform === 'win32' ? ';' : ':';
}
export function getPathEnvKey(env: EnvMap): string {
const keys = Object.keys(env).filter(isPathKey);
if (keys.length === 0) return preferredPathKey();
if (process.platform === 'win32') {
if (keys.includes('Path')) return 'Path';
if (keys.includes('PATH')) return 'PATH';
return keys[0];
}
if (keys.includes('PATH')) return 'PATH';
return keys[0];
}
export function getPathEnvValue(env: EnvMap): string {
const key = getPathEnvKey(env);
return env[key] ?? '';
}
export function setPathEnvValue(
env: EnvMap,
nextPath: string,
): EnvMap {
const nextEnv: EnvMap = { ...env };
for (const key of Object.keys(nextEnv)) {
if (isPathKey(key)) {
delete nextEnv[key];
}
}
nextEnv[getPathEnvKey(env)] = nextPath;
return nextEnv;
}
export function prependPathEntry(
env: EnvMap,
entry: string,
): { env: EnvMap; path: string } {
const current = getPathEnvValue(env);
const nextPath = current ? `${entry}${pathDelimiter()}${current}` : entry;
return {
env: setPathEnvValue(env, nextPath),
path: nextPath,
};
}
+8 -7
View File
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkS
import { createServer } from 'node:http';
import { delimiter, dirname, join } from 'node:path';
import { getClawXConfigDir } from './paths';
import { proxyAwareFetch } from './proxy-fetch';
const CLIENT_ID_KEYS = ['OPENCLAW_GEMINI_OAUTH_CLIENT_ID', 'GEMINI_CLI_OAUTH_CLIENT_ID'];
const CLIENT_SECRET_KEYS = [
@@ -243,7 +244,7 @@ async function installViaNpm(onProgress?: (msg: string) => void): Promise<boolea
async function installViaDirectDownload(onProgress?: (msg: string) => void): Promise<boolean> {
try {
onProgress?.('Downloading Gemini OAuth helper...');
const metaRes = await fetch('https://registry.npmjs.org/@google/gemini-cli-core/latest');
const metaRes = await proxyAwareFetch('https://registry.npmjs.org/@google/gemini-cli-core/latest');
if (!metaRes.ok) {
onProgress?.(`Failed to fetch Gemini package metadata: ${metaRes.status}`);
return false;
@@ -256,7 +257,7 @@ async function installViaDirectDownload(onProgress?: (msg: string) => void): Pro
return false;
}
const tarRes = await fetch(tarballUrl);
const tarRes = await proxyAwareFetch(tarballUrl);
if (!tarRes.ok) {
onProgress?.(`Failed to download Gemini package: ${tarRes.status}`);
return false;
@@ -440,7 +441,7 @@ async function waitForLocalCallback(params: {
async function getUserEmail(accessToken: string): Promise<string | undefined> {
try {
const response = await fetch(USERINFO_URL, {
const response = await proxyAwareFetch(USERINFO_URL, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (response.ok) {
@@ -489,7 +490,7 @@ async function pollOperation(
): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> {
for (let attempt = 0; attempt < 24; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 5000));
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { headers });
const response = await proxyAwareFetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { headers });
if (!response.ok) {
continue;
}
@@ -530,7 +531,7 @@ async function discoverProject(accessToken: string): Promise<string> {
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
} = {};
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
const response = await proxyAwareFetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
method: 'POST',
headers,
body: JSON.stringify(loadBody),
@@ -583,7 +584,7 @@ async function discoverProject(accessToken: string): Promise<string> {
(onboardBody.metadata as Record<string, unknown>).duetProject = envProject;
}
const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
const onboardResponse = await proxyAwareFetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
method: 'POST',
headers,
body: JSON.stringify(onboardBody),
@@ -638,7 +639,7 @@ async function exchangeCodeForTokens(
body.set('client_secret', clientSecret);
}
const response = await fetch(TOKEN_URL, {
const response = await proxyAwareFetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
+27 -5
View File
@@ -10,7 +10,7 @@
import { app } from 'electron';
import { join } from 'path';
import { existsSync, mkdirSync, appendFileSync } from 'fs';
import { appendFile, readFile, readdir, stat } from 'fs/promises';
import { appendFile, open, readdir, stat } from 'fs/promises';
/**
* Log levels
@@ -230,11 +230,33 @@ export function getRecentLogs(count?: number, minLevel?: LogLevel): string[] {
*/
export async function readLogFile(tailLines = 200): Promise<string> {
if (!logFilePath) return '(No log file found)';
const safeTailLines = Math.max(1, Math.floor(tailLines));
try {
const content = await readFile(logFilePath, 'utf-8');
const lines = content.split('\n');
if (lines.length <= tailLines) return content;
return lines.slice(-tailLines).join('\n');
const file = await open(logFilePath, 'r');
try {
const fileStat = await file.stat();
if (fileStat.size === 0) return '';
const chunkSize = 64 * 1024;
let position = fileStat.size;
let content = '';
let lineCount = 0;
while (position > 0 && lineCount <= safeTailLines) {
const bytesToRead = Math.min(chunkSize, position);
position -= bytesToRead;
const buffer = Buffer.allocUnsafe(bytesToRead);
await file.read(buffer, 0, bytesToRead, position);
content = `${buffer.toString('utf-8')}${content}`;
lineCount = content.split('\n').length - 1;
}
const lines = content.split('\n');
if (lines.length <= safeTailLines) return content;
return lines.slice(-safeTailLines).join('\n');
} finally {
await file.close();
}
} catch (err) {
return `(Failed to read log file: ${err})`;
}
+256
View File
@@ -0,0 +1,256 @@
/**
* Self-contained MiniMax Device Code OAuth flow.
*
* Implements RFC 8628 (Device Authorization Grant) with PKCE for MiniMax API.
* Zero dependency on openclaw extension modules — survives openclaw upgrades.
*
* Protocol:
* 1. POST /oauth/code → get user_code, verification_uri
* 2. Open verification_uri in browser
* 3. Poll POST /oauth/token with user_code until approved
* 4. Return { access, refresh, expires, resourceUrl }
*/
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { proxyAwareFetch } from './proxy-fetch';
// ── Constants ────────────────────────────────────────────────
export type MiniMaxRegion = 'cn' | 'global';
const MINIMAX_OAUTH_CONFIG = {
cn: {
baseUrl: 'https://api.minimaxi.com',
clientId: '78257093-7e40-4613-99e0-527b14b39113',
},
global: {
baseUrl: 'https://api.minimax.io',
clientId: '78257093-7e40-4613-99e0-527b14b39113',
},
} as const;
const MINIMAX_OAUTH_SCOPE = 'group_id profile model.completion';
const MINIMAX_OAUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:user_code';
function getOAuthEndpoints(region: MiniMaxRegion) {
const config = MINIMAX_OAUTH_CONFIG[region];
return {
codeEndpoint: `${config.baseUrl}/oauth/code`,
tokenEndpoint: `${config.baseUrl}/oauth/token`,
clientId: config.clientId,
baseUrl: config.baseUrl,
};
}
// ── Types ────────────────────────────────────────────────────
export interface MiniMaxOAuthToken {
access: string;
refresh: string;
expires: number;
resourceUrl?: string;
notification_message?: string;
}
interface MiniMaxOAuthAuthorization {
user_code: string;
verification_uri: string;
expired_in: number;
interval?: number;
state: string;
}
type TokenResult =
| { status: 'success'; token: MiniMaxOAuthToken }
| { status: 'pending'; message?: string }
| { status: 'error'; message: string };
export interface MiniMaxOAuthOptions {
openUrl: (url: string) => Promise<void>;
note: (message: string, title?: string) => Promise<void>;
progress: { update: (message: string) => void; stop: (message?: string) => void };
region?: MiniMaxRegion;
}
// ── PKCE helpers (self-contained, no openclaw dependency) ────
function generatePkce(): { verifier: string; challenge: string; state: string } {
const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
const state = randomBytes(16).toString('base64url');
return { verifier, challenge, state };
}
function toFormUrlEncoded(params: Record<string, string>): string {
return new URLSearchParams(params).toString();
}
// ── OAuth flow steps ─────────────────────────────────────────
async function requestOAuthCode(params: {
challenge: string;
state: string;
region: MiniMaxRegion;
}): Promise<MiniMaxOAuthAuthorization> {
const endpoints = getOAuthEndpoints(params.region);
const response = await proxyAwareFetch(endpoints.codeEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
'x-request-id': randomUUID(),
},
body: toFormUrlEncoded({
response_type: 'code',
client_id: endpoints.clientId,
scope: MINIMAX_OAUTH_SCOPE,
code_challenge: params.challenge,
code_challenge_method: 'S256',
state: params.state,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`MiniMax OAuth authorization failed: ${text || response.statusText}`);
}
const payload = (await response.json()) as MiniMaxOAuthAuthorization & { error?: string };
if (!payload.user_code || !payload.verification_uri) {
throw new Error(
payload.error ??
'MiniMax OAuth authorization returned an incomplete payload (missing user_code or verification_uri).',
);
}
if (payload.state !== params.state) {
throw new Error('MiniMax OAuth state mismatch: possible CSRF attack or session corruption.');
}
return payload;
}
async function pollOAuthToken(params: {
userCode: string;
verifier: string;
region: MiniMaxRegion;
}): Promise<TokenResult> {
const endpoints = getOAuthEndpoints(params.region);
const response = await proxyAwareFetch(endpoints.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: toFormUrlEncoded({
grant_type: MINIMAX_OAUTH_GRANT_TYPE,
client_id: endpoints.clientId,
user_code: params.userCode,
code_verifier: params.verifier,
}),
});
const text = await response.text();
let payload:
| {
status?: string;
base_resp?: { status_code?: number; status_msg?: string };
}
| undefined;
if (text) {
try {
payload = JSON.parse(text) as typeof payload;
} catch {
payload = undefined;
}
}
if (!response.ok) {
return {
status: 'error',
message:
(payload?.base_resp?.status_msg ?? text) || 'MiniMax OAuth failed to parse response.',
};
}
if (!payload) {
return { status: 'error', message: 'MiniMax OAuth failed to parse response.' };
}
const tokenPayload = payload as {
status: string;
access_token?: string | null;
refresh_token?: string | null;
expired_in?: number | null;
token_type?: string;
resource_url?: string;
notification_message?: string;
};
if (tokenPayload.status === 'error') {
return { status: 'error', message: 'An error occurred. Please try again later' };
}
if (tokenPayload.status !== 'success') {
return { status: 'pending', message: 'current user code is not authorized' };
}
if (!tokenPayload.access_token || !tokenPayload.refresh_token || !tokenPayload.expired_in) {
return { status: 'error', message: 'MiniMax OAuth returned incomplete token payload.' };
}
return {
status: 'success',
token: {
access: tokenPayload.access_token,
refresh: tokenPayload.refresh_token,
expires: tokenPayload.expired_in,
resourceUrl: tokenPayload.resource_url,
notification_message: tokenPayload.notification_message,
},
};
}
// ── Public API ───────────────────────────────────────────────
export async function loginMiniMaxPortalOAuth(params: MiniMaxOAuthOptions): Promise<MiniMaxOAuthToken> {
const region = params.region ?? 'global';
const { verifier, challenge, state } = generatePkce();
const oauth = await requestOAuthCode({ challenge, state, region });
const verificationUrl = oauth.verification_uri;
const noteLines = [
`Open ${verificationUrl} to approve access.`,
`If prompted, enter the code ${oauth.user_code}.`,
`Interval: ${oauth.interval ?? 'default (2000ms)'}, Expires at: ${oauth.expired_in} unix timestamp`,
];
await params.note(noteLines.join('\n'), 'MiniMax OAuth');
try {
await params.openUrl(verificationUrl);
} catch {
// Fall back to manual copy/paste if browser open fails.
}
let pollIntervalMs = oauth.interval ? oauth.interval : 2000;
const expireTimeMs = oauth.expired_in;
while (Date.now() < expireTimeMs) {
params.progress.update('Waiting for MiniMax OAuth approval…');
const result = await pollOAuthToken({
userCode: oauth.user_code,
verifier,
region,
});
if (result.status === 'success') {
return result.token;
}
if (result.status === 'error') {
throw new Error(result.message);
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
pollIntervalMs = Math.max(pollIntervalMs, 2000);
}
throw new Error('MiniMax OAuth timed out before authorization completed.');
}
+305
View File
@@ -0,0 +1,305 @@
import { createHash, randomBytes } from 'node:crypto';
import { createServer } from 'node:http';
import { proxyAwareFetch } from './proxy-fetch';
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
const AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize';
const TOKEN_URL = 'https://auth.openai.com/oauth/token';
// Must match the redirect URI expected by OpenAI Codex OAuth client.
const REDIRECT_URI = 'http://localhost:1455/auth/callback';
const SCOPE = 'openid profile email offline_access';
const JWT_CLAIM_PATH = 'https://api.openai.com/auth';
const ORIGINATOR = 'codex_cli_rs';
const SUCCESS_HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Authentication successful</title>
</head>
<body>
<p>Authentication successful. Return to ClawX to continue.</p>
</body>
</html>`;
export interface OpenAICodexOAuthCredentials {
access: string;
refresh: string;
expires: number;
accountId: string;
}
interface OpenAICodexAuthorizationFlow {
verifier: string;
state: string;
url: string;
}
interface OpenAICodexLocalServer {
close: () => void;
waitForCode: () => Promise<{ code: string } | null>;
}
function toBase64Url(buffer: Buffer): string {
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
function createPkce(): { verifier: string; challenge: string } {
const verifier = toBase64Url(randomBytes(32));
const challenge = toBase64Url(createHash('sha256').update(verifier).digest());
return { verifier, challenge };
}
function createState(): string {
return toBase64Url(randomBytes(32));
}
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
const value = input.trim();
if (!value) {
return {};
}
try {
const url = new URL(value);
return {
code: url.searchParams.get('code') ?? undefined,
state: url.searchParams.get('state') ?? undefined,
};
} catch {
// not a URL
}
if (value.includes('#')) {
const [code, state] = value.split('#', 2);
return { code, state };
}
if (value.includes('code=')) {
const params = new URLSearchParams(value);
return {
code: params.get('code') ?? undefined,
state: params.get('state') ?? undefined,
};
}
return { code: value };
}
function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
const payload = parts[1];
if (!payload) {
return null;
}
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
const decoded = Buffer.from(padded, 'base64').toString('utf8');
return JSON.parse(decoded) as Record<string, unknown>;
} catch {
return null;
}
}
function getAccountIdFromAccessToken(accessToken: string): string | null {
const payload = decodeJwtPayload(accessToken);
const authClaims = payload?.[JWT_CLAIM_PATH];
if (!authClaims || typeof authClaims !== 'object') {
return null;
}
const accountId = (authClaims as Record<string, unknown>).chatgpt_account_id;
if (typeof accountId !== 'string' || !accountId.trim()) {
return null;
}
return accountId;
}
async function createAuthorizationFlow(): Promise<OpenAICodexAuthorizationFlow> {
const { verifier, challenge } = createPkce();
const state = createState();
const url = new URL(AUTHORIZE_URL);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', CLIENT_ID);
url.searchParams.set('redirect_uri', REDIRECT_URI);
url.searchParams.set('scope', SCOPE);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('state', state);
url.searchParams.set('id_token_add_organizations', 'true');
url.searchParams.set('codex_cli_simplified_flow', 'true');
url.searchParams.set('originator', ORIGINATOR);
return { verifier, state, url: url.toString() };
}
function startLocalOAuthServer(state: string): Promise<OpenAICodexLocalServer | null> {
let lastCode: string | null = null;
const server = createServer((req, res) => {
try {
const url = new URL(req.url || '', 'http://localhost');
if (url.pathname !== '/auth/callback') {
res.statusCode = 404;
res.end('Not found');
return;
}
if (url.searchParams.get('state') !== state) {
res.statusCode = 400;
res.end('State mismatch');
return;
}
const code = url.searchParams.get('code');
if (!code) {
res.statusCode = 400;
res.end('Missing authorization code');
return;
}
lastCode = code;
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(SUCCESS_HTML);
} catch {
res.statusCode = 500;
res.end('Internal error');
}
});
return new Promise((resolve) => {
server
.listen(1455, 'localhost', () => {
resolve({
close: () => server.close(),
waitForCode: async () => {
const sleep = () => new Promise((r) => setTimeout(r, 100));
for (let i = 0; i < 600; i += 1) {
if (lastCode) {
return { code: lastCode };
}
await sleep();
}
return null;
},
});
})
.on('error', () => {
resolve(null);
});
});
}
async function exchangeAuthorizationCode(
code: string,
verifier: string,
): Promise<{ access: string; refresh: string; expires: number }> {
const response = await proxyAwareFetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
code,
code_verifier: verifier,
redirect_uri: REDIRECT_URI,
}),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`OpenAI token exchange failed (${response.status}): ${text}`);
}
const json = await response.json() as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== 'number') {
throw new Error('OpenAI token response missing fields');
}
return {
access: json.access_token,
refresh: json.refresh_token,
expires: Date.now() + json.expires_in * 1000,
};
}
export async function loginOpenAICodexOAuth(options: {
openUrl: (url: string) => Promise<void>;
onProgress?: (message: string) => void;
onManualCodeRequired?: (payload: { authorizationUrl: string; reason: 'port_in_use' | 'callback_timeout' }) => void;
onManualCodeInput?: () => Promise<string>;
}): Promise<OpenAICodexOAuthCredentials> {
const { verifier, state, url } = await createAuthorizationFlow();
options.onProgress?.('Opening OpenAI sign-in page…');
const server = await startLocalOAuthServer(state);
try {
await options.openUrl(url);
options.onProgress?.(
server ? 'Waiting for OpenAI OAuth callback…' : 'Callback port unavailable, waiting for manual authorization code…',
);
let code: string | undefined;
if (server) {
const result = await server.waitForCode();
code = result?.code ?? undefined;
if (!code && options.onManualCodeInput) {
options.onManualCodeRequired?.({ authorizationUrl: url, reason: 'callback_timeout' });
code = await options.onManualCodeInput();
}
} else {
if (!options.onManualCodeInput) {
throw new Error('Cannot start OpenAI OAuth callback server on localhost:1455');
}
options.onManualCodeRequired?.({ authorizationUrl: url, reason: 'port_in_use' });
code = await options.onManualCodeInput();
}
if (!code) {
throw new Error('Missing OpenAI authorization code');
}
const parsed = parseAuthorizationInput(code);
if (parsed.state && parsed.state !== state) {
throw new Error('OpenAI OAuth state mismatch');
}
code = parsed.code;
if (!code) {
throw new Error('Missing OpenAI authorization code');
}
const token = await exchangeAuthorizationCode(code, verifier);
const accountId = getAccountIdFromAccessToken(token.access);
if (!accountId) {
throw new Error('Failed to extract OpenAI accountId from token');
}
return {
access: token.access,
refresh: token.refresh,
expires: token.expires,
accountId,
};
} finally {
server?.close();
}
}
File diff suppressed because it is too large Load Diff
+93 -2
View File
@@ -31,6 +31,12 @@ function quoteForPowerShell(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
function getPackagedWindowsNodePath(): string | null {
if (!app.isPackaged || process.platform !== 'win32') return null;
const nodePath = join(process.resourcesPath, 'bin', 'node.exe');
return existsSync(nodePath) ? nodePath : null;
}
// ── CLI command string (for display / copy) ──────────────────────────────────
export function getOpenClawCliCommand(): string {
@@ -69,7 +75,12 @@ export function getOpenClawCliCommand(): string {
const cliDir = join(process.resourcesPath, 'cli');
const cmdPath = join(cliDir, 'openclaw.cmd');
if (existsSync(cmdPath)) {
return quoteForPowerShell(cmdPath);
return `& ${quoteForPowerShell(cmdPath)}`;
}
const bundledNode = getPackagedWindowsNodePath();
if (bundledNode) {
return `& ${quoteForPowerShell(bundledNode)} ${quoteForPowerShell(entryPath)}`;
}
}
@@ -104,6 +115,11 @@ function getPackagedCliWrapperPath(): string | null {
return null;
}
function getWindowsPowerShellPath(): string {
const systemRoot = process.env.SystemRoot || 'C:\\Windows';
return join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
}
// ── macOS / Linux install ────────────────────────────────────────────────────
function getCliTargetPath(): string {
@@ -165,6 +181,71 @@ function isCliInstalled(): boolean {
return true;
}
function ensureWindowsCliOnPath(): Promise<'updated' | 'already-present'> {
return new Promise((resolve, reject) => {
const cliWrapper = getPackagedCliWrapperPath();
if (!cliWrapper) {
reject(new Error('CLI wrapper not found in app resources.'));
return;
}
const cliDir = dirname(cliWrapper);
const helperPath = join(cliDir, 'update-user-path.ps1');
if (!existsSync(helperPath)) {
reject(new Error(`PATH helper not found at ${helperPath}`));
return;
}
const child = spawn(
getWindowsPowerShellPath(),
[
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-File',
helperPath,
'-Action',
'add',
'-CliDir',
cliDir,
],
{
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
},
);
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', reject);
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(stderr.trim() || `PowerShell exited with code ${code}`));
return;
}
const status = stdout.trim();
if (status === 'updated' || status === 'already-present') {
resolve(status);
return;
}
reject(new Error(`Unexpected PowerShell output: ${status || '(empty)'}`));
});
});
}
function ensureLocalBinInPath(): void {
if (process.platform === 'win32') return;
@@ -205,7 +286,17 @@ export async function autoInstallCliIfNeeded(
notify?: (path: string) => void,
): Promise<void> {
if (!app.isPackaged) return;
if (process.platform === 'win32') return; // NSIS handles it
if (process.platform === 'win32') {
try {
const result = await ensureWindowsCliOnPath();
if (result === 'updated') {
logger.info('Added Windows CLI directory to user PATH.');
}
} catch (error) {
logger.warn('Failed to ensure Windows CLI is on PATH:', error);
}
return;
}
const target = getCliTargetPath();
const wrapperSrc = getPackagedCliWrapperPath();
+17
View File
@@ -0,0 +1,17 @@
/**
* Build the external OpenClaw Control UI URL.
*
* OpenClaw 2026.3.13 imports one-time auth tokens from the URL fragment
* (`#token=...`) and strips them after load. Query-string tokens are removed
* by the UI bootstrap but are not imported for auth.
*/
export function buildOpenClawControlUiUrl(port: number, token: string): string {
const url = new URL(`http://127.0.0.1:${port}/`);
const trimmedToken = token.trim();
if (trimmedToken) {
url.hash = new URLSearchParams({ token: trimmedToken }).toString();
}
return url.toString();
}
+204
View File
@@ -0,0 +1,204 @@
import { app, utilityProcess } from 'electron';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { getOpenClawDir, getOpenClawEntryPath } from './paths';
import { logger } from './logger';
import { getUvMirrorEnv } from './uv-env';
const OPENCLAW_DOCTOR_TIMEOUT_MS = 60_000;
const MAX_DOCTOR_OUTPUT_BYTES = 10 * 1024 * 1024;
const OPENCLAW_DOCTOR_ARGS = ['doctor'];
const OPENCLAW_DOCTOR_FIX_ARGS = ['doctor', '--fix', '--yes', '--non-interactive'];
export type OpenClawDoctorMode = 'diagnose' | 'fix';
export interface OpenClawDoctorResult {
mode: OpenClawDoctorMode;
success: boolean;
exitCode: number | null;
stdout: string;
stderr: string;
command: string;
cwd: string;
durationMs: number;
timedOut?: boolean;
error?: string;
}
function appendDoctorOutput(
current: string,
currentBytes: number,
data: Buffer | string,
stream: 'stdout' | 'stderr',
alreadyTruncated: boolean,
): { output: string; bytes: number; truncated: boolean } {
if (alreadyTruncated) {
return { output: current, bytes: currentBytes, truncated: true };
}
const chunk = typeof data === 'string' ? Buffer.from(data) : data;
if (currentBytes + chunk.length <= MAX_DOCTOR_OUTPUT_BYTES) {
return {
output: current + chunk.toString(),
bytes: currentBytes + chunk.length,
truncated: false,
};
}
const remaining = Math.max(0, MAX_DOCTOR_OUTPUT_BYTES - currentBytes);
const appended = remaining > 0 ? chunk.subarray(0, remaining).toString() : '';
logger.warn(
`OpenClaw doctor ${stream} exceeded ${MAX_DOCTOR_OUTPUT_BYTES} bytes; truncating additional output`,
);
return {
output: current + appended,
bytes: MAX_DOCTOR_OUTPUT_BYTES,
truncated: true,
};
}
function getBundledBinPath(): string {
const target = `${process.platform}-${process.arch}`;
return app.isPackaged
? path.join(process.resourcesPath, 'bin')
: path.join(process.cwd(), 'resources', 'bin', target);
}
async function runDoctorCommandWithArgs(
mode: OpenClawDoctorMode,
args: string[],
): Promise<OpenClawDoctorResult> {
const openclawDir = getOpenClawDir();
const entryScript = getOpenClawEntryPath();
const command = `openclaw ${args.join(' ')}`;
const startedAt = Date.now();
if (!existsSync(entryScript)) {
const error = `OpenClaw entry script not found at ${entryScript}`;
logger.error(`Cannot run OpenClaw doctor: ${error}`);
return {
mode,
success: false,
exitCode: null,
stdout: '',
stderr: '',
command,
cwd: openclawDir,
durationMs: Date.now() - startedAt,
error,
};
}
const binPath = getBundledBinPath();
const binPathExists = existsSync(binPath);
const finalPath = binPathExists
? `${binPath}${path.delimiter}${process.env.PATH || ''}`
: process.env.PATH || '';
const uvEnv = await getUvMirrorEnv();
logger.info(
`Running OpenClaw doctor (mode=${mode}, entry="${entryScript}", args="${args.join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'})`,
);
return await new Promise<OpenClawDoctorResult>((resolve) => {
const child = utilityProcess.fork(entryScript, args, {
cwd: openclawDir,
stdio: 'pipe',
env: {
...process.env,
...uvEnv,
PATH: finalPath,
OPENCLAW_NO_RESPAWN: '1',
} as NodeJS.ProcessEnv,
});
let stdout = '';
let stderr = '';
let stdoutBytes = 0;
let stderrBytes = 0;
let stdoutTruncated = false;
let stderrTruncated = false;
let settled = false;
const finish = (result: Omit<OpenClawDoctorResult, 'durationMs'>) => {
if (settled) return;
settled = true;
resolve({
...result,
durationMs: Date.now() - startedAt,
});
};
const timeout = setTimeout(() => {
logger.error(`OpenClaw doctor timed out after ${OPENCLAW_DOCTOR_TIMEOUT_MS}ms`);
try {
child.kill();
} catch {
// ignore
}
finish({
mode,
success: false,
exitCode: null,
stdout,
stderr,
command,
cwd: openclawDir,
timedOut: true,
error: `Timed out after ${OPENCLAW_DOCTOR_TIMEOUT_MS}ms`,
});
}, OPENCLAW_DOCTOR_TIMEOUT_MS);
child.stdout?.on('data', (data) => {
const next = appendDoctorOutput(stdout, stdoutBytes, data, 'stdout', stdoutTruncated);
stdout = next.output;
stdoutBytes = next.bytes;
stdoutTruncated = next.truncated;
});
child.stderr?.on('data', (data) => {
const next = appendDoctorOutput(stderr, stderrBytes, data, 'stderr', stderrTruncated);
stderr = next.output;
stderrBytes = next.bytes;
stderrTruncated = next.truncated;
});
child.on('error', (error) => {
clearTimeout(timeout);
logger.error('Failed to spawn OpenClaw doctor process:', error);
finish({
mode,
success: false,
exitCode: null,
stdout,
stderr,
command,
cwd: openclawDir,
error: error instanceof Error ? error.message : String(error),
});
});
child.on('exit', (code) => {
clearTimeout(timeout);
logger.info(`OpenClaw doctor exited with code ${code ?? 'null'}`);
finish({
mode,
success: code === 0,
exitCode: code,
stdout,
stderr,
command,
cwd: openclawDir,
});
});
});
}
export async function runOpenClawDoctor(): Promise<OpenClawDoctorResult> {
return await runDoctorCommandWithArgs('diagnose', OPENCLAW_DOCTOR_ARGS);
}
export async function runOpenClawDoctorFix(): Promise<OpenClawDoctorResult> {
return await runDoctorCommandWithArgs('fix', OPENCLAW_DOCTOR_FIX_ARGS);
}
+47 -27
View File
@@ -1,43 +1,63 @@
import { readOpenClawConfig, writeOpenClawConfig } from './channel-config';
import { resolveProxySettings, type ProxySettings } from './proxy';
import { logger } from './logger';
import { withConfigLock } from './config-mutex';
interface SyncProxyOptions {
/**
* When true, keep an existing channels.telegram.proxy value if proxy is
* currently disabled in ClawX settings.
*/
preserveExistingWhenDisabled?: boolean;
}
/**
* Sync ClawX global proxy settings into OpenClaw channel config where the
* upstream runtime expects an explicit per-channel proxy knob.
*/
export async function syncProxyConfigToOpenClaw(settings: ProxySettings): Promise<void> {
const config = await readOpenClawConfig();
const telegramConfig = config.channels?.telegram;
export async function syncProxyConfigToOpenClaw(
settings: ProxySettings,
options: SyncProxyOptions = {},
): Promise<void> {
return withConfigLock(async () => {
const config = await readOpenClawConfig();
const telegramConfig = config.channels?.telegram;
if (!telegramConfig) {
return;
}
if (!telegramConfig) {
return;
}
const resolved = resolveProxySettings(settings);
const nextProxy = settings.proxyEnabled
? (resolved.allProxy || resolved.httpsProxy || resolved.httpProxy)
: '';
const currentProxy = typeof telegramConfig.proxy === 'string' ? telegramConfig.proxy : '';
const resolved = resolveProxySettings(settings);
const preserveExistingWhenDisabled = options.preserveExistingWhenDisabled !== false;
const nextProxy = settings.proxyEnabled
? (resolved.allProxy || resolved.httpsProxy || resolved.httpProxy)
: '';
const currentProxy = typeof telegramConfig.proxy === 'string' ? telegramConfig.proxy : '';
if (!nextProxy && !currentProxy) {
return;
}
if (!settings.proxyEnabled && preserveExistingWhenDisabled && currentProxy) {
logger.info('Skipped Telegram proxy sync because ClawX proxy is disabled and preserve mode is enabled');
return;
}
if (!config.channels) {
config.channels = {};
}
if (!nextProxy && !currentProxy) {
return;
}
config.channels.telegram = {
...telegramConfig,
};
if (!config.channels) {
config.channels = {};
}
if (nextProxy) {
config.channels.telegram.proxy = nextProxy;
} else {
delete config.channels.telegram.proxy;
}
config.channels.telegram = {
...telegramConfig,
};
await writeOpenClawConfig(config);
logger.info(`Synced Telegram proxy to OpenClaw config (${nextProxy || 'disabled'})`);
if (nextProxy) {
config.channels.telegram.proxy = nextProxy;
} else {
delete config.channels.telegram.proxy;
}
await writeOpenClawConfig(config);
logger.info(`Synced Telegram proxy to OpenClaw config (${nextProxy || 'disabled'})`);
});
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Dynamic imports for openclaw plugin-sdk subpath exports.
*
* openclaw is NOT in the asar's node_modules it lives at resources/openclaw/
* (extraResources). Static `import ... from 'openclaw/plugin-sdk/...'` would
* produce a runtime require() that fails inside the asar.
*
* Instead, we create a require context from the openclaw directory itself.
* Node.js package self-referencing allows a package to require its own exports
* by name, so `openclawRequire('openclaw/plugin-sdk/discord')` resolves via the
* exports map in openclaw's package.json.
*
* In dev mode (pnpm), the resolved path is in the pnpm virtual store where
* self-referencing also works. The projectRequire fallback covers edge cases.
*/
import { createRequire } from 'module';
import { join } from 'node:path';
import { getOpenClawDir, getOpenClawResolvedDir } from './paths';
const _openclawPath = getOpenClawDir();
const _openclawResolvedPath = getOpenClawResolvedDir();
const _openclawSdkRequire = createRequire(join(_openclawResolvedPath, 'package.json'));
const _projectSdkRequire = createRequire(join(_openclawPath, 'package.json'));
function requireOpenClawSdk(subpath: string): Record<string, unknown> {
try {
return _openclawSdkRequire(subpath);
} catch {
return _projectSdkRequire(subpath);
}
}
// --- Channel SDK dynamic imports ---
const _discordSdk = requireOpenClawSdk('openclaw/plugin-sdk/discord') as {
listDiscordDirectoryGroupsFromConfig: (...args: unknown[]) => Promise<unknown[]>;
listDiscordDirectoryPeersFromConfig: (...args: unknown[]) => Promise<unknown[]>;
normalizeDiscordMessagingTarget: (target: string) => string | undefined;
};
const _telegramSdk = requireOpenClawSdk('openclaw/plugin-sdk/telegram') as {
listTelegramDirectoryGroupsFromConfig: (...args: unknown[]) => Promise<unknown[]>;
listTelegramDirectoryPeersFromConfig: (...args: unknown[]) => Promise<unknown[]>;
normalizeTelegramMessagingTarget: (target: string) => string | undefined;
};
const _slackSdk = requireOpenClawSdk('openclaw/plugin-sdk/slack') as {
listSlackDirectoryGroupsFromConfig: (...args: unknown[]) => Promise<unknown[]>;
listSlackDirectoryPeersFromConfig: (...args: unknown[]) => Promise<unknown[]>;
normalizeSlackMessagingTarget: (target: string) => string | undefined;
};
const _whatsappSdk = requireOpenClawSdk('openclaw/plugin-sdk/whatsapp-shared') as {
normalizeWhatsAppMessagingTarget: (target: string) => string | undefined;
};
export const {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
normalizeDiscordMessagingTarget,
} = _discordSdk;
export const {
listTelegramDirectoryGroupsFromConfig,
listTelegramDirectoryPeersFromConfig,
normalizeTelegramMessagingTarget,
} = _telegramSdk;
export const {
listSlackDirectoryGroupsFromConfig,
listSlackDirectoryPeersFromConfig,
normalizeSlackMessagingTarget,
} = _slackSdk;
export const { normalizeWhatsAppMessagingTarget } = _whatsappSdk;
+6 -11
View File
@@ -5,7 +5,7 @@
* main thread.
*/
import { access, readFile, writeFile, readdir, mkdir, unlink } from 'fs/promises';
import { constants, Dirent } from 'fs';
import { constants } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { logger } from './logger';
@@ -78,16 +78,11 @@ async function resolveAllWorkspaceDirs(): Promise<string[]> {
// ignore config parse errors
}
try {
const entries: Dirent[] = await readdir(openclawDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith('workspace')) {
dirs.add(join(openclawDir, entry.name));
}
}
} catch {
// ignore read errors
}
// We intentionally do NOT scan ~/.openclaw/ for any directory starting
// with 'workspace'. Doing so causes a race condition where a recently deleted
// agent's workspace (e.g., workspace-code23) is found and resuscitated by
// the context merge routine before its deletion finishes. Only workspaces
// explicitly declared in openclaw.json should be seeded.
if (dirs.size === 0) {
dirs.add(join(openclawDir, 'workspace'));
+35 -9
View File
@@ -2,11 +2,14 @@
* Path Utilities
* Cross-platform path resolution helpers
*/
import { app } from 'electron';
import { createRequire } from 'node:module';
import { join } from 'path';
import { homedir } from 'os';
import { existsSync, mkdirSync, readFileSync, realpathSync } from 'fs';
import { logger } from './logger';
const require = createRequire(import.meta.url);
type ElectronAppLike = Pick<typeof import('electron').app, 'isPackaged' | 'getPath' | 'getAppPath'>;
export {
quoteForCmd,
@@ -16,6 +19,24 @@ export {
appendNodeRequireToNodeOptions,
} from './win-shell';
function getElectronApp() {
if (process.versions?.electron) {
return (require('electron') as typeof import('electron')).app;
}
const fallbackUserData = process.env.CLAWX_USER_DATA_DIR?.trim() || join(homedir(), '.clawx');
const fallbackAppPath = process.cwd();
const fallbackApp: ElectronAppLike = {
isPackaged: false,
getPath: (name) => {
if (name === 'userData') return fallbackUserData;
return fallbackUserData;
},
getAppPath: () => fallbackAppPath,
};
return fallbackApp;
}
/**
* Expand ~ to home directory
*/
@@ -51,14 +72,14 @@ export function getClawXConfigDir(): string {
* Get ClawX logs directory
*/
export function getLogsDir(): string {
return join(app.getPath('userData'), 'logs');
return join(getElectronApp().getPath('userData'), 'logs');
}
/**
* Get ClawX data directory
*/
export function getDataDir(): string {
return app.getPath('userData');
return getElectronApp().getPath('userData');
}
/**
@@ -74,7 +95,7 @@ export function ensureDir(dir: string): void {
* Get resources directory (for bundled assets)
*/
export function getResourcesDir(): string {
if (app.isPackaged) {
if (getElectronApp().isPackaged) {
return join(process.resourcesPath, 'resources');
}
return join(__dirname, '../../resources');
@@ -93,7 +114,7 @@ export function getPreloadPath(): string {
* - Development: from node_modules/openclaw
*/
export function getOpenClawDir(): string {
if (app.isPackaged) {
if (getElectronApp().isPackaged) {
return join(process.resourcesPath, 'openclaw');
}
// Development: use node_modules/openclaw
@@ -127,7 +148,7 @@ export function getOpenClawEntryPath(): string {
* Get ClawHub CLI entry script path (clawdhub.js)
*/
export function getClawHubCliEntryPath(): string {
return join(app.getAppPath(), 'node_modules', 'clawhub', 'bin', 'clawdhub.js');
return join(getElectronApp().getAppPath(), 'node_modules', 'clawhub', 'bin', 'clawdhub.js');
}
/**
@@ -135,7 +156,7 @@ export function getClawHubCliEntryPath(): string {
*/
export function getClawHubCliBinPath(): string {
const binName = process.platform === 'win32' ? 'clawhub.cmd' : 'clawhub';
return join(app.getAppPath(), 'node_modules', '.bin', binName);
return join(getElectronApp().getAppPath(), 'node_modules', '.bin', binName);
}
/**
@@ -192,6 +213,11 @@ export function getOpenClawStatus(): OpenClawStatus {
version,
};
logger.info('OpenClaw status:', status);
try {
const { logger } = require('./logger') as typeof import('./logger');
logger.info('OpenClaw status:', status);
} catch {
// Ignore logger bootstrap issues in non-Electron contexts such as unit tests.
}
return status;
}
+548
View File
@@ -0,0 +1,548 @@
/**
* Shared OpenClaw Plugin Install Utilities
*
* Provides version-aware install/upgrade logic for bundled OpenClaw plugins
* (DingTalk, WeCom, QQBot, Feishu, WeChat). Used both at app startup (to auto-upgrade
* stale plugins) and when a user configures a channel.
*/
import { app } from 'electron';
import path from 'node:path';
import { existsSync, cpSync, copyFileSync, statSync, mkdirSync, rmSync, readFileSync, writeFileSync, readdirSync, realpathSync } from 'node:fs';
import { readdir, stat, copyFile, mkdir } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { logger } from './logger';
function normalizeFsPathForWindows(filePath: string): string {
if (process.platform !== 'win32') return filePath;
if (!filePath) return filePath;
if (filePath.startsWith('\\\\?\\')) return filePath;
const windowsPath = filePath.replace(/\//g, '\\');
if (!path.win32.isAbsolute(windowsPath)) return windowsPath;
if (windowsPath.startsWith('\\\\')) {
return `\\\\?\\UNC\\${windowsPath.slice(2)}`;
}
return `\\\\?\\${windowsPath}`;
}
function fsPath(filePath: string): string {
return normalizeFsPathForWindows(filePath);
}
/**
* Unicode-safe recursive directory copy.
*
* Node.js `cpSync` / `cp` crash on Windows when paths contain non-ASCII
* characters such as Chinese (nodejs/node#54476). On Windows we fall back
* to a manual recursive walk using `copyFileSync` which is unaffected.
*/
export function cpSyncSafe(src: string, dest: string): void {
if (process.platform !== 'win32') {
cpSync(fsPath(src), fsPath(dest), { recursive: true, dereference: true });
return;
}
// Windows: manual recursive copy with per-file copyFileSync
_copyDirSyncRecursive(fsPath(src), fsPath(dest));
}
function _copyDirSyncRecursive(src: string, dest: string): void {
mkdirSync(dest, { recursive: true });
const entries = readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcChild = join(src, entry.name);
const destChild = join(dest, entry.name);
// Dereference symlinks: use statSync (follows links) instead of lstatSync
const info = statSync(srcChild);
if (info.isDirectory()) {
_copyDirSyncRecursive(srcChild, destChild);
} else {
copyFileSync(srcChild, destChild);
}
}
}
/**
* Async variant of `cpSyncSafe` for use with fs/promises.
*/
export async function cpAsyncSafe(src: string, dest: string): Promise<void> {
if (process.platform !== 'win32') {
const { cp } = await import('node:fs/promises');
await cp(fsPath(src), fsPath(dest), { recursive: true, dereference: true });
return;
}
// Windows: manual recursive copy with per-file copyFile
await _copyDirAsyncRecursive(fsPath(src), fsPath(dest));
}
async function _copyDirAsyncRecursive(src: string, dest: string): Promise<void> {
await mkdir(dest, { recursive: true });
const entries = await readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcChild = join(src, entry.name);
const destChild = join(dest, entry.name);
const info = await stat(srcChild);
if (info.isDirectory()) {
await _copyDirAsyncRecursive(srcChild, destChild);
} else {
await copyFile(srcChild, destChild);
}
}
}
function asErrnoException(error: unknown): NodeJS.ErrnoException | null {
if (error && typeof error === 'object') {
return error as NodeJS.ErrnoException;
}
return null;
}
function toErrorDiagnostic(error: unknown): { code?: string; name?: string; message: string } {
const errno = asErrnoException(error);
if (!errno) {
return { message: String(error) };
}
return {
code: typeof errno.code === 'string' ? errno.code : undefined,
name: errno.name,
message: errno.message || String(error),
};
}
// ── Known plugin-ID corrections ─────────────────────────────────────────────
// Some npm packages ship with an openclaw.plugin.json whose "id" field
// doesn't match the ID the plugin code actually exports. After copying we
// patch both the manifest AND the compiled JS so the Gateway accepts them.
const MANIFEST_ID_FIXES: Record<string, string> = {
'wecom-openclaw-plugin': 'wecom',
};
/**
* After a plugin has been copied to ~/.openclaw/extensions/<dir>, fix any
* known manifest-ID mismatches so the Gateway can load the plugin.
* Also patches package.json fields that the Gateway uses as "entry hints".
*/
export function fixupPluginManifest(targetDir: string): void {
// 1. Fix openclaw.plugin.json id
const manifestPath = join(targetDir, 'openclaw.plugin.json');
try {
const raw = readFileSync(fsPath(manifestPath), 'utf-8');
const manifest = JSON.parse(raw);
const oldId = manifest.id as string | undefined;
if (oldId && MANIFEST_ID_FIXES[oldId]) {
const newId = MANIFEST_ID_FIXES[oldId];
manifest.id = newId;
writeFileSync(fsPath(manifestPath), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
logger.info(`[plugin] Fixed manifest ID: ${oldId}${newId}`);
}
} catch {
// manifest may not exist yet — ignore
}
// 2. Fix package.json fields that Gateway uses as "entry hints"
const pkgPath = join(targetDir, 'package.json');
try {
const raw = readFileSync(fsPath(pkgPath), 'utf-8');
const pkg = JSON.parse(raw);
let modified = false;
// Check if the package name contains a legacy ID that needs fixing
for (const [oldId, newId] of Object.entries(MANIFEST_ID_FIXES)) {
if (typeof pkg.name === 'string' && pkg.name.includes(oldId)) {
pkg.name = pkg.name.replace(oldId, newId);
modified = true;
}
const install = pkg.openclaw?.install;
if (install) {
if (typeof install.npmSpec === 'string' && install.npmSpec.includes(oldId)) {
install.npmSpec = install.npmSpec.replace(oldId, newId);
modified = true;
}
if (typeof install.localPath === 'string' && install.localPath.includes(oldId)) {
install.localPath = install.localPath.replace(oldId, newId);
modified = true;
}
}
}
if (modified) {
writeFileSync(fsPath(pkgPath), JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
logger.info(`[plugin] Fixed package.json entry hints in ${targetDir}`);
}
} catch {
// ignore
}
// 3. Fix hardcoded plugin IDs in compiled JS entry files.
// The Gateway validates that the JS export's `id` matches the manifest.
patchPluginEntryIds(targetDir);
}
/**
* Patch the compiled JS entry files so the hardcoded `id` field in the
* plugin export matches the manifest. Without this, the Gateway rejects
* the plugin with "plugin id mismatch".
*/
function patchPluginEntryIds(targetDir: string): void {
const pkgPath = join(targetDir, 'package.json');
let pkg: Record<string, unknown>;
try {
pkg = JSON.parse(readFileSync(fsPath(pkgPath), 'utf-8'));
} catch {
return;
}
const entryFiles = [pkg.main, pkg.module].filter(Boolean) as string[];
for (const entry of entryFiles) {
const entryPath = join(targetDir, entry);
if (!existsSync(fsPath(entryPath))) continue;
let content: string;
try {
content = readFileSync(fsPath(entryPath), 'utf-8');
} catch {
continue;
}
let patched = false;
for (const [wrongId, correctId] of Object.entries(MANIFEST_ID_FIXES)) {
// Match patterns like: id: "wecom-openclaw-plugin" or id: 'wecom-openclaw-plugin'
const escapedWrongId = wrongId.replace(/-/g, '\\-');
const pattern = new RegExp(`(\\bid\\s*:\\s*)(["'])${escapedWrongId}\\2`, 'g');
const replaced = content.replace(pattern, `$1$2${correctId}$2`);
if (replaced !== content) {
content = replaced;
patched = true;
logger.info(`[plugin] Patched plugin ID in ${entry}: "${wrongId}" → "${correctId}"`);
}
}
if (patched) {
writeFileSync(fsPath(entryPath), content, 'utf-8');
}
}
}
// ── Plugin npm name mapping ──────────────────────────────────────────────────
const PLUGIN_NPM_NAMES: Record<string, string> = {
dingtalk: '@soimy/dingtalk',
wecom: '@wecom/wecom-openclaw-plugin',
'feishu-openclaw-plugin': '@larksuite/openclaw-lark',
qqbot: '@tencent-connect/openclaw-qqbot',
'openclaw-weixin': '@tencent-weixin/openclaw-weixin',
};
// ── Version helper ───────────────────────────────────────────────────────────
function readPluginVersion(pkgJsonPath: string): string | null {
try {
const raw = readFileSync(fsPath(pkgJsonPath), 'utf-8');
const parsed = JSON.parse(raw) as { version?: string };
return parsed.version ?? null;
} catch {
return null;
}
}
// ── pnpm-aware node_modules copy helpers ─────────────────────────────────────
/** Walk up from a path until we find a parent named node_modules. */
function findParentNodeModules(startPath: string): string | null {
let dir = startPath;
while (dir !== path.dirname(dir)) {
if (path.basename(dir) === 'node_modules') return dir;
dir = path.dirname(dir);
}
return null;
}
/** List packages inside a node_modules dir (handles @scoped packages). */
function listPackagesInDir(nodeModulesDir: string): Array<{ name: string; fullPath: string }> {
const result: Array<{ name: string; fullPath: string }> = [];
if (!existsSync(fsPath(nodeModulesDir))) return result;
const SKIP = new Set(['.bin', '.package-lock.json', '.modules.yaml', '.pnpm']);
for (const entry of readdirSync(fsPath(nodeModulesDir), { withFileTypes: true })) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
if (SKIP.has(entry.name)) continue;
const entryPath = join(nodeModulesDir, entry.name);
if (entry.name.startsWith('@')) {
try {
for (const sub of readdirSync(fsPath(entryPath))) {
result.push({ name: `${entry.name}/${sub}`, fullPath: join(entryPath, sub) });
}
} catch { /* ignore */ }
} else {
result.push({ name: entry.name, fullPath: entryPath });
}
}
return result;
}
/**
* Copy a plugin from a pnpm node_modules location, including its
* transitive runtime dependencies (replicates bundle-openclaw-plugins.mjs
* logic).
*/
export function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string, npmName: string): void {
let realPath: string;
try {
realPath = realpathSync(fsPath(npmPkgPath));
} catch {
throw new Error(`Cannot resolve real path for ${npmPkgPath}`);
}
// 1. Copy plugin package itself
rmSync(fsPath(targetDir), { recursive: true, force: true });
mkdirSync(fsPath(targetDir), { recursive: true });
cpSyncSafe(realPath, targetDir);
// 2. Collect transitive deps from pnpm virtual store
const rootVirtualNM = findParentNodeModules(realPath);
if (!rootVirtualNM) {
logger.warn(`[plugin] Cannot find virtual store node_modules for ${npmName}, plugin may lack deps`);
return;
}
// Read peer deps to skip (they're provided by the host gateway)
const SKIP_PACKAGES = new Set(['typescript', '@playwright/test']);
try {
const pluginPkg = JSON.parse(readFileSync(fsPath(join(targetDir, 'package.json')), 'utf-8'));
for (const peer of Object.keys(pluginPkg.peerDependencies || {})) {
SKIP_PACKAGES.add(peer);
}
} catch { /* ignore */ }
const collected = new Map<string, string>(); // realPath → packageName
const queue: Array<{ nodeModulesDir: string; skipPkg: string }> = [
{ nodeModulesDir: rootVirtualNM, skipPkg: npmName },
];
while (queue.length > 0) {
const { nodeModulesDir, skipPkg } = queue.shift()!;
for (const { name, fullPath } of listPackagesInDir(nodeModulesDir)) {
if (name === skipPkg) continue;
if (SKIP_PACKAGES.has(name) || name.startsWith('@types/')) continue;
let depRealPath: string;
try {
depRealPath = realpathSync(fsPath(fullPath));
} catch { continue; }
if (collected.has(depRealPath)) continue;
collected.set(depRealPath, name);
const depVirtualNM = findParentNodeModules(depRealPath);
if (depVirtualNM && depVirtualNM !== nodeModulesDir) {
queue.push({ nodeModulesDir: depVirtualNM, skipPkg: name });
}
}
}
// 3. Copy flattened deps into targetDir/node_modules/
const outputNM = join(targetDir, 'node_modules');
mkdirSync(fsPath(outputNM), { recursive: true });
const copiedNames = new Set<string>();
for (const [depRealPath, pkgName] of collected) {
if (copiedNames.has(pkgName)) continue;
copiedNames.add(pkgName);
const dest = join(outputNM, pkgName);
try {
mkdirSync(fsPath(path.dirname(dest)), { recursive: true });
cpSyncSafe(depRealPath, dest);
} catch { /* skip individual dep failures */ }
}
logger.info(`[plugin] Copied ${copiedNames.size} deps for ${npmName}`);
}
// ── Core install / upgrade logic ─────────────────────────────────────────────
export function ensurePluginInstalled(
pluginDirName: string,
candidateSources: string[],
pluginLabel: string,
): { installed: boolean; warning?: string } {
const targetDir = join(homedir(), '.openclaw', 'extensions', pluginDirName);
const targetManifest = join(targetDir, 'openclaw.plugin.json');
const targetPkgJson = join(targetDir, 'package.json');
const sourceDir = candidateSources.find((dir) => existsSync(fsPath(join(dir, 'openclaw.plugin.json'))));
// If already installed, check whether an upgrade is available
if (existsSync(fsPath(targetManifest))) {
if (!sourceDir) return { installed: true }; // no bundled source to compare, keep existing
const installedVersion = readPluginVersion(targetPkgJson);
const sourceVersion = readPluginVersion(join(sourceDir, 'package.json'));
if (!sourceVersion || !installedVersion || sourceVersion === installedVersion) {
return { installed: true }; // same version or unable to compare
}
// Version differs — fall through to overwrite install
logger.info(
`[plugin] Upgrading ${pluginLabel} plugin: ${installedVersion}${sourceVersion}`,
);
}
// Fresh install or upgrade — try bundled/build sources first
if (sourceDir) {
const extensionsRoot = join(homedir(), '.openclaw', 'extensions');
const attempts: Array<{ attempt: number; code?: string; name?: string; message: string }> = [];
const maxAttempts = process.platform === 'win32' ? 2 : 1;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
mkdirSync(fsPath(extensionsRoot), { recursive: true });
rmSync(fsPath(targetDir), { recursive: true, force: true });
cpSyncSafe(sourceDir, targetDir);
if (!existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) {
return { installed: false, warning: `Failed to install ${pluginLabel} plugin mirror (manifest missing).` };
}
fixupPluginManifest(targetDir);
logger.info(`Installed ${pluginLabel} plugin from bundled mirror: ${sourceDir}`);
return { installed: true };
} catch (error) {
const diagnostic = toErrorDiagnostic(error);
attempts.push({ attempt, ...diagnostic });
if (attempt < maxAttempts) {
try {
rmSync(fsPath(targetDir), { recursive: true, force: true });
} catch {
// Ignore cleanup failures before retry.
}
}
}
}
logger.warn(
`[plugin] Bundled mirror install failed for ${pluginLabel}`,
{
pluginDirName,
pluginLabel,
sourceDir,
targetDir,
platform: process.platform,
attempts,
},
);
return { installed: false, warning: `Failed to install bundled ${pluginLabel} plugin mirror` };
}
// Dev mode fallback: copy from node_modules with pnpm-aware dep resolution
if (!app.isPackaged) {
const npmName = PLUGIN_NPM_NAMES[pluginDirName];
if (npmName) {
const npmPkgPath = join(process.cwd(), 'node_modules', ...npmName.split('/'));
if (existsSync(fsPath(join(npmPkgPath, 'openclaw.plugin.json')))) {
const installedVersion = existsSync(fsPath(targetPkgJson)) ? readPluginVersion(targetPkgJson) : null;
const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json'));
if (sourceVersion && (!installedVersion || sourceVersion !== installedVersion)) {
logger.info(
`[plugin] ${installedVersion ? 'Upgrading' : 'Installing'} ${pluginLabel} plugin` +
`${installedVersion ? `: ${installedVersion}${sourceVersion}` : `: ${sourceVersion}`} (dev/node_modules)`,
);
try {
mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true });
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
fixupPluginManifest(targetDir);
if (existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) {
return { installed: true };
}
} catch (err) {
logger.warn(
`[plugin] Failed to install ${pluginLabel} plugin from node_modules`,
{
pluginDirName,
pluginLabel,
npmName,
npmPkgPath,
targetDir,
platform: process.platform,
...toErrorDiagnostic(err),
},
);
}
} else if (existsSync(fsPath(targetManifest))) {
return { installed: true }; // same version, already installed
}
}
}
}
return {
installed: false,
warning: `Bundled ${pluginLabel} plugin mirror not found. Checked: ${candidateSources.join(' | ')}`,
};
}
// ── Candidate source path builder ────────────────────────────────────────────
export function buildCandidateSources(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),
join(__dirname, '../../build/openclaw-plugins', pluginDirName),
];
}
// ── Per-channel plugin helpers ───────────────────────────────────────────────
export function ensureDingTalkPluginInstalled(): { installed: boolean; warning?: string } {
return ensurePluginInstalled('dingtalk', buildCandidateSources('dingtalk'), 'DingTalk');
}
export function ensureWeComPluginInstalled(): { installed: boolean; warning?: string } {
return ensurePluginInstalled('wecom', buildCandidateSources('wecom'), 'WeCom');
}
export function ensureFeishuPluginInstalled(): { installed: boolean; warning?: string } {
return ensurePluginInstalled(
'feishu-openclaw-plugin',
buildCandidateSources('feishu-openclaw-plugin'),
'Feishu',
);
}
export function ensureQQBotPluginInstalled(): { installed: boolean; warning?: string } {
return ensurePluginInstalled('qqbot', buildCandidateSources('qqbot'), 'QQ Bot');
}
export function ensureWeChatPluginInstalled(): { installed: boolean; warning?: string } {
return ensurePluginInstalled('openclaw-weixin', buildCandidateSources('openclaw-weixin'), 'WeChat');
}
// ── Bulk startup installer ───────────────────────────────────────────────────
/**
* All bundled plugins, in the same order as after-pack.cjs BUNDLED_PLUGINS.
*/
const ALL_BUNDLED_PLUGINS = [
{ fn: ensureDingTalkPluginInstalled, label: 'DingTalk' },
{ fn: ensureWeComPluginInstalled, label: 'WeCom' },
{ fn: ensureQQBotPluginInstalled, label: 'QQ Bot' },
{ fn: ensureFeishuPluginInstalled, label: 'Feishu' },
{ fn: ensureWeChatPluginInstalled, label: 'WeChat' },
] as const;
/**
* Ensure all bundled OpenClaw plugins are installed/upgraded in
* `~/.openclaw/extensions/`. Designed to be called once at app startup
* as a fire-and-forget task errors are logged but never thrown.
*/
export async function ensureAllBundledPluginsInstalled(): Promise<void> {
for (const { fn, label } of ALL_BUNDLED_PLUGINS) {
try {
const result = fn();
if (result.warning) {
logger.warn(`[plugin] ${label}: ${result.warning}`);
}
} catch (error) {
logger.warn(`[plugin] Failed to install/upgrade ${label} plugin:`, error);
}
}
}
+19
View File
@@ -18,6 +18,15 @@ const PROVIDER_KEY_ALIASES: Record<string, string> = {
export function getOpenClawProviderKeyForType(type: string, providerId: string): string {
if (MULTI_INSTANCE_PROVIDER_TYPES.has(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}`;
}
@@ -25,6 +34,16 @@ export function getOpenClawProviderKeyForType(type: string, providerId: string):
return PROVIDER_KEY_ALIASES[type] ?? type;
}
/**
* Get all vendorId values that map to the given openclaw.json key via alias.
* e.g. getAliasSourceTypes('minimax-portal') ['minimax-portal-cn']
*/
export function getAliasSourceTypes(openClawKey: string): string[] {
return Object.entries(PROVIDER_KEY_ALIASES)
.filter(([, target]) => target === openClawKey)
.map(([source]) => source);
}
export function isOAuthProviderType(type: string): boolean {
return OAUTH_PROVIDER_TYPE_SET.has(type);
}
+211
View File
@@ -0,0 +1,211 @@
/**
* Self-contained Qwen Device Code OAuth flow.
*
* Implements RFC 8628 (Device Authorization Grant) with PKCE for Qwen API.
* Zero dependency on openclaw extension modules survives openclaw upgrades.
*
* Protocol:
* 1. POST /api/v1/oauth2/device/code get device_code, user_code, verification_uri
* 2. Open verification_uri in browser
* 3. Poll POST /api/v1/oauth2/token with device_code until approved
* 4. Return { access, refresh, expires, resourceUrl }
*/
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { proxyAwareFetch } from './proxy-fetch';
// ── Constants ────────────────────────────────────────────────
const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai';
const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`;
const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`;
const QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56';
const QWEN_OAUTH_SCOPE = 'openid profile email model.completion';
const QWEN_OAUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
// ── Types ────────────────────────────────────────────────────
export interface QwenOAuthToken {
access: string;
refresh: string;
expires: number;
resourceUrl?: string;
}
interface QwenDeviceAuthorization {
device_code: string;
user_code: string;
verification_uri: string;
verification_uri_complete?: string;
expires_in: number;
interval?: number;
}
type DeviceTokenResult =
| { status: 'success'; token: QwenOAuthToken }
| { status: 'pending'; slowDown?: boolean }
| { status: 'error'; message: string };
export interface QwenOAuthOptions {
openUrl: (url: string) => Promise<void>;
note: (message: string, title?: string) => Promise<void>;
progress: { update: (message: string) => void; stop: (message?: string) => void };
}
// ── PKCE helpers (self-contained, no openclaw dependency) ────
function generatePkce(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
function toFormUrlEncoded(params: Record<string, string>): string {
return new URLSearchParams(params).toString();
}
// ── OAuth flow steps ─────────────────────────────────────────
async function requestDeviceCode(params: { challenge: string }): Promise<QwenDeviceAuthorization> {
const response = await proxyAwareFetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
'x-request-id': randomUUID(),
},
body: toFormUrlEncoded({
client_id: QWEN_OAUTH_CLIENT_ID,
scope: QWEN_OAUTH_SCOPE,
code_challenge: params.challenge,
code_challenge_method: 'S256',
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Qwen device authorization failed: ${text || response.statusText}`);
}
const payload = (await response.json()) as QwenDeviceAuthorization & { error?: string };
if (!payload.device_code || !payload.user_code || !payload.verification_uri) {
throw new Error(
payload.error ??
'Qwen device authorization returned an incomplete payload (missing user_code or verification_uri).',
);
}
return payload;
}
async function pollDeviceToken(params: {
deviceCode: string;
verifier: string;
}): Promise<DeviceTokenResult> {
const response = await proxyAwareFetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: toFormUrlEncoded({
grant_type: QWEN_OAUTH_GRANT_TYPE,
client_id: QWEN_OAUTH_CLIENT_ID,
device_code: params.deviceCode,
code_verifier: params.verifier,
}),
});
if (!response.ok) {
let payload: { error?: string; error_description?: string } | undefined;
try {
payload = (await response.json()) as { error?: string; error_description?: string };
} catch {
const text = await response.text();
return { status: 'error', message: text || response.statusText };
}
if (payload?.error === 'authorization_pending') {
return { status: 'pending' };
}
if (payload?.error === 'slow_down') {
return { status: 'pending', slowDown: true };
}
return {
status: 'error',
message: payload?.error_description || payload?.error || response.statusText,
};
}
const tokenPayload = (await response.json()) as {
access_token?: string | null;
refresh_token?: string | null;
expires_in?: number | null;
token_type?: string;
resource_url?: string;
};
if (!tokenPayload.access_token || !tokenPayload.refresh_token || !tokenPayload.expires_in) {
return { status: 'error', message: 'Qwen OAuth returned incomplete token payload.' };
}
return {
status: 'success',
token: {
access: tokenPayload.access_token,
refresh: tokenPayload.refresh_token,
expires: Date.now() + tokenPayload.expires_in * 1000,
resourceUrl: tokenPayload.resource_url,
},
};
}
// ── Public API ───────────────────────────────────────────────
export async function loginQwenPortalOAuth(params: QwenOAuthOptions): Promise<QwenOAuthToken> {
const { verifier, challenge } = generatePkce();
const device = await requestDeviceCode({ challenge });
const verificationUrl = device.verification_uri_complete || device.verification_uri;
await params.note(
[
`Open ${verificationUrl} to approve access.`,
`If prompted, enter the code ${device.user_code}.`,
].join('\n'),
'Qwen OAuth',
);
try {
await params.openUrl(verificationUrl);
} catch {
// Fall back to manual copy/paste if browser open fails.
}
const start = Date.now();
let pollIntervalMs = device.interval ? device.interval * 1000 : 2000;
const timeoutMs = device.expires_in * 1000;
while (Date.now() - start < timeoutMs) {
params.progress.update('Waiting for Qwen OAuth approval…');
const result = await pollDeviceToken({
deviceCode: device.device_code,
verifier,
});
if (result.status === 'success') {
return result.token;
}
if (result.status === 'error') {
throw new Error(`Qwen OAuth failed: ${result.message}`);
}
if (result.status === 'pending' && result.slowDown) {
pollIntervalMs = Math.min(pollIntervalMs * 1.5, 10000);
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error('Qwen OAuth timed out waiting for authorization.');
}
+11 -4
View File
@@ -33,6 +33,8 @@ export interface ProviderConfig {
name: string;
type: ProviderType;
baseUrl?: string;
apiProtocol?: 'openai-completions' | 'openai-responses' | 'anthropic-messages';
headers?: Record<string, string>;
model?: string;
fallbackModels?: string[];
fallbackProviderIds?: string[];
@@ -266,21 +268,26 @@ export async function getAllProvidersWithKeyInfo(): Promise<
const providers = await getAllProviders();
const results: Array<ProviderConfig & { hasKey: boolean; keyMasked: string | null }> = [];
const activeOpenClawProviders = await getActiveOpenClawProviders();
// When openclaw.json is deleted/missing, the active set is empty.
// In that case, all providers (including builtins) should be cleaned up.
const configMissing = activeOpenClawProviders.size === 0;
for (const provider of providers) {
// Sync check: If it's a custom/OAuth provider and it no longer exists in OpenClaw config
// (e.g. wiped by Gateway due to missing plugin, or manually deleted by user)
// we should remove it from ClawX UI to stay consistent.
const isBuiltin = BUILTIN_PROVIDER_TYPES.includes(provider.type);
const isBuiltin = (BUILTIN_PROVIDER_TYPES as readonly string[]).includes(provider.type);
// For custom/ollama providers, the OpenClaw config key is derived as
// "<type>-<suffix>" where suffix = first 8 chars of providerId with hyphens stripped.
// e.g. provider.id "custom-a1b2c3d4-..." → strip hyphens → "customa1b2c3d4..." → slice(0,8) → "customa1"
// → openClawKey = "custom-customa1"
// This must match getOpenClawProviderKey() in ipc-handlers.ts exactly.
const openClawKey = getOpenClawProviderKeyForType(provider.type, provider.id);
if (!isBuiltin && !activeOpenClawProviders.has(provider.type) && !activeOpenClawProviders.has(provider.id) && !activeOpenClawProviders.has(openClawKey)) {
console.log(`[Sync] Provider ${provider.id} (${provider.type}) missing from OpenClaw, dropping from ClawX UI`);
await deleteProvider(provider.id);
const isActive = activeOpenClawProviders.has(provider.type) || activeOpenClawProviders.has(provider.id) || activeOpenClawProviders.has(openClawKey);
if (configMissing || (!isBuiltin && !isActive)) {
console.log(`[Sync] Provider ${provider.id} (${provider.type}) missing from OpenClaw, hiding from UI`);
// Skip from display but don't delete from store — preserves API key
// associations so that restoring openclaw.json brings accounts back intact.
continue;
}
+254 -48
View File
@@ -5,13 +5,15 @@
*
* All file I/O uses async fs/promises to avoid blocking the main thread.
*/
import { readFile, writeFile, access, cp, mkdir } from 'fs/promises';
import { readFile, writeFile, access, mkdir } from 'fs/promises';
import { existsSync } from 'fs';
import { constants } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { getOpenClawDir } from './paths';
import { getOpenClawDir, getResourcesDir } from './paths';
import { logger } from './logger';
import { cpAsyncSafe } from './plugin-install';
import { withConfigLock } from './config-mutex';
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
@@ -29,6 +31,32 @@ interface OpenClawConfig {
[key: string]: unknown;
}
interface PreinstalledSkillSpec {
slug: string;
version?: string;
autoEnable?: boolean;
}
interface PreinstalledManifest {
skills?: PreinstalledSkillSpec[];
}
interface PreinstalledLockEntry {
slug: string;
version?: string;
}
interface PreinstalledLockFile {
skills?: PreinstalledLockEntry[];
}
interface PreinstalledMarker {
source: 'clawx-preinstalled';
slug: string;
version: string;
installedAt: string;
}
async function fileExists(p: string): Promise<boolean> {
try { await access(p, constants.F_OK); return true; } catch { return false; }
}
@@ -57,6 +85,27 @@ async function writeConfig(config: OpenClawConfig): Promise<void> {
await writeFile(OPENCLAW_CONFIG_PATH, json, 'utf-8');
}
async function setSkillsEnabled(skillKeys: string[], enabled: boolean): Promise<void> {
if (skillKeys.length === 0) {
return;
}
return withConfigLock(async () => {
const config = await readConfig();
if (!config.skills) {
config.skills = {};
}
if (!config.skills.entries) {
config.skills.entries = {};
}
for (const skillKey of skillKeys) {
const entry = config.skills.entries[skillKey] || {};
entry.enabled = enabled;
config.skills.entries[skillKey] = entry;
}
await writeConfig(config);
});
}
/**
* Get skill config
*/
@@ -73,55 +122,57 @@ export async function updateSkillConfig(
updates: { apiKey?: string; env?: Record<string, string> }
): Promise<{ success: boolean; error?: string }> {
try {
const config = await readConfig();
return await withConfigLock(async () => {
const config = await readConfig();
// Ensure skills.entries exists
if (!config.skills) {
config.skills = {};
}
if (!config.skills.entries) {
config.skills.entries = {};
}
// Get or create skill entry
const entry = config.skills.entries[skillKey] || {};
// Update apiKey
if (updates.apiKey !== undefined) {
const trimmed = updates.apiKey.trim();
if (trimmed) {
entry.apiKey = trimmed;
} else {
delete entry.apiKey;
// Ensure skills.entries exists
if (!config.skills) {
config.skills = {};
}
if (!config.skills.entries) {
config.skills.entries = {};
}
}
// Update env
if (updates.env !== undefined) {
const newEnv: Record<string, string> = {};
// Get or create skill entry
const entry = config.skills.entries[skillKey] || {};
for (const [key, value] of Object.entries(updates.env)) {
const trimmedKey = key.trim();
if (!trimmedKey) continue;
const trimmedVal = value.trim();
if (trimmedVal) {
newEnv[trimmedKey] = trimmedVal;
// Update apiKey
if (updates.apiKey !== undefined) {
const trimmed = updates.apiKey.trim();
if (trimmed) {
entry.apiKey = trimmed;
} else {
delete entry.apiKey;
}
}
if (Object.keys(newEnv).length > 0) {
entry.env = newEnv;
} else {
delete entry.env;
// Update env
if (updates.env !== undefined) {
const newEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(updates.env)) {
const trimmedKey = key.trim();
if (!trimmedKey) continue;
const trimmedVal = value.trim();
if (trimmedVal) {
newEnv[trimmedKey] = trimmedVal;
}
}
if (Object.keys(newEnv).length > 0) {
entry.env = newEnv;
} else {
delete entry.env;
}
}
}
// Save entry back
config.skills.entries[skillKey] = entry;
// Save entry back
config.skills.entries[skillKey] = entry;
await writeConfig(config);
return { success: true };
await writeConfig(config);
return { success: true };
});
} catch (err) {
console.error('Failed to update skill config:', err);
return { success: false, error: String(err) };
@@ -141,12 +192,7 @@ export async function getAllSkillConfigs(): Promise<Record<string, SkillEntry>>
* ~/.openclaw/skills/ on first launch. These come from the openclaw package's
* extensions directory and are available in both dev and packaged builds.
*/
const BUILTIN_SKILLS = [
{ slug: 'feishu-doc', sourceExtension: 'feishu' },
{ slug: 'feishu-drive', sourceExtension: 'feishu' },
{ slug: 'feishu-perm', sourceExtension: 'feishu' },
{ slug: 'feishu-wiki', sourceExtension: 'feishu' },
] as const;
const BUILTIN_SKILLS = [] as const;
/**
* Ensure built-in skills are deployed to ~/.openclaw/skills/<slug>/.
@@ -175,10 +221,170 @@ export async function ensureBuiltinSkillsInstalled(): Promise<void> {
try {
await mkdir(targetDir, { recursive: true });
await cp(sourceDir, targetDir, { recursive: true });
await cpAsyncSafe(sourceDir, targetDir);
logger.info(`Installed built-in skill: ${slug} -> ${targetDir}`);
} catch (error) {
logger.warn(`Failed to install built-in skill ${slug}:`, error);
}
}
}
const PREINSTALLED_MANIFEST_NAME = 'preinstalled-manifest.json';
const PREINSTALLED_MARKER_NAME = '.clawx-preinstalled.json';
async function readPreinstalledManifest(): Promise<PreinstalledSkillSpec[]> {
const candidates = [
join(getResourcesDir(), 'skills', PREINSTALLED_MANIFEST_NAME),
join(process.cwd(), 'resources', 'skills', PREINSTALLED_MANIFEST_NAME),
];
const manifestPath = candidates.find((p) => existsSync(p));
if (!manifestPath) {
return [];
}
try {
const raw = await readFile(manifestPath, 'utf-8');
const parsed = JSON.parse(raw) as PreinstalledManifest;
if (!Array.isArray(parsed.skills)) {
return [];
}
return parsed.skills.filter((s): s is PreinstalledSkillSpec => Boolean(s?.slug));
} catch (error) {
logger.warn('Failed to read preinstalled-skills manifest:', error);
return [];
}
}
function resolvePreinstalledSkillsSourceRoot(): string | null {
const candidates = [
join(getResourcesDir(), 'preinstalled-skills'),
join(process.cwd(), 'build', 'preinstalled-skills'),
join(__dirname, '../../build/preinstalled-skills'),
];
const root = candidates.find((dir) => existsSync(dir));
return root || null;
}
async function readPreinstalledLockVersions(sourceRoot: string): Promise<Map<string, string>> {
const lockPath = join(sourceRoot, '.preinstalled-lock.json');
if (!existsSync(lockPath)) {
return new Map();
}
try {
const raw = await readFile(lockPath, 'utf-8');
const parsed = JSON.parse(raw) as PreinstalledLockFile;
const versions = new Map<string, string>();
for (const entry of parsed.skills || []) {
const slug = entry.slug?.trim();
const version = entry.version?.trim();
if (slug && version) {
versions.set(slug, version);
}
}
return versions;
} catch (error) {
logger.warn('Failed to read preinstalled-skills lock file:', error);
return new Map();
}
}
async function tryReadMarker(markerPath: string): Promise<PreinstalledMarker | null> {
if (!existsSync(markerPath)) {
return null;
}
try {
const raw = await readFile(markerPath, 'utf-8');
const parsed = JSON.parse(raw) as PreinstalledMarker;
if (!parsed?.slug || !parsed?.version) {
return null;
}
return parsed;
} catch {
return null;
}
}
/**
* Ensure third-party preinstalled skills (bundled in app resources) are
* deployed to ~/.openclaw/skills/<slug>/ as full directories.
*
* Policy:
* - If skill is missing locally, install it.
* - If local skill exists without our marker, treat as user-managed and never overwrite.
* - If marker exists with same version, skip.
* - If marker exists with a different version, skip by default to avoid overwriting edits.
*/
export async function ensurePreinstalledSkillsInstalled(): Promise<void> {
const skills = await readPreinstalledManifest();
if (skills.length === 0) {
return;
}
const sourceRoot = resolvePreinstalledSkillsSourceRoot();
if (!sourceRoot) {
logger.warn('Preinstalled skills source root not found; skipping preinstall.');
return;
}
const lockVersions = await readPreinstalledLockVersions(sourceRoot);
const targetRoot = join(homedir(), '.openclaw', 'skills');
await mkdir(targetRoot, { recursive: true });
const toEnable: string[] = [];
for (const spec of skills) {
const sourceDir = join(sourceRoot, spec.slug);
const sourceManifest = join(sourceDir, 'SKILL.md');
if (!existsSync(sourceManifest)) {
logger.warn(`Preinstalled skill source missing SKILL.md, skipping: ${sourceDir}`);
continue;
}
const targetDir = join(targetRoot, spec.slug);
const targetManifest = join(targetDir, 'SKILL.md');
const markerPath = join(targetDir, PREINSTALLED_MARKER_NAME);
const desiredVersion = lockVersions.get(spec.slug)
|| (spec.version || 'unknown').trim()
|| 'unknown';
const marker = await tryReadMarker(markerPath);
if (existsSync(targetManifest)) {
if (!marker) {
logger.info(`Skipping user-managed skill: ${spec.slug}`);
continue;
}
if (marker.version === desiredVersion) {
continue;
}
logger.info(`Skipping preinstalled skill update for ${spec.slug} (local marker version=${marker.version}, desired=${desiredVersion})`);
continue;
}
try {
await mkdir(targetDir, { recursive: true });
await cpAsyncSafe(sourceDir, targetDir);
const markerPayload: PreinstalledMarker = {
source: 'clawx-preinstalled',
slug: spec.slug,
version: desiredVersion,
installedAt: new Date().toISOString(),
};
await writeFile(markerPath, `${JSON.stringify(markerPayload, null, 2)}\n`, 'utf-8');
if (spec.autoEnable) {
toEnable.push(spec.slug);
}
logger.info(`Installed preinstalled skill: ${spec.slug} -> ${targetDir}`);
} catch (error) {
logger.warn(`Failed to install preinstalled skill ${spec.slug}:`, error);
}
}
if (toEnable.length > 0) {
try {
await setSkillsEnabled(toEnable, true);
} catch (error) {
logger.warn('Failed to auto-enable preinstalled skills:', error);
}
}
}
+58 -40
View File
@@ -4,6 +4,8 @@
*/
import { randomBytes } from 'crypto';
import { app } from 'electron';
import { resolveSupportedLanguage } from '../../shared/language';
// Lazy-load electron-store (ESM module)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -25,7 +27,10 @@ export interface AppSettings {
language: string;
startMinimized: boolean;
launchAtStartup: boolean;
telemetryEnabled: boolean;
machineId: string;
hasReportedInstall: boolean;
// Gateway
gatewayAutoStart: boolean;
gatewayPort: number;
@@ -36,18 +41,17 @@ export interface AppSettings {
proxyHttpsServer: string;
proxyAllServer: string;
proxyBypassRules: string;
gatewayTransportPreference: 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only';
// Update
updateChannel: 'stable' | 'beta' | 'dev';
autoCheckUpdate: boolean;
autoDownloadUpdate: boolean;
skippedVersions: string[];
// UI State
sidebarCollapsed: boolean;
devModeUnlocked: boolean;
// Presets
selectedBundles: string[];
enabledSkills: string[];
@@ -57,40 +61,54 @@ export interface AppSettings {
/**
* Default settings
*/
const defaults: AppSettings = {
// General
theme: 'system',
language: 'en',
startMinimized: false,
launchAtStartup: false,
// Gateway
gatewayAutoStart: true,
gatewayPort: 18789,
gatewayToken: generateToken(),
proxyEnabled: false,
proxyServer: '',
proxyHttpServer: '',
proxyHttpsServer: '',
proxyAllServer: '',
proxyBypassRules: '<local>;localhost;127.0.0.1;::1',
gatewayTransportPreference: 'ws-first',
// Update
updateChannel: 'stable',
autoCheckUpdate: true,
autoDownloadUpdate: false,
skippedVersions: [],
// UI State
sidebarCollapsed: false,
devModeUnlocked: false,
// Presets
selectedBundles: ['productivity', 'developer'],
enabledSkills: [],
disabledSkills: [],
};
function getSystemLocale(): string {
const preferredLanguages = typeof app.getPreferredSystemLanguages === 'function'
? app.getPreferredSystemLanguages()
: [];
return preferredLanguages[0]
|| (typeof app.getLocale === 'function' ? app.getLocale() : '')
|| Intl.DateTimeFormat().resolvedOptions().locale
|| 'en';
}
function createDefaultSettings(): AppSettings {
return {
// General
theme: 'system',
language: resolveSupportedLanguage(getSystemLocale()),
startMinimized: false,
launchAtStartup: false,
telemetryEnabled: true,
machineId: '',
hasReportedInstall: false,
// Gateway
gatewayAutoStart: true,
gatewayPort: 18789,
gatewayToken: generateToken(),
proxyEnabled: false,
proxyServer: '',
proxyHttpServer: '',
proxyHttpsServer: '',
proxyAllServer: '',
proxyBypassRules: '<local>;localhost;127.0.0.1;::1',
// Update
updateChannel: 'stable',
autoCheckUpdate: true,
autoDownloadUpdate: false,
skippedVersions: [],
// UI State
sidebarCollapsed: false,
devModeUnlocked: false,
// Presets
selectedBundles: ['productivity', 'developer'],
enabledSkills: [],
disabledSkills: [],
};
}
/**
* Get the settings store instance (lazy initialization)
@@ -100,7 +118,7 @@ async function getSettingsStore() {
const Store = (await import('electron-store')).default;
settingsStoreInstance = new Store<AppSettings>({
name: 'settings',
defaults,
defaults: createDefaultSettings(),
});
}
return settingsStoreInstance;
+164
View File
@@ -0,0 +1,164 @@
import { PostHog } from 'posthog-node';
import { machineIdSync } from 'node-machine-id';
import { app } from 'electron';
import { getSetting, setSetting } from './store';
import { logger } from './logger';
const POSTHOG_API_KEY = 'phc_aGNegeJQP5FzNiF2rEoKqQbkuCpiiETMttplibXpB0n';
const POSTHOG_HOST = 'https://us.i.posthog.com';
const TELEMETRY_SHUTDOWN_TIMEOUT_MS = 1500;
let posthogClient: PostHog | null = null;
let distinctId: string = '';
function getCommonProperties(): Record<string, string> {
return {
$app_version: app.getVersion(),
$os: process.platform,
os_tag: process.platform,
arch: process.arch,
};
}
function isIgnorablePostHogShutdownError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const message = `${error.name} ${error.message}`.toLowerCase();
if (
message.includes('posthogfetchnetworkerror') ||
message.includes('network error while fetching posthog') ||
message.includes('timeouterror') ||
message.includes('aborted due to timeout') ||
message.includes('fetch failed')
) {
return true;
}
return 'cause' in error && error.cause !== error
? isIgnorablePostHogShutdownError(error.cause)
: false;
}
/**
* Initialize PostHog telemetry
*/
export async function initTelemetry(): Promise<void> {
try {
const telemetryEnabled = await getSetting('telemetryEnabled');
if (!telemetryEnabled) {
logger.info('Telemetry is disabled in settings');
return;
}
// Initialize PostHog client
posthogClient = new PostHog(POSTHOG_API_KEY, { host: POSTHOG_HOST });
// Get or generate machine ID
distinctId = await getSetting('machineId');
if (!distinctId) {
distinctId = machineIdSync();
await setSetting('machineId', distinctId);
logger.debug(`Generated new machine ID for telemetry: ${distinctId}`);
}
const properties = getCommonProperties();
// Check if this is a new installation
const hasReportedInstall = await getSetting('hasReportedInstall');
if (!hasReportedInstall) {
posthogClient.capture({
distinctId,
event: 'app_installed',
properties,
});
await setSetting('hasReportedInstall', true);
logger.info('Reported app_installed event');
}
// Always report app opened
posthogClient.capture({
distinctId,
event: 'app_opened',
properties,
});
logger.debug('Reported app_opened event');
} catch (error) {
logger.error('Failed to initialize telemetry:', error);
}
}
export function trackMetric(event: string, properties: Record<string, unknown> = {}): void {
logger.info(`[metric] ${event}`, properties);
}
export function captureTelemetryEvent(event: string, properties: Record<string, unknown> = {}): void {
if (!posthogClient || !distinctId) {
return;
}
try {
posthogClient.capture({
distinctId,
event,
properties: {
...getCommonProperties(),
...properties,
},
});
} catch (error) {
logger.debug(`Failed to capture telemetry event "${event}":`, error);
}
}
/**
* Best-effort telemetry shutdown that never blocks app exit on network issues.
*/
export async function shutdownTelemetry(): Promise<void> {
const client = posthogClient;
posthogClient = null;
distinctId = '';
if (!client) {
return;
}
let didTimeout = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const shutdownPromise = client.shutdown().catch((error) => {
if (isIgnorablePostHogShutdownError(error)) {
logger.debug('Ignored telemetry shutdown network error:', error);
return;
}
throw error;
});
try {
await Promise.race([
shutdownPromise,
new Promise<void>((resolve) => {
timeoutHandle = setTimeout(() => {
didTimeout = true;
resolve();
}, TELEMETRY_SHUTDOWN_TIMEOUT_MS);
}),
]);
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
if (didTimeout) {
logger.debug(`Skipped waiting for telemetry shutdown after ${TELEMETRY_SHUTDOWN_TIMEOUT_MS}ms`);
return;
}
logger.debug('Flushed telemetry events on shutdown');
} catch (error) {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
logger.error('Error shutting down telemetry:', error);
}
}
+119 -11
View File
@@ -4,6 +4,7 @@ export interface TokenUsageHistoryEntry {
agentId: string;
model?: string;
provider?: string;
content?: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
@@ -12,6 +13,14 @@ export interface TokenUsageHistoryEntry {
costUsd?: number;
}
export function extractSessionIdFromTranscriptFileName(fileName: string): string | undefined {
if (!fileName.endsWith('.jsonl') && !fileName.includes('.jsonl.reset.')) return undefined;
return fileName
.replace(/\.reset\..+$/, '')
.replace(/\.deleted\.jsonl$/, '')
.replace(/\.jsonl$/, '');
}
interface TranscriptUsageShape {
input?: number;
output?: number;
@@ -35,9 +44,59 @@ interface TranscriptLineShape {
modelRef?: string;
provider?: string;
usage?: TranscriptUsageShape;
details?: {
provider?: string;
model?: string;
usage?: TranscriptUsageShape;
content?: unknown;
externalContent?: {
provider?: string;
};
};
};
}
function normalizeUsageContent(value: unknown): string | undefined {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (Array.isArray(value)) {
const chunks = value
.map((item) => normalizeUsageContent(item))
.filter((item): item is string => Boolean(item));
if (chunks.length === 0) return undefined;
return chunks.join('\n\n');
}
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
if (typeof record.text === 'string') {
const trimmed = record.text.trim();
if (trimmed.length > 0) return trimmed;
}
if (typeof record.content === 'string') {
const trimmed = record.content.trim();
if (trimmed.length > 0) return trimmed;
}
if (Array.isArray(record.content)) {
return normalizeUsageContent(record.content);
}
if (typeof record.thinking === 'string') {
const trimmed = record.thinking.trim();
if (trimmed.length > 0) return trimmed;
}
try {
return JSON.stringify(record, null, 2);
} catch {
return undefined;
}
}
return undefined;
}
export function parseUsageEntriesFromJsonl(
content: string,
context: { sessionId: string; agentId: string },
@@ -58,18 +117,66 @@ export function parseUsageEntriesFromJsonl(
}
const message = parsed.message;
if (!message || message.role !== 'assistant' || !message.usage || !parsed.timestamp) {
if (!message || !parsed.timestamp) {
continue;
}
const usage = message.usage;
const inputTokens = usage.input ?? usage.promptTokens ?? 0;
const outputTokens = usage.output ?? usage.completionTokens ?? 0;
const cacheReadTokens = usage.cacheRead ?? 0;
const cacheWriteTokens = usage.cacheWrite ?? 0;
const totalTokens = usage.total ?? usage.totalTokens ?? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
if (message.role === 'assistant' && message.usage) {
const usage = message.usage;
const inputTokens = usage.input ?? usage.promptTokens ?? 0;
const outputTokens = usage.output ?? usage.completionTokens ?? 0;
const cacheReadTokens = usage.cacheRead ?? 0;
const cacheWriteTokens = usage.cacheWrite ?? 0;
const totalTokens = usage.total ?? usage.totalTokens ?? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
if (totalTokens <= 0 && !usage.cost?.total) {
if (totalTokens <= 0) {
continue;
}
const contentText = normalizeUsageContent((message as Record<string, unknown>).content);
entries.push({
timestamp: parsed.timestamp,
sessionId: context.sessionId,
agentId: context.agentId,
model: message.model ?? message.modelRef,
provider: message.provider,
...(contentText ? { content: contentText } : {}),
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
totalTokens,
costUsd: usage.cost?.total,
});
continue;
}
if (message.role !== 'toolResult') {
continue;
}
const details = message.details;
if (!details) {
continue;
}
const usage = details.usage;
const inputTokens = usage?.input ?? usage?.promptTokens ?? 0;
const outputTokens = usage?.output ?? usage?.completionTokens ?? 0;
const cacheReadTokens = usage?.cacheRead ?? 0;
const cacheWriteTokens = usage?.cacheWrite ?? 0;
const totalTokens = usage?.total ?? usage?.totalTokens ?? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
const provider = details.provider ?? details.externalContent?.provider ?? message.provider;
const model = details.model ?? message.model ?? message.modelRef;
const contentText = normalizeUsageContent(details.content)
?? normalizeUsageContent((message as Record<string, unknown>).content);
if (!provider && !model) {
continue;
}
if (totalTokens <= 0) {
continue;
}
@@ -77,14 +184,15 @@ export function parseUsageEntriesFromJsonl(
timestamp: parsed.timestamp,
sessionId: context.sessionId,
agentId: context.agentId,
model: message.model ?? message.modelRef,
provider: message.provider,
model,
provider,
...(contentText ? { content: contentText } : {}),
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
totalTokens,
costUsd: usage.cost?.total,
costUsd: usage?.cost?.total,
});
}
+48 -5
View File
@@ -2,16 +2,58 @@ import { readdir, readFile, stat } from 'fs/promises';
import { join } from 'path';
import { getOpenClawConfigDir } from './paths';
import { logger } from './logger';
import { parseUsageEntriesFromJsonl, type TokenUsageHistoryEntry } from './token-usage-core';
import {
extractSessionIdFromTranscriptFileName,
parseUsageEntriesFromJsonl,
type TokenUsageHistoryEntry,
} from './token-usage-core';
import { listConfiguredAgentIds } from './agent-config';
export { parseUsageEntriesFromJsonl, type TokenUsageHistoryEntry } from './token-usage-core';
export {
extractSessionIdFromTranscriptFileName,
parseUsageEntriesFromJsonl,
type TokenUsageHistoryEntry,
} from './token-usage-core';
async function listAgentIdsWithSessionDirs(): Promise<string[]> {
const openclawDir = getOpenClawConfigDir();
const agentsDir = join(openclawDir, 'agents');
const agentIds = new Set<string>();
try {
for (const agentId of await listConfiguredAgentIds()) {
const normalized = agentId.trim();
if (normalized) {
agentIds.add(normalized);
}
}
} catch {
// Ignore config discovery failures and fall back to disk scan.
}
try {
const agentEntries = await readdir(agentsDir, { withFileTypes: true });
for (const entry of agentEntries) {
if (entry.isDirectory()) {
const normalized = entry.name.trim();
if (normalized) {
agentIds.add(normalized);
}
}
}
} catch {
// Ignore disk discovery failures and return whatever we already found.
}
return [...agentIds];
}
async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }>> {
const openclawDir = getOpenClawConfigDir();
const agentsDir = join(openclawDir, 'agents');
try {
const agentEntries = await readdir(agentsDir);
const agentEntries = await listAgentIdsWithSessionDirs();
const files: Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }> = [];
for (const agentId of agentEntries) {
@@ -20,13 +62,14 @@ async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessi
const sessionEntries = await readdir(sessionsDir);
for (const fileName of sessionEntries) {
if (!fileName.endsWith('.jsonl') || fileName.includes('.deleted.')) continue;
const sessionId = extractSessionIdFromTranscriptFileName(fileName);
if (!sessionId) continue;
const filePath = join(sessionsDir, fileName);
try {
const fileStat = await stat(filePath);
files.push({
filePath,
sessionId: fileName.replace(/\.jsonl$/, ''),
sessionId,
agentId,
mtimeMs: fileStat.mtimeMs,
});
+471
View File
@@ -0,0 +1,471 @@
import { createRequire } from 'node:module';
import { randomUUID } from 'node:crypto';
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { deflateSync } from 'node:zlib';
import { normalizeOpenClawAccountId } from './channel-alias';
import { getOpenClawResolvedDir } from './paths';
export const DEFAULT_WECHAT_BASE_URL = 'https://ilinkai.weixin.qq.com';
const DEFAULT_ILINK_BOT_TYPE = '3';
const ACTIVE_LOGIN_TTL_MS = 5 * 60_000;
const QR_POLL_TIMEOUT_MS = 35_000;
const MAX_QR_REFRESH_COUNT = 3;
const OPENCLAW_DIR = join(homedir(), '.openclaw');
const WECHAT_STATE_DIR = join(OPENCLAW_DIR, 'openclaw-weixin');
const WECHAT_ACCOUNT_INDEX_FILE = join(WECHAT_STATE_DIR, 'accounts.json');
const WECHAT_ACCOUNTS_DIR = join(WECHAT_STATE_DIR, 'accounts');
const require = createRequire(import.meta.url);
type QrCodeMatrix = {
addData(input: string): void;
make(): void;
getModuleCount(): number;
isDark(row: number, col: number): boolean;
};
type QrCodeConstructor = new (typeNumber: number, errorCorrectionLevel: unknown) => QrCodeMatrix;
type QrErrorCorrectLevelModule = {
L: unknown;
};
type QrRenderDeps = {
QRCode: QrCodeConstructor;
QRErrorCorrectLevel: QrErrorCorrectLevelModule;
};
let qrRenderDeps: QrRenderDeps | null = null;
function getQrRenderDeps(): QrRenderDeps {
if (qrRenderDeps) {
return qrRenderDeps;
}
const openclawRequire = createRequire(join(getOpenClawResolvedDir(), 'package.json'));
const qrCodeModulePath = openclawRequire.resolve('qrcode-terminal/vendor/QRCode/index.js');
const qrErrorCorrectLevelPath = openclawRequire.resolve('qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js');
qrRenderDeps = {
QRCode: require(qrCodeModulePath),
QRErrorCorrectLevel: require(qrErrorCorrectLevelPath),
};
return qrRenderDeps;
}
type ActiveLogin = {
sessionKey: string;
qrcode: string;
qrcodeUrl: string;
startedAt: number;
apiBaseUrl: string;
};
type QrCodeResponse = {
qrcode: string;
qrcode_img_content: string;
};
type QrStatusResponse = {
status: 'wait' | 'scaned' | 'confirmed' | 'expired';
bot_token?: string;
ilink_bot_id?: string;
baseurl?: string;
ilink_user_id?: string;
};
export type WeChatLoginStartResult = {
sessionKey: string;
qrcodeUrl?: string;
message: string;
};
export type WeChatLoginWaitResult = {
connected: boolean;
message: string;
botToken?: string;
accountId?: string;
baseUrl?: string;
userId?: string;
};
const activeLogins = new Map<string, ActiveLogin>();
function createQrMatrix(input: string) {
const { QRCode, QRErrorCorrectLevel } = getQrRenderDeps();
const qr = new QRCode(-1, QRErrorCorrectLevel.L);
qr.addData(input);
qr.make();
return qr;
}
function fillPixel(
buf: Buffer,
x: number,
y: number,
width: number,
r: number,
g: number,
b: number,
a = 255,
) {
const idx = (y * width + x) * 4;
buf[idx] = r;
buf[idx + 1] = g;
buf[idx + 2] = b;
buf[idx + 3] = a;
}
function crcTable() {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i += 1) {
let c = i;
for (let k = 0; k < 8; k += 1) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
table[i] = c >>> 0;
}
return table;
}
const CRC_TABLE = crcTable();
function crc32(buf: Buffer) {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i += 1) {
crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type: string, data: Buffer) {
const typeBuf = Buffer.from(type, 'ascii');
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const crc = crc32(Buffer.concat([typeBuf, data]));
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(crc, 0);
return Buffer.concat([len, typeBuf, data, crcBuf]);
}
function encodePngRgba(buffer: Buffer, width: number, height: number) {
const stride = width * 4;
const raw = Buffer.alloc((stride + 1) * height);
for (let row = 0; row < height; row += 1) {
const rawOffset = row * (stride + 1);
raw[rawOffset] = 0;
buffer.copy(raw, rawOffset + 1, row * stride, row * stride + stride);
}
const compressed = deflateSync(raw);
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8;
ihdr[9] = 6;
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0;
return Buffer.concat([
signature,
pngChunk('IHDR', ihdr),
pngChunk('IDAT', compressed),
pngChunk('IEND', Buffer.alloc(0)),
]);
}
async function renderQrPngDataUrl(
input: string,
opts: { scale?: number; marginModules?: number } = {},
): Promise<string> {
const { scale = 6, marginModules = 4 } = opts;
const qr = createQrMatrix(input);
const modules = qr.getModuleCount();
const size = (modules + marginModules * 2) * scale;
const buf = Buffer.alloc(size * size * 4, 255);
for (let row = 0; row < modules; row += 1) {
for (let col = 0; col < modules; col += 1) {
if (!qr.isDark(row, col)) continue;
const startX = (col + marginModules) * scale;
const startY = (row + marginModules) * scale;
for (let y = 0; y < scale; y += 1) {
const pixelY = startY + y;
for (let x = 0; x < scale; x += 1) {
const pixelX = startX + x;
fillPixel(buf, pixelX, pixelY, size, 0, 0, 0, 255);
}
}
}
}
const png = encodePngRgba(buf, size, size);
return `data:image/png;base64,${png.toString('base64')}`;
}
function isLoginFresh(login: ActiveLogin): boolean {
return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;
}
function resolveConfigPath(): string {
const envPath = process.env.OPENCLAW_CONFIG?.trim();
if (envPath) return envPath;
return join(OPENCLAW_DIR, 'openclaw.json');
}
function loadWeChatRouteTag(accountId?: string): string | undefined {
try {
const configPath = resolveConfigPath();
if (!existsSync(configPath)) return undefined;
const raw = readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(raw) as {
channels?: Record<string, {
routeTag?: string | number;
accounts?: Record<string, { routeTag?: string | number }>;
}>;
};
const section = parsed.channels?.['openclaw-weixin'];
if (!section) return undefined;
if (accountId) {
const normalizedAccountId = normalizeOpenClawAccountId(accountId);
const scopedRouteTag = section.accounts?.[normalizedAccountId]?.routeTag;
if (typeof scopedRouteTag === 'number') return String(scopedRouteTag);
if (typeof scopedRouteTag === 'string' && scopedRouteTag.trim()) return scopedRouteTag.trim();
}
if (typeof section.routeTag === 'number') return String(section.routeTag);
if (typeof section.routeTag === 'string' && section.routeTag.trim()) return section.routeTag.trim();
} catch {
return undefined;
}
return undefined;
}
async function fetchWeChatQrCode(apiBaseUrl: string, accountId?: string, botType = DEFAULT_ILINK_BOT_TYPE): Promise<QrCodeResponse> {
const base = apiBaseUrl.endsWith('/') ? apiBaseUrl : `${apiBaseUrl}/`;
const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, base);
const headers: Record<string, string> = {};
const routeTag = loadWeChatRouteTag(accountId);
if (routeTag) {
headers.SKRouteTag = routeTag;
}
const response = await fetch(url.toString(), { headers });
if (!response.ok) {
const body = await response.text().catch(() => '(unreadable)');
throw new Error(`Failed to fetch QR code: ${response.status} ${response.statusText} ${body}`);
}
return await response.json() as QrCodeResponse;
}
async function pollWeChatQrStatus(apiBaseUrl: string, qrcode: string, accountId?: string): Promise<QrStatusResponse> {
const base = apiBaseUrl.endsWith('/') ? apiBaseUrl : `${apiBaseUrl}/`;
const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, base);
const headers: Record<string, string> = {
'iLink-App-ClientVersion': '1',
};
const routeTag = loadWeChatRouteTag(accountId);
if (routeTag) {
headers.SKRouteTag = routeTag;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), QR_POLL_TIMEOUT_MS);
try {
const response = await fetch(url.toString(), { headers, signal: controller.signal });
clearTimeout(timer);
const rawText = await response.text();
if (!response.ok) {
throw new Error(`Failed to poll QR status: ${response.status} ${response.statusText} ${rawText}`);
}
return JSON.parse(rawText) as QrStatusResponse;
} catch (error) {
clearTimeout(timer);
if (error instanceof Error && error.name === 'AbortError') {
return { status: 'wait' };
}
throw error;
}
}
async function readAccountIndex(): Promise<string[]> {
try {
const raw = await readFile(WECHAT_ACCOUNT_INDEX_FILE, 'utf-8');
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0);
} catch {
return [];
}
}
async function writeAccountIndex(accountIds: string[]): Promise<void> {
await mkdir(WECHAT_STATE_DIR, { recursive: true });
await writeFile(WECHAT_ACCOUNT_INDEX_FILE, JSON.stringify(accountIds, null, 2), 'utf-8');
}
export async function saveWeChatAccountState(rawAccountId: string, payload: {
token: string;
baseUrl?: string;
userId?: string;
}): Promise<string> {
const accountId = normalizeOpenClawAccountId(rawAccountId);
await mkdir(WECHAT_ACCOUNTS_DIR, { recursive: true });
const filePath = join(WECHAT_ACCOUNTS_DIR, `${accountId}.json`);
const data = {
token: payload.token.trim(),
savedAt: new Date().toISOString(),
...(payload.baseUrl?.trim() ? { baseUrl: payload.baseUrl.trim() } : {}),
...(payload.userId?.trim() ? { userId: payload.userId.trim() } : {}),
};
await writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
try {
await chmod(filePath, 0o600);
} catch {
// best effort only
}
const existingAccountIds = await readAccountIndex();
if (!existingAccountIds.includes(accountId)) {
await writeAccountIndex([...existingAccountIds, accountId]);
}
return accountId;
}
export async function startWeChatLoginSession(options: {
sessionKey?: string;
accountId?: string;
apiBaseUrl?: string;
force?: boolean;
}): Promise<WeChatLoginStartResult> {
const sessionKey = options.sessionKey?.trim() || randomUUID();
const apiBaseUrl = options.apiBaseUrl?.trim() || DEFAULT_WECHAT_BASE_URL;
const existing = activeLogins.get(sessionKey);
if (!options.force && existing && isLoginFresh(existing) && existing.qrcodeUrl) {
return {
sessionKey,
qrcodeUrl: existing.qrcodeUrl,
message: 'QR code is ready. Scan it with WeChat.',
};
}
const qrResponse = await fetchWeChatQrCode(apiBaseUrl, options.accountId);
const qrDataUrl = await renderQrPngDataUrl(qrResponse.qrcode_img_content);
activeLogins.set(sessionKey, {
sessionKey,
qrcode: qrResponse.qrcode,
qrcodeUrl: qrDataUrl,
startedAt: Date.now(),
apiBaseUrl,
});
return {
sessionKey,
qrcodeUrl: qrDataUrl,
message: 'Scan the QR code with WeChat to complete login.',
};
}
export async function waitForWeChatLoginSession(options: {
sessionKey: string;
timeoutMs?: number;
accountId?: string;
onQrRefresh?: (payload: { qrcodeUrl: string }) => void | Promise<void>;
}): Promise<WeChatLoginWaitResult> {
const login = activeLogins.get(options.sessionKey);
if (!login) {
return {
connected: false,
message: 'No active WeChat login session. Generate a new QR code and try again.',
};
}
if (!isLoginFresh(login)) {
activeLogins.delete(options.sessionKey);
return {
connected: false,
message: 'The QR code has expired. Generate a new QR code and try again.',
};
}
const timeoutMs = Math.max(options.timeoutMs ?? 480_000, 1000);
const deadline = Date.now() + timeoutMs;
let qrRefreshCount = 1;
while (Date.now() < deadline) {
const current = activeLogins.get(options.sessionKey);
if (!current) {
return {
connected: false,
message: 'The WeChat login session was cancelled.',
};
}
const statusResponse = await pollWeChatQrStatus(current.apiBaseUrl, current.qrcode, options.accountId);
switch (statusResponse.status) {
case 'wait':
case 'scaned':
break;
case 'expired': {
qrRefreshCount += 1;
if (qrRefreshCount > MAX_QR_REFRESH_COUNT) {
activeLogins.delete(options.sessionKey);
return {
connected: false,
message: 'The QR code expired too many times. Generate a new QR code and try again.',
};
}
const refreshedQr = await fetchWeChatQrCode(current.apiBaseUrl, options.accountId);
const refreshedQrDataUrl = await renderQrPngDataUrl(refreshedQr.qrcode_img_content);
activeLogins.set(options.sessionKey, {
...current,
qrcode: refreshedQr.qrcode,
qrcodeUrl: refreshedQrDataUrl,
startedAt: Date.now(),
});
await options.onQrRefresh?.({ qrcodeUrl: refreshedQrDataUrl });
break;
}
case 'confirmed':
activeLogins.delete(options.sessionKey);
if (!statusResponse.ilink_bot_id || !statusResponse.bot_token) {
return {
connected: false,
message: 'WeChat login succeeded but the server did not return the required account credentials.',
};
}
return {
connected: true,
botToken: statusResponse.bot_token,
accountId: statusResponse.ilink_bot_id,
baseUrl: statusResponse.baseurl,
userId: statusResponse.ilink_user_id,
message: 'WeChat connected successfully.',
};
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
activeLogins.delete(options.sessionKey);
return {
connected: false,
message: 'Timed out waiting for WeChat QR confirmation.',
};
}
export async function cancelWeChatLoginSession(sessionKey?: string): Promise<void> {
if (!sessionKey) {
activeLogins.clear();
return;
}
activeLogins.delete(sessionKey);
}
export async function clearWeChatLoginState(): Promise<void> {
activeLogins.clear();
await rm(WECHAT_STATE_DIR, { recursive: true, force: true });
}
+48 -5
View File
@@ -2,7 +2,7 @@ import { dirname, join } from 'path';
import { homedir } from 'os';
import { createRequire } from 'module';
import { EventEmitter } from 'events';
import { existsSync, mkdirSync, rmSync } from 'fs';
import { existsSync, mkdirSync, rmSync, readdirSync } from 'fs';
import { deflateSync } from 'zlib';
import { getOpenClawDir, getOpenClawResolvedDir } from './paths';
@@ -11,12 +11,24 @@ const require = createRequire(import.meta.url);
// Resolve dependencies from OpenClaw package context (pnpm-safe)
const openclawPath = getOpenClawDir();
const openclawResolvedPath = getOpenClawResolvedDir();
// Primary: resolves from openclaw's real (dereferenced) path in pnpm store.
// In packaged builds this is the flat `resources/openclaw/node_modules/`.
const openclawRequire = createRequire(join(openclawResolvedPath, 'package.json'));
// Fallback: resolves from the symlink path (`node_modules/openclaw`).
// In dev mode, Node walks UP from here to `<project>/node_modules/`, which
// contains ClawX's own devDependencies — packages that are NOT deps of openclaw
// (e.g. @whiskeysockets/baileys) become resolvable through pnpm hoisting.
const projectRequire = createRequire(join(openclawPath, 'package.json'));
function resolveOpenClawPackageJson(packageName: string): string {
const specifier = `${packageName}/package.json`;
// 1. Try openclaw's own deps (works in packaged mode + openclaw transitive deps)
try {
return openclawRequire.resolve(specifier);
} catch { /* fall through */ }
// 2. Fallback to project-level deps (works in dev mode for ClawX devDependencies)
try {
return projectRequire.resolve(specifier);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new Error(
@@ -28,7 +40,8 @@ function resolveOpenClawPackageJson(packageName: string): string {
}
const baileysPath = dirname(resolveOpenClawPackageJson('@whiskeysockets/baileys'));
const qrcodeTerminalPath = dirname(resolveOpenClawPackageJson('qrcode-terminal'));
const qrCodeModulePath = openclawRequire.resolve('qrcode-terminal/vendor/QRCode/index.js');
const qrErrorCorrectLevelPath = openclawRequire.resolve('qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js');
// Load Baileys dependencies dynamically
const {
@@ -39,8 +52,8 @@ const {
} = require(baileysPath);
// Load QRCode dependencies dynamically
const QRCodeModule = require(join(qrcodeTerminalPath, 'vendor', 'QRCode', 'index.js'));
const QRErrorCorrectLevelModule = require(join(qrcodeTerminalPath, 'vendor', 'QRCode', 'QRErrorCorrectLevel.js'));
const QRCodeModule = require(qrCodeModulePath);
const QRErrorCorrectLevelModule = require(qrErrorCorrectLevelPath);
// Types from Baileys (approximate since we don't have types for dynamic require)
interface BaileysError extends Error {
@@ -182,6 +195,7 @@ export class WhatsAppLoginManager extends EventEmitter {
private qr: string | null = null;
private accountId: string | null = null;
private active: boolean = false;
private loginSucceeded: boolean = false;
private retryCount: number = 0;
private maxRetries: number = 5;
@@ -195,6 +209,7 @@ export class WhatsAppLoginManager extends EventEmitter {
private async finishLogin(accountId: string): Promise<void> {
if (!this.active) return;
console.log('[WhatsAppLogin] Finishing login, closing socket to hand over to Gateway...');
this.loginSucceeded = true;
await this.stop();
// Allow enough time for WhatsApp server to fully release the session
await new Promise(resolve => setTimeout(resolve, 5000));
@@ -221,6 +236,7 @@ export class WhatsAppLoginManager extends EventEmitter {
this.accountId = accountId;
this.active = true;
this.loginSucceeded = false;
this.qr = null;
this.retryCount = 0;
@@ -385,6 +401,8 @@ export class WhatsAppLoginManager extends EventEmitter {
* Stop current login process
*/
async stop(): Promise<void> {
const shouldCleanup = !this.loginSucceeded && this.accountId;
const cleanupAccountId = this.accountId;
this.active = false;
this.qr = null;
if (this.socket) {
@@ -405,7 +423,32 @@ export class WhatsAppLoginManager extends EventEmitter {
}
this.socket = null;
}
// Clean up the credentials directory that was created during start()
// when the login was cancelled (not successfully authenticated).
// This prevents listConfiguredChannels() from reporting WhatsApp
// as configured based solely on the existence of this directory.
if (shouldCleanup && cleanupAccountId) {
try {
const authDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp', cleanupAccountId);
if (existsSync(authDir)) {
rmSync(authDir, { recursive: true, force: true });
console.log(`[WhatsAppLogin] Cleaned up auth dir for cancelled login: ${authDir}`);
// Also remove the parent whatsapp dir if it's now empty
const parentDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
if (existsSync(parentDir)) {
const remaining = readdirSync(parentDir);
if (remaining.length === 0) {
rmSync(parentDir, { recursive: true, force: true });
console.log('[WhatsAppLogin] Removed empty whatsapp credentials directory');
}
}
}
} catch (err) {
console.error('[WhatsAppLogin] Failed to clean up auth dir after cancel:', err);
}
}
}
}
export const whatsAppLoginManager = new WhatsAppLoginManager();
export const whatsAppLoginManager = new WhatsAppLoginManager();
+4
View File
@@ -49,6 +49,10 @@ export default [
selector: "CallExpression[callee.type='MemberExpression'][callee.property.name='invoke'][callee.object.type='MemberExpression'][callee.object.property.name='ipcRenderer'][callee.object.object.type='MemberExpression'][callee.object.object.property.name='electron'][callee.object.object.object.name='window']",
message: 'Use invokeIpc from @/lib/api-client instead of window.electron.ipcRenderer.invoke.',
},
{
selector: "CallExpression[callee.name='fetch'] Literal[value=/^https?:\\/\\/(127\\.0\\.0\\.1|localhost)(:\\d+)?\\//]",
message: 'Do not call local endpoints directly from renderer. Route through host-api/api-client proxies.',
},
],
},
},
+45 -11
View File
@@ -1,15 +1,30 @@
{
"name": "clawx",
"version": "0.1.24-alpha.1",
"version": "0.3.2",
"pnpm": {
"onlyBuiltDependencies": [
"@discordjs/opus",
"@whiskeysockets/baileys",
"electron",
"esbuild",
"koffi",
"node-llama-cpp",
"protobufjs",
"sharp"
]
],
"overrides": {
"isbinaryfile": "^5.0.0",
"https-proxy-agent": "7.0.6"
},
"supportedArchitectures": {
"os": [
"current"
],
"cpu": [
"x64",
"arm64"
]
}
},
"description": "ClawX - Graphical AI Assistant based on OpenClaw",
"main": "dist-electron/main/index.js",
@@ -18,37 +33,51 @@
"private": true,
"scripts": {
"init": "pnpm install && pnpm run uv:download",
"predev": "zx scripts/prepare-preinstalled-skills-dev.mjs",
"dev": "vite",
"build": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && electron-builder",
"build": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder",
"build:vite": "vite build",
"bundle:openclaw-plugins": "zx scripts/bundle-openclaw-plugins.mjs",
"bundle:preinstalled-skills": "zx scripts/bundle-preinstalled-skills.mjs",
"lint": "eslint . --fix",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"comms:replay": "node scripts/comms/replay.mjs",
"comms:baseline": "node scripts/comms/baseline.mjs",
"comms:compare": "node scripts/comms/compare.mjs",
"uv:download": "zx scripts/download-bundled-uv.mjs",
"uv:download:mac": "zx scripts/download-bundled-uv.mjs --platform=mac",
"uv:download:win": "zx scripts/download-bundled-uv.mjs --platform=win",
"uv:download:linux": "zx scripts/download-bundled-uv.mjs --platform=linux",
"uv:download:all": "zx scripts/download-bundled-uv.mjs --all",
"node:download:win": "zx scripts/download-bundled-node.mjs --platform=win",
"prep:win-binaries": "pnpm run uv:download:win && pnpm run node:download:win",
"icons": "zx scripts/generate-icons.mjs",
"package": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && electron-builder",
"package:mac": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && electron-builder --mac",
"package:win": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && electron-builder --win",
"package:linux": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && electron-builder --linux",
"release": "pnpm run uv:download && vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && electron-builder --publish always",
"package": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs",
"package:mac": "pnpm run package && electron-builder --mac --publish never",
"package:mac:local": "SKIP_PREINSTALLED_SKILLS=1 pnpm run package && electron-builder --mac --publish never",
"package:win": "pnpm run prep:win-binaries && pnpm run package && electron-builder --win --publish never",
"package:linux": "pnpm run package && electron-builder --linux --publish never",
"release": "pnpm run uv:download && pnpm run package && electron-builder --publish always",
"version:patch": "pnpm version patch",
"version:minor": "pnpm version minor",
"version:major": "pnpm version major",
"postversion": "git push && git push --tags"
},
"dependencies": {
"@sinclair/typebox": "^0.34.48",
"clawhub": "^0.5.0",
"electron-store": "^11.0.2",
"electron-updater": "^6.8.3",
"lru-cache": "^11.2.6",
"ms": "^2.1.3",
"node-machine-id": "^1.1.12",
"posthog-node": "^5.28.0",
"ws": "^8.19.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@larksuite/openclaw-lark": "2026.3.25",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
@@ -61,7 +90,9 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@soimy/dingtalk": "^3.1.4",
"@soimy/dingtalk": "^3.4.2",
"@tencent-connect/openclaw-qqbot": "^1.6.5",
"@tencent-weixin/openclaw-weixin": "^2.0.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.3.0",
@@ -71,6 +102,8 @@
"@typescript-eslint/eslint-plugin": "^8.56.0",
"@typescript-eslint/parser": "^8.56.0",
"@vitejs/plugin-react": "^5.1.4",
"@wecom/wecom-openclaw-plugin": "^2026.3.24",
"@whiskeysockets/baileys": "7.0.0-rc.9",
"autoprefixer": "^10.4.24",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -84,7 +117,7 @@
"i18next": "^25.8.11",
"jsdom": "^28.1.0",
"lucide-react": "^0.563.0",
"openclaw": "2026.3.1",
"openclaw": "2026.3.24",
"png2icons": "^2.0.1",
"postcss": "^8.5.6",
"react": "^19.2.4",
@@ -99,6 +132,7 @@
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.9.3",
"use-stick-to-bottom": "^1.1.3",
"vite": "^7.3.1",
"vite-plugin-electron": "^0.29.0",
"vite-plugin-electron-renderer": "^0.14.6",
@@ -106,5 +140,5 @@
"zustand": "^5.0.11",
"zx": "^8.8.5"
},
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268"
}
+2422 -4148
View File
File diff suppressed because it is too large Load Diff
-143
View File
@@ -1,143 +0,0 @@
# Refactor Summary
## Scope
This branch captures local refactors focused on frontend UX polish, IPC call consolidation, transport abstraction, and channel page responsiveness.
## Key Changes
### 1. Frontend IPC consolidation
- Replaced scattered direct `window.electron.ipcRenderer.invoke(...)` calls with unified `invokeIpc(...)` usage.
- Added lint guard to prevent new direct renderer IPC invokes outside the API layer.
- Introduced a centralized API client with:
- error normalization (`AppError`)
- unified `app:request` support + compatibility fallback
- retry helper for timeout/network errors
### 2. Transport abstraction (extensible protocol layer)
- Added transport routing abstraction inside `src/lib/api-client.ts`:
- `ipc`, `ws`, `http`
- rule-based channel routing
- transport registration/unregistration
- failure backoff and fallback behavior
- Added default transport initialization in app entry.
- Added gateway-specific transport adapters for WS/HTTP.
### 3. HTTP path moved to Electron main-process proxy
- Added `gateway:httpProxy` IPC handler in main process to avoid renderer-side CORS issues.
- Preload allowlist updated for `gateway:httpProxy`.
- Gateway HTTP transport now uses IPC proxy instead of browser `fetch` direct-to-gateway.
### 4. Settings improvements (Developer-focused transport control)
- Added persisted setting `gatewayTransportPreference`.
- Added runtime application of transport preference in app bootstrap.
- Added UI option (Developer section) to choose routing strategy:
- WS First / HTTP First / WS Only / HTTP Only / IPC Only
- Added i18n strings for EN/ZH/JA.
### 5. Channel page performance optimization
- `fetchChannels` now supports options:
- `probe` (manual refresh can force probe)
- `silent` (background refresh without full-page loading lock)
- Channel status event refresh now debounced (300ms) to reduce refresh storms.
- Initial loading spinner only shown when no existing data.
- Manual refresh uses local spinner state and non-blocking update.
### 6. UX and component enhancements
- Added shared feedback state component for consistent empty/loading/error states.
- Added telemetry helpers and quick-action/dashboard refinements.
- Setup/settings/providers/chat/skills/cron pages received targeted UX and reliability fixes.
### 7. IPC main handler compatibility improvements
- Expanded `app:request` coverage for provider/update/settings/cron/usage actions.
- Unsupported app requests now return structured error response instead of throwing, reducing noisy handler exceptions.
### 8. Tests
- Added unit tests for API client behavior and feedback state rendering.
- Added transport fallback/backoff coverage in API client tests.
## Files Added
- `src/lib/api-client.ts`
- `src/lib/telemetry.ts`
- `src/components/common/FeedbackState.tsx`
- `tests/unit/api-client.test.ts`
- `tests/unit/feedback-state.test.tsx`
- `refactor.md`
## Notes
- Navigation order in sidebar is kept aligned with `main` ordering.
- This commit snapshots current local refactor state for follow-up cleanup/cherry-pick work.
## Incremental Updates (2026-03-08)
### 9. Channel i18n fixes
- Added missing `channels` locale keys in EN/ZH/JA to prevent raw key fallback:
- `configured`, `configuredDesc`, `configuredBadge`, `deleteConfirm`
- Fixed confirm dialog namespace usage on Channels page:
- `common:actions.confirm`, `common:actions.delete`, `common:actions.cancel`
### 10. Channel save/delete behavior aligned to reload-first strategy
- Added Gateway reload capability in `GatewayManager`:
- `reload()` (SIGUSR1 on macOS/Linux, restart fallback on failure/unsupported platforms)
- `debouncedReload()` for coalesced config-change reloads
- Wired channel config operations to reload pipeline:
- `channel:saveConfig`
- `channel:deleteConfig`
- `channel:setEnabled`
- Removed redundant renderer-side forced restart call after WhatsApp configuration.
### 11. OpenClaw config compatibility for graceful reload
- Ensured `commands.restart = true` is persisted in OpenClaw config write paths:
- `electron/utils/channel-config.ts`
- `electron/utils/openclaw-auth.ts`
- Added sanitize fallback that auto-enables `commands.restart` before Gateway start.
### 12. Channels page data consistency fixes
- Unified configured state derivation so the following sections share one source:
- stats cards
- configured channels list
- available channel configured badge
- Fixed post-delete refresh by explicitly refetching both:
- configured channel types
- channel status list
### 13. Channels UX resilience during Gateway restart/reconnect
- Added delayed gateway warning display to reduce transient false alarms.
- Added "running snapshot" rendering strategy:
- keep previous channels/configured view during `starting/reconnecting` when live response is temporarily empty
- avoids UI flashing to zero counts / empty configured state
- Added automatic refresh once Gateway transitions back to `running`.
### 14. Channel enable/disable UX rollback
- Rolled back renderer-side channel enable/disable controls due to multi-channel state mixing risk.
- Removed channel-card toggle entry point and setup-dialog enable switch.
- Restored stable channel configuration flow (save/delete + refresh consistency).
### 15. Cron i18n completion and consistency
- Replaced remaining hardcoded Cron UI strings with i18n keys:
- dialog actions (`Cancel`, `Saving...`)
- card actions (`Edit`, `Delete`)
- trigger failure message
- confirm dialog namespace usage (`common:actions.*`)
- Refactored cron schedule display parser to return localized strings instead of hardcoded English.
- Added new locale keys in EN/ZH/JA:
- `toast.failedTrigger`
- `schedule.everySeconds/everyMinutes/everyHours/everyDays/onceAt/weeklyAt/monthlyAtDay/dailyAt/unknown`
### 16. Gateway log noise reduction
- Added stderr classification downgrade for expected loopback websocket transient logs:
- `[ws] handshake timeout ... remote=127.0.0.1`
- `[ws] closed before connect ... remote=127.0.0.1`
- These lines now log at debug level instead of warn during reload/reconnect windows.
### 17. External gateway shutdown compatibility
- Added capability cache for externally managed Gateway shutdown RPC.
- If `shutdown` is unsupported (`unknown method: shutdown`), mark it unsupported and skip future shutdown RPC attempts to avoid repeated warnings.
### 18. Chat history sidebar grouping (ChatGPT-style buckets)
- Updated chat session history display in sidebar to time buckets:
- Today / Yesterday / Within 1 Week / Within 2 Weeks / Within 1 Month / Older than 1 Month
- Added `historyBuckets` locale keys in EN/ZH/JA (`chat` namespace).
- Fixed i18n namespace usage for bucket labels in sidebar:
- explicitly resolves via `chat:historyBuckets.*` to avoid raw key fallback.
- Removed forced uppercase rendering for bucket headers to preserve localized casing.
- Grouping now applies to all sessions (including `:main`) for consistent bucket visibility and behavior.
+10 -1
View File
@@ -14,4 +14,13 @@ case "$1" in
esac
export OPENCLAW_EMBEDDED_IN="ClawX"
ELECTRON_RUN_AS_NODE=1 exec "$INSTALL_DIR/ClawX.exe" "$INSTALL_DIR/resources/openclaw/openclaw.mjs" "$@"
NODE_EXE="$INSTALL_DIR/resources/bin/node.exe"
OPENCLAW_ENTRY="$INSTALL_DIR/resources/openclaw/openclaw.mjs"
if [ -f "$NODE_EXE" ]; then
if "$NODE_EXE" -e 'const [maj,min]=process.versions.node.split(".").map(Number);process.exit((maj>22||maj===22&&min>=16)?0:1)' >/dev/null 2>&1; then
exec "$NODE_EXE" "$OPENCLAW_ENTRY" "$@"
fi
fi
ELECTRON_RUN_AS_NODE=1 exec "$INSTALL_DIR/ClawX.exe" "$OPENCLAW_ENTRY" "$@"
+15 -2
View File
@@ -15,9 +15,22 @@ rem on non-English Windows (e.g. Chinese CP936). Save the previous codepage to r
for /f "tokens=2 delims=:." %%a in ('chcp') do set /a "_CP=%%a" 2>nul
chcp 65001 >nul 2>&1
set ELECTRON_RUN_AS_NODE=1
set OPENCLAW_EMBEDDED_IN=ClawX
"%~dp0..\..\ClawX.exe" "%~dp0..\openclaw\openclaw.mjs" %*
set "NODE_EXE=%~dp0..\bin\node.exe"
set "OPENCLAW_ENTRY=%~dp0..\openclaw\openclaw.mjs"
set "_USE_BUNDLED_NODE=0"
if exist "%NODE_EXE%" (
"%NODE_EXE%" -e "const [maj,min]=process.versions.node.split('.').map(Number);process.exit((maj>22||maj===22&&min>=16)?0:1)" >nul 2>&1
if not errorlevel 1 set "_USE_BUNDLED_NODE=1"
)
if "%_USE_BUNDLED_NODE%"=="1" (
"%NODE_EXE%" "%OPENCLAW_ENTRY%" %*
) else (
set ELECTRON_RUN_AS_NODE=1
"%~dp0..\..\ClawX.exe" "%OPENCLAW_ENTRY%" %*
)
set _EXIT=%ERRORLEVEL%
if defined _CP chcp %_CP% >nul 2>&1
+150
View File
@@ -0,0 +1,150 @@
param(
[Parameter(Mandatory = $true)]
[ValidateSet('add', 'remove')]
[string]$Action,
[Parameter(Mandatory = $true)]
[string]$CliDir
)
$ErrorActionPreference = 'Stop'
function Get-UserPathRegistryValue {
$raw = [Environment]::GetEnvironmentVariable('Path', 'User')
$kind = [Microsoft.Win32.RegistryValueKind]::ExpandString
try {
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $false)
if ($null -ne $key) {
try {
$stored = $key.GetValue('Path', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
if ($null -ne $stored) {
$raw = [string]$stored
}
} catch {
# Fallback to Environment API value
}
try {
$kind = $key.GetValueKind('Path')
} catch {
# Keep default ExpandString
}
$key.Close()
}
} catch {
# Fallback to Environment API value
}
return @{
Raw = $raw
Kind = $kind
}
}
function Normalize-PathEntry {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) {
return ''
}
return $Value.Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
}
$pathMeta = Get-UserPathRegistryValue
$current = $pathMeta.Raw
$entries = @()
if (-not [string]::IsNullOrWhiteSpace($current)) {
$entries = $current -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
}
$target = Normalize-PathEntry $CliDir
$seen = [System.Collections.Generic.HashSet[string]]::new()
$nextEntries = New-Object System.Collections.Generic.List[string]
foreach ($entry in $entries) {
$normalized = Normalize-PathEntry $entry
if ([string]::IsNullOrWhiteSpace($normalized)) {
continue
}
if ($normalized -eq $target) {
continue
}
if ($seen.Add($normalized)) {
$nextEntries.Add($entry.Trim().Trim('"'))
}
}
$status = 'already-present'
if ($Action -eq 'add') {
if ($seen.Add($target)) {
$nextEntries.Add($CliDir)
$status = 'updated'
}
} elseif ($entries.Count -ne $nextEntries.Count) {
$status = 'updated'
}
$isLikelyCorruptedWrite = (
$Action -eq 'add' -and
$entries.Count -gt 1 -and
$nextEntries.Count -le 1
)
if ($isLikelyCorruptedWrite) {
throw "Refusing to rewrite user PATH: input had $($entries.Count) entries but output has $($nextEntries.Count)."
}
$newPath = if ($nextEntries.Count -eq 0) { $null } else { $nextEntries -join ';' }
try {
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
if ($null -eq $key) {
throw 'Unable to open HKCU\Environment for write.'
}
if ([string]::IsNullOrWhiteSpace($newPath)) {
$key.DeleteValue('Path', $false)
} else {
$kind = if ($pathMeta.Kind -eq [Microsoft.Win32.RegistryValueKind]::String) {
[Microsoft.Win32.RegistryValueKind]::String
} else {
[Microsoft.Win32.RegistryValueKind]::ExpandString
}
$key.SetValue('Path', $newPath, $kind)
}
$key.Close()
} catch {
throw "Failed to write HKCU\\Environment\\Path: $($_.Exception.Message)"
}
try {
Add-Type -Namespace OpenClaw -Name NativeMethods -MemberDefinition @"
[System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Auto)]
public static extern System.IntPtr SendMessageTimeout(
System.IntPtr hWnd,
int Msg,
System.IntPtr wParam,
string lParam,
int fuFlags,
int uTimeout,
out System.IntPtr lpdwResult
);
"@
$result = [IntPtr]::Zero
[OpenClaw.NativeMethods]::SendMessageTimeout(
[IntPtr]0xffff,
0x001A,
[IntPtr]::Zero,
'Environment',
0x0002,
5000,
[ref]$result
) | Out-Null
} catch {
Write-Warning "PATH updated but failed to broadcast environment change: $($_.Exception.Message)"
}
Write-Output $status
Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

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