Compare commits

...
419 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
paisleyandashione 2c5c82bb74 Refactor clawx (#344)
Co-authored-by: ashione <skyzlxuan@gmail.com>
2026-03-09 13:10:42 +08:00
Lingxuan Zuo 3d804a9f5e refactor IPC (#341) 2026-03-08 11:54:49 +08:00
Lingxuan Zuo c03d92e9a2 Fix/moonshot cn web search domain (#338) 2026-03-08 00:00:47 +08:00
Ikko Eltociear Ashimine b41a8eedd9 docs: add Japanese README (#326) 2026-03-06 18:56:27 +08:00
Haze 8c44419eaa 0.1.23 2026-03-06 17:17:49 +08:00
Lê Đức Cườngandcuongld12 e1599ee7ef fix(packaging): generate valid Linux desktop entry comment (#322)
Co-authored-by: cuongld12 <cuongld12@vingroup.net>
2026-03-06 16:35:01 +08:00
paisley c5f5e02f4a v0.1.23-beta.0 2026-03-06 16:23:57 +08:00
paisley 3ce4b5d17a fix(linux): single-line description and correct desktop.entry for valid .desktop file (#325) 2026-03-06 16:22:25 +08:00
paisley e7d4cf73d5 Fix ubuntu debs (#320) 2026-03-06 15:07:05 +08:00
paisley 01efd87642 fix(gateway): resolve undefined PID and NODE_OPTIONS errors in packaged app (#315) 2026-03-06 11:03:56 +08:00
paisley 0901d9912a Increase chat send RPC timeout to 120s for high-latency networks (#311) 2026-03-05 15:49:36 +08:00
Mark Leeandslicenfer c5058908af docs: align README command list with package scripts (#299)
Co-authored-by: slicenfer <16222938+slicenfer@user.noreply.gitee.com>
2026-03-05 15:21:59 +08:00
Haze b6adf58f0f 0.1.22 2026-03-05 14:34:05 +08:00
paisley 0720ee0d7f v0.1.22-beta.1 2026-03-05 12:07:06 +08:00
DigHuang 2f17e74944 fix(session): remove limit parameter from sessions.list RPC call (#304) 2026-03-05 12:05:12 +08:00
paisley ee4ff651a7 docs(i18n): update channel setup tutorial links (#303) 2026-03-05 11:25:01 +08:00
paisley 8eae7df3a1 refactor(setup): remove channel connection step from onboarding (#301) 2026-03-05 10:49:59 +08:00
Haze d14ae0a8f5 0.1.22-beta.0 2026-03-04 21:46:15 +08:00
be800f6cfc fix(channel): Feishu channel gateway wont restart after config (#291)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-03-04 21:45:44 +08:00
paisley 5ddb7d281d fix two clawx instances bug (#294) 2026-03-04 21:20:21 +08:00
fd58e721bd Openclaw 环境变量乱码 (#293)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-03-04 21:18:46 +08:00
paisley 52748d78b5 refactor(gateway): migrate from child_process.spawn to utilityProcess.fork (#290) 2026-03-04 20:56:19 +08:00
DigHuang 76df84e68c fix: ghost session cleanup, new chat switching, and markdown overflow handling (#287) 2026-03-04 18:54:47 +08:00
paisley 89028756e1 v0.1.22-alpha.2 2026-03-04 15:15:43 +08:00
paisley ee189a1bde fix: override NSIS process check and replace taskkill to prevent black window flashes 2026-03-04 15:14:01 +08:00
paisley e8a0390b93 v0.1.22-alpha.1 2026-03-04 14:57:49 +08:00
paisley 9703f361f9 fix: add -WindowStyle Hidden to powershell command to prevent window flash on Windows (#285) 2026-03-04 14:51:44 +08:00
Lingxuan Zuoandzuolingxuan 30b03add1c feat: gate model overrides and load full token history (#271)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
2026-03-04 11:52:54 +08:00
paisley 3371e4fe74 v0.1.22-alpha.0 2026-03-04 11:41:40 +08:00
paisley 5049709c5d fix(ui): use custom ConfirmDialog for deletions to prevent input blocking on Windows (#282) 2026-03-04 11:38:16 +08:00
Haze 828cae0186 0.1.21 2026-03-03 23:37:32 +08:00
paisley 402129354a 0.1.21-beta.3 2026-03-03 19:10:56 +08:00
DigHuang c49c7f18bd feat(chat): enhance sidebar with session management and deletion (#274) 2026-03-03 19:07:42 +08:00
Haze f18c91fd6a 0.1.21-beta.2 2026-03-03 18:17:17 +08:00
paisley d0a38519d2 0.1.21-beta.1 2026-03-03 18:07:53 +08:00
paisley 2b966cc573 fix: resolve empty marketplace search results by supporting fallback regex parsing (#273) 2026-03-03 17:36:09 +08:00
Haze c1719b3d52 0.1.21-beta.0 2026-03-03 17:01:52 +08:00
paisley 296a755396 chore: remove playwright e2e tests and dependencies (#270) 2026-03-03 14:23:33 +08:00
Lingxuan Zuoandzuolingxuan e52916a7ef fallback model/providers (#259)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
2026-03-03 10:18:52 +08:00
paisley bc47b455b5 fix(gateway): resolve model switch failure and websocket timeouts during rapid oauth restarts (#266) 2026-03-03 10:18:23 +08:00
9532400053 fix(win): Gateway restart win terminal open error (#265)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-03-02 21:28:01 +08:00
Haze 382d737fa7 0.1.21-alpha.3 2026-03-02 19:06:44 +08:00
paisley f17ffe32c7 fix: patch node-domexception in bundled openclaw 3.1 to avoid ESM loader crash (#264) 2026-03-02 18:42:32 +08:00
Lingxuan Zuoandzuolingxuan e40f4b2163 Add built-in proxy settings for Electron and Gateway (#239)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
2026-03-02 17:33:06 +08:00
Haze c09b45832b 0.1.21-alpha.2 2026-03-02 16:54:45 +08:00
paisley 08e5e4fabb chore: upgrade openclaw to 2026.3.1 (#257) 2026-03-02 16:53:55 +08:00
Haze d4d12cf1fd 0.1.21-alpha.1 2026-03-02 16:53:04 +08:00
paisley c0b1288557 fix: preserve feishu SKILL.md files in bundled openclaw extensions (#256) 2026-03-02 16:49:06 +08:00
paisley 29ef9591cf feat(settings): auto-set default provider after successful OAuth (#254) 2026-03-02 15:13:55 +08:00
Haze 19406757f1 0.1.21-alpha.0 2026-03-02 13:25:37 +08:00
Lingxuan Zuo 62108bdc23 Add dashboard token usage history (#240) 2026-03-02 13:20:33 +08:00
paisley 0bc4b7cbc2 feat: pre-install feishu skills to ~/.openclaw/skills on first launch (#252) 2026-03-02 13:20:07 +08:00
Haze 91cb69d01c 0.1.20 2026-03-02 10:53:44 +08:00
Haze 4b5644418e 0.1.20-beta.0 2026-03-01 17:43:01 +08:00
730d5466dd fix(gateway): optimize gateway start robustness in version 0.1.19 (#244)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-03-01 17:42:25 +08:00
7d0621dcc2 fix(provider): ollama provider fix (#246)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-03-01 17:40:07 +08:00
Haze a8f61d5a61 0.1.19 2026-02-28 20:42:12 +08:00
a3462e2de6 fix(cron): corn task channel warn (#237)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-02-28 20:37:55 +08:00
bdafbca8d4 chore: replace clawx.app with claw-x.com and update maintainer email (#236)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-02-28 20:08:28 +08:00
dainaatm 1b4da25d0d star-history (#235) 2026-02-28 19:25:50 +08:00
Haze 9d17202ea3 0.1.19-alpha.9 2026-02-28 18:41:09 +08:00
9d07f611a3 fix(install): windows install error (#233)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-02-28 18:39:10 +08:00
paisley 3353de91b8 feat(providers): add API key apply links and update Qwen auth mode (#232) 2026-02-28 18:11:16 +08:00
Haze cfd387980d 0.1.19-alpha.8 2026-02-28 17:09:52 +08:00
Mao Mr bf62639573 fix(chat): show loading when run from console and when sending in app (#228) 2026-02-28 17:09:00 +08:00
paisley 95a4c0234a chore: downgrade openclaw to 2.25 (#229) 2026-02-28 17:04:43 +08:00
Haze ed9b733798 0.1.19-alpha.7 2026-02-28 16:47:46 +08:00
Haze a75b96f739 fix(electron-builder): disable warnings as errors for NSIS build to prevent uninstaller warnings (#227) 2026-02-28 16:47:22 +08:00
Lingxuan Zuo 8cda9235b3 Feat/Add ByteDance Ark provider (#226) 2026-02-28 16:44:58 +08:00
Haze 1b45d891c3 0.1.19-alpha.6 2026-02-28 16:12:01 +08:00
b4ef2bd51d feat(openrouter):add claw-x header (#213)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-02-28 15:46:26 +08:00
6859656847 Changes from background agent bc-b072b4af-98b7-4de6-bc1c-8faa623cdb13 (#210)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-02-28 15:37:02 +08:00
Haze 163099add8 0.1.19-alpha.5 2026-02-28 15:18:14 +08:00
4d948347ea fix(cron): remove cron task channel config (#222)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-02-28 15:14:02 +08:00
paisley e303841373 feat: bundle dingtalk plugin mirror cross‑platform and fix windows packaging 2026-02-28 15:02:21 +08:00
Haze bffa85e0e8 0.1.19-alpha.4 2026-02-28 14:47:28 +08:00
paisley dbf88a79be Fix dingding plugin (#221) 2026-02-28 14:42:55 +08:00
Haze 98703a0ab8 0.1.19-alpha.3 2026-02-28 12:11:11 +08:00
paisley 864d6a499e chore:update minimax default model (#218) 2026-02-28 12:06:42 +08:00
Haze afad6c58b0 0.1.19-alpha.2 2026-02-28 11:46:01 +08:00
paisley 270bc1e402 fix qwen url (#217) 2026-02-28 11:38:04 +08:00
paisley d63810f54b feat(channels): add DingTalk via bundled plugin mirror and pure Node deploy (#215) 2026-02-28 11:36:40 +08:00
Felix d4f77a442c misc: improve timeout handling and warnings in skills (#211) 2026-02-28 10:16:36 +08:00
Haze 538d285c71 0.1.19-alpha.1 2026-02-28 00:03:04 +08:00
96bd37d1b1 fix(macos): chat history loading slow problem (#212)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-02-28 00:01:44 +08:00
386d4c5454 fix(win): Windows stability improvements (#207) (#208)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-02-27 22:10:35 +08:00
paisley 0fb1a1a78d fix minimax oauth failed and upgrade openclaw to 2.26 (#206) 2026-02-27 19:05:56 +08:00
paisleyandHaze f70d5b0c28 feat: add new provider for minimax and qwen portals (#203)
Co-authored-by: Haze <709547807@qq.com>
2026-02-27 14:59:37 +08:00
Haze 5d548da2e6 0.1.18 2026-02-27 11:33:02 +08:00
Haze 7d8dd344ad feat: add new provider icons for minimax and qwen portals (#202) 2026-02-27 11:32:40 +08:00
Haze 3a1666ca59 0.1.18-beta.7 2026-02-26 23:24:08 +08:00
paisley 5bc72948d9 fix: resolve custom provider display bug and remove (CN) suffix from provider names (#200) 2026-02-26 23:17:00 +08:00
87616b4250 fix(model): claw-x header (#198)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-02-26 22:57:59 +08:00
Haze 2a50aea448 0.1.18-beta.6 2026-02-26 22:57:29 +08:00
Haze 3420ee2447 fix(app-state): refactor quitting logic to use setQuitting function (#199) 2026-02-26 22:57:03 +08:00
Haze 45d5e9e576 0.1.18-beta.5 2026-02-26 20:51:08 +08:00
Haze f36e143f3f chore(release): remove releaseType from GitHub publish configuration and add blockmap file to release artifacts (#197) 2026-02-26 20:48:28 +08:00
Haze 8a2db04556 0.1.18-beta.4 2026-02-26 20:33:34 +08:00
Haze aaa2864281 fix(updater): improve macOS update handling with forced app.quit() fallback (#196) 2026-02-26 20:33:01 +08:00
Haze 0b2236f91f 0.1.18-beta.3 2026-02-26 18:55:54 +08:00
paisley efe091b301 fix: custom providers overwrite each other in openclaw config (#192) 2026-02-26 18:55:40 +08:00
Haze 0f3505661d 0.1.18-beta.2 2026-02-26 18:54:51 +08:00
Haze 0be07eab2a fix(md): clawx md injection error (#193) 2026-02-26 18:53:53 +08:00
paisley 7929a43601 fix add model provider (#190) 2026-02-26 18:32:06 +08:00
Haze d38a6b012d 0.1.18-beta.1 2026-02-26 17:20:42 +08:00
Haze c591ceb73d chore: skip Electron binary download in CI workflows (#188) 2026-02-26 17:15:52 +08:00
Haze e46243b5b6 0.1.18-beta.0 2026-02-26 17:09:41 +08:00
Haze a14552a474 fix(updater): opt macos electron updator (#187) 2026-02-26 17:08:21 +08:00
Haze 9ff0eb81fd 0.1.18-alpha.4 2026-02-26 15:24:52 +08:00
Haze 45932a95c3 refactor(chat): modify newSession logic to avoid resetting old sessio… (#185) 2026-02-26 15:23:59 +08:00
paisley 7b16b6af14 feat: support OAuth & API key for Qwen/MiniMax providers (#177) 2026-02-26 15:11:37 +08:00
Haze e1ae68ce7e 0.1.18-alpha.3 2026-02-26 14:48:12 +08:00
Haze 4f50630291 feat(gateway, openclaw-auth): add browser config synchronization (#184) 2026-02-26 14:47:36 +08:00
Haze 402aeeb98b 0.1.18-alpha.2 2026-02-26 14:35:45 +08:00
Haze d11f45cc73 fix(chat): enhance file path extraction and preserve optimistic user … (#182) 2026-02-26 14:35:33 +08:00
Haze 8f9fc056a4 fix(chat): improve message layout and text wrapping in ChatMessage co… (#181) 2026-02-26 13:46:23 +08:00
Haze 4bbeb0d181 0.1.18-alpha.1 2026-02-26 11:56:13 +08:00
Haze 1ca0017f85 feat(openclaw): add repair function for ClawX-only bootstrap files an… (#180) 2026-02-26 11:55:19 +08:00
Haze 96ac13fd90 feat(chat, gateway): implement error recovery timer and enhance histo… (#179) 2026-02-26 11:26:39 +08:00
Felix 264ef460a5 fix(pkg): move clawhub to dependencies to package into dmg (#178) 2026-02-26 11:14:37 +08:00
Haze 6584cadd26 0.1.18-alpha.0 2026-02-26 00:02:07 +08:00
Haze 6383e10d63 feat(chat): reformat streaming output (#173) 2026-02-25 23:59:55 +08:00
Haze 02d88e4963 0.1.17 2026-02-25 23:25:43 +08:00
Haze 0038f26465 0.1.17-alpha.4 2026-02-25 23:22:23 +08:00
Haze 516e5fecd1 fix(updater): enable autoInstallOnAppQuit for macOS to ensure proper … (#172) 2026-02-25 23:21:37 +08:00
9a039ab9fb ClawX windows path robustness (#171)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-02-25 22:46:59 +08:00
Haze ade1b2ac8b 0.1.17-alpha.3 2026-02-25 21:50:01 +08:00
Haze b84b645aa1 feat(gateway): implement unloading of launchctl service to prevent au… (#170) 2026-02-25 21:49:24 +08:00
Haze 7baafea262 0.1.17-alpha.2 2026-02-25 20:10:27 +08:00
Haze 6990793e69 Fix/chore fix on macos (#169) 2026-02-25 20:10:09 +08:00
Haze d317dfd153 0.1.17-alpha.1 2026-02-25 18:57:43 +08:00
Haze e8c11887d0 feat(gateway): sync gateway token and update agent models on provider… (#168) 2026-02-25 18:56:04 +08:00
HazeandCursor 2804b6da73 fix(gateway): remove --dev flag from gateway startup args (#167)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 18:29:29 +08:00
Haze f0738af2eb 0.1.17-alpha.0 2026-02-25 16:39:06 +08:00
Haze 265b12281c fix(model): custom model choose error (#164) 2026-02-25 16:38:03 +08:00
Cursor AgentandHaze 1166074a52 Merge branch 'fix/uv_installation_error' into main
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-02-25 08:09:36 +00:00
2dfcc4e600 最新修改验证 (#162)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-02-25 16:07:30 +08:00
Haze 25476488c6 feat(electron): enhance uv binary resolution and logging
- Introduced a new function to resolve the best available uv binary, prioritizing the bundled version in production.
- Improved logging for uv binary availability checks and Python installation processes.
- Refactored the check for uv installation to streamline the process and provide clearer error messages.
- Updated the setupManagedPython function to handle installation attempts with and without a mirror environment.
2026-02-25 15:34:31 +08:00
Cursor AgentandHaze 9bdc868056 chore: release v0.1.17-beta.1
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-02-25 06:37:36 +00:00
Cursor AgentandHaze eaac3664bd chore: bump openclaw from 2026.2.23 to 2026.2.24
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-02-25 06:36:28 +00:00
HazeandHaze 6453702bf9 feat(electron): integrate ClawX context into openclaw workspace initi… (#161)
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-02-25 14:22:16 +08:00
5d1d704031 feat(package): compress artifact size (#160)
Co-authored-by: Cursor Agent <cursor-agent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-02-25 14:07:48 +08:00
3da8c71602 Development environment setup (#157)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-02-25 10:31:01 +08:00
Haze 0adbac1688 0.1.16 2026-02-24 21:01:28 +08:00
Haze 1fae91667d 0.1.16-alpha.4 2026-02-24 19:52:37 +08:00
Haze 4e7733353c fix(gateway): improve process termination handling and add timeout (#153) 2026-02-24 19:47:36 +08:00
Haze 0102ba5870 0.1.16-alpha.3 2026-02-24 18:00:49 +08:00
Haze f48452b52b chore(electron): disable native module rebuilding for bundled openclaw dependencies 2026-02-24 17:57:52 +08:00
Haze 6eb764191e 0.1.16-alpha.2 2026-02-24 16:58:26 +08:00
Haze b6e62aa7e6 chore(release): update build commands in release workflow to use pnpm… (#152) 2026-02-24 16:58:16 +08:00
Haze 1b5ffaefb7 0.1.16-alpha.1 2026-02-24 16:45:11 +08:00
Haze d55305839f feat(updater): implement auto-install countdown and cancellation for updates (#151) 2026-02-24 16:41:53 +08:00
Haze d572176b06 0.1.16-alpha.0 2026-02-24 16:20:01 +08:00
Haze 1b02d63399 chore(release): update release workflow to build and publish artifacts (#150) 2026-02-24 16:19:31 +08:00
Haze cf27bb0d64 feat(openclaw): update openclaw version 223 (#149) 2026-02-24 16:09:39 +08:00
Haze f32521b484 0.1.15 2026-02-20 22:28:38 +08:00
Haze a0096ef36c 0.1.15-alpha.0 2026-02-20 21:23:35 +08:00
Haze 34105d60c6 fix(gateway): gateway start waiting (#120) 2026-02-20 21:23:06 +08:00
Haze f821949829 fix(model): update gemini apikey modify function (#119) 2026-02-20 20:17:30 +08:00
Haze 1808990f5b 0.1.14 2026-02-20 18:46:00 +08:00
Haze b87e676826 feat(chat): add cleaning for Gateway-injected conversation (#118) 2026-02-20 18:45:28 +08:00
Haze 15f3ef1f72 0.1.14-alpha.5 2026-02-20 11:00:05 +08:00
Haze 98b6367f44 chore(deps): update package versions in package.json and pnpm-lock.yaml (#117) 2026-02-20 10:58:57 +08:00
Haze baea49328d 0.1.14-alpha.4 2026-02-20 10:49:12 +08:00
Haze c969e899c8 feat(chat): enhance image extraction and file path handling in messages (#116) 2026-02-20 10:48:46 +08:00
Haze 1c418ab22a 0.1.14-alpha.3 2026-02-19 01:54:01 +08:00
Haze 4483718405 0.1.14-alpha.2 2026-02-19 01:52:03 +08:00
Haze b9b9eb9c02 feat(electron): implement minimize to tray on close for Windows (#106) 2026-02-19 01:50:31 +08:00
Haze c112899118 0.1.14-alpha.1 2026-02-19 00:33:20 +08:00
Haze 1759b98443 fix(chat): optimize chat with pic response (#103) 2026-02-19 00:29:10 +08:00
Haze 7360a0adcf 0.1.14-alpha.0 2026-02-18 20:31:09 +08:00
Haze 5005d405d9 chore(deps): update dependencies and devDependencies in package.json (#100) 2026-02-18 20:28:42 +08:00
Haze 26ce009a41 chore(guide): opt guide frontend optimization (#88) 2026-02-14 20:35:32 +08:00
Haze bbc0f8f818 0.1.13 2026-02-14 18:52:39 +08:00
Haze 3ebafa5266 0.1.13-alpha.0 2026-02-14 18:01:38 +08:00
Haze d108a850ef feat(chat): opt pic show in chat history (#87) 2026-02-14 18:00:57 +08:00
Haze e4093ddc47 doc(README): update readme add screenshot (#86) 2026-02-14 14:22:17 +08:00
paisley 6c6fa0bb1c fix: resolve gateway token mismatch and openrouter validation (#85) 2026-02-14 12:29:46 +08:00
Haze 9b638d479c 0.1.12 2026-02-14 01:08:04 +08:00
Haze 18907a8f24 0.1.12-alpha.7 2026-02-14 00:22:03 +08:00
Haze cf8091d81f feat(chat): improve media handling and caching for user messages (#81) 2026-02-14 00:21:04 +08:00
Haze 051803869d 0.1.12-alpha.6 2026-02-13 20:44:10 +08:00
Haze 8a49c66891 fix(chat): enhance file handling and error reporting in ChatInput (#80) 2026-02-13 20:43:42 +08:00
DigHuang d61bc24306 0.1.12-alpha.5 2026-02-13 19:16:51 +08:00
DigHuang b6f4d79571 fix(win-clean): simplify uninstaller prompt; remove Chinese UI (#79) 2026-02-13 19:16:12 +08:00
DigHuang 43cddca017 0.1.12-alpha.4 2026-02-13 18:46:58 +08:00
DigHuang 52a3d07365 0.1.12-alpha.3 2026-02-13 18:46:40 +08:00
DigHuang e87d4e8c8f fix(win-clean): installer data-delete dialog logic (#78) 2026-02-13 18:45:45 +08:00
Haze e5d0cc2f9e 0.1.12-alpha.2 2026-02-13 18:24:48 +08:00
Haze 36fb4bffc7 feat(chat): implement file upload support with staging and media handle (#77) 2026-02-13 18:23:47 +08:00
DigHuang a159fd2b4c 0.1.12-alpha.1 2026-02-13 18:16:36 +08:00
DigHuang 4e386ffc37 fix(win-clean): Chinese UI and normalize ClawX paths (#76) 2026-02-13 18:15:02 +08:00
paisley e8915831dc feat: localize custom provider name and ollama placeholder (#75) 2026-02-13 17:42:52 +08:00
DigHuang 9844d3de95 0.1.12-alpha.0 2026-02-13 17:27:52 +08:00
DigHuang 5946deec05 feat(clean): NSIS uninstaller script and include it (#74) 2026-02-13 17:25:27 +08:00
Haze e7a36320ee 0.1.11 2026-02-13 16:32:51 +08:00
Haze 312f8f38de chore(url): update documentation links to point to the new domain (#73) 2026-02-13 16:31:34 +08:00
Haze 3b52eab5f3 0.1.11-alpha.0 2026-02-13 15:42:43 +08:00
Haze b1f582e152 feat(ipc-handlers): implement fallback validation for OpenAI API key (#72) 2026-02-13 15:42:21 +08:00
Haze 097e70cc73 Update copyright holder in LICENSE file 2026-02-13 15:07:34 +08:00
Felix 6a865da4f1 feat(setup): enhance provider selection with dropdown menu and keyboard accessibility (#71) 2026-02-13 11:40:56 +08:00
paisley 712f379226 fix(setup): translate docsUrl before opening in browser (#70) 2026-02-13 11:19:56 +08:00
arianachennn-design 748cd83c73 doc: readme with community QR code (#67) 2026-02-12 19:08:14 +08:00
Haze b4d6e62055 0.1.10 2026-02-12 18:07:34 +08:00
Haze 19e67a4bb2 0.1.10-alpha.1 2026-02-12 17:05:53 +08:00
Haze c0e8bcfe48 fix(release): alpha beta yml upload workflow failed (#66) 2026-02-12 17:04:50 +08:00
Felix 541a85a5b0 feat: ensure OpenClaw skills directory exists and add a clear button to the marketplace search input (#65) 2026-02-12 16:54:56 +08:00
Haze 582287c74c 0.1.10-alpha.0 2026-02-12 13:29:43 +08:00
Haze 300a219f95 chore(UI): ux model settings (#64) 2026-02-12 13:28:20 +08:00
paisley 8ade06d7b8 fix(channels): restrict available set to primary channels only (#63) 2026-02-12 13:09:02 +08:00
Felix a8e26362b9 refactor: Directly add new chat session to state upon creation (#62) 2026-02-12 12:09:14 +08:00
Felix 8ab1b3af36 misc: provider icons, tooltip in chat toolbar, conditionally display the "Open Skills Folder" button and update "Documentation" to "Website" in settings (#60) 2026-02-12 11:11:28 +08:00
Haze 2ae4201639 chore(release): update yml file patterns in release workflow (#56) 2026-02-11 18:53:01 +08:00
Haze 8ae15b8dd4 0.1.9 2026-02-11 18:32:51 +08:00
Haze b63c222162 fix(electron): register activate handler after app is ready (#55) 2026-02-11 18:31:50 +08:00
paisley d7a7c6dcff fix(setup): resolve conflicts, fix light mode styles, add Feishu permission warning (#53) 2026-02-11 17:59:40 +08:00
Haze f457ce757b 0.1.9-alpha.1 2026-02-11 17:43:43 +08:00
Haze 7cdf4ad13a chore(release): update yml file handling in release workflow (#52) 2026-02-11 17:43:14 +08:00
Felix fcba8b86d5 fix(chat): improve message handling, fix type errors and migrate changes to enhance branch (#50) 2026-02-11 17:10:53 +08:00
paisley bc7da0085b chore: fix some ui problems (#51) 2026-02-11 17:08:57 +08:00
paisley 6e09a69f4f feat(app): i18n (#48) 2026-02-11 15:34:53 +08:00
Haze 505a64438e 0.1.9-alpha.0 2026-02-11 13:57:42 +08:00
Felix f9581d2516 fix(chat): improve handling of streaming messages and add pending state management (#46) 2026-02-11 13:55:34 +08:00
Haze ab9b8b6e87 0.1.8 2026-02-11 13:51:12 +08:00
Haze 1267e0cc56 fix(updater): update function support alpha (#45) 2026-02-11 13:49:03 +08:00
DigHuang 0ced0b042c refactor(provider): provider API validation & CN defaults (#47) 2026-02-11 13:48:15 +08:00
paisley 01f4d4800e Fix channel configured status (#44) 2026-02-11 11:44:06 +08:00
Haze 92c1b68a54 0.1.8-alpha.0 2026-02-11 11:37:30 +08:00
DigHuang 1b508d5bde feat(provider): mainly support moonshot / siliconflow on setup (#43) 2026-02-11 11:33:33 +08:00
Haze 563fcd2f24 chore(logo): fix release yml and chang logo (#42) 2026-02-11 11:32:26 +08:00
Felix a0505490cd feat: enhance ClawHub integration with new CLI paths and IPC handlers for config and skills directories (#41) 2026-02-11 11:32:03 +08:00
paisley 177cf4c1ea fix(whatsapp): resolve 401 unauthorized on channel handoff (#40) 2026-02-11 11:11:01 +08:00
Haze 0b6667e2e7 0.1.7 2026-02-10 19:27:06 +08:00
Haze 6c16d4e0ac fix(release): update base URL for asset hosting in configuration files (#39) 2026-02-10 19:26:26 +08:00
Felix 816a0e24a2 misc: chat stop button and tool typing indicator and prevent sending message during composition (#37) 2026-02-10 19:12:22 +08:00
paisley 29d0db706f fix(whatsapp): wait for creds update and fix config path (#38) 2026-02-10 19:07:44 +08:00
Haze f950c37a73 fix(release): streamline file upload and verification process (#36) 2026-02-10 17:59:45 +08:00
Haze c527974e6f Add Contributor Covenant Code of Conduct
This document outlines the Code of Conduct for community members, detailing pledges, standards, enforcement responsibilities, and consequences for violations.
2026-02-10 17:04:27 +08:00
Haze a445a56391 feat(release): add make_latest flag to release workflow (#35) 2026-02-10 16:44:06 +08:00
Haze 4a7ea45608 0.1.6 2026-02-10 16:27:58 +08:00
Haze 5d49b32643 feat(release): add zip target for macOS builds and update release wor… (#34) 2026-02-10 16:27:21 +08:00
DigHuang a5ba7512a3 fix(whats-app): resolve OpenClaw paths and package resolution (#33) 2026-02-10 16:21:00 +08:00
paisley 518b5f6323 fix: correct Telegram allowlist configuration key (#31) 2026-02-10 16:18:34 +08:00
366 changed files with 60206 additions and 11536 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.
+4
View File
@@ -10,6 +10,8 @@ on:
jobs:
check:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: '1'
steps:
- name: Checkout code
uses: actions/checkout@v6
@@ -37,6 +39,8 @@ jobs:
build:
runs-on: windows-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: '1'
steps:
- name: Checkout code
uses: actions/checkout@v6
+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
+288 -63
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,40 +61,191 @@ 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
if: matrix.platform == 'mac'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Code signing
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
# Notarization
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
# Increase file descriptor limit to handle large number of files during code signing
ulimit -n 65536
echo "File descriptor limit: $(ulimit -n)"
pnpm run package:mac
# Windows specific steps
- name: Build Windows
if: matrix.platform == 'win'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# For code signing (optional)
# CSC_LINK: ${{ secrets.WIN_CERTS }}
# CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTS_PASSWORD }}
run: pnpm run package:win
# 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 package:linux
- name: Upload artifacts
@@ -114,11 +254,14 @@ jobs:
name: release-${{ matrix.platform }}
path: |
release/*.dmg
release/*.zip
release/*.blockmap
release/*.exe
release/*.AppImage
release/*.deb
release/*.rpm
release/latest*.yml
release/*.yml
!release/builder-debug.yml
retention-days: 7
# ──────────────────────────────────────────────────────────────
@@ -129,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: |
@@ -145,19 +286,26 @@ jobs:
echo "=== File tree ==="
tree release-artifacts/ || find release-artifacts/ -print
- name: Create GitHub Release
- name: Remove duplicate builder-debug files
run: |
echo "Removing builder-debug.yml files to avoid duplicate asset upload conflicts..."
find release-artifacts/ -name "builder-debug.yml" -delete -print || true
- name: Create GitHub Release (as pre-release)
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
release-artifacts/**/*.dmg
release-artifacts/**/*.zip
release-artifacts/**/*.exe
release-artifacts/**/*.AppImage
release-artifacts/**/*.deb
release-artifacts/**/*.rpm
release-artifacts/**/latest*.yml
release-artifacts/**/*.yml
draft: false
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') }}
prerelease: true
make_latest: false
generate_release_notes: true
body: |
## 🚀 ClawX ${{ github.ref_name }}
@@ -191,7 +339,8 @@ jobs:
- **macOS**: On first launch, you may see "cannot verify developer". Go to System Preferences → Security & Privacy to allow the app to run
- **Windows**: SmartScreen may block the app. Click "More info" → "Run anyway" to proceed
- **Linux**: AppImage requires executable permission: `chmod +x ClawX-*.AppImage`
- **Linux AppImage**: First run `chmod +x ClawX-*.AppImage` to add execute permission. On Ubuntu 22.04 you may also need `sudo apt install libfuse2`; on Ubuntu 24.04 use `sudo apt install libfuse2t64`
- **Linux .deb (Ubuntu 24.04)**: If installation fails due to missing dependencies, use `sudo apt install libgtk-3-0t64 libnotify4t64 libxss1t64` before installing
---
@@ -203,10 +352,12 @@ jobs:
# Job: Upload to Alibaba Cloud OSS
# Uploads all release artifacts to OSS for:
# - Official website downloads (via release-info.json)
# - electron-updater auto-update (via latest-*.yml)
# - electron-updater auto-update (via {channel}-*.yml)
#
# Directory structure on OSS:
# latest/ → always overwritten with the newest version
# Directory structure on OSS (channel-separated):
# latest/ → stable releases (latest.yml, latest-mac.yml, …)
# alpha/ → alpha releases (alpha.yml, alpha-mac.yml, …)
# beta/ → beta releases (beta.yml, beta-mac.yml, …)
# releases/vX.Y.Z/ → permanent archive, never deleted
# ──────────────────────────────────────────────────────────────
upload-oss:
@@ -214,15 +365,13 @@ 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
- name: Extract version and channel
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
@@ -230,43 +379,66 @@ jobs:
else
VERSION="${{ github.event.inputs.version }}"
fi
# Detect channel from semver prerelease tag
# e.g. 0.1.8-alpha.0 → alpha, 1.0.0-beta.1 → beta, 1.0.0 → latest
if [[ "$VERSION" =~ -([a-zA-Z]+) ]]; then
CHANNEL="${BASH_REMATCH[1]}"
else
CHANNEL="latest"
fi
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "tag=v${VERSION}" >> $GITHUB_OUTPUT
echo "Detected version: ${VERSION}"
echo "channel=${CHANNEL}" >> $GITHUB_OUTPUT
echo "Detected version: ${VERSION}, channel: ${CHANNEL}"
- name: Prepare upload directories
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.tag }}"
CHANNEL="${{ steps.version.outputs.channel }}"
mkdir -p staging/latest
mkdir -p staging/${CHANNEL}
mkdir -p staging/releases/${TAG}
# Flatten all platform artifacts into staging directories
find release-artifacts/ -type f | while read file; do
filename=$(basename "$file")
cp "$file" "staging/latest/${filename}"
cp "$file" "staging/${CHANNEL}/${filename}"
cp "$file" "staging/releases/${TAG}/${filename}"
done
echo "=== staging/latest/ ==="
ls -lh staging/latest/
echo "=== staging/${CHANNEL}/ ==="
ls -lh staging/${CHANNEL}/
echo ""
echo "=== staging/releases/${TAG}/ ==="
ls -lh staging/releases/${TAG}/
# Note: Do NOT rename yml files. electron-updater (generic provider) always
# requests "latest-mac.yml", "latest.yml", etc. regardless of feed URL.
# Channel separation is achieved by directory: /alpha/, /beta/, /latest/.
- name: Verify yml files present
run: |
CHANNEL="${{ steps.version.outputs.channel }}"
echo "=== staging/${CHANNEL}/ (update metadata) ==="
ls -la staging/${CHANNEL}/*.yml 2>/dev/null || echo "No yml files found (check electron-builder outputs)"
- name: Generate release-info.json
run: |
VERSION="${{ steps.version.outputs.version }}"
BASE_URL="https://valuecell-clawx.oss-cn-hangzhou.aliyuncs.com/latest"
CHANNEL="${{ steps.version.outputs.channel }}"
BASE_URL="https://oss.intelli-spectrum.com/${CHANNEL}"
jq -n \
--arg version "$VERSION" \
--arg channel "$CHANNEL" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg base "$BASE_URL" \
--arg changelog "https://github.com/${{ github.repository }}/releases/tag/v${VERSION}" \
'{
version: $version,
channel: $channel,
releaseDate: $date,
downloads: {
mac: {
@@ -286,10 +458,10 @@ jobs:
}
},
changelog: $changelog
}' > staging/latest/release-info.json
}' > staging/${CHANNEL}/release-info.json
echo "=== release-info.json ==="
cat staging/latest/release-info.json
cat staging/${CHANNEL}/release-info.json
- name: Install and configure ossutil
env:
@@ -309,18 +481,20 @@ jobs:
ossutil --version
- name: "Upload to OSS: latest/ (overwrite)"
- name: "Upload to OSS: {channel}/ (overwrite)"
run: |
# Clean old latest/ to remove stale version files
ossutil rm -r -f oss://valuecell-clawx/latest/ || true
CHANNEL="${{ steps.version.outputs.channel }}"
# Only clean the current channel's directory — never touch other channels
ossutil rm -r -f oss://valuecell-clawx/${CHANNEL}/ || true
# Upload all files with no-cache so clients always get the freshest version
ossutil cp -r -f \
staging/latest/ \
oss://valuecell-clawx/latest/ \
--meta "Cache-Control:no-cache,no-store,must-revalidate"
--meta="Cache-Control:no-cache,no-store,must-revalidate" \
staging/${CHANNEL}/ \
oss://valuecell-clawx/${CHANNEL}/
echo "Uploaded to latest/"
echo "Uploaded to ${CHANNEL}/"
- name: "Upload to OSS: releases/vX.Y.Z/ (archive)"
run: |
@@ -337,9 +511,10 @@ jobs:
- name: Verify OSS upload
run: |
TAG="${{ steps.version.outputs.tag }}"
CHANNEL="${{ steps.version.outputs.channel }}"
echo "=== latest/ ==="
ossutil ls oss://valuecell-clawx/latest/ --short
echo "=== ${CHANNEL}/ ==="
ossutil ls oss://valuecell-clawx/${CHANNEL}/ --short
echo ""
echo "=== releases/${TAG}/ ==="
@@ -347,4 +522,54 @@ jobs:
echo ""
echo "=== Verify release-info.json ==="
curl -sL "https://valuecell-clawx.oss-cn-hangzhou.aliyuncs.com/latest/release-info.json" | jq .
ossutil cp oss://valuecell-clawx/${CHANNEL}/release-info.json /tmp/release-info.json -f
jq . /tmp/release-info.json
echo ""
echo "=== Verify update yml ==="
if [ "${CHANNEL}" = "latest" ]; then
YML_PREFIX="latest"
else
YML_PREFIX="${CHANNEL}"
fi
echo "electron-updater expects ${YML_PREFIX}-mac.yml, ${YML_PREFIX}.yml, etc. in ${CHANNEL}/:"
ossutil ls oss://valuecell-clawx/${CHANNEL}/ --short | grep "${YML_PREFIX}.*\\.yml" || echo "(none found)"
echo ""
echo "All files uploaded and verified successfully!"
# ──────────────────────────────────────────────────────────────
# Job: Finalize Release
# Promotes the GitHub Release from pre-release to latest AFTER
# both GitHub Release assets and OSS uploads are fully complete.
# This ensures /releases/latest API never returns an incomplete
# release — the website and electron-updater only see it when
# all platform artifacts are ready.
# ──────────────────────────────────────────────────────────────
finalize:
needs: [publish, upload-oss]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Promote release from pre-release to latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${GITHUB_REF#refs/tags/}"
IS_PRERELEASE_CHANNEL=false
if [[ "$TAG" == *"alpha"* ]] || [[ "$TAG" == *"beta"* ]]; then
IS_PRERELEASE_CHANNEL=true
fi
if [ "$IS_PRERELEASE_CHANNEL" = "true" ]; then
echo "Tag $TAG is an alpha/beta release — keeping as pre-release."
else
echo "Promoting $TAG from pre-release to latest release..."
gh release edit "$TAG" \
--prerelease=false \
--latest \
--repo "${{ github.repository }}"
echo "Release $TAG is now the latest release."
fi
+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
+45
View File
@@ -0,0 +1,45 @@
# AGENTS.md
## Cursor Cloud specific instructions
### Overview
ClawX is a cross-platform **Electron desktop app** (React 19 + Vite + TypeScript) providing a GUI for the OpenClaw AI agent runtime. It uses pnpm as its package manager (pinned version in `package.json`'s `packageManager` field).
### Quick reference
Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
| Task | Command |
|------|---------|
| Install deps + download uv | `pnpm run init` |
| Dev server (Vite + Electron) | `pnpm dev` |
| Lint (ESLint, auto-fix) | `pnpm run lint` |
| Type check | `pnpm run typecheck` |
| Unit tests (Vitest) | `pnpm test` |
| 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` |
### Non-obvious caveats
- **pnpm version**: The exact pnpm version is pinned via `packageManager` in `package.json`. Use `corepack enable && corepack prepare` to activate the correct version before installing.
- **Electron on headless Linux**: The dbus errors (`Failed to connect to the bus`) are expected and harmless in a headless/cloud environment. The app still runs fine with `$DISPLAY` set (e.g., `:1` via Xvfb/VNC).
- **`pnpm run lint` race condition**: If `pnpm run uv:download` was recently run, ESLint may fail with `ENOENT: no such file or directory, scandir '/workspace/temp_uv_extract'` because the temp directory was created and removed during download. Simply re-run lint after the download script finishes.
- **Build scripts warning**: `pnpm install` may warn about ignored build scripts for `@discordjs/opus` and `koffi`. These are optional messaging-channel dependencies and the warnings are safe to ignore.
- **`pnpm run init`**: This is a convenience script that runs `pnpm install` followed by `pnpm run uv:download`. Either run `pnpm run init` or run the two steps separately.
- **Gateway startup**: When running `pnpm dev`, the OpenClaw Gateway process starts automatically on port 18789. It takes ~10-30 seconds to become ready. Gateway readiness is not required for UI development—the app functions without it (shows "connecting" state).
- **No database**: The app uses `electron-store` (JSON files) and OS keychain. No database setup is needed.
- **AI Provider keys**: Actual AI chat requires at least one provider API key configured via Settings > AI Providers. The app is fully navigable and testable without keys.
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, scans both configured agents and any runtime agent directories found on disk, and treats normal, `.deleted.jsonl`, and `.jsonl.reset.*` transcripts as valid history sources. It extracts assistant/tool usage records with `message.usage` and aggregates fields such as input/output/cache/total tokens and cost from those structured records.
- **Models page aggregation**: The 7-day/30-day filters are relative rolling windows, not calendar-month buckets. When grouped by time, the chart should keep all day buckets in the selected window; only model grouping is intentionally capped to the top entries.
- **OpenClaw Doctor in UI**: In Settings > Advanced > Developer, the app exposes both `Run Doctor` (`openclaw doctor --json`) and `Run Doctor Fix` (`openclaw doctor --fix --yes --non-interactive`) through the host-api. Renderer code should call the host route, not spawn CLI processes directly.
- **Renderer/Main API boundary (important)**:
- Renderer must use `src/lib/host-api.ts` and `src/lib/api-client.ts` as the single entry for backend calls.
- Do not add new direct `window.electron.ipcRenderer.invoke(...)` calls in pages/components; expose them through host-api/api-client instead.
- Do not call Gateway HTTP endpoints directly from renderer (`fetch('http://127.0.0.1:18789/...')` etc.). Use Main-process proxy channels (`hostapi:fetch`, `gateway:httpProxy`) to avoid CORS/env drift.
- Transport policy is Main-owned and fixed as `WS -> HTTP -> IPC fallback`; renderer should not implement protocol switching UI/business logic.
- **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.
+128
View File
@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
public@valuecell.ai.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 ClawX Team
Copyright (c) 2026 ValueCell Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+445
View File
@@ -0,0 +1,445 @@
<p align="center">
<img src="src/assets/logo.svg" width="128" height="128" alt="ClawX Logo" />
</p>
<h1 align="center">ClawX</h1>
<p align="center">
<strong>OpenClaw AIエージェントのためのデスクトップインターフェース</strong>
</p>
<p align="center">
<a href="#機能">機能</a> •
<a href="#なぜclawxなのか">なぜClawXなのか</a> •
<a href="#はじめに">はじめに</a> •
<a href="#アーキテクチャ">アーキテクチャ</a> •
<a href="#開発">開発</a> •
<a href="#コントリビューション">コントリビューション</a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/platform-MacOS%20%7C%20Windows%20%7C%20Linux-blue" alt="Platform" />
<img src="https://img.shields.io/badge/electron-40+-47848F?logo=electron" alt="Electron" />
<img src="https://img.shields.io/badge/react-19-61DAFB?logo=react" alt="React" />
<a href="https://discord.com/invite/84Kex3GGAh" target="_blank">
<img src="https://img.shields.io/discord/1399603591471435907?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb" alt="chat on Discord" />
</a>
<img src="https://img.shields.io/github/downloads/ValueCell-ai/ClawX/total?color=%23027DEB" alt="Downloads" />
<img src="https://img.shields.io/badge/license-MIT-green" alt="License" />
</p>
<p align="center">
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | 日本語
</p>
---
## 概要
**ClawX**は、強力なAIエージェントと日常のユーザーとの間のギャップを埋めます。[OpenClaw](https://github.com/OpenClaw)をベースに構築されており、コマンドラインによるAIオーケストレーションを、アクセスしやすく美しいデスクトップ体験に変換します。ターミナルは不要です。
ワークフローの自動化、AI搭載チャネルの管理、インテリジェントなタスクのスケジューリングなど、ClawXはAIエージェントを効果的に活用するために必要なインターフェースを提供します。
ClawXはベストプラクティスのモデルプロバイダーが事前設定されており、Windowsおよび多言語設定をネイティブにサポートしています。もちろん、**設定 → 詳細設定 → 開発者モード**から高度な設定を微調整することもできます。
---
## スクリーンショット
<p align="center">
<img src="resources/screenshot/jp/chat.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/jp/cron.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/jp/skills.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/jp/channels.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/jp/models.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/jp/settings.png" style="width: 100%; height: auto;">
</p>
---
## なぜClawXなのか
AIエージェントの構築にコマンドラインの習得は不要であるべきです。ClawXはシンプルな哲学のもとに設計されました:**強力な技術には、あなたの時間を尊重するインターフェースがふさわしい。**
| 課題 | ClawXのソリューション |
|------|----------------------|
| 複雑なCLIセットアップ | ワンクリックインストールとガイド付きセットアップウィザード |
| 設定ファイル | リアルタイムバリデーション付きのビジュアル設定 |
| プロセス管理 | ゲートウェイライフサイクルの自動管理 |
| 複数のAIプロバイダー | 統合プロバイダー設定パネル |
| スキル/プラグインのインストール | 組み込みのスキルマーケットプレイスと管理機能 |
### OpenClaw内蔵
ClawXは公式の**OpenClaw**コアを直接ベースに構築されています。別途インストールを必要とせず、アプリケーション内にランタイムを組み込むことで、シームレスな「バッテリー同梱」体験を提供します。
私たちはアップストリームのOpenClawプロジェクトとの厳密な整合性を維持することにコミットしており、公式リリースが提供する最新の機能、安定性の改善、エコシステムの互換性に常にアクセスできることを保証します。
---
## 機能
### 🎯 ゼロ設定バリア
インストールから最初のAIインタラクションまで、すべてのセットアップを直感的なグラフィカルインターフェースで完了できます。ターミナルコマンド不要、YAMLファイル不要、環境変数の探索も不要です。
### 💬 インテリジェントチャットインターフェース
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングに加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
各 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など)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuth(Codex サブスクリプション)の両方に対応しています。
OpenAI-compatible ゲートウェイを **Custom プロバイダー** で使う場合、**設定 → AI Providers → Provider 編集** でカスタム `User-Agent` を設定でき、互換性が必要なエンドポイントで有効です。
### 🌙 アダプティブテーマ
ライトモード、ダークモード、またはシステム同期テーマ。ClawXはあなたの好みに自動的に適応します。
### 🚀 自動起動設定
**設定 → 通用** から **システム起動時に自動起動** を有効化すると、ログイン後に ClawX が自動的に起動します。
---
## はじめに
### システム要件
- **オペレーティングシステム**: macOS 11以上、Windows 10以上、またはLinuxUbuntu 20.04以上)
- **メモリ**: 最低4GB RAM8GB推奨)
- **ストレージ**: 1GBの空きディスク容量
### インストール
#### ビルド済みリリース(推奨)
[Releases](https://github.com/ValueCell-ai/ClawX/releases)ページから、お使いのプラットフォーム向けの最新リリースをダウンロードしてください。
#### ソースからビルド
```bash
# リポジトリをクローン
git clone https://github.com/ValueCell-ai/ClawX.git
cd ClawX
# プロジェクトの初期化
pnpm run init
# 開発モードで起動
pnpm dev
```
### 初回起動
ClawXを初めて起動すると、**セットアップウィザード**が以下の手順をガイドします:
1. **言語と地域** 使用する言語・地域の設定
2. **AIプロバイダー** – APIキーまたは OAuth(ブラウザ/デバイスログイン対応プロバイダー)で追加
3. **スキルバンドル** – 一般的なユースケース向けの事前設定スキルを選択
4. **検証** – メインインターフェースに入る前に設定をテスト
サポート対象のシステム言語がある場合、ウィザードはその言語を初期選択し、未対応の場合は英語にフォールバックします。
### プロキシ設定
ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネルがローカルプロキシクライアントを介してインターネットにアクセスする必要がある環境向けに、組み込みのプロキシ設定が含まれています。
**設定 → ゲートウェイ → プロキシ**を開いて以下を設定します:
- **プロキシサーバー**: すべてのリクエストのデフォルトプロキシ
- **バイパスルール**: 直接接続すべきホスト(セミコロン、カンマ、または改行で区切る)
- **開発者モード**では、オプションで以下をオーバーライドできます:
- **HTTP プロキシ**
- **HTTPS プロキシ**
- **ALL_PROXY / SOCKS**
推奨されるローカル設定例:
```text
プロキシサーバー: http://127.0.0.1:7890
```
注意事項:
- `host:port`のみの値はHTTPとして扱われます。
- 高度なプロキシフィールドが空の場合、ClawXは`プロキシサーバー`にフォールバックします。
- プロキシ設定を保存すると、Electronのネットワーク設定が即座に再適用され、ゲートウェイが自動的に再起動されます。
- ClawXはTelegramが有効な場合、プロキシをOpenClawのTelegramチャネル設定にも同期します。
- ClawXのプロキシが無効な状態では、Gatewayの通常再起動時に既存のTelegramチャネルプロキシ設定を保持します。
- OpenClaw設定のTelegramプロキシを明示的に消したい場合は、プロキシ無効の状態で一度「保存」を実行してください。
- **設定 → 詳細 → 開発者** では **OpenClaw Doctor** を実行でき、`openclaw doctor --json` の診断出力をアプリ内で確認できます。
- Windows のパッケージ版では、同梱された `openclaw` CLI/TUI は端末入力を安定させるため、同梱の `node.exe` エントリーポイント経由で実行されます。
---
## アーキテクチャ
ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を採用しています。Renderer は単一クライアント抽象を呼び出し、プロトコル選択とライフサイクルは Main が管理します:
```┌─────────────────────────────────────────────────────────────────┐
│ ClawX デスクトップアプリ │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Electron メインプロセス │ │
│ │ • ウィンドウ&アプリケーションライフサイクル管理 │ │
│ │ • ゲートウェイプロセスの監視 │ │
│ │ • システム統合(トレイ、通知、キーチェーン) │ │
│ │ • 自動アップデートオーケストレーション │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ IPC(権威ある制御プレーン) │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ React レンダラープロセス │ │
│ │ • モダンなコンポーネントベースUI(React 19) │ │
│ │ • Zustandによるステート管理 │ │
│ │ • 統一 host-api/api-client 呼び出し │ │
│ │ • リッチなMarkdownレンダリング │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────────┘
│ Main管理のトランスポート戦略
│(WS優先、HTTP次点、IPCフォールバック)
┌─────────────────────────────────────────────────────────────────┐
│ Host API と Main プロキシ層 │
│ │
│ • hostapi:fetchMainプロキシ、CORS回避) │
│ • gateway:httpProxyRendererはGateway HTTPに直アクセスしない) │
│ • 統一エラーマッピングとリトライ/バックオフ │
└──────────────────────────────┬──────────────────────────────────┘
│ WS / HTTP / IPC フォールバック
┌─────────────────────────────────────────────────────────────────┐
│ OpenClaw ゲートウェイ │
│ │
│ • AIエージェントランタイムとオーケストレーション │
│ • メッセージチャネル管理 │
│ • スキル/プラグイン実行環境 │
│ • プロバイダー抽象化レイヤー │
└─────────────────────────────────────────────────────────────────┘
```
### 設計原則
- **プロセス分離**: AIランタイムは別プロセスで動作し、重い計算処理中でもUIの応答性を確保します
- **フロントエンド呼び出しの単一入口**: Renderer は host-api/api-client を通じて呼び出し、下位プロトコルに依存しません
- **Mainによるトランスポート制御**: WS/HTTP の選択と IPC フォールバックを Main で一元管理します
- **グレースフルリカバリ**: 再接続・タイムアウト・バックオフで一時的障害を自動処理します
- **セキュアストレージ**: APIキーや機密データは、OSのネイティブセキュアストレージ機構を活用します
- **CORSセーフ設計**: ローカルHTTPはMainプロキシ経由とし、Renderer側CORS問題を回避します
### プロセスモデルと 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** を使用してください。
---
## ユースケース
### 🤖 パーソナルAIアシスタント
質問への回答、メールの下書き、ドキュメントの要約、日常タスクのサポートなど、汎用的なAIエージェントを設定できます。すべてクリーンなデスクトップインターフェースから操作できます。
### 📊 自動モニタリング
ニュースフィード、価格追跡、特定イベントの監視などを行うスケジュールエージェントを設定できます。結果はお好みの通知チャネルに配信されます。
### 💻 開発者の生産性向上
AI を開発ワークフローに統合できます。エージェントを使用して、コードレビュー、ドキュメント生成、反復的なコーディングタスクの自動化が可能です。
### 🔄 ワークフロー自動化
複数のスキルを連鎖させて、高度な自動化パイプラインを作成できます。データの処理、コンテンツの変換、アクションのトリガーを、すべてビジュアルにオーケストレーションできます。
---
## 開発
### 前提条件
- **Node.js**: 22以上(LTS推奨)
- **パッケージマネージャー**: pnpm 9以上(推奨)またはnpm
### プロジェクト構成
```ClawX/
├── electron/ # Electron メインプロセス
│ ├── api/ # メイン側 API ルーターとハンドラー
│ │ └── routes/ # RPC/HTTP プロキシのルートモジュール
│ ├── services/ # Provider/Secrets/ランタイムサービス
│ │ ├── providers/ # provider/account モデル同期ロジック
│ │ └── secrets/ # OS キーチェーンと秘密情報管理
│ ├── shared/ # 共通 Provider スキーマ/定数
│ │ └── providers/
│ ├── main/ # アプリ入口、ウィンドウ、IPC 登録
│ ├── gateway/ # OpenClaw ゲートウェイプロセスマネージャー
│ ├── preload/ # セキュア IPC ブリッジ
│ └── utils/ # ユーティリティ(ストレージ、認証、パス)
├── src/ # React レンダラープロセス
│ ├── lib/ # フロントエンド統一 API とエラーモデル
│ ├── stores/ # Zustand ストア(settings/chat/gateway
│ ├── components/ # 再利用可能な UI コンポーネント
│ ├── pages/ # Setup/Dashboard/Chat/Channels/Skills/Cron/Settings
│ ├── i18n/ # ローカライズリソース
│ └── types/ # TypeScript 型定義
├── tests/
│ └── unit/ # Vitest ユニット/統合寄りテスト
├── resources/ # 静的アセット(アイコン、画像)
└── scripts/ # ビルド/ユーティリティスクリプト
```
### 利用可能なコマンド
```bash
# 開発
pnpm run init # 依存関係のインストール + uvのダウンロード
pnpm dev # ホットリロードで起動(不足時は同梱スキルを自動準備)
# コード品質
pnpm lint # ESLintを実行
pnpm typecheck # TypeScriptの型チェック
# テスト
pnpm test # ユニットテストを実行
pnpm run comms:replay # 通信リプレイ指標を算出
pnpm run comms:baseline # 通信ベースラインを更新
pnpm run comms:compare # リプレイ指標をベースライン閾値と比較
# ビルド&パッケージ
pnpm run build:vite # フロントエンドのみビルド
pnpm build # フルプロダクションビルド(パッケージアセット含む)
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` が必須シナリオと閾値を検証します。
### 技術スタック
| レイヤー | 技術 |
|---------|------|
| ランタイム | Electron 40以上 |
| UIフレームワーク | React 19 + TypeScript |
| スタイリング | Tailwind CSS + shadcn/ui |
| ステート管理 | Zustand |
| ビルド | Vite + electron-builder |
| テスト | Vitest + Playwright |
| アニメーション | Framer Motion |
| アイコン | Lucide React |
---
## コントリビューション
コミュニティからのコントリビューションを歓迎します!バグ修正、新機能、ドキュメントの改善、翻訳など、あらゆる貢献がClawXをより良くするのに役立ちます。
### コントリビューション方法
1. リポジトリを**フォーク**する
2. フィーチャーブランチを**作成**する(`git checkout -b feature/amazing-feature`
3. 明確なメッセージで変更を**コミット**する
4. ブランチに**プッシュ**する
5. **プルリクエスト**を作成する
### ガイドライン
- 既存のコードスタイルに従う(ESLint + Prettier
- 新機能にはテストを書く
- 必要に応じてドキュメントを更新する
- コミットはアトミックかつ説明的に保つ
---
## 謝辞
ClawXは優れたオープンソースプロジェクトの上に構築されています:
- [OpenClaw](https://github.com/OpenClaw) AIエージェントランタイム
- [Electron](https://www.electronjs.org/) – クロスプラットフォームデスクトップフレームワーク
- [React](https://react.dev/) UIコンポーネントライブラリ
- [shadcn/ui](https://ui.shadcn.com/) – 美しくデザインされたコンポーネント
- [Zustand](https://github.com/pmndrs/zustand) 軽量ステート管理
---
## コミュニティ
コミュニティに参加して、他のユーザーとつながり、サポートを受け、体験を共有しましょう。
| 企業微信 | Feishuグループ | Discord |
| :---: | :---: | :---: |
| <img src="src/assets/community/wecom-qr.png" width="150" alt="WeChat QRコード" /> | <img src="src/assets/community/feishu-qr.png" width="150" alt="Feishu QRコード" /> | <img src="src/assets/community/20260212-185822.png" width="150" alt="Discord QRコード" /> |
### ClawX パートナープログラム 🚀
ClawX パートナープログラムを開始します。特に、カスタム AI エージェントや自動化ニーズを持つより多くの顧客に ClawX を紹介してくださるパートナーを募集しています。
パートナーの皆さまには、見込みユーザーや案件との接点づくりを担っていただき、ClawX チームは技術サポート、カスタマイズ、統合を全面的に提供します。
AI ツールや自動化に関心のある顧客とお仕事をされている方は、ぜひご一緒できればうれしいです。
詳細は DM いただくか、[public@valuecell.ai](mailto:public@valuecell.ai) までメールでご連絡ください。
---
## スター履歴
<p align="center">
<img src="https://api.star-history.com/svg?repos=ValueCell-ai/ClawX&type=Date" alt="スター履歴チャート" />
</p>
---
## ライセンス
ClawXは[MITライセンス](LICENSE)の下でリリースされています。本ソフトウェアの使用、変更、配布は自由に行えます。
---
<p align="center">
<sub>ValueCell Teamが❤️を込めて開発</sub>
</p>
+190 -49
View File
@@ -19,15 +19,20 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-blue" alt="Platform" />
<img src="https://img.shields.io/badge/platform-MacOS%20%7C%20Windows%20%7C%20Linux-blue" alt="Platform" />
<img src="https://img.shields.io/badge/electron-40+-47848F?logo=electron" alt="Electron" />
<img src="https://img.shields.io/badge/react-19-61DAFB?logo=react" alt="React" />
<a href="https://discord.com/invite/84Kex3GGAh" target="_blank">
<img src="https://img.shields.io/discord/1399603591471435907?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb" alt="chat on Discord" />
</a>
<img src="https://img.shields.io/github/downloads/ValueCell-ai/ClawX/total?color=%23027DEB" alt="Downloads" />
<img src="https://img.shields.io/badge/license-MIT-green" alt="License" />
</p>
<p align="center">
English | <a href="README.zh-CN.md">简体中文</a> | <a href="README.ja-JP.md">日本語</a>
</p>
---
## Overview
@@ -36,6 +41,35 @@
Whether you're automating workflows, managing AI-powered channels, or scheduling intelligent tasks, ClawX provides the interface you need to harness AI agents effectively.
ClawX comes pre-configured with best-practice model providers and natively supports Windows as well as multi-language settings. Of course, you can also fine-tune advanced configurations via **Settings → Advanced → Developer Mode**.
---
## Screenshot
<p align="center">
<img src="resources/screenshot/en/chat.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/en/cron.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/en/skills.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<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>
---
## Why ClawX
@@ -64,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
@@ -110,24 +161,56 @@ 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.
### Proxy Settings
ClawX includes built-in proxy settings for environments where Electron, the OpenClaw Gateway, or channels such as Telegram need to reach the internet through a local proxy client.
Open **Settings → Gateway → Proxy** and configure:
- **Proxy Server**: the default proxy for all requests
- **Bypass Rules**: hosts that should connect directly, separated by semicolons, commas, or new lines
- In **Developer Mode**, you can optionally override:
- **HTTP Proxy**
- **HTTPS Proxy**
- **ALL_PROXY / SOCKS**
Recommended local examples:
```text
Proxy Server: http://127.0.0.1:7890
```
Notes:
- A bare `host:port` value is treated as HTTP.
- If advanced proxy fields are left empty, ClawX falls back to `Proxy Server`.
- Saving proxy settings reapplies Electron networking immediately and restarts the Gateway automatically.
- ClawX also syncs the proxy to OpenClaw's Telegram channel config when Telegram is enabled.
- 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 │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
@@ -138,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 │
@@ -160,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.
---
@@ -195,60 +301,67 @@ 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 dev # Start with hot reload
pnpm dev:electron # Launch Electron directly
pnpm run init # Install dependencies + download uv
pnpm dev # Start with hot reload (auto-prepares bundled skills if missing)
# Quality
pnpm lint # Run ESLint
pnpm lint:fix # Auto-fix issues
pnpm typecheck # TypeScript validation
# Testing
pnpm test # Run unit tests
pnpm test:watch # Watch mode
pnpm test:coverage # Generate coverage report
pnpm test:e2e # Run Playwright E2E tests
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 build # Full production build
pnpm package # Package for current platform
pnpm run build:vite # Build frontend only
pnpm build # Full production build (with packaging assets)
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 |
@@ -297,6 +410,34 @@ ClawX is built on the shoulders of excellent open-source projects:
---
## Community
Join our community to connect with other users, get support, and share your experiences.
| Enterprise WeChat | Feishu Group | Discord |
| :---: | :---: | :---: |
| <img src="src/assets/community/wecom-qr.png" width="150" alt="WeChat QR Code" /> | <img src="src/assets/community/feishu-qr.png" width="150" alt="Feishu QR Code" /> | <img src="src/assets/community/20260212-185822.png" width="150" alt="Discord QR Code" /> |
### ClawX Partner Program 🚀
We're launching the ClawX Partner Program and looking for partners who can help introduce ClawX to more clients, especially those with custom AI agent or automation needs.
Partners help connect us with potential users and projects, while the ClawX team provides full technical support, customization, and integration.
If you work with clients interested in AI tools or automation, we'd love to collaborate.
DM us or email [public@valuecell.ai](mailto:public@valuecell.ai) to learn more.
---
## Star History
<p align="center">
<img src="https://api.star-history.com/svg?repos=ValueCell-ai/ClawX&type=Date" alt="Star History Chart" />
</p>
---
## License
ClawX is released under the [MIT License](LICENSE). You're free to use, modify, and distribute this software.
+449
View File
@@ -0,0 +1,449 @@
<p align="center">
<img src="src/assets/logo.svg" width="128" height="128" alt="ClawX Logo" />
</p>
<h1 align="center">ClawX</h1>
<p align="center">
<strong>OpenClaw AI 智能体的桌面客户端</strong>
</p>
<p align="center">
<a href="#功能特性">功能特性</a> •
<a href="#为什么选择-clawx">为什么选择 ClawX</a> •
<a href="#快速上手">快速上手</a> •
<a href="#系统架构">系统架构</a> •
<a href="#开发指南">开发指南</a> •
<a href="#参与贡献">参与贡献</a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/platform-MacOS%20%7C%20Windows%20%7C%20Linux-blue" alt="Platform" />
<img src="https://img.shields.io/badge/electron-40+-47848F?logo=electron" alt="Electron" />
<img src="https://img.shields.io/badge/react-19-61DAFB?logo=react" alt="React" />
<a href="https://discord.com/invite/84Kex3GGAh" target="_blank">
<img src="https://img.shields.io/discord/1399603591471435907?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb" alt="chat on Discord" />
</a>
<img src="https://img.shields.io/github/downloads/ValueCell-ai/ClawX/total?color=%23027DEB" alt="Downloads" />
<img src="https://img.shields.io/badge/license-MIT-green" alt="License" />
</p>
<p align="center">
<a href="README.md">English</a> | 简体中文 | <a href="README.ja-JP.md">日本語</a>
</p>
---
## 概述
**ClawX** 是连接强大 AI 智能体与普通用户之间的桥梁。基于 [OpenClaw](https://github.com/OpenClaw) 构建,它将命令行式的 AI 编排转变为易用、美观的桌面体验——无需使用终端。
无论是自动化工作流、连接通讯软件,还是调度智能定时任务,ClawX 都能提供高效易用的图形界面,帮助你充分发挥 AI 智能体的能力。
ClawX 预置了最佳实践的模型供应商配置,原生支持 Windows 平台以及多语言设置。当然,你也可以通过 **设置 → 高级 → 开发者模式** 来进行精细的高级配置。
---
## 截图预览
<p align="center">
<img src="resources/screenshot/zh/chat.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/zh/cron.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/zh/skills.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/zh/channels.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/zh/models.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/zh/settings.png" style="width: 100%; height: auto;">
</p>
---
## 为什么选择 ClawX
构建 AI 智能体不应该需要精通命令行。ClawX 的设计理念很简单:**强大的技术值得拥有一个尊重用户时间的界面。**
| 痛点 | ClawX 解决方案 |
|------|----------------|
| 复杂的命令行配置 | 一键安装,配合引导式设置向导 |
| 手动编辑配置文件 | 可视化设置界面,实时校验 |
| 进程管理繁琐 | 自动管理网关生命周期 |
| 多 AI 供应商切换 | 统一的供应商配置面板 |
| 技能/插件安装复杂 | 内置技能市场与管理界面 |
### 内置 OpenClaw 核心
ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们将运行时嵌入应用内部,提供开箱即用的无缝体验。
我们致力于与上游 OpenClaw 项目保持严格同步,确保你始终可以使用官方发布的最新功能、稳定性改进和生态兼容性。
---
## 功能特性
### 🎯 零配置门槛
从安装到第一次 AI 对话,全程通过直观的图形界面完成。无需终端命令,无需 YAML 文件,无需到处寻找环境变量。
### 💬 智能聊天界面
通过现代化的聊天体验与 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 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuthCodex 订阅)登录。
如果你通过 **自定义(CustomProvider** 对接 OpenAI-compatible 网关,可以在 **设置 → AI Providers → 编辑 Provider** 中配置自定义 `User-Agent`,以提高兼容性。
### 🌙 自适应主题
支持浅色模式、深色模式或跟随系统主题。ClawX 自动适应你的偏好设置。
### 🚀 开机启动控制
**设置 → 通用** 中,你可以开启 **开机自动启动**,让 ClawX 在系统登录后自动启动。
---
## 快速上手
### 系统要求
- **操作系统**macOS 11+、Windows 10+ 或 LinuxUbuntu 20.04+
- **内存**:最低 4GB RAM(推荐 8GB
- **存储空间**:1GB 可用磁盘空间
### 安装方式
#### 预构建版本(推荐)
从 [Releases](https://github.com/ValueCell-ai/ClawX/releases) 页面下载适用于你平台的最新版本。
#### 从源码构建
```bash
# 克隆仓库
git clone https://github.com/ValueCell-ai/ClawX.git
cd ClawX
# 初始化项目
pnpm run init
# 以开发模式启动
pnpm dev
```
### 首次启动
首次启动 ClawX 时,**设置向导** 将引导你完成以下步骤:
1. **语言与区域** 配置你的首选语言和地区
2. **AI 供应商** – 通过 API 密钥或 OAuth(支持浏览器/设备登录的供应商)添加账号
3. **技能包** 选择适用于常见场景的预配置技能
4. **验证** 在进入主界面前测试你的配置
如果系统语言在支持列表中,向导会默认选中该语言;否则回退到英文。
> MoonshotKimi)说明:ClawX 默认保持开启 Kimi 的 web search。
> 当配置 Moonshot 后,ClawX 也会将 OpenClaw 配置中的 Kimi web search 同步到中国区端点(`https://api.moonshot.cn/v1`)。
### 代理设置
ClawX 内置了代理设置,适用于需要通过本地代理客户端访问外网的场景,包括 Electron 本身、OpenClaw Gateway,以及 Telegram 这类频道的联网请求。
打开 **设置 → 网关 → 代理**,配置以下内容:
- **代理服务器**:所有请求默认使用的代理
- **绕过规则**:需要直连的主机,使用分号、逗号或换行分隔
-**开发者模式** 下,还可以单独覆盖:
- **HTTP 代理**
- **HTTPS 代理**
- **ALL_PROXY / SOCKS**
本地代理的常见填写示例:
```text
代理服务器: http://127.0.0.1:7890
```
说明:
- 只填写 `host:port` 时,会按 HTTP 代理处理。
- 高级代理项留空时,会自动回退到“代理服务器”。
- 保存代理设置后,Electron 网络层会立即重新应用代理,并自动重启 Gateway。
- 如果启用了 TelegramClawX 还会把代理同步到 OpenClaw 的 Telegram 频道配置中。
- 当 ClawX 代理处于关闭状态时,Gateway 的常规重启会保留已有的 Telegram 频道代理配置。
- 如果你要明确清空 OpenClaw 中的 Telegram 代理,请在关闭代理后点一次“保存代理设置”。
-**设置 → 高级 → 开发者** 中,可以直接运行 **OpenClaw Doctor**,执行 `openclaw doctor --json` 并在应用内查看诊断输出。
- 在 Windows 打包版本中,内置的 `openclaw` CLI/TUI 会通过随包分发的 `node.exe` 入口运行,以保证终端输入行为稳定。
---
## 系统架构
ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用统一客户端抽象,协议选择与进程生命周期由 Electron 主进程统一管理:
```┌─────────────────────────────────────────────────────────────────┐
│ ClawX 桌面应用 │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Electron 主进程 │ │
│ │ • 窗口与应用生命周期管理 │ │
│ │ • 网关进程监控 │ │
│ │ • 系统集成(托盘、通知、密钥链) │ │
│ │ • 自动更新编排 │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ IPC(权威控制面) │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ React 渲染进程 │ │
│ │ • 现代组件化 UI(React 19 │ │
│ │ • Zustand 状态管理 │ │
│ │ • 统一 host-api/api-client 调用 │ │
│ │ • Markdown 富文本渲染 │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────────┘
│ 主进程统一传输策略
│(WS 优先,HTTP 次之,IPC 回退)
┌─────────────────────────────────────────────────────────────────┐
│ Host API 与主进程代理层 │
│ │
│ • hostapi:fetch(主进程代理,规避开发/生产 CORS) │
│ • gateway:httpProxy(渲染进程不直连 Gateway HTTP
│ • 统一错误映射与重试/退避策略 │
└──────────────────────────────┬──────────────────────────────────┘
│ WS / HTTP / IPC 回退
┌─────────────────────────────────────────────────────────────────┐
│ OpenClaw 网关 │
│ │
│ • AI 智能体运行时与编排 │
│ • 消息频道管理 │
│ • 技能/插件执行环境 │
│ • 供应商抽象层 │
└─────────────────────────────────────────────────────────────────┘
```
### 设计原则
- **进程隔离**:AI 运行时在独立进程中运行,确保即使在高负载计算期间 UI 也能保持响应
- **前端调用单一入口**:渲染层统一走 host-api/api-client,不感知底层协议细节
- **主进程掌控传输策略**:WS/HTTP 选择与 IPC 回退在主进程集中处理,提升稳定性
- **优雅恢复**:内置重连、超时、退避逻辑,自动处理瞬时故障
- **安全存储**:API 密钥和敏感数据利用操作系统原生的安全存储机制
- **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** 执行完整退出。
---
## 使用场景
### 🤖 个人 AI 助手
配置一个通用 AI 智能体,可以回答问题、撰写邮件、总结文档并协助处理日常任务——全部通过简洁的桌面界面完成。
### 📊 自动化监控
设置定时智能体来监控新闻动态、追踪价格变动或监听特定事件。结果将推送到你偏好的通知渠道。
### 💻 开发者效率工具
将 AI 融入你的开发工作流。使用智能体进行代码审查、生成文档或自动化重复性编码任务。
### 🔄 工作流自动化
将多个技能串联起来,创建复杂的自动化流水线。处理数据、转换内容、触发操作——全部通过可视化方式编排。
---
## 开发指南
### 前置要求
- **Node.js**22+(推荐 LTS 版本)
- **包管理器**pnpm 9+(推荐)或 npm
### 项目结构
```ClawX/
├── electron/ # Electron 主进程
│ ├── api/ # 主进程 API 路由与处理器
│ │ └── routes/ # RPC/HTTP 代理路由模块
│ ├── services/ # Provider、Secrets 与运行时服务
│ │ ├── providers/ # Provider/account 模型同步逻辑
│ │ └── secrets/ # 系统钥匙串与密钥存储
│ ├── 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/ # 构建与工具脚本
```
### 常用命令
```bash
# 开发
pnpm run init # 安装依赖并下载 uv
pnpm dev # 以热重载模式启动(若缺失会自动准备预装技能包)
# 代码质量
pnpm lint # 运行 ESLint 检查
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: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` 会校验必选场景与阈值。
### 技术栈
| 层级 | 技术 |
|------|------|
| 运行时 | Electron 40+ |
| UI 框架 | React 19 + TypeScript |
| 样式 | Tailwind CSS + shadcn/ui |
| 状态管理 | Zustand |
| 构建工具 | Vite + electron-builder |
| 测试 | Vitest + Playwright |
| 动画 | Framer Motion |
| 图标 | Lucide React |
---
## 参与贡献
我们欢迎社区的各种贡献!无论是修复 Bug、开发新功能、改进文档还是翻译——每一份贡献都让 ClawX 变得更好。
### 如何贡献
1. **Fork** 本仓库
2. **创建** 功能分支(`git checkout -b feature/amazing-feature`
3. **提交** 清晰描述的变更
4. **推送** 到你的分支
5. **创建** Pull Request
### 贡献规范
- 遵循现有代码风格(ESLint + Prettier
- 为新功能编写测试
- 按需更新文档
- 保持提交原子化且描述清晰
---
## 致谢
ClawX 构建于以下优秀的开源项目之上:
- [OpenClaw](https://github.com/OpenClaw) AI 智能体运行时
- [Electron](https://www.electronjs.org/) 跨平台桌面框架
- [React](https://react.dev/) UI 组件库
- [shadcn/ui](https://ui.shadcn.com/) 精美设计的组件库
- [Zustand](https://github.com/pmndrs/zustand) 轻量级状态管理
---
## 社区
加入我们的社区,与其他用户交流、获取帮助、分享你的使用体验。
| 企业微信 | 飞书群组 | Discord |
| :---: | :---: | :---: |
| <img src="src/assets/community/wecom-qr.png" width="150" alt="企业微信二维码" /> | <img src="src/assets/community/feishu-qr.png" width="150" alt="飞书二维码" /> | <img src="src/assets/community/20260212-185822.png" width="150" alt="Discord 二维码" /> |
### ClawX 合作伙伴计划 🚀
我们正在启动 ClawX 合作伙伴计划,寻找能够帮助我们将 ClawX 介绍给更多客户的合作伙伴,尤其是那些有定制化 AI 智能体或自动化需求的客户。
合作伙伴负责帮助我们连接潜在用户和项目,ClawX 团队则提供完整的技术支持、定制开发与集成服务。
如果你服务的客户对 AI 工具或自动化方案感兴趣,欢迎与我们合作。
欢迎私信我们,或发送邮件至 [public@valuecell.ai](mailto:public@valuecell.ai) 了解更多。
---
## Stars 历史
<p align="center">
<img src="https://api.star-history.com/svg?repos=ValueCell-ai/ClawX&type=Date" alt="Stars 历史图表" />
</p>
---
## 许可证
ClawX 基于 [MIT 许可证](LICENSE) 发布。你可以自由地使用、修改和分发本软件。
---
<p align="center">
<sub>由 ValueCell 团队用 ❤️ 打造</sub>
</p>
+52 -18
View File
@@ -1,7 +1,7 @@
appId: app.clawx.desktop
productName: ClawX
copyright: Copyright © 2026 ClawX
compression: normal
compression: maximum
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
directories:
@@ -21,34 +21,53 @@ extraResources:
- "!icons/*.md"
- "!icons/*.svg"
- "!bin/**"
- "!screenshot/**"
# OpenClaw package (node_modules copied separately by afterPack hook
# because electron-builder respects .gitignore which excludes node_modules/)
- from: build/openclaw/
to: openclaw/
# Pre-bundled third-party skills (full directories, not only SKILL.md)
- from: build/preinstalled-skills/
to: resources/preinstalled-skills/
# NOTE: OpenClaw plugin mirrors (dingtalk, etc.) are bundled by the
# afterPack hook (after-pack.cjs) directly from node_modules, so they
# don't need an extraResources entry here.
afterPack: ./scripts/after-pack.cjs
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.
# All native modules (opus, sharp, koffi …) belong to openclaw, which is
# bundled separately (bundle-openclaw.mjs → extraResources) and runs in its
# own process — @electron/rebuild must NOT touch them.
npmRebuild: false
# Auto-update configuration
# Primary: Alibaba Cloud OSS (fast for Chinese users, used for auto-update)
# Fallback: GitHub Releases (backup, used when OSS is unavailable)
publish:
- provider: generic
url: https://valuecell-clawx.oss-cn-hangzhou.aliyuncs.com/latest
url: https://oss.intelli-spectrum.com/latest
useMultipleRangeRequest: false
- provider: github
owner: ValueCell-ai
repo: ClawX
releaseType: release
# macOS Configuration
mac:
extraResources:
- from: resources/bin/darwin-${arch}
to: bin
- from: resources/cli/posix/
to: cli/
category: public.app-category.productivity
icon: resources/icons/icon.icns
target:
@@ -56,6 +75,10 @@ mac:
arch:
- x64
- arm64
- target: zip
arch:
- x64
- arm64
darkModeSupport: true
hardenedRuntime: true
gatekeeperAssess: false
@@ -67,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
@@ -84,22 +111,23 @@ dmg:
# Windows Configuration
win:
forceCodeSigning: false
# Skip update signature verification: we ship via OSS + GitHub without a
# code-signing certificate, so verifying would always fail on the updater.
verifyUpdateCodeSignature: false
signAndEditExecutable: true
extraResources:
- from: resources/bin/win32-${arch}
to: bin
- from: resources/cli/win32/
to: cli/
icon: resources/icons/icon.ico
target:
- target: nsis
arch:
- x64
- arm64
arch: x64
nsis:
oneClick: false
perMachine: false
warningsAsErrors: false
allowToChangeInstallationDirectory: true
deleteAppDataOnUninstall: false
differentialPackage: true
@@ -108,11 +136,17 @@ nsis:
shortcutName: ClawX
uninstallDisplayName: ClawX
license: LICENSE
include: scripts/installer.nsh
installerIcon: resources/icons/icon.ico
uninstallerIcon: resources/icons/icon.ico
# Linux Configuration
linux:
extraResources:
- from: resources/bin/linux-${arch}
to: bin
- from: resources/cli/posix/
to: cli/
icon: resources/icons
target:
- target: AppImage
@@ -127,32 +161,32 @@ linux:
arch:
- x64
category: Utility
maintainer: ClawX Team <team@clawx.app>
maintainer: ClawX Team <public@valuecell.ai>
vendor: ClawX
synopsis: AI Assistant powered by OpenClaw
description: |
ClawX is a graphical AI assistant application that integrates with
OpenClaw Gateway to provide intelligent automation and assistance
across multiple messaging platforms.
description: ClawX is a graphical AI assistant application that integrates with OpenClaw Gateway to provide intelligent automation and assistance across multiple messaging platforms.
desktop:
entry:
Name: ClawX
Comment: AI Assistant powered by OpenClaw
Categories: Utility;Network;
Keywords: ai;assistant;automation;chat;
StartupWMClass: clawx
appImage:
license: LICENSE
deb:
depends:
- libgtk-3-0
- libnotify4
# Use OR syntax to support both Ubuntu 22.04 and Ubuntu 24.04 (t64 transition).
# Ubuntu 24.04 renamed many libraries with a t64 suffix (64-bit time_t ABI transition).
- libgtk-3-0 | libgtk-3-0t64
- libnotify4 | libnotify4t64
- libnss3
- libxss1
- libxtst6
- libxss1 | libxss1t64
- libxtst6 | libxtst6t64
- xdg-utils
- libatspi2.0-0
- libatspi2.0-0 | libatspi2.0-0t64
- libuuid1
afterInstall: scripts/linux/after-install.sh
afterRemove: scripts/linux/after-remove.sh
+11
View File
@@ -0,0 +1,11 @@
import type { BrowserWindow } from 'electron';
import type { GatewayManager } from '../gateway/manager';
import type { ClawHubService } from '../gateway/clawhub';
import type { HostEventBus } from './event-bus';
export interface HostApiContext {
gatewayManager: GatewayManager;
clawHubService: ClawHubService;
eventBus: HostEventBus;
mainWindow: BrowserWindow | null;
}
+36
View File
@@ -0,0 +1,36 @@
import type { ServerResponse } from 'http';
type EventPayload = unknown;
export class HostEventBus {
private readonly clients = new Set<ServerResponse>();
addSseClient(res: ServerResponse): void {
this.clients.add(res);
res.on('close', () => {
this.clients.delete(res);
});
}
emit(eventName: string, payload: EventPayload): void {
const message = `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`;
for (const client of this.clients) {
try {
client.write(message);
} catch {
this.clients.delete(client);
}
}
}
closeAll(): void {
for (const client of this.clients) {
try {
client.end();
} catch {
// Ignore individual client close failures.
}
}
this.clients.clear();
}
}
+73
View File
@@ -0,0 +1,73 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { PORTS } from '../utils/config';
/**
* Allowed CORS origins — only the Electron renderer (Vite dev or production)
* and the OpenClaw Gateway are permitted to make cross-origin requests.
*/
const ALLOWED_ORIGINS = new Set([
`http://127.0.0.1:${PORTS.CLAWX_DEV}`,
`http://localhost:${PORTS.CLAWX_DEV}`,
`http://127.0.0.1:${PORTS.OPENCLAW_GATEWAY}`,
`http://localhost:${PORTS.OPENCLAW_GATEWAY}`,
]);
export async function parseJsonBody<T>(req: IncomingMessage): Promise<T> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const raw = Buffer.concat(chunks).toString('utf8').trim();
if (!raw) {
return {} as T;
}
return JSON.parse(raw) as T;
}
/**
* Validate that mutation requests (POST/PUT/DELETE) carry a JSON Content-Type.
* This prevents "simple request" CSRF where the browser skips the preflight
* when Content-Type is text/plain or application/x-www-form-urlencoded.
*/
export function requireJsonContentType(req: IncomingMessage): boolean {
if (req.method === 'GET' || req.method === 'OPTIONS' || req.method === 'HEAD') {
return true;
}
// Requests without a body (content-length 0 or absent) are safe — CSRF
// "simple request" attacks rely on sending a crafted body.
const contentLength = req.headers['content-length'];
if (contentLength === '0' || contentLength === undefined) {
return true;
}
const ct = req.headers['content-type'] || '';
return ct.includes('application/json');
}
export function setCorsHeaders(res: ServerResponse, origin?: string): void {
// Only reflect the Origin header back if it is in the allow-list.
// Omitting the header for unknown origins causes the browser to block
// the response — this is the intended behavior for untrusted callers.
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
export function sendJson(res: ServerResponse, statusCode: number, payload: unknown): void {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(payload));
}
export function sendNoContent(res: ServerResponse): void {
res.statusCode = 204;
res.end();
}
export function sendText(res: ServerResponse, statusCode: number, text: string): void {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end(text);
}
+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;
}
+37
View File
@@ -0,0 +1,37 @@
import type { IncomingMessage, ServerResponse } from 'http';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../../utils/openclaw-doctor';
export async function handleAppRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/events' && req.method === 'GET') {
// CORS headers are already set by the server middleware.
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
});
res.write(': connected\n\n');
ctx.eventBus.addSseClient(res);
// Send a current-state snapshot immediately so renderer subscribers do not
// miss lifecycle transitions that happened before the SSE connection opened.
res.write(`event: gateway:status\ndata: ${JSON.stringify(ctx.gatewayManager.getStatus())}\n\n`);
return true;
}
if (url.pathname === '/api/app/openclaw-doctor' && req.method === 'POST') {
const body = await parseJsonBody<{ mode?: 'diagnose' | 'fix' }>(req);
const mode = body.mode === 'fix' ? 'fix' : 'diagnose';
sendJson(res, 200, mode === 'fix' ? await runOpenClawDoctorFix() : await runOpenClawDoctor());
return true;
}
// OPTIONS is handled by the server middleware; no route-level handler needed.
return false;
}
File diff suppressed because it is too large Load Diff
+568
View File
@@ -0,0 +1,568 @@
import { readFile } from 'node:fs/promises';
import type { IncomingMessage, ServerResponse } from 'http';
import { join } from 'node:path';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import { getOpenClawConfigDir } from '../../utils/paths';
import { toOpenClawChannelType, toUiChannelType } from '../../utils/channel-alias';
interface GatewayCronJob {
id: string;
name: string;
description?: string;
enabled: boolean;
createdAtMs: number;
updatedAtMs: number;
schedule: { kind: string; expr?: string; everyMs?: number; at?: string; tz?: string };
payload: { kind: string; message?: string; text?: string };
delivery?: { mode: string; channel?: string; to?: string; accountId?: string };
sessionTarget?: string;
state: {
nextRunAtMs?: number;
runningAtMs?: number;
lastRunAtMs?: number;
lastStatus?: string;
lastError?: string;
lastDurationMs?: number;
};
}
interface CronRunLogEntry {
jobId?: string;
action?: string;
status?: string;
error?: string;
summary?: string;
sessionId?: string;
sessionKey?: string;
ts?: number;
runAtMs?: number;
durationMs?: number;
model?: string;
provider?: string;
}
interface CronSessionKeyParts {
agentId: string;
jobId: string;
runSessionId?: string;
}
interface CronSessionFallbackMessage {
id: string;
role: 'assistant' | 'system';
content: string;
timestamp: number;
isError?: boolean;
}
function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
if (!sessionKey.startsWith('agent:')) return null;
const parts = sessionKey.split(':');
if (parts.length < 4 || parts[2] !== 'cron') return null;
const agentId = parts[1] || 'main';
const jobId = parts[3];
if (!jobId) return null;
if (parts.length === 4) {
return { agentId, jobId };
}
if (parts.length === 6 && parts[4] === 'run' && parts[5]) {
return { agentId, jobId, runSessionId: parts[5] };
}
return null;
}
function normalizeTimestampMs(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value < 1e12 ? value * 1000 : value;
}
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return undefined;
}
function formatDuration(durationMs: number | undefined): string | null {
if (!durationMs || !Number.isFinite(durationMs)) return null;
if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
if (durationMs < 10_000) return `${(durationMs / 1000).toFixed(1)}s`;
return `${Math.round(durationMs / 1000)}s`;
}
function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSessionFallbackMessage | null {
const timestamp = normalizeTimestampMs(entry.ts) ?? normalizeTimestampMs(entry.runAtMs);
if (!timestamp) return null;
const status = typeof entry.status === 'string' ? entry.status.toLowerCase() : '';
const summary = typeof entry.summary === 'string' ? entry.summary.trim() : '';
const error = typeof entry.error === 'string' ? entry.error.trim() : '';
let content = summary || error;
if (!content) {
content = status === 'error'
? 'Scheduled task failed.'
: 'Scheduled task completed.';
}
if (status === 'error' && !content.toLowerCase().startsWith('run failed:')) {
content = `Run failed: ${content}`;
}
const meta: string[] = [];
const duration = formatDuration(entry.durationMs);
if (duration) meta.push(`Duration: ${duration}`);
if (entry.provider && entry.model) {
meta.push(`Model: ${entry.provider}/${entry.model}`);
} else if (entry.model) {
meta.push(`Model: ${entry.model}`);
}
if (meta.length > 0) {
content = `${content}\n\n${meta.join(' | ')}`;
}
return {
id: `cron-run-${entry.sessionId ?? entry.ts ?? index}`,
role: status === 'error' ? 'system' : 'assistant',
content,
timestamp,
...(status === 'error' ? { isError: true } : {}),
};
}
async function readCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
const logPath = join(getOpenClawConfigDir(), 'cron', 'runs', `${jobId}.jsonl`);
const raw = await readFile(logPath, 'utf8').catch(() => '');
if (!raw.trim()) return [];
const entries: CronRunLogEntry[] = [];
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const entry = JSON.parse(trimmed) as CronRunLogEntry;
if (!entry || entry.jobId !== jobId) continue;
if (entry.action && entry.action !== 'finished') continue;
entries.push(entry);
} catch {
// Ignore malformed log lines so one bad entry does not hide the rest.
}
}
return entries;
}
async function readSessionStoreEntry(
agentId: string,
sessionKey: string,
): Promise<Record<string, unknown> | undefined> {
const storePath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json');
const raw = await readFile(storePath, 'utf8').catch(() => '');
if (!raw.trim()) return undefined;
try {
const store = JSON.parse(raw) as Record<string, unknown>;
const directEntry = store[sessionKey];
if (directEntry && typeof directEntry === 'object') {
return directEntry as Record<string, unknown>;
}
const sessions = (store as { sessions?: unknown }).sessions;
if (Array.isArray(sessions)) {
const arrayEntry = sessions.find((entry) => {
if (!entry || typeof entry !== 'object') return false;
const record = entry as Record<string, unknown>;
return record.key === sessionKey || record.sessionKey === sessionKey;
});
if (arrayEntry && typeof arrayEntry === 'object') {
return arrayEntry as Record<string, unknown>;
}
}
} catch {
return undefined;
}
return undefined;
}
export function buildCronSessionFallbackMessages(params: {
sessionKey: string;
job?: Pick<GatewayCronJob, 'name' | 'payload' | 'state'>;
runs: CronRunLogEntry[];
sessionEntry?: { label?: string; updatedAt?: number };
limit?: number;
}): CronSessionFallbackMessage[] {
const parsed = parseCronSessionKey(params.sessionKey);
if (!parsed) return [];
const matchingRuns = params.runs
.filter((entry) => {
if (!parsed.runSessionId) return true;
return entry.sessionId === parsed.runSessionId
|| entry.sessionKey === `${params.sessionKey}`;
})
.sort((a, b) => {
const left = normalizeTimestampMs(a.ts) ?? normalizeTimestampMs(a.runAtMs) ?? 0;
const right = normalizeTimestampMs(b.ts) ?? normalizeTimestampMs(b.runAtMs) ?? 0;
return left - right;
});
const messages: CronSessionFallbackMessage[] = [];
const prompt = params.job?.payload?.message || params.job?.payload?.text || '';
const taskName = params.job?.name?.trim()
|| params.sessionEntry?.label?.replace(/^Cron:\s*/, '').trim()
|| '';
const firstRelevantTimestamp = matchingRuns.length > 0
? (normalizeTimestampMs(matchingRuns[0]?.runAtMs) ?? normalizeTimestampMs(matchingRuns[0]?.ts))
: (normalizeTimestampMs(params.job?.state?.runningAtMs) ?? params.sessionEntry?.updatedAt);
if (taskName || prompt) {
const lines = [taskName ? `Scheduled task: ${taskName}` : 'Scheduled task'];
if (prompt) lines.push(`Prompt: ${prompt}`);
messages.push({
id: `cron-meta-${parsed.jobId}`,
role: 'system',
content: lines.join('\n'),
timestamp: Math.max(0, (firstRelevantTimestamp ?? Date.now()) - 1),
});
}
matchingRuns.forEach((entry, index) => {
const message = buildCronRunMessage(entry, index);
if (message) messages.push(message);
});
if (matchingRuns.length === 0) {
const runningAt = normalizeTimestampMs(params.job?.state?.runningAtMs);
if (runningAt) {
messages.push({
id: `cron-running-${parsed.jobId}`,
role: 'system',
content: 'This scheduled task is still running in OpenClaw, but no chat transcript is available yet.',
timestamp: runningAt,
});
} else if (messages.length === 0) {
messages.push({
id: `cron-empty-${parsed.jobId}`,
role: 'system',
content: 'No chat transcript is available for this scheduled task yet.',
timestamp: params.sessionEntry?.updatedAt ?? Date.now(),
});
}
}
const limit = typeof params.limit === 'number' && Number.isFinite(params.limit)
? Math.max(1, Math.floor(params.limit))
: messages.length;
return messages.slice(-limit);
}
type JsonRecord = Record<string, unknown>;
type GatewayCronDelivery = NonNullable<GatewayCronJob['delivery']>;
function getUnsupportedCronDeliveryError(channel: string | undefined): string | null {
if (!channel) return null;
return toUiChannelType(channel) === 'wechat'
? 'WeChat scheduled delivery is not supported because the plugin requires a live conversation context token.'
: null;
}
function normalizeCronDelivery(
rawDelivery: unknown,
fallbackMode: GatewayCronDelivery['mode'] = 'none',
): GatewayCronDelivery {
if (!rawDelivery || typeof rawDelivery !== 'object') {
return { mode: fallbackMode };
}
const delivery = rawDelivery as JsonRecord;
const mode = typeof delivery.mode === 'string' && delivery.mode.trim()
? delivery.mode.trim()
: fallbackMode;
const channel = typeof delivery.channel === 'string' && delivery.channel.trim()
? toOpenClawChannelType(delivery.channel.trim())
: undefined;
const to = typeof delivery.to === 'string' && delivery.to.trim()
? delivery.to.trim()
: undefined;
const accountId = typeof delivery.accountId === 'string' && delivery.accountId.trim()
? delivery.accountId.trim()
: undefined;
if (mode === 'announce' && !channel) {
return { mode: 'none' };
}
return {
mode,
...(channel ? { channel } : {}),
...(to ? { to } : {}),
...(accountId ? { accountId } : {}),
};
}
function normalizeCronDeliveryPatch(rawDelivery: unknown): Record<string, unknown> {
if (!rawDelivery || typeof rawDelivery !== 'object') {
return {};
}
const delivery = rawDelivery as JsonRecord;
const patch: Record<string, unknown> = {};
if ('mode' in delivery) {
patch.mode = typeof delivery.mode === 'string' && delivery.mode.trim()
? delivery.mode.trim()
: 'none';
}
if ('channel' in delivery) {
patch.channel = typeof delivery.channel === 'string' && delivery.channel.trim()
? toOpenClawChannelType(delivery.channel.trim())
: '';
}
if ('to' in delivery) {
patch.to = typeof delivery.to === 'string' ? delivery.to : '';
}
if ('accountId' in delivery) {
patch.accountId = typeof delivery.accountId === 'string' ? delivery.accountId : '';
}
return patch;
}
function buildCronUpdatePatch(input: Record<string, unknown>): Record<string, unknown> {
const patch = { ...input };
if (typeof patch.schedule === 'string') {
patch.schedule = { kind: 'cron', expr: patch.schedule };
}
if (typeof patch.message === 'string') {
patch.payload = { kind: 'agentTurn', message: patch.message };
delete patch.message;
}
if ('delivery' in patch) {
patch.delivery = normalizeCronDeliveryPatch(patch.delivery);
}
return patch;
}
function transformCronJob(job: GatewayCronJob) {
const message = job.payload?.message || job.payload?.text || '';
const gatewayDelivery = normalizeCronDelivery(job.delivery);
const channelType = gatewayDelivery.channel ? toUiChannelType(gatewayDelivery.channel) : undefined;
const delivery = channelType
? { ...gatewayDelivery, channel: channelType }
: gatewayDelivery;
const target = channelType
? {
channelType,
channelId: delivery.accountId || gatewayDelivery.channel,
channelName: channelType,
recipient: delivery.to,
}
: undefined;
const lastRun = job.state?.lastRunAtMs
? {
time: new Date(job.state.lastRunAtMs).toISOString(),
success: job.state.lastStatus === 'ok',
error: job.state.lastError,
duration: job.state.lastDurationMs,
}
: undefined;
const nextRun = job.state?.nextRunAtMs
? new Date(job.state.nextRunAtMs).toISOString()
: undefined;
return {
id: job.id,
name: job.name,
message,
schedule: job.schedule,
delivery,
target,
enabled: job.enabled,
createdAt: new Date(job.createdAtMs).toISOString(),
updatedAt: new Date(job.updatedAtMs).toISOString(),
lastRun,
nextRun,
};
}
export async function handleCronRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/cron/session-history' && req.method === 'GET') {
const sessionKey = url.searchParams.get('sessionKey')?.trim() || '';
const parsedSession = parseCronSessionKey(sessionKey);
if (!parsedSession) {
sendJson(res, 400, { success: false, error: `Invalid cron sessionKey: ${sessionKey}` });
return true;
}
const rawLimit = Number(url.searchParams.get('limit') || '200');
const limit = Number.isFinite(rawLimit)
? Math.min(Math.max(Math.floor(rawLimit), 1), 200)
: 200;
try {
const [jobsResult, runs, sessionEntry] = await Promise.all([
ctx.gatewayManager.rpc('cron.list', { includeDisabled: true })
.catch(() => ({ jobs: [] as GatewayCronJob[] })),
readCronRunLog(parsedSession.jobId),
readSessionStoreEntry(parsedSession.agentId, sessionKey),
]);
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
const job = jobs.find((item) => item.id === parsedSession.jobId);
const messages = buildCronSessionFallbackMessages({
sessionKey,
job,
runs,
sessionEntry: sessionEntry ? {
label: typeof sessionEntry.label === 'string' ? sessionEntry.label : undefined,
updatedAt: normalizeTimestampMs(sessionEntry.updatedAt),
} : undefined,
limit,
});
sendJson(res, 200, { messages });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/cron/jobs' && req.method === 'GET') {
try {
const result = await ctx.gatewayManager.rpc('cron.list', { includeDisabled: true });
const data = result as { jobs?: GatewayCronJob[] };
const jobs = data?.jobs ?? [];
for (const job of jobs) {
const isIsolatedAgent =
(job.sessionTarget === 'isolated' || !job.sessionTarget) &&
job.payload?.kind === 'agentTurn';
const needsRepair =
isIsolatedAgent &&
job.delivery?.mode === 'announce' &&
!job.delivery?.channel;
if (needsRepair) {
try {
await ctx.gatewayManager.rpc('cron.update', {
id: job.id,
patch: { delivery: { mode: 'none' } },
});
job.delivery = { mode: 'none' };
if (job.state?.lastError?.includes('Channel is required')) {
job.state.lastError = undefined;
job.state.lastStatus = 'ok';
}
} catch {
// ignore per-job repair failure
}
}
}
sendJson(res, 200, jobs.map(transformCronJob));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/cron/jobs' && req.method === 'POST') {
try {
const input = await parseJsonBody<{
name: string;
message: string;
schedule: string;
delivery?: GatewayCronDelivery;
enabled?: boolean;
}>(req);
const delivery = normalizeCronDelivery(input.delivery);
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(delivery.channel);
if (delivery.mode === 'announce' && unsupportedDeliveryError) {
sendJson(res, 400, { success: false, error: unsupportedDeliveryError });
return true;
}
const result = await ctx.gatewayManager.rpc('cron.add', {
name: input.name,
schedule: { kind: 'cron', expr: input.schedule },
payload: { kind: 'agentTurn', message: input.message },
enabled: input.enabled ?? true,
wakeMode: 'next-heartbeat',
sessionTarget: 'isolated',
delivery,
});
sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/cron/jobs/') && req.method === 'PUT') {
try {
const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length));
const input = await parseJsonBody<Record<string, unknown>>(req);
const patch = buildCronUpdatePatch(input);
const deliveryPatch = patch.delivery && typeof patch.delivery === 'object'
? patch.delivery as Record<string, unknown>
: undefined;
const deliveryChannel = typeof deliveryPatch?.channel === 'string' && deliveryPatch.channel.trim()
? deliveryPatch.channel.trim()
: undefined;
const deliveryMode = typeof deliveryPatch?.mode === 'string' && deliveryPatch.mode.trim()
? deliveryPatch.mode.trim()
: undefined;
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(deliveryChannel);
if (unsupportedDeliveryError && deliveryMode !== 'none') {
sendJson(res, 400, { success: false, error: unsupportedDeliveryError });
return true;
}
const result = await ctx.gatewayManager.rpc('cron.update', { id, patch });
sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/cron/jobs/') && req.method === 'DELETE') {
try {
const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length));
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.remove', { id }));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/cron/toggle' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ id: string; enabled: boolean }>(req);
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.update', { id: body.id, patch: { enabled: body.enabled } }));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/cron/trigger' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ id: string }>(req);
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.run', { id: body.id, mode: 'force' }));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
+200
View File
@@ -0,0 +1,200 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { dialog, nativeImage } from 'electron';
import crypto from 'node:crypto';
import { extname, join } from 'node:path';
import { homedir } from 'node:os';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
const EXT_MIME_MAP: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
'.mkv': 'video/x-matroska',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
'.pdf': 'application/pdf',
'.zip': 'application/zip',
'.gz': 'application/gzip',
'.tar': 'application/x-tar',
'.7z': 'application/x-7z-compressed',
'.rar': 'application/vnd.rar',
'.json': 'application/json',
'.xml': 'application/xml',
'.csv': 'text/csv',
'.txt': 'text/plain',
'.md': 'text/markdown',
'.html': 'text/html',
'.css': 'text/css',
'.js': 'text/javascript',
'.ts': 'text/typescript',
'.py': 'text/x-python',
};
function getMimeType(ext: string): string {
return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream';
}
function mimeToExt(mimeType: string): string {
for (const [ext, mime] of Object.entries(EXT_MIME_MAP)) {
if (mime === mimeType) return ext;
}
return '';
}
const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound');
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
try {
const img = nativeImage.createFromPath(filePath);
if (img.isEmpty()) return null;
const size = img.getSize();
const maxDim = 512;
if (size.width > maxDim || size.height > maxDim) {
const resized = size.width >= size.height
? img.resize({ width: maxDim })
: img.resize({ height: maxDim });
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
}
const { readFile } = await import('node:fs/promises');
const buf = await readFile(filePath);
return `data:${mimeType};base64,${buf.toString('base64')}`;
} catch {
return null;
}
}
export async function handleFileRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/files/stage-paths' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ filePaths: string[] }>(req);
const fsP = await import('node:fs/promises');
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
const results = [];
for (const filePath of body.filePaths) {
const id = crypto.randomUUID();
const ext = extname(filePath);
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
await fsP.copyFile(filePath, stagedPath);
const s = await fsP.stat(stagedPath);
const mimeType = getMimeType(ext);
const fileName = filePath.split(/[\\/]/).pop() || 'file';
const preview = mimeType.startsWith('image/')
? await generateImagePreview(stagedPath, mimeType)
: null;
results.push({ id, fileName, mimeType, fileSize: s.size, stagedPath, preview });
}
sendJson(res, 200, results);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/files/stage-buffer' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ base64: string; fileName: string; mimeType: string }>(req);
const fsP = await import('node:fs/promises');
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
const id = crypto.randomUUID();
const ext = extname(body.fileName) || mimeToExt(body.mimeType);
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
const buffer = Buffer.from(body.base64, 'base64');
await fsP.writeFile(stagedPath, buffer);
const mimeType = body.mimeType || getMimeType(ext);
const preview = mimeType.startsWith('image/')
? await generateImagePreview(stagedPath, mimeType)
: null;
sendJson(res, 200, {
id,
fileName: body.fileName,
mimeType,
fileSize: buffer.length,
stagedPath,
preview,
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/files/thumbnails' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ paths: Array<{ filePath: string; mimeType: string }> }>(req);
const fsP = await import('node:fs/promises');
const results: Record<string, { preview: string | null; fileSize: number }> = {};
for (const { filePath, mimeType } of body.paths) {
try {
const s = await fsP.stat(filePath);
const preview = mimeType.startsWith('image/')
? await generateImagePreview(filePath, mimeType)
: null;
results[filePath] = { preview, fileSize: s.size };
} catch {
results[filePath] = { preview: null, fileSize: 0 };
}
}
sendJson(res, 200, results);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/files/save-image' && req.method === 'POST') {
try {
const body = await parseJsonBody<{
base64?: string;
mimeType?: string;
filePath?: string;
defaultFileName: string;
}>(req);
const ext = body.defaultFileName.includes('.')
? body.defaultFileName.split('.').pop()!
: (body.mimeType?.split('/')[1] || 'png');
const result = await dialog.showSaveDialog({
defaultPath: join(homedir(), 'Downloads', body.defaultFileName),
filters: [
{ name: 'Images', extensions: [ext, 'png', 'jpg', 'jpeg', 'webp', 'gif'] },
{ name: 'All Files', extensions: ['*'] },
],
});
if (result.canceled || !result.filePath) {
sendJson(res, 200, { success: false });
return true;
}
const fsP = await import('node:fs/promises');
if (body.filePath) {
await fsP.copyFile(body.filePath, result.filePath);
} else if (body.base64) {
await fsP.writeFile(result.filePath, Buffer.from(body.base64, 'base64'));
} else {
sendJson(res, 400, { success: false, error: 'No image data provided' });
return true;
}
sendJson(res, 200, { success: true, savedPath: result.filePath });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
+130
View File
@@ -0,0 +1,130 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { PORTS } from '../../utils/config';
import { buildOpenClawControlUiUrl } from '../../utils/openclaw-control-ui';
import { getSetting } from '../../utils/store';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
export async function handleGatewayRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/app/gateway-info' && req.method === 'GET') {
const status = ctx.gatewayManager.getStatus();
const token = await getSetting('gatewayToken');
const port = status.port || PORTS.OPENCLAW_GATEWAY;
sendJson(res, 200, {
wsUrl: `ws://127.0.0.1:${port}/ws`,
token,
port,
});
return true;
}
if (url.pathname === '/api/gateway/status' && req.method === 'GET') {
sendJson(res, 200, ctx.gatewayManager.getStatus());
return true;
}
if (url.pathname === '/api/gateway/health' && req.method === 'GET') {
const health = await ctx.gatewayManager.checkHealth();
sendJson(res, 200, health);
return true;
}
if (url.pathname === '/api/gateway/start' && req.method === 'POST') {
try {
await ctx.gatewayManager.start();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/gateway/stop' && req.method === 'POST') {
try {
await ctx.gatewayManager.stop();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/gateway/restart' && req.method === 'POST') {
try {
await ctx.gatewayManager.restart();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/gateway/control-ui' && req.method === 'GET') {
try {
const status = ctx.gatewayManager.getStatus();
const token = await getSetting('gatewayToken');
const port = status.port || PORTS.OPENCLAW_GATEWAY;
const urlValue = buildOpenClawControlUiUrl(port, token);
sendJson(res, 200, { success: true, url: urlValue, token, port });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/chat/send-with-media' && req.method === 'POST') {
try {
const body = await parseJsonBody<{
sessionKey: string;
message: string;
deliver?: boolean;
idempotencyKey: string;
media?: Array<{ filePath: string; mimeType: string; fileName: string }>;
}>(req);
const VISION_MIME_TYPES = new Set([
'image/png', 'image/jpeg', 'image/bmp', 'image/webp',
]);
const imageAttachments: Array<{ content: string; mimeType: string; fileName: string }> = [];
const fileReferences: string[] = [];
if (body.media && body.media.length > 0) {
const fsP = await import('node:fs/promises');
for (const m of body.media) {
fileReferences.push(`[media attached: ${m.filePath} (${m.mimeType}) | ${m.filePath}]`);
if (VISION_MIME_TYPES.has(m.mimeType)) {
const fileBuffer = await fsP.readFile(m.filePath);
imageAttachments.push({
content: fileBuffer.toString('base64'),
mimeType: m.mimeType,
fileName: m.fileName,
});
}
}
}
const message = fileReferences.length > 0
? [body.message, ...fileReferences].filter(Boolean).join('\n')
: body.message;
const rpcParams: Record<string, unknown> = {
sessionKey: body.sessionKey,
message,
deliver: body.deliver ?? false,
idempotencyKey: body.idempotencyKey,
};
if (imageAttachments.length > 0) {
rpcParams.attachments = imageAttachments;
}
const result = await ctx.gatewayManager.rpc('chat.send', rpcParams, 120000);
sendJson(res, 200, { success: true, result });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
+29
View File
@@ -0,0 +1,29 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { logger } from '../../utils/logger';
import type { HostApiContext } from '../context';
import { sendJson } from '../route-utils';
export async function handleLogRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/logs' && req.method === 'GET') {
const tailLines = Number(url.searchParams.get('tailLines') || '100');
sendJson(res, 200, { content: await logger.readLogFile(Number.isFinite(tailLines) ? tailLines : 100) });
return true;
}
if (url.pathname === '/api/logs/dir' && req.method === 'GET') {
sendJson(res, 200, { dir: logger.getLogDir() });
return true;
}
if (url.pathname === '/api/logs/files' && req.method === 'GET') {
sendJson(res, 200, { files: await logger.listLogFiles() });
return true;
}
return false;
}
+354
View File
@@ -0,0 +1,354 @@
import type { IncomingMessage, ServerResponse } from 'http';
import {
type ProviderConfig,
} from '../../utils/secure-storage';
import {
getProviderConfig,
} from '../../utils/provider-registry';
import { deviceOAuthManager, type OAuthProviderType } from '../../utils/device-oauth';
import { browserOAuthManager, type BrowserOAuthProviderType } from '../../utils/browser-oauth';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import {
syncDefaultProviderToRuntime,
syncDeletedProviderApiKeyToRuntime,
syncDeletedProviderToRuntime,
syncProviderApiKeyToRuntime,
syncSavedProviderToRuntime,
syncUpdatedProviderToRuntime,
} from '../../services/providers/provider-runtime-sync';
import { validateApiKeyWithProvider } from '../../services/providers/provider-validation';
import { getProviderService } from '../../services/providers/provider-service';
import { providerAccountToConfig } from '../../services/providers/provider-store';
import type { ProviderAccount } from '../../shared/providers/types';
import { logger } from '../../utils/logger';
const legacyProviderRoutesWarned = new Set<string>();
function hasObjectChanges<T extends Record<string, unknown>>(
existing: T,
patch: Partial<T> | undefined,
): boolean {
if (!patch) return false;
const keys = Object.keys(patch) as Array<keyof T>;
if (keys.length === 0) return false;
return keys.some((key) => JSON.stringify(existing[key]) !== JSON.stringify(patch[key]));
}
export async function handleProviderRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
const providerService = getProviderService();
const logLegacyProviderRoute = (route: string): void => {
if (legacyProviderRoutesWarned.has(route)) return;
legacyProviderRoutesWarned.add(route);
logger.warn(
`[provider-migration] Legacy HTTP route "${route}" is deprecated. Prefer /api/provider-accounts endpoints.`,
);
};
if (url.pathname === '/api/provider-vendors' && req.method === 'GET') {
sendJson(res, 200, await providerService.listVendors());
return true;
}
if (url.pathname === '/api/provider-accounts' && req.method === 'GET') {
sendJson(res, 200, await providerService.listAccounts());
return true;
}
if (url.pathname === '/api/provider-accounts' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ account: ProviderAccount; apiKey?: string }>(req);
const account = await providerService.createAccount(body.account, body.apiKey);
await syncSavedProviderToRuntime(providerAccountToConfig(account), body.apiKey, ctx.gatewayManager);
sendJson(res, 200, { success: true, account });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/provider-accounts/default' && req.method === 'GET') {
sendJson(res, 200, { accountId: await providerService.getDefaultAccountId() ?? null });
return true;
}
if (url.pathname === '/api/provider-accounts/default' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{ accountId: string }>(req);
const currentDefault = await providerService.getDefaultAccountId();
if (currentDefault === body.accountId) {
sendJson(res, 200, { success: true, noChange: true });
return true;
}
await providerService.setDefaultAccount(body.accountId);
await syncDefaultProviderToRuntime(body.accountId, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'GET') {
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
sendJson(res, 200, await providerService.getAccount(accountId));
return true;
}
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'PUT') {
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
try {
const body = await parseJsonBody<{ updates: Partial<ProviderAccount>; apiKey?: string }>(req);
const existing = await providerService.getAccount(accountId);
if (!existing) {
sendJson(res, 404, { success: false, error: 'Provider account not found' });
return true;
}
const hasPatchChanges = hasObjectChanges(existing as unknown as Record<string, unknown>, body.updates);
if (!hasPatchChanges && body.apiKey === undefined) {
sendJson(res, 200, { success: true, noChange: true, account: existing });
return true;
}
const nextAccount = await providerService.updateAccount(accountId, body.updates, body.apiKey);
await syncUpdatedProviderToRuntime(providerAccountToConfig(nextAccount), body.apiKey, ctx.gatewayManager);
sendJson(res, 200, { success: true, account: nextAccount });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'DELETE') {
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
try {
const existing = await providerService.getAccount(accountId);
const runtimeProviderKey = existing?.authMode === 'oauth_browser'
? (existing.vendorId === 'google'
? 'google-gemini-cli'
: (existing.vendorId === 'openai' ? 'openai-codex' : undefined))
: undefined;
if (url.searchParams.get('apiKeyOnly') === '1') {
await providerService.deleteLegacyProviderApiKey(accountId);
await syncDeletedProviderApiKeyToRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
runtimeProviderKey,
);
sendJson(res, 200, { success: true });
return true;
}
await providerService.deleteAccount(accountId);
await syncDeletedProviderToRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
ctx.gatewayManager,
runtimeProviderKey,
);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers' && req.method === 'GET') {
logLegacyProviderRoute('GET /api/providers');
sendJson(res, 200, await providerService.listLegacyProvidersWithKeyInfo());
return true;
}
if (url.pathname === '/api/providers/default' && req.method === 'GET') {
logLegacyProviderRoute('GET /api/providers/default');
sendJson(res, 200, { providerId: await providerService.getDefaultLegacyProvider() ?? null });
return true;
}
if (url.pathname === '/api/providers/default' && req.method === 'PUT') {
logLegacyProviderRoute('PUT /api/providers/default');
try {
const body = await parseJsonBody<{ providerId: string }>(req);
const currentDefault = await providerService.getDefaultLegacyProvider();
if (currentDefault === body.providerId) {
sendJson(res, 200, { success: true, noChange: true });
return true;
}
await providerService.setDefaultLegacyProvider(body.providerId);
await syncDefaultProviderToRuntime(body.providerId, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers/validate' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers/validate');
try {
const body = await parseJsonBody<{ providerId: string; apiKey: string; options?: { baseUrl?: string; apiProtocol?: string } }>(req);
const provider = await providerService.getLegacyProvider(body.providerId);
const providerType = provider?.type || body.providerId;
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
const resolvedBaseUrl = body.options?.baseUrl || provider?.baseUrl || registryBaseUrl;
const resolvedProtocol = body.options?.apiProtocol || provider?.apiProtocol;
sendJson(res, 200, await validateApiKeyWithProvider(providerType, body.apiKey, { baseUrl: resolvedBaseUrl, apiProtocol: resolvedProtocol }));
} catch (error) {
sendJson(res, 500, { valid: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers/oauth/start' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers/oauth/start');
try {
const body = await parseJsonBody<{
provider: OAuthProviderType | BrowserOAuthProviderType;
region?: 'global' | 'cn';
accountId?: string;
label?: string;
}>(req);
if (body.provider === 'google' || body.provider === 'openai') {
await browserOAuthManager.startFlow(body.provider, {
accountId: body.accountId,
label: body.label,
});
} else {
await deviceOAuthManager.startFlow(body.provider, body.region, {
accountId: body.accountId,
label: body.label,
});
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers/oauth/cancel' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers/oauth/cancel');
try {
await deviceOAuthManager.stopFlow();
await browserOAuthManager.stopFlow();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers/oauth/submit' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers/oauth/submit');
try {
const body = await parseJsonBody<{ code: string }>(req);
const accepted = browserOAuthManager.submitManualCode(body.code || '');
if (!accepted) {
sendJson(res, 400, { success: false, error: 'No active manual OAuth input pending' });
return true;
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers');
try {
const body = await parseJsonBody<{ config: ProviderConfig; apiKey?: string }>(req);
const config = body.config;
await providerService.saveLegacyProvider(config);
if (body.apiKey !== undefined) {
const trimmedKey = body.apiKey.trim();
if (trimmedKey) {
await providerService.setLegacyProviderApiKey(config.id, trimmedKey);
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
}
}
await syncSavedProviderToRuntime(config, body.apiKey, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/providers/') && req.method === 'GET') {
logLegacyProviderRoute('GET /api/providers/:id');
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
if (providerId.endsWith('/api-key')) {
const actualId = providerId.slice(0, -('/api-key'.length));
sendJson(res, 200, { apiKey: await providerService.getLegacyProviderApiKey(actualId) });
return true;
}
if (providerId.endsWith('/has-api-key')) {
const actualId = providerId.slice(0, -('/has-api-key'.length));
sendJson(res, 200, { hasKey: await providerService.hasLegacyProviderApiKey(actualId) });
return true;
}
sendJson(res, 200, await providerService.getLegacyProvider(providerId));
return true;
}
if (url.pathname.startsWith('/api/providers/') && req.method === 'PUT') {
logLegacyProviderRoute('PUT /api/providers/:id');
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
try {
const body = await parseJsonBody<{ updates: Partial<ProviderConfig>; apiKey?: string }>(req);
const existing = await providerService.getLegacyProvider(providerId);
if (!existing) {
sendJson(res, 404, { success: false, error: 'Provider not found' });
return true;
}
const hasPatchChanges = hasObjectChanges(existing as unknown as Record<string, unknown>, body.updates);
if (!hasPatchChanges && body.apiKey === undefined) {
sendJson(res, 200, { success: true, noChange: true });
return true;
}
const nextConfig: ProviderConfig = { ...existing, ...body.updates, updatedAt: new Date().toISOString() };
await providerService.saveLegacyProvider(nextConfig);
if (body.apiKey !== undefined) {
const trimmedKey = body.apiKey.trim();
if (trimmedKey) {
await providerService.setLegacyProviderApiKey(providerId, trimmedKey);
await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey);
} else {
await providerService.deleteLegacyProviderApiKey(providerId);
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
}
}
await syncUpdatedProviderToRuntime(nextConfig, body.apiKey, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/providers/') && req.method === 'DELETE') {
logLegacyProviderRoute('DELETE /api/providers/:id');
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
try {
const existing = await providerService.getLegacyProvider(providerId);
if (url.searchParams.get('apiKeyOnly') === '1') {
await providerService.deleteLegacyProviderApiKey(providerId);
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
sendJson(res, 200, { success: true });
return true;
}
await providerService.deleteLegacyProvider(providerId);
await syncDeletedProviderToRuntime(existing, providerId, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
+96
View File
@@ -0,0 +1,96 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { join } from 'node:path';
import { getOpenClawConfigDir } from '../../utils/paths';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
export async function handleSessionRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/sessions/delete' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ sessionKey: string }>(req);
const sessionKey = body.sessionKey;
if (!sessionKey || !sessionKey.startsWith('agent:')) {
sendJson(res, 400, { success: false, error: `Invalid sessionKey: ${sessionKey}` });
return true;
}
const parts = sessionKey.split(':');
if (parts.length < 3) {
sendJson(res, 400, { success: false, error: `sessionKey has too few parts: ${sessionKey}` });
return true;
}
const agentId = parts[1];
const sessionsDir = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions');
const sessionsJsonPath = join(sessionsDir, 'sessions.json');
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
const sessionsJson = JSON.parse(raw) as Record<string, unknown>;
let uuidFileName: string | undefined;
let resolvedSrcPath: string | undefined;
if (Array.isArray(sessionsJson.sessions)) {
const entry = (sessionsJson.sessions as Array<Record<string, unknown>>)
.find((s) => s.key === sessionKey || s.sessionKey === sessionKey);
if (entry) {
uuidFileName = (entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (!uuidFileName && typeof entry.id === 'string') {
uuidFileName = `${entry.id}.jsonl`;
}
}
}
if (!uuidFileName && sessionsJson[sessionKey] != null) {
const val = sessionsJson[sessionKey];
if (typeof val === 'string') {
uuidFileName = val;
} else if (typeof val === 'object' && val !== null) {
const entry = val as Record<string, unknown>;
const absFile = (entry.sessionFile ?? entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (absFile) {
if (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/)) {
resolvedSrcPath = absFile;
} else {
uuidFileName = absFile;
}
} else {
const uuidVal = (entry.id ?? entry.sessionId) as string | undefined;
if (uuidVal) uuidFileName = uuidVal.endsWith('.jsonl') ? uuidVal : `${uuidVal}.jsonl`;
}
}
}
if (!uuidFileName && !resolvedSrcPath) {
sendJson(res, 404, { success: false, error: `Cannot resolve file for session: ${sessionKey}` });
return true;
}
if (!resolvedSrcPath) {
if (!uuidFileName!.endsWith('.jsonl')) uuidFileName = `${uuidFileName}.jsonl`;
resolvedSrcPath = join(sessionsDir, uuidFileName!);
}
const dstPath = resolvedSrcPath.replace(/\.jsonl$/, '.deleted.jsonl');
try {
await fsP.access(resolvedSrcPath);
await fsP.rename(resolvedSrcPath, dstPath);
} catch {
// Non-fatal; still try to update sessions.json.
}
const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8');
const json2 = JSON.parse(raw2) as Record<string, unknown>;
if (Array.isArray(json2.sessions)) {
json2.sessions = (json2.sessions as Array<Record<string, unknown>>)
.filter((s) => s.key !== sessionKey && s.sessionKey !== sessionKey);
} else if (json2[sessionKey]) {
delete json2[sessionKey];
}
await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8');
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
+112
View File
@@ -0,0 +1,112 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { applyProxySettings } from '../../main/proxy';
import { syncLaunchAtStartupSettingFromStore } from '../../main/launch-at-startup';
import { syncProxyConfigToOpenClaw } from '../../utils/openclaw-proxy';
import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../../utils/store';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
async function handleProxySettingsChange(ctx: HostApiContext): Promise<void> {
const settings = await getAllSettings();
await syncProxyConfigToOpenClaw(settings, { preserveExistingWhenDisabled: false });
await applyProxySettings(settings);
if (ctx.gatewayManager.getStatus().state === 'running') {
await ctx.gatewayManager.restart();
}
}
function patchTouchesProxy(patch: Partial<AppSettings>): boolean {
return Object.keys(patch).some((key) => (
key === 'proxyEnabled' ||
key === 'proxyServer' ||
key === 'proxyHttpServer' ||
key === 'proxyHttpsServer' ||
key === 'proxyAllServer' ||
key === 'proxyBypassRules'
));
}
function patchTouchesLaunchAtStartup(patch: Partial<AppSettings>): boolean {
return Object.prototype.hasOwnProperty.call(patch, 'launchAtStartup');
}
export async function handleSettingsRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/settings' && req.method === 'GET') {
sendJson(res, 200, await getAllSettings());
return true;
}
if (url.pathname === '/api/settings' && req.method === 'PUT') {
try {
const patch = await parseJsonBody<Partial<AppSettings>>(req);
const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>;
for (const [key, value] of entries) {
await setSetting(key, value);
}
if (patchTouchesProxy(patch)) {
await handleProxySettingsChange(ctx);
}
if (patchTouchesLaunchAtStartup(patch)) {
await syncLaunchAtStartupSettingFromStore();
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/settings/') && req.method === 'GET') {
const key = url.pathname.slice('/api/settings/'.length) as keyof AppSettings;
try {
sendJson(res, 200, { value: await getSetting(key) });
} catch (error) {
sendJson(res, 404, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/settings/') && req.method === 'PUT') {
const key = url.pathname.slice('/api/settings/'.length) as keyof AppSettings;
try {
const body = await parseJsonBody<{ value: AppSettings[keyof AppSettings] }>(req);
await setSetting(key, body.value);
if (
key === 'proxyEnabled' ||
key === 'proxyServer' ||
key === 'proxyHttpServer' ||
key === 'proxyHttpsServer' ||
key === 'proxyAllServer' ||
key === 'proxyBypassRules'
) {
await handleProxySettingsChange(ctx);
}
if (key === 'launchAtStartup') {
await syncLaunchAtStartupSettingFromStore();
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/settings/reset' && req.method === 'POST') {
try {
await resetSettings();
await handleProxySettingsChange(ctx);
await syncLaunchAtStartupSettingFromStore();
sendJson(res, 200, { success: true, settings: await getAllSettings() });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
+101
View File
@@ -0,0 +1,101 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { getAllSkillConfigs, updateSkillConfig } from '../../utils/skill-config';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
export async function handleSkillRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/skills/configs' && req.method === 'GET') {
sendJson(res, 200, await getAllSkillConfigs());
return true;
}
if (url.pathname === '/api/skills/config' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{
skillKey: string;
apiKey?: string;
env?: Record<string, string>;
}>(req);
sendJson(res, 200, await updateSkillConfig(body.skillKey, {
apiKey: body.apiKey,
env: body.env,
}));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/search' && req.method === 'POST') {
try {
const body = await parseJsonBody<Record<string, unknown>>(req);
sendJson(res, 200, {
success: true,
results: await ctx.clawHubService.search(body),
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/install' && req.method === 'POST') {
try {
const body = await parseJsonBody<Record<string, unknown>>(req);
await ctx.clawHubService.install(body);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/uninstall' && req.method === 'POST') {
try {
const body = await parseJsonBody<Record<string, unknown>>(req);
await ctx.clawHubService.uninstall(body);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/list' && req.method === 'GET') {
try {
sendJson(res, 200, { success: true, results: await ctx.clawHubService.listInstalled() });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/open-readme' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ slug?: string; skillKey?: string; baseDir?: string }>(req);
await ctx.clawHubService.openSkillReadme(body.skillKey || body.slug || '', body.slug, body.baseDir);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/open-path' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ slug?: string; skillKey?: string; baseDir?: string }>(req);
await ctx.clawHubService.openSkillPath(body.skillKey || body.slug || '', body.slug, body.baseDir);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
+26
View File
@@ -0,0 +1,26 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { getRecentTokenUsageHistory } from '../../utils/token-usage';
import type { HostApiContext } from '../context';
import { sendJson } from '../route-utils';
export async function handleUsageRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/usage/recent-token-history' && req.method === 'GET') {
const rawLimit = url.searchParams.get('limit');
let limit: number | undefined;
if (rawLimit != null && rawLimit.trim() !== '') {
const parsedLimit = Number(rawLimit);
if (Number.isFinite(parsedLimit)) {
limit = Math.max(Math.floor(parsedLimit), 1);
}
}
sendJson(res, 200, await getRecentTokenUsageHistory(limit));
return true;
}
return false;
}
+114
View File
@@ -0,0 +1,114 @@
import { randomBytes } from 'node:crypto';
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { PORTS } from '../utils/config';
import { logger } from '../utils/logger';
import type { HostApiContext } from './context';
import { handleAppRoutes } from './routes/app';
import { handleGatewayRoutes } from './routes/gateway';
import { handleSettingsRoutes } from './routes/settings';
import { handleProviderRoutes } from './routes/providers';
import { handleAgentRoutes } from './routes/agents';
import { handleChannelRoutes } from './routes/channels';
import { handleLogRoutes } from './routes/logs';
import { handleUsageRoutes } from './routes/usage';
import { handleSkillRoutes } from './routes/skills';
import { handleFileRoutes } from './routes/files';
import { handleSessionRoutes } from './routes/sessions';
import { handleCronRoutes } from './routes/cron';
import { sendJson, setCorsHeaders, requireJsonContentType } from './route-utils';
type RouteHandler = (
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
) => Promise<boolean>;
const routeHandlers: RouteHandler[] = [
handleAppRoutes,
handleGatewayRoutes,
handleSettingsRoutes,
handleProviderRoutes,
handleAgentRoutes,
handleChannelRoutes,
handleSkillRoutes,
handleFileRoutes,
handleSessionRoutes,
handleCronRoutes,
handleLogRoutes,
handleUsageRoutes,
];
/**
* Per-session secret token used to authenticate Host API requests.
* Generated once at server start and shared with the renderer via IPC.
* This prevents cross-origin attackers from reading sensitive data even
* if they can reach 127.0.0.1:3210 (the CORS wildcard alone is not
* sufficient because browsers attach the Origin header but not a secret).
*/
let hostApiToken: string = '';
/** Retrieve the current Host API auth token (for use by IPC proxy). */
export function getHostApiToken(): string {
return hostApiToken;
}
export function startHostApiServer(ctx: HostApiContext, port = PORTS.CLAWX_HOST_API): Server {
// Generate a cryptographically random token for this session.
hostApiToken = randomBytes(32).toString('hex');
const server = createServer(async (req, res) => {
try {
const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`);
// ── CORS headers ─────────────────────────────────────────
// Set origin-aware CORS headers early so every response
// (including error responses) carries them consistently.
const origin = req.headers.origin;
setCorsHeaders(res, origin);
// CORS preflight — respond before auth so browsers can negotiate.
if (req.method === 'OPTIONS') {
res.statusCode = 204;
res.end();
return;
}
// ── Auth gate ──────────────────────────────────────────────
// Every non-preflight request must carry a valid Bearer token.
// Accept via Authorization header (preferred) or ?token= query
// parameter (for EventSource which cannot set custom headers).
const authHeader = req.headers.authorization || '';
const bearerToken = authHeader.startsWith('Bearer ')
? authHeader.slice(7)
: (requestUrl.searchParams.get('token') || '');
if (bearerToken !== hostApiToken) {
sendJson(res, 401, { success: false, error: 'Unauthorized' });
return;
}
// ── Content-Type gate (anti-CSRF) ──────────────────────────
// Mutation requests must use application/json to force a CORS
// preflight, preventing "simple request" CSRF attacks.
if (!requireJsonContentType(req)) {
sendJson(res, 415, { success: false, error: 'Content-Type must be application/json' });
return;
}
for (const handler of routeHandlers) {
if (await handler(req, res, requestUrl, ctx)) {
return;
}
}
sendJson(res, 404, { success: false, error: `No route for ${req.method} ${requestUrl.pathname}` });
} catch (error) {
logger.error('Host API request failed:', error);
sendJson(res, 500, { success: false, error: String(error) });
}
});
server.listen(port, '127.0.0.1', () => {
logger.info(`Host API server listening on http://127.0.0.1:${port}`);
});
return server;
}
+166 -26
View File
@@ -6,7 +6,7 @@ import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { app, shell } from 'electron';
import { getOpenClawConfigDir, ensureDir } from '../utils/paths';
import { getOpenClawConfigDir, ensureDir, getClawHubCliBinPath, getClawHubCliEntryPath, quoteForCmd } from '../utils/paths';
export interface ClawHubSearchParams {
query: string;
@@ -33,9 +33,18 @@ 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;
private cliEntryPath: string;
private useNodeRunner: boolean;
private ansiRegex: RegExp;
constructor() {
@@ -44,11 +53,17 @@ export class ClawHubService {
this.workDir = getOpenClawConfigDir();
ensureDir(this.workDir);
// In development, we use the locally installed clawhub CLI from node_modules
const isWin = process.platform === 'win32';
const binName = isWin ? 'clawhub.cmd' : 'clawhub';
const localCli = path.resolve(app.getAppPath(), 'node_modules', '.bin', binName);
this.cliPath = localCli;
const binPath = getClawHubCliBinPath();
const entryPath = getClawHubCliEntryPath();
this.cliEntryPath = entryPath;
if (!app.isPackaged && fs.existsSync(binPath)) {
this.cliPath = binPath;
this.useNodeRunner = false;
} else {
this.cliPath = process.execPath;
this.useNodeRunner = true;
}
const esc = String.fromCharCode(27);
const csi = String.fromCharCode(155);
const pattern = `(?:${esc}|${csi})[[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]`;
@@ -59,22 +74,95 @@ 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
*/
private async runCommand(args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
console.log(`Running ClawHub command: ${this.cliPath} ${args.join(' ')}`);
if (this.useNodeRunner && !fs.existsSync(this.cliEntryPath)) {
reject(new Error(`ClawHub CLI entry not found at: ${this.cliEntryPath}`));
return;
}
if (!this.useNodeRunner && !fs.existsSync(this.cliPath)) {
reject(new Error(`ClawHub CLI not found at: ${this.cliPath}`));
return;
}
const commandArgs = this.useNodeRunner ? [this.cliEntryPath, ...args] : args;
const displayCommand = [this.cliPath, ...commandArgs].join(' ');
console.log(`Running ClawHub command: ${displayCommand}`);
const isWin = process.platform === 'win32';
const child = spawn(this.cliPath, args, {
const useShell = isWin && !this.useNodeRunner;
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
const env = {
...baseEnv,
CI: 'true',
FORCE_COLOR: '0',
};
if (this.useNodeRunner) {
env.ELECTRON_RUN_AS_NODE = '1';
}
const spawnCmd = useShell ? quoteForCmd(this.cliPath) : this.cliPath;
const spawnArgs = useShell ? commandArgs.map(a => quoteForCmd(a)) : commandArgs;
const child = spawn(spawnCmd, spawnArgs, {
cwd: this.workDir,
shell: isWin,
shell: useShell,
env: {
...process.env,
CI: 'true',
FORCE_COLOR: '0', // Disable colors for easier parsing
...env,
CLAWHUB_WORKDIR: this.workDir,
},
windowsHide: true,
});
let stdout = '';
@@ -131,7 +219,7 @@ export class ClawHubService {
// Format could be: slug vversion description (score)
// Or sometimes: slug vversion description
const match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)\s+(.+)$/);
let match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)\s+(.+)$/);
if (match) {
const slug = match[1];
const version = match[2];
@@ -147,11 +235,29 @@ export class ClawHubService {
description,
};
}
// Fallback for new clawhub search format without version:
// slug name/description (score)
match = cleanLine.match(/^(\S+)\s+(.+)$/);
if (match) {
const slug = match[1];
let description = match[2];
// Clean up score if present at the end
description = description.replace(/\(\d+\.\d+\)$/, '').trim();
return {
slug,
name: slug,
version: 'latest', // Fallback version since it's not provided
description,
};
}
return null;
}).filter((s): s is ClawHubSkillResult => s !== null);
} catch (error) {
console.error('ClawHub search error:', error);
return [];
throw error;
}
}
@@ -187,7 +293,7 @@ export class ClawHubService {
}).filter((s): s is ClawHubSkillResult => s !== null);
} catch (error) {
console.error('ClawHub explore error:', error);
return [];
throw error;
}
}
@@ -240,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')) {
@@ -252,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');
@@ -301,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;
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { GatewayManager, GatewayStatus } from './manager';
/**
* Channel types supported by OpenClaw
*/
export type ChannelType = 'whatsapp' | 'telegram' | 'discord' | 'wechat';
export type ChannelType = 'whatsapp' | 'dingtalk' | 'telegram' | 'discord' | 'wechat';
/**
* Channel status
+342
View File
@@ -0,0 +1,342 @@
import { app } from 'electron';
import path from 'path';
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 { 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>>;
openclawDir: string;
entryScript: string;
gatewayArgs: string[];
forkEnv: Record<string, string | undefined>;
mode: 'dev' | 'packaged';
binPathExists: boolean;
loadedProviderKeyCount: number;
proxySummary: string;
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, { preserveExistingWhenDisabled: true });
try {
await sanitizeOpenClawConfig();
} catch (err) {
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) {
logger.warn('Failed to sync gateway token to openclaw.json:', err);
}
try {
await syncBrowserConfigToOpenClaw();
} catch (err) {
logger.warn('Failed to sync browser config to openclaw.json:', err);
}
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 }> {
const providerEnv: Record<string, string> = {};
const providerTypes = getKeyableProviderTypes();
let loadedProviderKeyCount = 0;
try {
const defaultProviderId = await getDefaultProvider();
if (defaultProviderId) {
const defaultProvider = await getProvider(defaultProviderId);
const defaultProviderType = defaultProvider?.type;
const defaultProviderKey = await getApiKey(defaultProviderId);
if (defaultProviderType && defaultProviderKey) {
const envVar = getProviderEnvVar(defaultProviderType);
if (envVar) {
providerEnv[envVar] = defaultProviderKey;
loadedProviderKeyCount++;
}
}
}
} catch (err) {
logger.warn('Failed to load default provider key for environment injection:', err);
}
for (const providerType of providerTypes) {
try {
const key = await getApiKey(providerType);
if (key) {
const envVar = getProviderEnvVar(providerType);
if (envVar) {
providerEnv[envVar] = key;
loadedProviderKeyCount++;
}
}
} catch (err) {
logger.warn(`Failed to load API key for ${providerType}:`, err);
}
}
return { providerEnv, loadedProviderKeyCount };
}
async function resolveChannelStartupPolicy(): Promise<{
skipChannels: boolean;
channelStartupSummary: string;
}> {
try {
const configuredChannels = await listConfiguredChannels();
if (configuredChannels.length === 0) {
return {
skipChannels: true,
channelStartupSummary: 'skipped(no configured channels)',
};
}
return {
skipChannels: false,
channelStartupSummary: `enabled(${configuredChannels.join(',')})`,
};
} catch (error) {
logger.warn('Failed to determine configured channels for gateway launch:', error);
return {
skipChannels: false,
channelStartupSummary: 'enabled(unknown)',
};
}
}
export async function prepareGatewayLaunchContext(port: number): Promise<GatewayLaunchContext> {
const openclawDir = getOpenClawDir();
const entryScript = getOpenClawEntryPath();
if (!isOpenClawPresent()) {
throw new Error(`OpenClaw package not found at: ${openclawDir}`);
}
const appSettings = await getAllSettings();
await syncGatewayConfigBeforeLaunch(appSettings);
if (!existsSync(entryScript)) {
throw new Error(`OpenClaw entry script not found at: ${entryScript}`);
}
const gatewayArgs = ['gateway', '--port', String(port), '--token', appSettings.gatewayToken, '--allow-unconfigured'];
const mode = app.isPackaged ? 'packaged' : 'dev';
const platform = process.platform;
const arch = process.arch;
const target = `${platform}-${arch}`;
const binPath = app.isPackaged
? path.join(process.resourcesPath, 'bin')
: path.join(process.cwd(), 'resources', 'bin', target);
const binPathExists = existsSync(binPath);
const { providerEnv, loadedProviderKeyCount } = await loadProviderEnv();
const { skipChannels, channelStartupSummary } = await resolveChannelStartupPolicy();
const uvEnv = await getUvMirrorEnv();
const proxyEnv = buildProxyEnv(appSettings);
const resolvedProxy = resolveProxySettings(appSettings);
const proxySummary = appSettings.proxyEnabled
? `http=${resolvedProxy.httpProxy || '-'}, https=${resolvedProxy.httpsProxy || '-'}, all=${resolvedProxy.allProxy || '-'}`
: 'disabled';
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
const baseEnvRecord = baseEnv as Record<string, string | undefined>;
const baseEnvPatched = binPathExists
? prependPathEntry(baseEnvRecord, binPath).env
: baseEnvRecord;
const forkEnv: Record<string, string | undefined> = {
...baseEnvPatched,
...providerEnv,
...uvEnv,
...proxyEnv,
OPENCLAW_GATEWAY_TOKEN: appSettings.gatewayToken,
OPENCLAW_SKIP_CHANNELS: skipChannels ? '1' : '',
CLAWDBOT_SKIP_CHANNELS: skipChannels ? '1' : '',
OPENCLAW_NO_RESPAWN: '1',
};
return {
appSettings,
openclawDir,
entryScript,
gatewayArgs,
forkEnv,
mode,
binPathExists,
loadedProviderKeyCount,
proxySummary,
channelStartupSummary,
};
}
+124
View File
@@ -0,0 +1,124 @@
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();
if (this.pingInterval) {
clearInterval(this.pingInterval);
}
this.pingInterval = setInterval(() => {
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>;
onUnhealthy: (errorMessage: string) => void;
onError: (error: unknown) => void;
intervalMs?: number;
}): void {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
}
this.healthCheckInterval = setInterval(async () => {
if (!options.shouldCheck()) {
return;
}
try {
const health = await options.checkHealth();
if (!health.ok) {
const errorMessage = health.error ?? 'Health check failed';
logger.warn(`Gateway health check failed: ${errorMessage}`);
options.onUnhealthy(errorMessage);
}
} catch (error) {
logger.error('Gateway health check error:', error);
options.onError(error);
}
}, options.intervalMs ?? 30000);
}
clear(): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
this.resetHeartbeatState();
}
private resetHeartbeatState(): void {
this.lastPingAt = 0;
this.waitingForAlive = false;
this.consecutiveMisses = 0;
this.timeoutTriggered = false;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { GatewayEventType, type JsonRpcNotification } from './protocol';
import { logger } from '../utils/logger';
type GatewayEventEmitter = {
emit: (event: string, payload: unknown) => boolean;
};
export function dispatchProtocolEvent(
emitter: GatewayEventEmitter,
event: string,
payload: unknown,
): void {
switch (event) {
case 'tick':
break;
case 'chat':
emitter.emit('chat:message', { message: payload });
break;
case 'agent': {
// Keep "agent" on the canonical notification path to avoid double
// handling in renderer when both notification and chat-message are wired.
emitter.emit('notification', { method: event, params: payload });
break;
}
case 'channel.status':
emitter.emit('channel:status', payload as { channelId: string; status: string });
break;
default:
emitter.emit('notification', { method: event, params: payload });
}
}
export function dispatchJsonRpcNotification(
emitter: GatewayEventEmitter,
notification: JsonRpcNotification,
): void {
emitter.emit('notification', notification);
switch (notification.method) {
case GatewayEventType.CHANNEL_STATUS_CHANGED:
emitter.emit('channel:status', notification.params as { channelId: string; status: string });
break;
case GatewayEventType.MESSAGE_RECEIVED:
emitter.emit('chat:message', notification.params as { message: unknown });
break;
case GatewayEventType.ERROR: {
const errorData = notification.params as { message?: string };
emitter.emit('error', new Error(errorData.message || 'Gateway error'));
break;
}
default:
logger.debug(`Unknown Gateway notification: ${notification.method}`);
}
}
+31
View File
@@ -0,0 +1,31 @@
import { logger } from '../utils/logger';
import { isLifecycleSuperseded, nextLifecycleEpoch } from './process-policy';
export class LifecycleSupersededError extends Error {
constructor(message: string) {
super(message);
this.name = 'LifecycleSupersededError';
}
}
export class GatewayLifecycleController {
private epoch = 0;
getCurrentEpoch(): number {
return this.epoch;
}
bump(reason: string): number {
this.epoch = nextLifecycleEpoch(this.epoch);
logger.debug(`Gateway lifecycle epoch advanced to ${this.epoch} (${reason})`);
return this.epoch;
}
assert(expectedEpoch: number, phase: string): void {
if (isLifecycleSuperseded(expectedEpoch, this.epoch)) {
throw new LifecycleSupersededError(
`Gateway ${phase} superseded (expectedEpoch=${expectedEpoch}, currentEpoch=${this.epoch})`,
);
}
}
}
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
import { app, utilityProcess } from 'electron';
import { existsSync, writeFileSync } from 'fs';
import path from 'path';
import type { GatewayLaunchContext } from './config-sync';
import type { GatewayLifecycleState } from './process-policy';
import { logger } from '../utils/logger';
import { appendNodeRequireToNodeOptions } from '../utils/paths';
const GATEWAY_FETCH_PRELOAD_SOURCE = `'use strict';
(function () {
var _f = globalThis.fetch;
if (typeof _f !== 'function') return;
if (globalThis.__clawxFetchPatched) return;
globalThis.__clawxFetchPatched = true;
globalThis.fetch = function clawxFetch(input, init) {
var url =
typeof input === 'string' ? input
: input && typeof input === 'object' && typeof input.url === 'string'
? input.url : '';
if (url.indexOf('openrouter.ai') !== -1) {
init = init ? Object.assign({}, init) : {};
var prev = init.headers;
var flat = {};
if (prev && typeof prev.forEach === 'function') {
prev.forEach(function (v, k) { flat[k] = v; });
} else if (prev && typeof prev === 'object') {
Object.assign(flat, prev);
}
delete flat['http-referer'];
delete flat['HTTP-Referer'];
delete flat['x-title'];
delete flat['X-Title'];
flat['HTTP-Referer'] = 'https://claw-x.com';
flat['X-Title'] = 'ClawX';
init.headers = flat;
}
return _f.call(globalThis, input, init);
};
if (process.platform === 'win32') {
try {
var cp = require('child_process');
if (!cp.__clawxPatched) {
cp.__clawxPatched = true;
['spawn', 'exec', 'execFile', 'fork', 'spawnSync', 'execSync', 'execFileSync'].forEach(function(method) {
var original = cp[method];
if (typeof original !== 'function') return;
cp[method] = function() {
var args = Array.prototype.slice.call(arguments);
var optIdx = -1;
for (var i = 1; i < args.length; i++) {
var a = args[i];
if (a && typeof a === 'object' && !Array.isArray(a)) {
optIdx = i;
break;
}
}
if (optIdx >= 0) {
args[optIdx].windowsHide = true;
} else {
var opts = { windowsHide: true };
if (typeof args[args.length - 1] === 'function') {
args.splice(args.length - 1, 0, opts);
} else {
args.push(opts);
}
}
return original.apply(this, args);
};
});
}
} catch (e) {
// ignore
}
}
})();
`;
function ensureGatewayFetchPreload(): string {
const dest = path.join(app.getPath('userData'), 'gateway-fetch-preload.cjs');
try {
writeFileSync(dest, GATEWAY_FETCH_PRELOAD_SOURCE, 'utf-8');
} catch {
// best-effort
}
return dest;
}
export async function launchGatewayProcess(options: {
port: number;
launchContext: GatewayLaunchContext;
sanitizeSpawnArgs: (args: string[]) => string[];
getCurrentState: () => GatewayLifecycleState;
getShouldReconnect: () => boolean;
onStderrLine: (line: string) => void;
onSpawn: (pid: number | undefined) => void;
onExit: (child: Electron.UtilityProcess, code: number | null) => void;
onError: (error: Error) => void;
}): Promise<{ child: Electron.UtilityProcess; lastSpawnSummary: string }> {
const {
openclawDir,
entryScript,
gatewayArgs,
forkEnv,
mode,
binPathExists,
loadedProviderKeyCount,
proxySummary,
channelStartupSummary,
} = options.launchContext;
logger.info(
`Starting Gateway process (mode=${mode}, port=${options.port}, entry="${entryScript}", args="${options.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount}, channels=${channelStartupSummary}, proxy=${proxySummary})`,
);
const lastSpawnSummary = `mode=${mode}, entry="${entryScript}", args="${options.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}"`;
const runtimeEnv = { ...forkEnv };
if (!app.isPackaged) {
try {
const preloadPath = ensureGatewayFetchPreload();
if (existsSync(preloadPath)) {
runtimeEnv.NODE_OPTIONS = appendNodeRequireToNodeOptions(
runtimeEnv.NODE_OPTIONS,
preloadPath,
);
}
} catch (err) {
logger.warn('Failed to set up OpenRouter headers preload:', err);
}
}
return await new Promise<{ child: Electron.UtilityProcess; lastSpawnSummary: string }>((resolve, reject) => {
const child = utilityProcess.fork(entryScript, gatewayArgs, {
cwd: openclawDir,
stdio: 'pipe',
env: runtimeEnv as NodeJS.ProcessEnv,
serviceName: 'OpenClaw Gateway',
});
let settled = false;
const resolveOnce = () => {
if (settled) return;
settled = true;
resolve({ child, lastSpawnSummary });
};
const rejectOnce = (error: Error) => {
if (settled) return;
settled = true;
reject(error);
};
child.on('error', (error) => {
logger.error('Gateway process spawn error:', error);
options.onError(error);
rejectOnce(error);
});
child.on('exit', (code: number) => {
const expectedExit = !options.getShouldReconnect() || options.getCurrentState() === 'stopped';
const level = expectedExit ? logger.info : logger.warn;
level(`Gateway process exited (code=${code}, expected=${expectedExit ? 'yes' : 'no'})`);
options.onExit(child, code);
});
child.stderr?.on('data', (data) => {
const raw = data.toString();
for (const line of raw.split(/\r?\n/)) {
options.onStderrLine(line);
}
});
child.on('spawn', () => {
logger.info(`Gateway process started (pid=${child.pid})`);
options.onSpawn(child.pid);
resolveOnce();
});
});
}
+120
View File
@@ -0,0 +1,120 @@
export interface ReconnectConfig {
maxAttempts: number;
baseDelay: number;
maxDelay: number;
}
export const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
maxAttempts: 10,
baseDelay: 1000,
maxDelay: 30000,
};
export function nextLifecycleEpoch(currentEpoch: number): number {
return currentEpoch + 1;
}
export function isLifecycleSuperseded(expectedEpoch: number, currentEpoch: number): boolean {
return expectedEpoch !== currentEpoch;
}
export interface ReconnectAttemptContext {
scheduledEpoch: number;
currentEpoch: number;
shouldReconnect: boolean;
}
export function getReconnectSkipReason(context: ReconnectAttemptContext): string | null {
if (!context.shouldReconnect) {
return 'auto-reconnect disabled';
}
if (isLifecycleSuperseded(context.scheduledEpoch, context.currentEpoch)) {
return `stale reconnect callback (scheduledEpoch=${context.scheduledEpoch}, currentEpoch=${context.currentEpoch})`;
}
return null;
}
export interface ReconnectScheduleContext {
shouldReconnect: boolean;
hasReconnectTimer: boolean;
reconnectAttempts: number;
maxAttempts: number;
baseDelay: number;
maxDelay: number;
}
export type ReconnectScheduleDecision =
| { action: 'skip'; reason: string }
| { action: 'already-scheduled' }
| { action: 'fail'; attempts: number; maxAttempts: number }
| { action: 'schedule'; nextAttempt: number; maxAttempts: number; delay: number };
export function getReconnectScheduleDecision(
context: ReconnectScheduleContext,
): ReconnectScheduleDecision {
if (!context.shouldReconnect) {
return { action: 'skip', reason: 'auto-reconnect disabled' };
}
if (context.hasReconnectTimer) {
return { action: 'already-scheduled' };
}
if (context.reconnectAttempts >= context.maxAttempts) {
return {
action: 'fail',
attempts: context.reconnectAttempts,
maxAttempts: context.maxAttempts,
};
}
const delay = Math.min(
context.baseDelay * Math.pow(2, context.reconnectAttempts),
context.maxDelay,
);
return {
action: 'schedule',
nextAttempt: context.reconnectAttempts + 1,
maxAttempts: context.maxAttempts,
delay,
};
}
export type GatewayLifecycleState = 'stopped' | 'starting' | 'running' | 'error' | 'reconnecting';
export interface RestartDeferralContext {
state: GatewayLifecycleState;
startLock: boolean;
}
/**
* Restart requests should not interrupt an in-flight startup/reconnect flow.
* Doing so can kill a just-spawned process and leave the manager stopped.
*/
export function shouldDeferRestart(context: RestartDeferralContext): boolean {
return context.startLock || context.state === 'starting' || context.state === 'reconnecting';
}
export interface DeferredRestartActionContext extends RestartDeferralContext {
hasPendingRestart: boolean;
shouldReconnect: boolean;
}
export type DeferredRestartAction = 'none' | 'wait' | 'drop' | 'execute';
/**
* Decide what to do with a pending deferred restart once lifecycle changes.
*
* A deferred restart is an explicit restart() call that was postponed because
* the manager was mid-startup/reconnect. When the in-flight operation settles
* we must honour the request — even if the gateway is now running — because
* the caller may have changed config (e.g. provider switch) that the current
* process hasn't picked up.
*/
export function getDeferredRestartAction(context: DeferredRestartActionContext): DeferredRestartAction {
if (!context.hasPendingRestart) return 'none';
if (shouldDeferRestart(context)) return 'wait';
if (!context.shouldReconnect) return 'drop';
return 'execute';
}
+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 };
}
}
+42
View File
@@ -0,0 +1,42 @@
export interface PendingGatewayRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}
export function clearPendingGatewayRequests(
pendingRequests: Map<string, PendingGatewayRequest>,
error: Error,
): void {
for (const [, request] of pendingRequests) {
clearTimeout(request.timeout);
request.reject(error);
}
pendingRequests.clear();
}
export function resolvePendingGatewayRequest(
pendingRequests: Map<string, PendingGatewayRequest>,
id: string,
value: unknown,
): boolean {
const request = pendingRequests.get(id);
if (!request) return false;
clearTimeout(request.timeout);
pendingRequests.delete(id);
request.resolve(value);
return true;
}
export function rejectPendingGatewayRequest(
pendingRequests: Map<string, PendingGatewayRequest>,
id: string,
error: Error,
): boolean {
const request = pendingRequests.get(id);
if (!request) return false;
clearTimeout(request.timeout);
pendingRequests.delete(id);
request.reject(error);
return true;
}
+91
View File
@@ -0,0 +1,91 @@
import { logger } from '../utils/logger';
import {
getDeferredRestartAction,
shouldDeferRestart,
type GatewayLifecycleState,
} from './process-policy';
type RestartDeferralState = {
state: GatewayLifecycleState;
startLock: boolean;
};
type DeferredRestartContext = RestartDeferralState & {
shouldReconnect: boolean;
};
export class GatewayRestartController {
private deferredRestartPending = false;
private restartDebounceTimer: NodeJS.Timeout | null = null;
isRestartDeferred(context: RestartDeferralState): boolean {
return shouldDeferRestart(context);
}
markDeferredRestart(reason: string, context: RestartDeferralState): void {
if (!this.deferredRestartPending) {
logger.info(
`Deferring Gateway restart (${reason}) until startup/reconnect settles (state=${context.state}, startLock=${context.startLock})`,
);
} else {
logger.debug(
`Gateway restart already deferred; keeping pending request (${reason}, state=${context.state}, startLock=${context.startLock})`,
);
}
this.deferredRestartPending = true;
}
flushDeferredRestart(
trigger: string,
context: DeferredRestartContext,
executeRestart: () => void,
): void {
const action = getDeferredRestartAction({
hasPendingRestart: this.deferredRestartPending,
state: context.state,
startLock: context.startLock,
shouldReconnect: context.shouldReconnect,
});
if (action === 'none') return;
if (action === 'wait') {
logger.debug(
`Deferred Gateway restart still waiting (${trigger}, state=${context.state}, startLock=${context.startLock})`,
);
return;
}
this.deferredRestartPending = false;
if (action === 'drop') {
logger.info(
`Dropping deferred Gateway restart (${trigger}) because lifecycle already recovered (state=${context.state}, shouldReconnect=${context.shouldReconnect})`,
);
return;
}
logger.info(`Executing deferred Gateway restart now (${trigger})`);
executeRestart();
}
debouncedRestart(delayMs: number, executeRestart: () => void): void {
if (this.restartDebounceTimer) {
clearTimeout(this.restartDebounceTimer);
}
logger.debug(`Gateway restart debounced (will fire in ${delayMs}ms)`);
this.restartDebounceTimer = setTimeout(() => {
this.restartDebounceTimer = null;
executeRestart();
}, delayMs);
}
clearDebounceTimer(): void {
if (this.restartDebounceTimer) {
clearTimeout(this.restartDebounceTimer);
this.restartDebounceTimer = null;
}
}
resetDeferredRestart(): void {
this.deferredRestartPending = false;
}
}
+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;
}
}
+106
View File
@@ -0,0 +1,106 @@
import { logger } from '../utils/logger';
import { LifecycleSupersededError } from './lifecycle-controller';
import { getGatewayStartupRecoveryAction } from './startup-recovery';
export interface ExistingGatewayInfo {
port: number;
externalToken?: string;
}
type StartupHooks = {
port: number;
ownedPid?: number;
shouldWaitForPortFree: boolean;
maxStartAttempts?: number;
resetStartupStderrLines: () => void;
getStartupStderrLines: () => string[];
assertLifecycle: (phase: string) => void;
findExistingGateway: (port: number, ownedPid?: number) => Promise<ExistingGatewayInfo | null>;
connect: (port: number, externalToken?: string) => Promise<void>;
onConnectedToExistingGateway: () => void;
waitForPortFree: (port: number) => Promise<void>;
startProcess: () => Promise<void>;
waitForReady: (port: number) => Promise<void>;
onConnectedToManagedGateway: () => void;
runDoctorRepair: () => Promise<boolean>;
onDoctorRepairSuccess: () => void;
delay: (ms: number) => Promise<void>;
};
export async function runGatewayStartupSequence(hooks: StartupHooks): Promise<void> {
let configRepairAttempted = false;
let startAttempts = 0;
const maxStartAttempts = hooks.maxStartAttempts ?? 3;
while (true) {
startAttempts++;
hooks.assertLifecycle('start');
hooks.resetStartupStderrLines();
try {
logger.debug('Checking for existing Gateway...');
const existing = await hooks.findExistingGateway(hooks.port, hooks.ownedPid);
hooks.assertLifecycle('start/find-existing');
if (existing) {
logger.debug(`Found existing Gateway on port ${existing.port}`);
await hooks.connect(existing.port, existing.externalToken);
hooks.assertLifecycle('start/connect-existing');
hooks.onConnectedToExistingGateway();
return;
}
logger.debug('No existing Gateway found, starting new process...');
if (hooks.shouldWaitForPortFree) {
await hooks.waitForPortFree(hooks.port);
hooks.assertLifecycle('start/wait-port');
}
await hooks.startProcess();
hooks.assertLifecycle('start/start-process');
await hooks.waitForReady(hooks.port);
hooks.assertLifecycle('start/wait-ready');
await hooks.connect(hooks.port);
hooks.assertLifecycle('start/connect');
hooks.onConnectedToManagedGateway();
return;
} catch (error) {
if (error instanceof LifecycleSupersededError) {
throw error;
}
const recoveryAction = getGatewayStartupRecoveryAction({
startupError: error,
startupStderrLines: hooks.getStartupStderrLines(),
configRepairAttempted,
attempt: startAttempts,
maxAttempts: maxStartAttempts,
});
if (recoveryAction === 'repair') {
configRepairAttempted = true;
logger.warn(
'Detected invalid OpenClaw config during Gateway startup; running doctor repair before retry',
);
const repaired = await hooks.runDoctorRepair();
if (repaired) {
logger.info('OpenClaw doctor repair completed; retrying Gateway startup');
hooks.onDoctorRepairSuccess();
continue;
}
logger.error('OpenClaw doctor repair failed; not retrying Gateway startup');
}
if (recoveryAction === 'retry') {
logger.warn(`Transient start error: ${String(error)}. Retrying... (${startAttempts}/${maxStartAttempts})`);
await hooks.delay(1000);
continue;
}
throw error;
}
}
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Gateway startup recovery heuristics.
*
* This module is intentionally dependency-free so it can be unit-tested
* without Electron/runtime mocks.
*/
const INVALID_CONFIG_PATTERNS: RegExp[] = [
/\binvalid config\b/i,
/\bconfig invalid\b/i,
/\bunrecognized key\b/i,
/\brun:\s*openclaw doctor --fix\b/i,
];
const TRANSIENT_START_ERROR_PATTERNS: RegExp[] = [
/WebSocket closed before handshake/i,
/ECONNREFUSED/i,
/Gateway process exited before becoming ready/i,
/Timed out waiting for connect\.challenge/i,
/Connect handshake timeout/i,
];
function normalizeLogLine(value: string): string {
return value.trim();
}
/**
* Returns true when text appears to indicate OpenClaw config validation failure.
*/
export function isInvalidConfigSignal(text: string): boolean {
const normalized = normalizeLogLine(text);
if (!normalized) return false;
return INVALID_CONFIG_PATTERNS.some((pattern) => pattern.test(normalized));
}
/**
* Returns true when either startup stderr lines or startup error message
* indicate an OpenClaw config validation failure.
*/
export function hasInvalidConfigFailureSignal(
startupError: unknown,
startupStderrLines: string[],
): boolean {
for (const line of startupStderrLines) {
if (isInvalidConfigSignal(line)) {
return true;
}
}
const errorText = startupError instanceof Error
? `${startupError.name}: ${startupError.message}`
: String(startupError ?? '');
return isInvalidConfigSignal(errorText);
}
/**
* Retry guard for one-time config repair during a single startup flow.
*/
export function shouldAttemptConfigAutoRepair(
startupError: unknown,
startupStderrLines: string[],
alreadyAttempted: boolean,
): boolean {
if (alreadyAttempted) return false;
return hasInvalidConfigFailureSignal(startupError, startupStderrLines);
}
export function isTransientGatewayStartError(error: unknown): boolean {
const errorText = error instanceof Error
? `${error.name}: ${error.message}`
: String(error ?? '');
return TRANSIENT_START_ERROR_PATTERNS.some((pattern) => pattern.test(errorText));
}
export type GatewayStartupRecoveryAction = 'repair' | 'retry' | 'fail';
export function getGatewayStartupRecoveryAction(options: {
startupError: unknown;
startupStderrLines: string[];
configRepairAttempted: boolean;
attempt: number;
maxAttempts: number;
}): GatewayStartupRecoveryAction {
if (shouldAttemptConfigAutoRepair(
options.startupError,
options.startupStderrLines,
options.configRepairAttempted,
)) {
return 'repair';
}
if (options.attempt < options.maxAttempts && isTransientGatewayStartError(options.startupError)) {
return 'retry';
}
return 'fail';
}
+48
View File
@@ -0,0 +1,48 @@
export type GatewayStderrClassification = {
level: 'drop' | 'debug' | 'warn';
normalized: string;
};
const MAX_STDERR_LINES = 120;
export function classifyGatewayStderrMessage(message: string): GatewayStderrClassification {
const msg = message.trim();
if (!msg) {
return { level: 'drop', normalized: msg };
}
// Known noisy lines that are not actionable for Gateway lifecycle debugging.
if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) {
return { level: 'drop', normalized: msg };
}
if (msg.includes('closed before connect') && msg.includes('token mismatch')) {
return { level: 'drop', normalized: msg };
}
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 };
if (msg.includes('DeprecationWarning')) return { level: 'debug', normalized: msg };
if (msg.includes('Debugger attached')) return { level: 'debug', normalized: msg };
// Electron restricts NODE_OPTIONS in packaged apps; this is expected and harmless.
if (msg.includes('node: --require is not allowed in NODE_OPTIONS')) {
return { level: 'debug', normalized: msg };
}
return { level: 'warn', normalized: msg };
}
export function recordGatewayStartupStderrLine(lines: string[], line: string): void {
const normalized = line.trim();
if (!normalized) return;
lines.push(normalized);
if (lines.length > MAX_STDERR_LINES) {
lines.splice(0, lines.length - MAX_STDERR_LINES);
}
}
+38
View File
@@ -0,0 +1,38 @@
import { PORTS } from '../utils/config';
import { logger } from '../utils/logger';
import type { GatewayStatus } from './manager';
type GatewayStateHooks = {
emitStatus: (status: GatewayStatus) => void;
onTransition?: (previousState: GatewayStatus['state'], nextState: GatewayStatus['state']) => void;
};
export class GatewayStateController {
private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY };
constructor(private readonly hooks: GatewayStateHooks) {}
getStatus(): GatewayStatus {
return { ...this.status };
}
isConnected(isSocketOpen: boolean): boolean {
return this.status.state === 'running' && isSocketOpen;
}
setStatus(update: Partial<GatewayStatus>): void {
const previousState = this.status.state;
this.status = { ...this.status, ...update };
if (this.status.state === 'running' && this.status.connectedAt) {
this.status.uptime = Date.now() - this.status.connectedAt;
}
this.hooks.emitStatus(this.status);
if (previousState !== this.status.state) {
logger.debug(`Gateway state changed: ${previousState} -> ${this.status.state}`);
this.hooks.onTransition?.(previousState, this.status.state);
}
}
}
+371
View File
@@ -0,0 +1,371 @@
import { app, utilityProcess } from 'electron';
import path from 'path';
import { existsSync } from 'fs';
import WebSocket from 'ws';
import { getOpenClawDir, getOpenClawEntryPath } from '../utils/paths';
import { getUvMirrorEnv } from '../utils/uv-env';
import { isPythonReady, setupManagedPython } from '../utils/uv-setup';
import { logger } from '../utils/logger';
import { prependPathEntry } from '../utils/env-path';
export function warmupManagedPythonReadiness(): void {
void isPythonReady().then((pythonReady) => {
if (!pythonReady) {
logger.info('Python environment missing or incomplete, attempting background repair...');
void setupManagedPython().catch((err) => {
logger.error('Background Python repair failed:', err);
});
}
}).catch((err) => {
logger.error('Failed to check Python environment:', err);
});
}
export async function terminateOwnedGatewayProcess(child: Electron.UtilityProcess): Promise<void> {
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'})`);
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) {
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);
});
}
export async function unloadLaunchctlGatewayService(): Promise<void> {
if (process.platform !== 'darwin') return;
try {
const uid = process.getuid?.();
if (uid === undefined) return;
const launchdLabel = 'ai.openclaw.gateway';
const serviceTarget = `gui/${uid}/${launchdLabel}`;
const cp = await import('child_process');
const fsPromises = await import('fs/promises');
const os = await import('os');
const loaded = await new Promise<boolean>((resolve) => {
cp.exec(`launchctl print ${serviceTarget}`, { timeout: 5000 }, (err) => {
resolve(!err);
});
});
if (!loaded) return;
logger.info(`Unloading launchctl service ${serviceTarget} to prevent auto-respawn`);
await new Promise<void>((resolve) => {
cp.exec(`launchctl bootout ${serviceTarget}`, { timeout: 10000 }, (err) => {
if (err) {
logger.warn(`Failed to bootout launchctl service: ${err.message}`);
} else {
logger.info('Successfully unloaded launchctl gateway service');
}
resolve();
});
});
await new Promise((resolve) => setTimeout(resolve, 2000));
try {
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${launchdLabel}.plist`);
await fsPromises.access(plistPath);
await fsPromises.unlink(plistPath);
logger.info(`Removed legacy launchd plist to prevent reload on next login: ${plistPath}`);
} catch {
// File doesn't exist or can't be removed -- not fatal
}
} catch (err) {
logger.warn('Error while unloading launchctl gateway service:', err);
}
}
export async function waitForPortFree(port: number, timeoutMs = 30000): Promise<void> {
const net = await import('net');
const start = Date.now();
const pollInterval = 500;
let logged = false;
while (Date.now() - start < timeoutMs) {
const available = await new Promise<boolean>((resolve) => {
const server = net.createServer();
server.once('error', () => resolve(false));
server.once('listening', () => {
server.close(() => resolve(true));
});
server.listen(port, '127.0.0.1');
});
if (available) {
const elapsed = Date.now() - start;
if (elapsed > pollInterval) {
logger.info(`Port ${port} became available after ${elapsed}ms`);
}
return;
}
if (!logged) {
logger.info(`Waiting for port ${port} to become available (Windows TCP TIME_WAIT)...`);
logged = true;
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
logger.warn(`Port ${port} still occupied after ${timeoutMs}ms, proceeding anyway`);
}
async function getListeningProcessIds(port: number): Promise<string[]> {
const cmd = process.platform === 'win32'
? `netstat -ano | findstr :${port}`
: `lsof -i :${port} -sTCP:LISTEN -t`;
const cp = await import('child_process');
const { stdout } = await new Promise<{ stdout: string }>((resolve) => {
cp.exec(cmd, { timeout: 5000, windowsHide: true }, (err, stdout) => {
if (err) {
resolve({ stdout: '' });
} else {
resolve({ stdout });
}
});
});
if (!stdout.trim()) {
return [];
}
if (process.platform === 'win32') {
const pids: string[] = [];
for (const line of stdout.trim().split(/\r?\n/)) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 5 && parts[3] === 'LISTENING') {
pids.push(parts[4]);
}
}
return [...new Set(pids)];
}
return [...new Set(stdout.trim().split(/\r?\n/).map((value) => value.trim()).filter(Boolean))];
}
async function terminateOrphanedProcessIds(port: number, pids: string[]): Promise<void> {
logger.info(`Found orphaned process listening on port ${port} (PIDs: ${pids.join(', ')}), attempting to kill...`);
if (process.platform === 'darwin') {
await unloadLaunchctlGatewayService();
}
for (const pid of pids) {
try {
if (process.platform === 'win32') {
const cp = await import('child_process');
await new Promise<void>((resolve) => {
cp.exec(
`taskkill /F /PID ${pid} /T`,
{ timeout: 5000, windowsHide: true },
() => resolve(),
);
});
} else {
process.kill(parseInt(pid, 10), 'SIGTERM');
}
} catch {
// Ignore processes that have already exited.
}
}
await new Promise((resolve) => setTimeout(resolve, process.platform === 'win32' ? 2000 : 3000));
if (process.platform !== 'win32') {
for (const pid of pids) {
try {
process.kill(parseInt(pid, 10), 0);
process.kill(parseInt(pid, 10), 'SIGKILL');
} catch {
// Already exited.
}
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
export async function findExistingGatewayProcess(options: {
port: number;
ownedPid?: number;
}): Promise<{ port: number; externalToken?: string } | null> {
const { port, ownedPid } = options;
try {
try {
const pids = await getListeningProcessIds(port);
if (pids.length > 0 && (!ownedPid || !pids.includes(String(ownedPid)))) {
await terminateOrphanedProcessIds(port, pids);
if (process.platform === 'win32') {
await waitForPortFree(port, 10000);
}
return null;
}
} catch (err) {
logger.warn('Error checking for existing process on port:', err);
}
return await new Promise<{ port: number; externalToken?: string } | null>((resolve) => {
const testWs = new WebSocket(`ws://localhost:${port}/ws`);
const timeout = setTimeout(() => {
testWs.close();
resolve(null);
}, 2000);
testWs.on('open', () => {
clearTimeout(timeout);
testWs.close();
resolve({ port });
});
testWs.on('error', () => {
clearTimeout(timeout);
resolve(null);
});
});
} catch {
return null;
}
}
export async function runOpenClawDoctorRepair(): Promise<boolean> {
const openclawDir = getOpenClawDir();
const entryScript = getOpenClawEntryPath();
if (!existsSync(entryScript)) {
logger.error(`Cannot run OpenClaw doctor repair: entry script not found at ${entryScript}`);
return false;
}
const platform = process.platform;
const arch = process.arch;
const target = `${platform}-${arch}`;
const binPath = app.isPackaged
? path.join(process.resourcesPath, 'bin')
: path.join(process.cwd(), 'resources', 'bin', target);
const binPathExists = existsSync(binPath);
const baseProcessEnv = process.env as Record<string, string | undefined>;
const baseEnvPatched = binPathExists
? prependPathEntry(baseProcessEnv, binPath).env
: baseProcessEnv;
const uvEnv = await getUvMirrorEnv();
const doctorArgs = ['doctor', '--fix', '--yes', '--non-interactive'];
logger.info(
`Running OpenClaw doctor repair (entry="${entryScript}", args="${doctorArgs.join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'})`,
);
return await new Promise<boolean>((resolve) => {
const forkEnv: Record<string, string | undefined> = {
...baseEnvPatched,
...uvEnv,
OPENCLAW_NO_RESPAWN: '1',
};
const child = utilityProcess.fork(entryScript, doctorArgs, {
cwd: openclawDir,
stdio: 'pipe',
env: forkEnv as NodeJS.ProcessEnv,
});
let settled = false;
const finish = (ok: boolean) => {
if (settled) return;
settled = true;
resolve(ok);
};
const timeout = setTimeout(() => {
logger.error('OpenClaw doctor repair timed out after 120000ms');
try {
child.kill();
} catch {
// ignore
}
finish(false);
}, 120000);
child.on('error', (err) => {
clearTimeout(timeout);
logger.error('Failed to spawn OpenClaw doctor repair process:', err);
finish(false);
});
child.stdout?.on('data', (data) => {
const raw = data.toString();
for (const line of raw.split(/\r?\n/)) {
const normalized = line.trim();
if (!normalized) continue;
logger.debug(`[Gateway doctor stdout] ${normalized}`);
}
});
child.stderr?.on('data', (data) => {
const raw = data.toString();
for (const line of raw.split(/\r?\n/)) {
const normalized = line.trim();
if (!normalized) continue;
logger.warn(`[Gateway doctor stderr] ${normalized}`);
}
});
child.on('exit', (code: number) => {
clearTimeout(timeout);
if (code === 0) {
logger.info('OpenClaw doctor repair completed successfully');
finish(true);
return;
}
logger.warn(`OpenClaw doctor repair exited (code=${code})`);
finish(false);
});
});
}
+318
View File
@@ -0,0 +1,318 @@
import WebSocket from 'ws';
import type { DeviceIdentity } from '../utils/device-identity';
import type { PendingGatewayRequest } from './request-store';
import {
buildDeviceAuthPayload,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from '../utils/device-identity';
import { logger } from '../utils/logger';
export async function probeGatewayReady(
port: number,
timeoutMs = 1500,
): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const testWs = new WebSocket(`ws://localhost:${port}/ws`);
let settled = false;
const resolveOnce = (value: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
try {
testWs.close();
} catch {
// ignore
}
resolve(value);
};
const timeout = setTimeout(() => {
resolveOnce(false);
}, timeoutMs);
testWs.on('open', () => {
// Do not resolve on plain socket open. The gateway can accept the TCP/WebSocket
// connection before it is ready to issue protocol challenges, which previously
// caused a false "ready" result and then a full connect() stall.
});
testWs.on('message', (data) => {
try {
const message = JSON.parse(data.toString()) as { type?: string; event?: string };
if (message.type === 'event' && message.event === 'connect.challenge') {
resolveOnce(true);
}
} catch {
// ignore malformed probe payloads
}
});
testWs.on('error', () => {
resolveOnce(false);
});
testWs.on('close', () => {
resolveOnce(false);
});
});
}
export async function waitForGatewayReady(options: {
port: number;
getProcessExitCode: () => number | null;
retries?: number;
intervalMs?: number;
}): Promise<void> {
const retries = options.retries ?? 2400;
const intervalMs = options.intervalMs ?? 200;
for (let i = 0; i < retries; i++) {
const exitCode = options.getProcessExitCode();
if (exitCode !== null) {
logger.error(`Gateway process exited before ready (code=${exitCode})`);
throw new Error(`Gateway process exited before becoming ready (code=${exitCode})`);
}
try {
const ready = await probeGatewayReady(options.port, 1500);
if (ready) {
logger.debug(`Gateway ready after ${i + 1} attempt(s)`);
return;
}
} catch {
// Gateway not ready yet.
}
if (i > 0 && i % 10 === 0) {
logger.debug(`Still waiting for Gateway... (attempt ${i + 1}/${retries})`);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
logger.error(`Gateway failed to become ready after ${retries} attempts on port ${options.port}`);
throw new Error(`Gateway failed to start after ${retries} retries (port ${options.port})`);
}
export function buildGatewayConnectFrame(options: {
challengeNonce: string;
token: string;
deviceIdentity: DeviceIdentity | null;
platform: string;
}): { connectId: string; frame: Record<string, unknown> } {
const connectId = `connect-${Date.now()}`;
const role = 'operator';
const scopes = ['operator.admin'];
const signedAtMs = Date.now();
const clientId = 'gateway-client';
const clientMode = 'ui';
const device = (() => {
if (!options.deviceIdentity) return undefined;
const payload = buildDeviceAuthPayload({
deviceId: options.deviceIdentity.deviceId,
clientId,
clientMode,
role,
scopes,
signedAtMs,
token: options.token ?? null,
nonce: options.challengeNonce,
});
const signature = signDevicePayload(options.deviceIdentity.privateKeyPem, payload);
return {
id: options.deviceIdentity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(options.deviceIdentity.publicKeyPem),
signature,
signedAt: signedAtMs,
nonce: options.challengeNonce,
};
})();
return {
connectId,
frame: {
type: 'req',
id: connectId,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: clientId,
displayName: 'ClawX',
version: '0.1.0',
platform: options.platform,
mode: clientMode,
},
auth: {
token: options.token,
},
caps: [],
role,
scopes,
device,
},
},
};
}
export async function connectGatewaySocket(options: {
port: number;
deviceIdentity: DeviceIdentity | null;
platform: string;
pendingRequests: Map<string, PendingGatewayRequest>;
getToken: () => Promise<string>;
onHandshakeComplete: (ws: WebSocket) => void;
onMessage: (message: unknown) => void;
onCloseAfterHandshake: () => void;
}): Promise<WebSocket> {
logger.debug(`Connecting Gateway WebSocket (ws://localhost:${options.port}/ws)`);
return await new Promise<WebSocket>((resolve, reject) => {
const wsUrl = `ws://localhost:${options.port}/ws`;
const ws = new WebSocket(wsUrl);
let handshakeComplete = false;
let connectId: string | null = null;
let handshakeTimeout: NodeJS.Timeout | null = null;
let challengeTimer: NodeJS.Timeout | null = null;
let challengeReceived = false;
let settled = false;
const cleanupHandshakeRequest = () => {
if (challengeTimer) {
clearTimeout(challengeTimer);
challengeTimer = null;
}
if (handshakeTimeout) {
clearTimeout(handshakeTimeout);
handshakeTimeout = null;
}
if (connectId && options.pendingRequests.has(connectId)) {
const request = options.pendingRequests.get(connectId);
if (request) {
clearTimeout(request.timeout);
}
options.pendingRequests.delete(connectId);
}
};
const resolveOnce = () => {
if (settled) return;
settled = true;
cleanupHandshakeRequest();
resolve(ws);
};
const rejectOnce = (error: unknown) => {
if (settled) return;
settled = true;
cleanupHandshakeRequest();
reject(error instanceof Error ? error : new Error(String(error)));
};
const sendConnectHandshake = async (challengeNonce: string) => {
logger.debug('Sending connect handshake with challenge nonce');
const currentToken = await options.getToken();
const connectPayload = buildGatewayConnectFrame({
challengeNonce,
token: currentToken,
deviceIdentity: options.deviceIdentity,
platform: options.platform,
});
connectId = connectPayload.connectId;
ws.send(JSON.stringify(connectPayload.frame));
const requestTimeout = setTimeout(() => {
if (!handshakeComplete) {
logger.error('Gateway connect handshake timed out');
ws.close();
rejectOnce(new Error('Connect handshake timeout'));
}
}, 10000);
handshakeTimeout = requestTimeout;
options.pendingRequests.set(connectId, {
resolve: () => {
handshakeComplete = true;
logger.debug('Gateway connect handshake completed');
options.onHandshakeComplete(ws);
resolveOnce();
},
reject: (error) => {
logger.error('Gateway connect handshake failed:', error);
rejectOnce(error);
},
timeout: requestTimeout,
});
};
challengeTimer = setTimeout(() => {
if (!challengeReceived && !settled) {
logger.error('Gateway connect.challenge not received within timeout');
ws.close();
rejectOnce(new Error('Timed out waiting for connect.challenge from Gateway'));
}
}, 10000);
ws.on('open', () => {
logger.debug('Gateway WebSocket opened, waiting for connect.challenge...');
});
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
if (
!challengeReceived &&
typeof message === 'object' && message !== null &&
message.type === 'event' && message.event === 'connect.challenge'
) {
challengeReceived = true;
if (challengeTimer) {
clearTimeout(challengeTimer);
challengeTimer = null;
}
const nonce = message.payload?.nonce as string | undefined;
if (!nonce) {
rejectOnce(new Error('Gateway connect.challenge missing nonce'));
return;
}
logger.debug('Received connect.challenge, sending handshake');
void sendConnectHandshake(nonce);
return;
}
options.onMessage(message);
} catch (error) {
logger.debug('Failed to parse Gateway WebSocket message:', error);
}
});
ws.on('close', (code, reason) => {
const reasonStr = reason?.toString() || 'unknown';
logger.warn(`Gateway WebSocket closed (code=${code}, reason=${reasonStr}, handshake=${handshakeComplete ? 'ok' : 'pending'})`);
if (!handshakeComplete) {
rejectOnce(new Error(`WebSocket closed before handshake: ${reasonStr}`));
return;
}
cleanupHandshakeRequest();
options.onCloseAfterHandshake();
});
ws.on('error', (error) => {
if (error.message?.includes('closed before handshake') || (error as NodeJS.ErrnoException).code === 'ECONNREFUSED') {
logger.debug(`Gateway WebSocket connection error (transient): ${error.message}`);
} else {
logger.error('Gateway WebSocket error:', error);
}
if (!handshakeComplete) {
rejectOnce(error);
}
});
});
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Application quit state.
*
* Exposed as a function accessor (not a bare `export let`) so that every
* import site reads the *live* value. With `export let`, bundlers that
* compile to CJS may snapshot the variable at import time, causing
* `isQuitting` to stay `false` forever and preventing the window from
* closing on Windows/Linux.
*/
let _isQuitting = false;
export function isQuitting(): boolean {
return _isQuitting;
}
export function setQuitting(value = true): void {
_isQuitting = value;
}
+470 -76
View File
@@ -3,6 +3,7 @@
* Manages window creation, system tray, and IPC handlers
*/
import { app, BrowserWindow, nativeImage, session, shell } from 'electron';
import type { Server } from 'node:http';
import { join } from 'path';
import { GatewayManager } from '../gateway/manager';
import { registerIpcHandlers } from './ipc-handlers';
@@ -12,16 +13,109 @@ 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, 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';
import { browserOAuthManager } from '../utils/browser-oauth';
import { whatsAppLoginManager } from '../utils/whatsapp-login';
import { syncAllProviderAuthToRuntime } from '../services/providers/provider-runtime-sync';
// Disable GPU acceleration for better compatibility
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).
//
// Rationale (following VS Code's philosophy):
// - Page/file loading is async data fetching — zero GPU dependency.
// - The original per-platform GPU branching was added to avoid CPU rendering
// competing with sync I/O on Windows, but all file I/O is now async
// (fs/promises), so that concern no longer applies.
// - Software rendering is deterministic across all hardware; GPU compositing
// behaviour varies between vendors (Intel, AMD, NVIDIA, Apple Silicon) and
// driver versions, making it the #1 source of rendering bugs in Electron.
//
// Users who want GPU acceleration can pass `--enable-gpu` on the CLI or
// set `"disable-hardware-acceleration": false` in the app config (future).
app.disableHardwareAcceleration();
// On Linux, set CHROME_DESKTOP so Chromium can find the correct .desktop file.
// On Wayland this maps the running window to clawx.desktop (→ icon + app grouping);
// on X11 it supplements the StartupWMClass matching.
// Must be called before app.whenReady() / before any window is created.
if (process.platform === 'linux') {
app.setDesktopName('clawx.desktop');
}
// Prevent multiple instances of the app from running simultaneously.
// Without this, two instances each spawn their own gateway process on the
// same port, then each treats the other's gateway as "orphaned" and kills
// it — creating an infinite kill/restart loop on Windows.
// 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();
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)
@@ -55,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,
@@ -69,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' };
});
@@ -97,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
*/
@@ -105,95 +262,332 @@ 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
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
const isGatewayUrl = details.url.includes('127.0.0.1:18789') || details.url.includes('localhost:18789');
// Override security headers ONLY for the OpenClaw Gateway Control UI.
// The URL filter ensures this callback only fires for gateway requests,
// avoiding unnecessary overhead on every other HTTP response.
session.defaultSession.webRequest.onHeadersReceived(
{ urls: ['http://127.0.0.1:18789/*', 'http://localhost:18789/*'] },
(details, callback) => {
const headers = { ...details.responseHeaders };
delete headers['X-Frame-Options'];
delete headers['x-frame-options'];
if (headers['Content-Security-Policy']) {
headers['Content-Security-Policy'] = headers['Content-Security-Policy'].map(
(csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *")
);
}
if (headers['content-security-policy']) {
headers['content-security-policy'] = headers['content-security-policy'].map(
(csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *")
);
}
callback({ responseHeaders: headers });
},
);
if (!isGatewayUrl) {
callback({ responseHeaders: details.responseHeaders });
// Register IPC handlers
registerIpcHandlers(gatewayManager, clawHubService, window);
hostApiServer = startHostApiServer({
gatewayManager,
clawHubService,
eventBus: hostEventBus,
mainWindow: window,
});
// Register update handlers
registerUpdateHandlers(appUpdater, window);
// Note: Auto-check for updates is driven by the renderer (update store init)
// so it respects the user's "Auto-check for updates" setting.
// Repair any bootstrap files that only contain ClawX markers (no OpenClaw
// template content). This fixes a race condition where ensureClawXContext()
// previously created the file before the gateway could seed the full template.
void repairClawXOnlyBootstrapFiles().catch((error) => {
logger.warn('Failed to repair bootstrap files:', error);
});
// Pre-deploy built-in skills (feishu-doc, feishu-drive, feishu-perm, feishu-wiki)
// to ~/.openclaw/skills/ so they are immediately available without manual install.
void ensureBuiltinSkillsInstalled().catch((error) => {
logger.warn('Failed to install built-in skills:', error);
});
// Pre-deploy bundled third-party skills from resources/preinstalled-skills.
// This installs full skill directories (not only SKILL.md) in an idempotent,
// non-destructive way and never blocks startup.
void ensurePreinstalledSkillsInstalled().catch((error) => {
logger.warn('Failed to install preinstalled skills:', error);
});
// 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 }) => {
hostEventBus.emit('gateway:status', status);
if (status.state === 'running') {
void ensureClawXContext().catch((error) => {
logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error);
});
}
});
gatewayManager.on('error', (error) => {
hostEventBus.emit('gateway:error', { message: error.message });
});
gatewayManager.on('notification', (notification) => {
hostEventBus.emit('gateway:notification', notification);
});
gatewayManager.on('chat:message', (data) => {
hostEventBus.emit('gateway:chat-message', data);
});
gatewayManager.on('channel:status', (data) => {
hostEventBus.emit('gateway:channel-status', data);
});
gatewayManager.on('exit', (code) => {
hostEventBus.emit('gateway:exit', { code });
});
deviceOAuthManager.on('oauth:code', (payload) => {
hostEventBus.emit('oauth:code', payload);
});
deviceOAuthManager.on('oauth:start', (payload) => {
hostEventBus.emit('oauth:start', payload);
});
deviceOAuthManager.on('oauth:success', (payload) => {
hostEventBus.emit('oauth:success', { ...payload, success: true });
});
deviceOAuthManager.on('oauth:error', (error) => {
hostEventBus.emit('oauth:error', error);
});
browserOAuthManager.on('oauth:start', (payload) => {
hostEventBus.emit('oauth:start', payload);
});
browserOAuthManager.on('oauth:code', (payload) => {
hostEventBus.emit('oauth:code', payload);
});
browserOAuthManager.on('oauth:success', (payload) => {
hostEventBus.emit('oauth:success', { ...payload, success: true });
});
browserOAuthManager.on('oauth:error', (error) => {
hostEventBus.emit('oauth:error', error);
});
whatsAppLoginManager.on('qr', (data) => {
hostEventBus.emit('channel:whatsapp-qr', data);
});
whatsAppLoginManager.on('success', (data) => {
hostEventBus.emit('channel:whatsapp-success', data);
});
whatsAppLoginManager.on('error', (error) => {
hostEventBus.emit('channel:whatsapp-error', error);
});
// Start Gateway automatically (this seeds missing bootstrap files with full templates)
const gatewayAutoStart = await getSetting('gatewayAutoStart');
if (gatewayAutoStart) {
try {
await syncAllProviderAuthToRuntime();
logger.debug('Auto-starting Gateway...');
await gatewayManager.start();
logger.info('Gateway auto-start succeeded');
} catch (error) {
logger.error('Gateway auto-start failed:', error);
mainWindow?.webContents.send('gateway:error', String(error));
}
} else {
logger.info('Gateway auto-start disabled in settings');
}
// Merge ClawX context snippets into the workspace bootstrap files.
// The gateway seeds workspace files asynchronously after its HTTP server
// is ready, so ensureClawXContext will retry until the target files appear.
void ensureClawXContext().catch((error) => {
logger.warn('Failed to merge ClawX context into workspace:', error);
});
// Auto-install openclaw CLI and shell completions (non-blocking).
void autoInstallCliIfNeeded((installedPath) => {
mainWindow?.webContents.send('openclaw:cli-installed', installedPath);
}).then(() => {
generateCompletionCache();
installCompletionToProfile();
}).catch((error) => {
logger.warn('CLI auto-install failed:', error);
});
}
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);
}
gatewayManager = new GatewayManager();
clawHubService = new ClawHubService();
hostEventBus = new HostEventBus();
// 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;
}
const headers = { ...details.responseHeaders };
delete headers['X-Frame-Options'];
delete headers['x-frame-options'];
if (headers['Content-Security-Policy']) {
headers['Content-Security-Policy'] = headers['Content-Security-Policy'].map(
(csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *")
);
}
if (headers['content-security-policy']) {
headers['content-security-policy'] = headers['content-security-policy'].map(
(csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *")
);
}
callback({ responseHeaders: headers });
logger.debug('Main window is not ready yet; deferring second-instance focus until ready-to-show');
});
// Register IPC handlers
registerIpcHandlers(gatewayManager, clawHubService, mainWindow);
// Application lifecycle
app.whenReady().then(() => {
void initialize().catch((error) => {
logger.error('Application initialization failed:', error);
});
// Register update handlers
registerUpdateHandlers(appUpdater, mainWindow);
// 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();
}
});
});
// Check for updates after a delay (only in production)
if (!process.env.VITE_DEV_SERVER_URL) {
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('before-quit', (event) => {
setQuitting();
const action = requestQuitLifecycleAction(quitLifecycleState);
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(() => {
appUpdater.checkForUpdates().catch((err) => {
console.error('Failed to check for updates:', err);
});
}, 10000);
}
process.exit(1);
}, 3000).unref();
};
// Handle window close
mainWindow.on('closed', () => {
mainWindow = null;
process.on('uncaughtException', (error) => {
emergencyGatewayCleanup('Uncaught exception in main process', error);
});
// Start Gateway automatically
try {
logger.debug('Auto-starting Gateway...');
await gatewayManager.start();
logger.info('Gateway auto-start succeeded');
} catch (error) {
logger.error('Gateway auto-start failed:', error);
mainWindow?.webContents.send('gateway:error', String(error));
}
process.on('unhandledRejection', (reason) => {
emergencyGatewayCleanup('Unhandled promise rejection in main process', reason);
});
}
// Application lifecycle
app.whenReady().then(initialize);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow();
}
});
app.on('before-quit', async () => {
await gatewayManager.stop();
});
// Export for testing
export { mainWindow, gatewayManager };
File diff suppressed because it is too large Load Diff
+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;
}
+1 -1
View File
@@ -176,7 +176,7 @@ export function createMenu(): void {
{
label: 'Documentation',
click: async () => {
await shell.openExternal('https://clawx.dev');
await shell.openExternal('https://claw-x.com');
},
},
{
+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 }] : [],
},
};
}
+22
View File
@@ -0,0 +1,22 @@
import { session } from 'electron';
import { getAllSettings, type AppSettings } from '../utils/store';
import { buildElectronProxyConfig } from '../utils/proxy';
import { logger } from '../utils/logger';
export async function applyProxySettings(
partialSettings?: Pick<AppSettings, 'proxyEnabled' | 'proxyServer' | 'proxyBypassRules'>
): Promise<void> {
const settings = partialSettings ?? await getAllSettings();
const config = buildElectronProxyConfig(settings);
await session.defaultSession.setProxy(config);
try {
await session.defaultSession.closeAllConnections();
} catch (error) {
logger.debug('Failed to close existing connections after proxy update:', error);
}
logger.info(
`Applied Electron proxy (${config.mode}${config.proxyRules ? `, server=${config.proxyRules}` : ''}${config.proxyBypassRules ? `, bypass=${config.proxyBypassRules}` : ''})`
);
}
+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();
};
}
+22 -15
View File
@@ -29,8 +29,9 @@ export function createTray(mainWindow: BrowserWindow): Tray {
// Windows: use .ico for best quality in system tray
iconPath = join(iconsDir, 'icon.ico');
} else if (process.platform === 'darwin') {
// macOS: use 16x16 PNG as template image
iconPath = join(iconsDir, '16x16.png');
// macOS: use Template.png for proper status bar icon
// The "Template" suffix tells macOS to treat it as a template image
iconPath = join(iconsDir, 'tray-icon-Template.png');
} else {
// Linux: use 32x32 PNG
iconPath = join(iconsDir, '32x32.png');
@@ -41,9 +42,14 @@ export function createTray(mainWindow: BrowserWindow): Tray {
// Fallback to icon.png if platform-specific icon not found
if (icon.isEmpty()) {
icon = nativeImage.createFromPath(join(iconsDir, 'icon.png'));
// Still try to set as template for macOS
if (process.platform === 'darwin') {
icon.setTemplateImage(true);
}
}
// On macOS, set as template image for proper dark/light mode support
// Note: Using "Template" suffix in filename automatically marks it as template image
// But we can also explicitly set it for safety
if (process.platform === 'darwin') {
icon.setTemplateImage(true);
}
@@ -53,14 +59,17 @@ export function createTray(mainWindow: BrowserWindow): Tray {
// Set tooltip
tray.setToolTip('ClawX - AI Assistant');
const showWindow = () => {
if (mainWindow.isDestroyed()) return;
mainWindow.show();
mainWindow.focus();
};
// Create context menu
const contextMenu = Menu.buildFromTemplate([
{
label: 'Show ClawX',
click: () => {
mainWindow.show();
mainWindow.focus();
},
click: showWindow,
},
{
type: 'separator',
@@ -82,22 +91,17 @@ export function createTray(mainWindow: BrowserWindow): Tray {
label: 'Quick Actions',
submenu: [
{
label: 'Open Dashboard',
label: 'Open Chat',
click: () => {
if (mainWindow.isDestroyed()) return;
mainWindow.show();
mainWindow.webContents.send('navigate', '/');
},
},
{
label: 'Open Chat',
click: () => {
mainWindow.show();
mainWindow.webContents.send('navigate', '/chat');
},
},
{
label: 'Open Settings',
click: () => {
if (mainWindow.isDestroyed()) return;
mainWindow.show();
mainWindow.webContents.send('navigate', '/settings');
},
@@ -110,6 +114,7 @@ export function createTray(mainWindow: BrowserWindow): Tray {
{
label: 'Check for Updates...',
click: () => {
if (mainWindow.isDestroyed()) return;
mainWindow.webContents.send('update:check');
},
},
@@ -128,6 +133,7 @@ export function createTray(mainWindow: BrowserWindow): Tray {
// Click to show window (Windows/Linux)
tray.on('click', () => {
if (mainWindow.isDestroyed()) return;
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
@@ -138,6 +144,7 @@ export function createTray(mainWindow: BrowserWindow): Tray {
// Double-click to show window (Windows)
tray.on('double-click', () => {
if (mainWindow.isDestroyed()) return;
mainWindow.show();
mainWindow.focus();
});
+137 -52
View File
@@ -3,11 +3,17 @@
* Handles automatic application updates using electron-updater
*
* Update providers are configured in electron-builder.yml (OSS primary, GitHub fallback).
* electron-updater handles provider resolution automatically.
* For prerelease channels (alpha, beta), the feed URL is overridden at runtime
* to point at the channel-specific OSS directory (e.g. /alpha/, /beta/).
*/
import { autoUpdater, UpdateInfo, ProgressInfo, UpdateDownloadedEvent } from 'electron-updater';
import { BrowserWindow, app, ipcMain } from 'electron';
import { logger } from '../utils/logger';
import { EventEmitter } from 'events';
import { setQuitting } from './app-state';
/** Base CDN URL (without trailing channel path) */
const OSS_BASE_URL = 'https://oss.intelli-spectrum.com';
export interface UpdateStatus {
status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';
@@ -26,25 +32,61 @@ export interface UpdaterEvents {
'error': (error: Error) => void;
}
/**
* Detect the update channel from a semver version string.
* e.g. "0.1.8-alpha.0" → "alpha", "1.0.0-beta.1" → "beta", "1.0.0" → "latest"
*/
function detectChannel(version: string): string {
const match = version.match(/-([a-zA-Z]+)/);
return match ? match[1] : 'latest';
}
export class AppUpdater extends EventEmitter {
private mainWindow: BrowserWindow | null = null;
private status: UpdateStatus = { status: 'idle' };
private autoInstallTimer: NodeJS.Timeout | null = null;
private autoInstallCountdown = 0;
/** Delay (in seconds) before auto-installing a downloaded update. */
private static readonly AUTO_INSTALL_DELAY_SECONDS = 5;
constructor() {
super();
// EventEmitter treats an unhandled 'error' event as fatal. Keep a default
// listener so updater failures surface in logs/UI without terminating main.
this.on('error', (error: Error) => {
logger.error('[Updater] AppUpdater emitted error:', error);
});
// Configure auto-updater
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
// Use logger
autoUpdater.logger = {
info: (msg: string) => console.log('[Updater]', msg),
warn: (msg: string) => console.warn('[Updater]', msg),
error: (msg: string) => console.error('[Updater]', msg),
debug: (msg: string) => console.debug('[Updater]', msg),
info: (msg: string) => logger.info('[Updater]', msg),
warn: (msg: string) => logger.warn('[Updater]', msg),
error: (msg: string) => logger.error('[Updater]', msg),
debug: (msg: string) => logger.debug('[Updater]', msg),
};
// Override feed URL for prerelease channels so that
// alpha -> /alpha/alpha-mac.yml, beta -> /beta/beta-mac.yml, etc.
const version = app.getVersion();
const channel = detectChannel(version);
const feedUrl = `${OSS_BASE_URL}/${channel}`;
logger.info(`[Updater] Version: ${version}, channel: ${channel}, feedUrl: ${feedUrl}`);
// Set channel so electron-updater requests the correct yml filename.
// e.g. channel "alpha" → requests alpha-mac.yml, channel "latest" → requests latest-mac.yml
autoUpdater.channel = channel;
autoUpdater.setFeedURL({
provider: 'generic',
url: feedUrl,
useMultipleRangeRequest: false,
});
this.setupListeners();
}
@@ -89,6 +131,10 @@ export class AppUpdater extends EventEmitter {
autoUpdater.on('update-downloaded', (event: UpdateDownloadedEvent) => {
this.updateStatus({ status: 'downloaded', info: event });
this.emit('update-downloaded', event);
if (autoUpdater.autoDownload) {
this.startAutoInstallCountdown();
}
});
autoUpdater.on('error', (error: Error) => {
@@ -101,7 +147,12 @@ export class AppUpdater extends EventEmitter {
* Update status and notify renderer
*/
private updateStatus(newStatus: Partial<UpdateStatus>): void {
this.status = { ...this.status, ...newStatus };
this.status = {
status: newStatus.status ?? this.status.status,
info: newStatus.info,
progress: newStatus.progress,
error: newStatus.error,
};
this.sendToRenderer('update:status-changed', this.status);
}
@@ -115,15 +166,36 @@ export class AppUpdater extends EventEmitter {
}
/**
* Check for updates
* electron-updater automatically tries providers defined in electron-builder.yml in order
* Check for updates.
* electron-updater automatically tries providers defined in electron-builder.yml in order.
*
* In dev mode (not packed), autoUpdater.checkForUpdates() silently returns
* null without emitting any events, so we must detect this and force a
* final status so the UI never gets stuck in 'checking'.
*/
async checkForUpdates(): Promise<UpdateInfo | null> {
try {
const result = await autoUpdater.checkForUpdates();
return result?.updateInfo || null;
// In dev mode (app not packaged), autoUpdater silently returns null
// without emitting ANY events (not even checking-for-update).
// Detect this and force an error so the UI never stays silent.
if (result == null) {
this.updateStatus({
status: 'error',
error: 'Update check skipped (dev mode app is not packaged)',
});
return null;
}
// Safety net: if events somehow didn't fire, force a final state.
if (this.status.status === 'checking' || this.status.status === 'idle') {
this.updateStatus({ status: 'not-available' });
}
return result.updateInfo || null;
} catch (error) {
console.error('[Updater] Check for updates failed:', error);
logger.error('[Updater] Check for updates failed:', error);
this.updateStatus({ status: 'error', error: (error as Error).message || String(error) });
throw error;
}
@@ -136,18 +208,60 @@ export class AppUpdater extends EventEmitter {
try {
await autoUpdater.downloadUpdate();
} catch (error) {
console.error('[Updater] Download update failed:', error);
logger.error('[Updater] Download update failed:', error);
throw error;
}
}
/**
* Install update and restart app
* Install update and restart.
*
* On macOS, electron-updater delegates to Squirrel.Mac (ShipIt). The
* native quitAndInstall() spawns ShipIt then internally calls app.quit().
* However, the tray close handler in index.ts intercepts window close
* and hides to tray unless isQuitting is true. Squirrel's internal quit
* sometimes fails to trigger before-quit in time, so we set isQuitting
* BEFORE calling quitAndInstall(). This lets the native quit flow close
* the window cleanly while ShipIt runs independently to replace the app.
*/
quitAndInstall(): void {
logger.info('[Updater] quitAndInstall called');
setQuitting();
autoUpdater.quitAndInstall();
}
/**
* Start a countdown that auto-installs the downloaded update.
* Sends `update:auto-install-countdown` events to the renderer each second.
*/
private startAutoInstallCountdown(): void {
this.clearAutoInstallTimer();
this.autoInstallCountdown = AppUpdater.AUTO_INSTALL_DELAY_SECONDS;
this.sendToRenderer('update:auto-install-countdown', { seconds: this.autoInstallCountdown });
this.autoInstallTimer = setInterval(() => {
this.autoInstallCountdown--;
this.sendToRenderer('update:auto-install-countdown', { seconds: this.autoInstallCountdown });
if (this.autoInstallCountdown <= 0) {
this.clearAutoInstallTimer();
this.quitAndInstall();
}
}, 1000);
}
cancelAutoInstall(): void {
this.clearAutoInstallTimer();
this.sendToRenderer('update:auto-install-countdown', { seconds: -1, cancelled: true });
}
private clearAutoInstallTimer(): void {
if (this.autoInstallTimer) {
clearInterval(this.autoInstallTimer);
this.autoInstallTimer = null;
}
}
/**
* Set update channel (stable, beta, dev)
*/
@@ -189,13 +303,14 @@ export function registerUpdateHandlers(
return updater.getCurrentVersion();
});
// Check for updates
// Check for updates always return final status so the renderer
// never gets stuck in 'checking' waiting for a push event.
ipcMain.handle('update:check', async () => {
try {
const info = await updater.checkForUpdates();
return { success: true, info };
await updater.checkForUpdates();
return { success: true, status: updater.getStatus() };
} catch (error) {
return { success: false, error: String(error) };
return { success: false, error: String(error), status: updater.getStatus() };
}
});
@@ -227,42 +342,12 @@ export function registerUpdateHandlers(
return { success: true };
});
// Forward update events to renderer
updater.on('checking-for-update', () => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:checking');
}
// Cancel pending auto-install countdown
ipcMain.handle('update:cancelAutoInstall', () => {
updater.cancelAutoInstall();
return { success: true };
});
updater.on('update-available', (info) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:available', info);
}
});
updater.on('update-not-available', (info) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:not-available', info);
}
});
updater.on('download-progress', (progress) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:progress', progress);
}
});
updater.on('update-downloaded', (event) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:downloaded', event);
}
});
updater.on('error', (error) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:error', error.message);
}
});
}
// Export singleton instance
+39 -2
View File
@@ -21,6 +21,9 @@ const electronAPI = {
'gateway:stop',
'gateway:restart',
'gateway:rpc',
'gateway:httpProxy',
'hostapi:fetch',
'hostapi:token',
'gateway:health',
'gateway:getControlUiUrl',
// OpenClaw
@@ -41,6 +44,7 @@ const electronAPI = {
'app:platform',
'app:quit',
'app:relaunch',
'app:request',
// Window controls
'window:minimize',
'window:maximize',
@@ -49,8 +53,10 @@ const electronAPI = {
// Settings
'settings:get',
'settings:set',
'settings:setMany',
'settings:getAll',
'settings:reset',
'usage:recentTokenHistory',
// Update
'update:status',
'update:version',
@@ -59,23 +65,26 @@ const electronAPI = {
'update:install',
'update:setChannel',
'update:setAutoDownload',
'update:cancelAutoInstall',
// Env
'env:getConfig',
'env:setApiKey',
'env:deleteApiKey',
// Provider
'provider:encryptionAvailable',
'provider:list',
'provider:get',
'provider:save',
'provider:delete',
'provider:setApiKey',
'provider:updateWithKey',
'provider:deleteApiKey',
'provider:hasApiKey',
'provider:getApiKey',
'provider:setDefault',
'provider:getDefault',
'provider:validateKey',
'provider:requestOAuth',
'provider:cancelOAuth',
// Cron
'cron:list',
'cron:create',
@@ -115,10 +124,20 @@ const electronAPI = {
'log:getFilePath',
'log:getDir',
'log:listFiles',
// File staging & media
'file:stage',
'file:stageBuffer',
'media:getThumbnails',
'media:saveImage',
// Chat send with media (reads staged files in main process)
'chat:sendWithMedia',
// Session management
'session:delete',
// OpenClaw extras
'openclaw:getDir',
'openclaw:getConfigDir',
'openclaw:getSkillsDir',
'openclaw:getCliCommand',
'openclaw:installCliMac',
];
if (validChannels.includes(channel)) {
@@ -141,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',
@@ -151,7 +173,12 @@ const electronAPI = {
'update:progress',
'update:downloaded',
'update:error',
'update:auto-install-countdown',
'cron:updated',
'oauth:code',
'oauth:success',
'oauth:error',
'openclaw:cli-installed',
];
if (validChannels.includes(channel)) {
@@ -180,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',
@@ -190,6 +223,10 @@ const electronAPI = {
'update:progress',
'update:downloaded',
'update:error',
'update:auto-install-countdown',
'oauth:code',
'oauth:success',
'oauth:error',
];
if (validChannels.includes(channel)) {
@@ -0,0 +1,47 @@
import type { ProviderConfig } from '../../shared/providers/types';
import {
getDefaultProviderAccountId,
providerConfigToAccount,
saveProviderAccount,
} from './provider-store';
import { getClawXProviderStore } from './store-instance';
const PROVIDER_STORE_SCHEMA_VERSION = 2;
export async function ensureProviderStoreMigrated(): Promise<void> {
const store = await getClawXProviderStore();
const schemaVersion = Number(store.get('schemaVersion') ?? 0);
if (schemaVersion >= PROVIDER_STORE_SCHEMA_VERSION) {
return;
}
// 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);
}
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);
}
@@ -0,0 +1,709 @@
import type { GatewayManager } from '../../gateway/manager';
import { getProviderAccount, listProviderAccounts } from './provider-store';
import { getProviderSecret } from '../secrets/secret-store';
import type { ProviderConfig } from '../../utils/secure-storage';
import { getAllProviders, getApiKey, getDefaultProvider, getProvider } from '../../utils/secure-storage';
import { getProviderConfig, getProviderDefaultModel } from '../../utils/provider-registry';
import {
removeProviderFromOpenClaw,
saveOAuthTokenToOpenClaw,
saveProviderKeyToOpenClaw,
setOpenClawDefaultModel,
setOpenClawDefaultModelWithOverride,
syncProviderConfigToOpenClaw,
updateAgentModelProvider,
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}`;
}
if (type === 'minimax-portal-cn') {
return 'minimax-portal';
}
return type;
}
async function resolveRuntimeProviderKey(config: ProviderConfig): Promise<string> {
const account = await getProviderAccount(config.id);
if (account?.authMode === 'oauth_browser') {
if (config.type === 'google') {
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
}
if (config.type === 'openai') {
return OPENAI_OAUTH_RUNTIME_PROVIDER;
}
}
return getOpenClawProviderKey(config.type, config.id);
}
async function getBrowserOAuthRuntimeProvider(config: ProviderConfig): Promise<string | null> {
const account = await getProviderAccount(config.id);
if (account?.authMode !== 'oauth_browser') {
return null;
}
const secret = await getProviderSecret(config.id);
if (secret?.type !== 'oauth') {
return null;
}
if (config.type === 'google') {
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
}
if (config.type === 'openai') {
return OPENAI_OAUTH_RUNTIME_PROVIDER;
}
return null;
}
export function getProviderModelRef(config: ProviderConfig): string | undefined {
const providerKey = getOpenClawProviderKey(config.type, config.id);
if (config.model) {
return config.model.startsWith(`${providerKey}/`)
? config.model
: `${providerKey}/${config.model}`;
}
const defaultModel = getProviderDefaultModel(config.type);
if (!defaultModel) {
return undefined;
}
return defaultModel.startsWith(`${providerKey}/`)
? defaultModel
: `${providerKey}/${defaultModel}`;
}
export async function getProviderFallbackModelRefs(config: ProviderConfig): Promise<string[]> {
const allProviders = await getAllProviders();
const providerMap = new Map(allProviders.map((provider) => [provider.id, provider]));
const seen = new Set<string>();
const results: string[] = [];
const providerKey = getOpenClawProviderKey(config.type, config.id);
for (const fallbackModel of config.fallbackModels ?? []) {
const normalizedModel = fallbackModel.trim();
if (!normalizedModel) continue;
const modelRef = normalizedModel.startsWith(`${providerKey}/`)
? normalizedModel
: `${providerKey}/${normalizedModel}`;
if (seen.has(modelRef)) continue;
seen.add(modelRef);
results.push(modelRef);
}
for (const fallbackId of config.fallbackProviderIds ?? []) {
if (!fallbackId || fallbackId === config.id) continue;
const fallbackProvider = providerMap.get(fallbackId);
if (!fallbackProvider) continue;
const modelRef = getProviderModelRef(fallbackProvider);
if (!modelRef || seen.has(modelRef)) continue;
seen.add(modelRef);
results.push(modelRef);
}
return results;
}
type GatewayRefreshMode = 'reload' | 'restart';
function scheduleGatewayRefresh(
gatewayManager: GatewayManager | undefined,
message: string,
options?: { delayMs?: number; onlyIfRunning?: boolean; mode?: GatewayRefreshMode },
): void {
if (!gatewayManager) {
return;
}
if (options?.onlyIfRunning && gatewayManager.getStatus().state === 'stopped') {
return;
}
logger.info(message);
if (options?.mode === 'restart') {
gatewayManager.debouncedRestart(options?.delayMs);
return;
}
gatewayManager.debouncedReload(options?.delayMs);
}
export async function syncProviderApiKeyToRuntime(
providerType: string,
providerId: string,
apiKey: string,
): Promise<void> {
const ock = getOpenClawProviderKey(providerType, providerId);
await saveProviderKeyToOpenClaw(ock, apiKey);
}
export async function syncAllProviderAuthToRuntime(): Promise<void> {
const accounts = await listProviderAccounts();
for (const account of accounts) {
const runtimeProviderKey = await resolveRuntimeProviderKey({
id: account.id,
name: account.label,
type: account.vendorId,
baseUrl: account.baseUrl,
model: account.model,
fallbackModels: account.fallbackModels,
fallbackProviderIds: account.fallbackAccountIds,
enabled: account.enabled,
createdAt: account.createdAt,
updatedAt: account.updatedAt,
});
const secret = await getProviderSecret(account.id);
if (!secret) {
continue;
}
if (secret.type === 'api_key') {
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
continue;
}
if (secret.type === 'local' && secret.apiKey) {
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
continue;
}
if (secret.type === 'oauth') {
await saveOAuthTokenToOpenClaw(runtimeProviderKey, {
access: secret.accessToken,
refresh: secret.refreshToken,
expires: secret.expiresAt,
email: secret.email,
projectId: secret.subject,
});
}
}
}
async function syncProviderSecretToRuntime(
config: ProviderConfig,
runtimeProviderKey: string,
apiKey: string | undefined,
): Promise<void> {
const secret = await getProviderSecret(config.id);
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await saveProviderKeyToOpenClaw(runtimeProviderKey, trimmedKey);
}
return;
}
if (secret?.type === 'api_key') {
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
return;
}
if (secret?.type === 'oauth') {
await saveOAuthTokenToOpenClaw(runtimeProviderKey, {
access: secret.accessToken,
refresh: secret.refreshToken,
expires: secret.expiresAt,
email: secret.email,
projectId: secret.subject,
});
return;
}
if (secret?.type === 'local' && secret.apiKey) {
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
}
}
async function resolveRuntimeSyncContext(config: ProviderConfig): Promise<RuntimeProviderSyncContext | null> {
const runtimeProviderKey = await resolveRuntimeProviderKey(config);
const meta = getProviderConfig(config.type);
const api = config.apiProtocol || (config.type === 'custom' ? 'openai-completions' : meta?.api);
if (!api) {
return null;
}
return {
runtimeProviderKey,
meta,
api,
};
}
async function syncRuntimeProviderConfig(
config: ProviderConfig,
context: RuntimeProviderSyncContext,
): Promise<void> {
await syncProviderConfigToOpenClaw(context.runtimeProviderKey, config.model, {
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl, context.api),
api: context.api,
apiKeyEnv: context.meta?.apiKeyEnv,
headers: 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';
}
}
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 reload after saving provider "${context.runtimeProviderKey}" config`,
);
}
export async function syncUpdatedProviderToRuntime(
config: ProviderConfig,
apiKey: string | undefined,
gatewayManager?: GatewayManager,
): Promise<void> {
const context = await syncProviderToRuntime(config, apiKey);
if (!context) {
return;
}
const ock = context.runtimeProviderKey;
const fallbackModels = await getProviderFallbackModelRefs(config);
const defaultProviderId = await getDefaultProvider();
if (defaultProviderId === config.id) {
const modelOverride = config.model ? `${ock}/${config.model}` : undefined;
if (config.type !== 'custom') {
if (shouldUseExplicitDefaultOverride(config, ock)) {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl, context.api),
api: context.api,
apiKeyEnv: context.meta?.apiKeyEnv,
headers: config.headers ?? context.meta?.headers,
}, fallbackModels);
} else {
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
}
} else {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl, config.apiProtocol || 'openai-completions'),
api: config.apiProtocol || 'openai-completions',
headers: config.headers,
}, fallbackModels);
}
}
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider update:', err);
}
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway reload after updating provider "${ock}" config`,
);
}
export async function syncDeletedProviderToRuntime(
provider: ProviderConfig | null,
providerId: string,
gatewayManager?: GatewayManager,
runtimeProviderKey?: string,
): Promise<void> {
if (!provider?.type) {
return;
}
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
await removeProviderFromOpenClaw(ock);
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway restart after deleting provider "${ock}"`,
{ mode: 'restart' },
);
}
export async function syncDeletedProviderApiKeyToRuntime(
provider: ProviderConfig | null,
providerId: string,
runtimeProviderKey?: string,
): Promise<void> {
if (!provider?.type) {
return;
}
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
await removeProviderFromOpenClaw(ock);
}
export async function syncDefaultProviderToRuntime(
providerId: string,
gatewayManager?: GatewayManager,
): Promise<void> {
const provider = await getProvider(providerId);
if (!provider) {
return;
}
const ock = await resolveRuntimeProviderKey(provider);
const providerKey = await getApiKey(providerId);
const fallbackModels = await getProviderFallbackModelRefs(provider);
const oauthTypes = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
const browserOAuthRuntimeProvider = await getBrowserOAuthRuntimeProvider(provider);
const isOAuthProvider = (oauthTypes.includes(provider.type) && !providerKey) || Boolean(browserOAuthRuntimeProvider);
if (!isOAuthProvider) {
const modelOverride = provider.model
? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`)
: undefined;
if (provider.type === 'custom') {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'),
api: provider.apiProtocol || 'openai-completions',
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);
}
if (providerKey) {
await saveProviderKeyToOpenClaw(ock, providerKey);
}
} else {
if (browserOAuthRuntimeProvider) {
const secret = await getProviderSecret(provider.id);
if (secret?.type === 'oauth') {
await saveOAuthTokenToOpenClaw(browserOAuthRuntimeProvider, {
access: secret.accessToken,
refresh: secret.refreshToken,
expires: secret.expiresAt,
email: secret.email,
projectId: secret.subject,
});
}
const defaultModelRef = browserOAuthRuntimeProvider === GOOGLE_OAUTH_RUNTIME_PROVIDER
? GOOGLE_OAUTH_DEFAULT_MODEL_REF
: OPENAI_OAUTH_DEFAULT_MODEL_REF;
const modelOverride = provider.model
? (provider.model.startsWith(`${browserOAuthRuntimeProvider}/`)
? provider.model
: `${browserOAuthRuntimeProvider}/${provider.model}`)
: defaultModelRef;
await setOpenClawDefaultModel(browserOAuthRuntimeProvider, modelOverride, fallbackModels);
logger.info(`Configured openclaw.json for browser OAuth provider "${provider.id}"`);
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after browser OAuth switch:', err);
}
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway reload after provider switch to "${browserOAuthRuntimeProvider}"`,
);
return;
}
const defaultBaseUrl = provider.type === 'minimax-portal'
? 'https://api.minimax.io/anthropic'
: (provider.type === 'minimax-portal-cn' ? 'https://api.minimaxi.com/anthropic' : 'https://portal.qwen.ai/v1');
const api: 'anthropic-messages' | 'openai-completions' =
(provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn')
? 'anthropic-messages'
: 'openai-completions';
let baseUrl = provider.baseUrl || defaultBaseUrl;
if ((provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') && baseUrl) {
baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
}
const targetProviderKey = (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn')
? 'minimax-portal'
: provider.type;
await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), {
baseUrl,
api,
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
}, fallbackModels);
logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`);
try {
const defaultModelId = provider.model?.split('/').pop();
await updateAgentModelProvider(targetProviderKey, {
baseUrl,
api,
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
models: defaultModelId ? [{ id: defaultModelId, name: defaultModelId }] : [],
});
} catch (err) {
logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err);
}
}
if (
provider.type === 'custom' &&
providerKey &&
provider.baseUrl
) {
const modelId = provider.model;
await updateAgentModelProvider(ock, {
baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'),
api: provider.apiProtocol || 'openai-completions',
models: modelId ? [{ id: modelId, name: modelId }] : [],
apiKey: providerKey,
});
}
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 reload after provider switch to "${ock}"`,
{ onlyIfRunning: true },
);
}
@@ -0,0 +1,391 @@
import {
PROVIDER_DEFINITIONS,
getProviderDefinition,
} from '../../shared/providers/registry';
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,
providerAccountToConfig,
providerConfigToAccount,
saveProviderAccount,
setDefaultProviderAccount,
} from './provider-store';
import {
deleteApiKey,
deleteProvider,
getApiKey,
hasApiKey,
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[]> {
return PROVIDER_DEFINITIONS;
}
async listAccounts(): Promise<ProviderAccount[]> {
await ensureProviderStoreMigrated();
// ── 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> {
await ensureProviderStoreMigrated();
return getProviderAccount(accountId);
}
async getDefaultAccountId(): Promise<string | undefined> {
await ensureProviderStoreMigrated();
return getDefaultProviderAccountId();
}
async createAccount(account: ProviderAccount, apiKey?: string): Promise<ProviderAccount> {
await ensureProviderStoreMigrated();
// 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());
}
return (await getProviderAccount(account.id)) ?? account;
}
async updateAccount(
accountId: string,
patch: Partial<ProviderAccount>,
apiKey?: string,
): Promise<ProviderAccount> {
await ensureProviderStoreMigrated();
const existing = await getProviderAccount(accountId);
if (!existing) {
throw new Error('Provider account not found');
}
const nextAccount: ProviderAccount = {
...existing,
...patch,
id: accountId,
updatedAt: patch.updatedAt ?? new Date().toISOString(),
};
// Only save to providerAccounts store — skip legacy saveProvider().
await saveProviderAccount(nextAccount);
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await storeApiKey(accountId, trimmedKey);
} else {
await deleteApiKey(accountId);
}
}
return (await getProviderAccount(accountId)) ?? nextAccount;
}
async deleteAccount(accountId: string): Promise<boolean> {
await ensureProviderStoreMigrated();
return deleteProvider(accountId);
}
/**
* @deprecated Use listAccounts() and map account data in callers.
*/
async listLegacyProviders(): Promise<ProviderConfig[]> {
logLegacyProviderApiUsage('listLegacyProviders', 'listAccounts');
const accounts = await this.listAccounts();
return accounts.map(providerAccountToConfig);
}
/**
* @deprecated Use listAccounts() + secret-store based key summary.
*/
async listLegacyProvidersWithKeyInfo(): Promise<ProviderWithKeyInfo[]> {
logLegacyProviderApiUsage('listLegacyProvidersWithKeyInfo', 'listAccounts');
const providers = await this.listLegacyProviders();
const results: ProviderWithKeyInfo[] = [];
for (const provider of providers) {
const apiKey = await getApiKey(provider.id);
results.push({
...provider,
hasKey: !!apiKey,
keyMasked: maskApiKey(apiKey),
});
}
return results;
}
/**
* @deprecated Use getAccount(accountId).
*/
async getLegacyProvider(providerId: string): Promise<ProviderConfig | null> {
logLegacyProviderApiUsage('getLegacyProvider', 'getAccount');
await ensureProviderStoreMigrated();
const account = await getProviderAccount(providerId);
return account ? providerAccountToConfig(account) : null;
}
/**
* @deprecated Use createAccount()/updateAccount().
*/
async saveLegacyProvider(config: ProviderConfig): Promise<void> {
logLegacyProviderApiUsage('saveLegacyProvider', 'createAccount/updateAccount');
await ensureProviderStoreMigrated();
const account = providerConfigToAccount(config);
const existing = await getProviderAccount(config.id);
if (existing) {
await this.updateAccount(config.id, account);
return;
}
await this.createAccount(account);
}
/**
* @deprecated Use deleteAccount(accountId).
*/
async deleteLegacyProvider(providerId: string): Promise<boolean> {
logLegacyProviderApiUsage('deleteLegacyProvider', 'deleteAccount');
await ensureProviderStoreMigrated();
await this.deleteAccount(providerId);
return true;
}
/**
* @deprecated Use setDefaultAccount(accountId).
*/
async setDefaultLegacyProvider(providerId: string): Promise<void> {
logLegacyProviderApiUsage('setDefaultLegacyProvider', 'setDefaultAccount');
await this.setDefaultAccount(providerId);
}
/**
* @deprecated Use getDefaultAccountId().
*/
async getDefaultLegacyProvider(): Promise<string | undefined> {
logLegacyProviderApiUsage('getDefaultLegacyProvider', 'getDefaultAccountId');
return this.getDefaultAccountId();
}
/**
* @deprecated Use secret-store APIs by accountId.
*/
async setLegacyProviderApiKey(providerId: string, apiKey: string): Promise<boolean> {
logLegacyProviderApiUsage('setLegacyProviderApiKey', 'setProviderSecret(accountId, api_key)');
return storeApiKey(providerId, apiKey);
}
/**
* @deprecated Use secret-store APIs by accountId.
*/
async getLegacyProviderApiKey(providerId: string): Promise<string | null> {
logLegacyProviderApiUsage('getLegacyProviderApiKey', 'getProviderSecret(accountId)');
return getApiKey(providerId);
}
/**
* @deprecated Use secret-store APIs by accountId.
*/
async deleteLegacyProviderApiKey(providerId: string): Promise<boolean> {
logLegacyProviderApiUsage('deleteLegacyProviderApiKey', 'deleteProviderSecret(accountId)');
return deleteApiKey(providerId);
}
/**
* @deprecated Use secret-store APIs by accountId.
*/
async hasLegacyProviderApiKey(providerId: string): Promise<boolean> {
logLegacyProviderApiUsage('hasLegacyProviderApiKey', 'getProviderSecret(accountId)');
return hasApiKey(providerId);
}
async setDefaultAccount(accountId: string): Promise<void> {
await ensureProviderStoreMigrated();
await setDefaultProviderAccount(accountId);
await setDefaultProvider(accountId);
}
getVendorDefinition(vendorId: string): ProviderDefinition | undefined {
return getProviderDefinition(vendorId);
}
}
const providerService = new ProviderService();
export function getProviderService(): ProviderService {
return providerService;
}
@@ -0,0 +1,106 @@
import type { ProviderAccount, ProviderConfig, ProviderType } from '../../shared/providers/types';
import { getProviderDefinition } from '../../shared/providers/registry';
import { getClawXProviderStore } from './store-instance';
const PROVIDER_STORE_SCHEMA_VERSION = 1;
function inferAuthMode(type: ProviderType): ProviderAccount['authMode'] {
if (type === 'ollama') {
return 'local';
}
const definition = getProviderDefinition(type);
if (definition?.defaultAuthMode) {
return definition.defaultAuthMode;
}
return 'api_key';
}
export function providerConfigToAccount(
config: ProviderConfig,
options?: { isDefault?: boolean },
): ProviderAccount {
return {
id: config.id,
vendorId: config.type,
label: config.name,
authMode: inferAuthMode(config.type),
baseUrl: config.baseUrl,
apiProtocol: config.apiProtocol || (config.type === 'custom' || config.type === 'ollama'
? 'openai-completions'
: getProviderDefinition(config.type)?.providerConfig?.api),
headers: config.headers,
model: config.model,
fallbackModels: config.fallbackModels,
fallbackAccountIds: config.fallbackProviderIds,
enabled: config.enabled,
isDefault: options?.isDefault ?? false,
createdAt: config.createdAt,
updatedAt: config.updatedAt,
};
}
export function providerAccountToConfig(account: ProviderAccount): ProviderConfig {
return {
id: account.id,
name: account.label,
type: account.vendorId,
baseUrl: account.baseUrl,
apiProtocol: account.apiProtocol,
headers: account.headers,
model: account.model,
fallbackModels: account.fallbackModels,
fallbackProviderIds: account.fallbackAccountIds,
enabled: account.enabled,
createdAt: account.createdAt,
updatedAt: account.updatedAt,
};
}
export async function listProviderAccounts(): Promise<ProviderAccount[]> {
const store = await getClawXProviderStore();
const accounts = store.get('providerAccounts') as Record<string, ProviderAccount> | undefined;
return Object.values(accounts ?? {});
}
export async function getProviderAccount(accountId: string): Promise<ProviderAccount | null> {
const store = await getClawXProviderStore();
const accounts = store.get('providerAccounts') as Record<string, ProviderAccount> | undefined;
return accounts?.[accountId] ?? null;
}
export async function saveProviderAccount(account: ProviderAccount): Promise<void> {
const store = await getClawXProviderStore();
const accounts = (store.get('providerAccounts') ?? {}) as Record<string, ProviderAccount>;
accounts[account.id] = account;
store.set('providerAccounts', accounts);
store.set('schemaVersion', PROVIDER_STORE_SCHEMA_VERSION);
}
export async function deleteProviderAccount(accountId: string): Promise<void> {
const store = await getClawXProviderStore();
const accounts = (store.get('providerAccounts') ?? {}) as Record<string, ProviderAccount>;
delete accounts[accountId];
store.set('providerAccounts', accounts);
if (store.get('defaultProviderAccountId') === accountId) {
store.delete('defaultProviderAccountId');
}
}
export async function setDefaultProviderAccount(accountId: string): Promise<void> {
const store = await getClawXProviderStore();
store.set('defaultProviderAccountId', accountId);
const accounts = (store.get('providerAccounts') ?? {}) as Record<string, ProviderAccount>;
for (const account of Object.values(accounts)) {
account.isDefault = account.id === accountId;
}
store.set('providerAccounts', accounts);
}
export async function getDefaultProviderAccountId(): Promise<string | undefined> {
const store = await getClawXProviderStore();
return store.get('defaultProviderAccountId') as string | undefined;
}
@@ -0,0 +1,388 @@
import { proxyAwareFetch } from '../../utils/proxy-fetch';
import { getProviderConfig } from '../../utils/provider-registry';
type ValidationProfile =
| 'openai-completions'
| 'openai-responses'
| 'google-query-key'
| 'anthropic-header'
| 'openrouter'
| 'none';
type ValidationResult = { valid: boolean; error?: string; status?: number };
function logValidationStatus(provider: string, status: number): void {
console.log(`[clawx-validate] ${provider} HTTP ${status}`);
}
function maskSecret(secret: string): string {
if (!secret) return '';
if (secret.length <= 8) return `${secret.slice(0, 2)}***`;
return `${secret.slice(0, 4)}***${secret.slice(-4)}`;
}
function sanitizeValidationUrl(rawUrl: string): string {
try {
const url = new URL(rawUrl);
const key = url.searchParams.get('key');
if (key) url.searchParams.set('key', maskSecret(key));
return url.toString();
} catch {
return rawUrl;
}
}
function sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
const next = { ...headers };
if (next.Authorization?.startsWith('Bearer ')) {
const token = next.Authorization.slice('Bearer '.length);
next.Authorization = `Bearer ${maskSecret(token)}`;
}
if (next['x-api-key']) {
next['x-api-key'] = maskSecret(next['x-api-key']);
}
return next;
}
function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, '');
}
function buildOpenAiModelsUrl(baseUrl: string): string {
return `${normalizeBaseUrl(baseUrl)}/models?limit=1`;
}
function resolveOpenAiProbeUrls(
baseUrl: string,
apiProtocol: 'openai-completions' | 'openai-responses',
): { modelsUrl: string; probeUrl: string } {
const normalizedBase = normalizeBaseUrl(baseUrl);
const endpointSuffixPattern = /(\/responses?|\/chat\/completions)$/;
const rootBase = normalizedBase.replace(endpointSuffixPattern, '');
const modelsUrl = buildOpenAiModelsUrl(rootBase);
if (apiProtocol === 'openai-responses') {
const probeUrl = /(\/responses?)$/.test(normalizedBase)
? normalizedBase
: `${rootBase}/responses`;
return { modelsUrl, probeUrl };
}
const probeUrl = /\/chat\/completions$/.test(normalizedBase)
? normalizedBase
: `${rootBase}/chat/completions`;
return { modelsUrl, probeUrl };
}
function logValidationRequest(
provider: string,
method: string,
url: string,
headers: Record<string, string>,
): void {
console.log(
`[clawx-validate] ${provider} request ${method} ${sanitizeValidationUrl(url)} headers=${JSON.stringify(sanitizeHeaders(headers))}`,
);
}
function getValidationProfile(
providerType: string,
options?: { apiProtocol?: string }
): ValidationProfile {
const providerApi = options?.apiProtocol || getProviderConfig(providerType)?.api;
if (providerApi === 'anthropic-messages') {
return 'anthropic-header';
}
if (providerApi === 'openai-responses') {
return 'openai-responses';
}
if (providerApi === 'openai-completions') {
return 'openai-completions';
}
switch (providerType) {
case 'anthropic':
return 'anthropic-header';
case 'google':
return 'google-query-key';
case 'openrouter':
return 'openrouter';
case 'ollama':
return 'none';
default:
return 'openai-completions';
}
}
async function performProviderValidationRequest(
providerLabel: string,
url: string,
headers: Record<string, string>,
): Promise<ValidationResult> {
try {
logValidationRequest(providerLabel, 'GET', url, headers);
const response = await proxyAwareFetch(url, { headers });
logValidationStatus(providerLabel, response.status);
const data = await response.json().catch(() => ({}));
const result = classifyAuthResponse(response.status, data);
return { ...result, status: response.status };
} catch (error) {
return {
valid: false,
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
function classifyAuthResponse(
status: number,
data: unknown,
): { valid: boolean; error?: string } {
if (status >= 200 && status < 300) return { valid: true };
if (status === 429) return { valid: true };
if (status === 401 || status === 403) return { valid: false, error: 'Invalid API key' };
const obj = data as { error?: { message?: string }; message?: string } | null;
const msg = obj?.error?.message || obj?.message || `API error: ${status}`;
return { valid: false, error: msg };
}
async function validateOpenAiCompatibleKey(
providerType: string,
apiKey: string,
apiProtocol: 'openai-completions' | 'openai-responses',
baseUrl?: string,
): Promise<ValidationResult> {
const trimmedBaseUrl = baseUrl?.trim();
if (!trimmedBaseUrl) {
return { valid: false, error: `Base URL is required for provider "${providerType}" validation` };
}
const headers = { Authorization: `Bearer ${apiKey}` };
const { modelsUrl, probeUrl } = resolveOpenAiProbeUrls(trimmedBaseUrl, apiProtocol);
const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers);
if (modelsResult.status === 404) {
console.log(
`[clawx-validate] ${providerType} /models returned 404, falling back to ${apiProtocol} probe`,
);
if (apiProtocol === 'openai-responses') {
return await performResponsesProbe(providerType, probeUrl, headers);
}
return await performChatCompletionsProbe(providerType, probeUrl, headers);
}
return modelsResult;
}
async function performResponsesProbe(
providerLabel: string,
url: string,
headers: Record<string, string>,
): Promise<ValidationResult> {
try {
logValidationRequest(providerLabel, 'POST', url, headers);
const response = await proxyAwareFetch(url, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'validation-probe',
input: 'hi',
}),
});
logValidationStatus(providerLabel, response.status);
const data = await response.json().catch(() => ({}));
if (response.status === 401 || response.status === 403) {
return { valid: false, error: 'Invalid API key' };
}
if (
(response.status >= 200 && response.status < 300) ||
response.status === 400 ||
response.status === 429
) {
return { valid: true };
}
return classifyAuthResponse(response.status, data);
} catch (error) {
return {
valid: false,
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
async function performChatCompletionsProbe(
providerLabel: string,
url: string,
headers: Record<string, string>,
): Promise<ValidationResult> {
try {
logValidationRequest(providerLabel, 'POST', url, headers);
const response = await proxyAwareFetch(url, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'validation-probe',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 1,
}),
});
logValidationStatus(providerLabel, response.status);
const data = await response.json().catch(() => ({}));
if (response.status === 401 || response.status === 403) {
return { valid: false, error: 'Invalid API key' };
}
if (
(response.status >= 200 && response.status < 300) ||
response.status === 400 ||
response.status === 429
) {
return { valid: true };
}
return classifyAuthResponse(response.status, data);
} catch (error) {
return {
valid: false,
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
async function performAnthropicMessagesProbe(
providerLabel: string,
url: string,
headers: Record<string, string>,
): Promise<ValidationResult> {
try {
logValidationRequest(providerLabel, 'POST', url, headers);
const response = await proxyAwareFetch(url, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'validation-probe',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 1,
}),
});
logValidationStatus(providerLabel, response.status);
const data = await response.json().catch(() => ({}));
if (response.status === 401 || response.status === 403) {
return { valid: false, error: 'Invalid API key' };
}
if (
(response.status >= 200 && response.status < 300) ||
response.status === 400 ||
response.status === 429
) {
return { valid: true };
}
return classifyAuthResponse(response.status, data);
} catch (error) {
return {
valid: false,
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
async function validateGoogleQueryKey(
providerType: string,
apiKey: string,
baseUrl?: string,
): Promise<ValidationResult> {
const base = normalizeBaseUrl(baseUrl || 'https://generativelanguage.googleapis.com/v1beta');
const url = `${base}/models?pageSize=1&key=${encodeURIComponent(apiKey)}`;
return await performProviderValidationRequest(providerType, url, {});
}
async function validateAnthropicHeaderKey(
providerType: string,
apiKey: string,
baseUrl?: string,
): Promise<ValidationResult> {
const rawBase = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1');
const base = rawBase.endsWith('/v1') ? rawBase : `${rawBase}/v1`;
const url = `${base}/models?limit=1`;
const headers = {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
};
const modelsResult = await performProviderValidationRequest(providerType, url, headers);
// If the endpoint doesn't implement /models (like Minimax Anthropic compatibility), fallback to a /messages probe.
if (
modelsResult.status === 404 ||
modelsResult.status === 400 ||
modelsResult.error?.includes('API error: 404') ||
modelsResult.error?.includes('API error: 400')
) {
console.log(
`[clawx-validate] ${providerType} /models returned error, falling back to /messages probe`,
);
const messagesUrl = `${base}/messages`;
return await performAnthropicMessagesProbe(providerType, messagesUrl, headers);
}
return modelsResult;
}
async function validateOpenRouterKey(
providerType: string,
apiKey: string,
): Promise<ValidationResult> {
const url = 'https://openrouter.ai/api/v1/auth/key';
const headers = { Authorization: `Bearer ${apiKey}` };
return await performProviderValidationRequest(providerType, url, headers);
}
export async function validateApiKeyWithProvider(
providerType: string,
apiKey: string,
options?: { baseUrl?: string; apiProtocol?: string },
): Promise<ValidationResult> {
const profile = getValidationProfile(providerType, options);
const resolvedBaseUrl = options?.baseUrl || getProviderConfig(providerType)?.baseUrl;
if (profile === 'none') {
return { valid: true };
}
const trimmedKey = apiKey.trim();
if (!trimmedKey) {
return { valid: false, error: 'API key is required' };
}
try {
switch (profile) {
case 'openai-completions':
return await validateOpenAiCompatibleKey(
providerType,
trimmedKey,
'openai-completions',
resolvedBaseUrl,
);
case 'openai-responses':
return await validateOpenAiCompatibleKey(
providerType,
trimmedKey,
'openai-responses',
resolvedBaseUrl,
);
case 'google-query-key':
return await validateGoogleQueryKey(providerType, trimmedKey, resolvedBaseUrl);
case 'anthropic-header':
return await validateAnthropicHeaderKey(providerType, trimmedKey, resolvedBaseUrl);
case 'openrouter':
return await validateOpenRouterKey(providerType, trimmedKey);
default:
return { valid: false, error: `Unsupported validation profile for provider: ${providerType}` };
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { valid: false, error: errorMessage };
}
}
@@ -0,0 +1,23 @@
// Lazy-load electron-store (ESM module) from the main process only.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let providerStore: any = null;
export async function getClawXProviderStore() {
if (!providerStore) {
const Store = (await import('electron-store')).default;
providerStore = new Store({
name: 'clawx-providers',
defaults: {
schemaVersion: 0,
providers: {} as Record<string, unknown>,
providerAccounts: {} as Record<string, unknown>,
apiKeys: {} as Record<string, string>,
providerSecrets: {} as Record<string, unknown>,
defaultProvider: null as string | null,
defaultProviderAccountId: null as string | null,
},
});
}
return providerStore;
}
+82
View File
@@ -0,0 +1,82 @@
import type { ProviderSecret } from '../../shared/providers/types';
import { getClawXProviderStore } from '../providers/store-instance';
export interface SecretStore {
get(accountId: string): Promise<ProviderSecret | null>;
set(secret: ProviderSecret): Promise<void>;
delete(accountId: string): Promise<void>;
}
export class ElectronStoreSecretStore implements SecretStore {
async get(accountId: string): Promise<ProviderSecret | null> {
const store = await getClawXProviderStore();
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
const secret = secrets[accountId];
if (secret) {
return secret;
}
const apiKeys = (store.get('apiKeys') ?? {}) as Record<string, string>;
const apiKey = apiKeys[accountId];
if (!apiKey) {
return null;
}
return {
type: 'api_key',
accountId,
apiKey,
};
}
async set(secret: ProviderSecret): Promise<void> {
const store = await getClawXProviderStore();
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
secrets[secret.accountId] = secret;
store.set('providerSecrets', secrets);
// Keep legacy apiKeys in sync until the rest of the app moves to account-based secrets.
const apiKeys = (store.get('apiKeys') ?? {}) as Record<string, string>;
if (secret.type === 'api_key') {
apiKeys[secret.accountId] = secret.apiKey;
} else if (secret.type === 'local') {
if (secret.apiKey) {
apiKeys[secret.accountId] = secret.apiKey;
} else {
delete apiKeys[secret.accountId];
}
} else {
delete apiKeys[secret.accountId];
}
store.set('apiKeys', apiKeys);
}
async delete(accountId: string): Promise<void> {
const store = await getClawXProviderStore();
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
delete secrets[accountId];
store.set('providerSecrets', secrets);
const apiKeys = (store.get('apiKeys') ?? {}) as Record<string, string>;
delete apiKeys[accountId];
store.set('apiKeys', apiKeys);
}
}
const secretStore = new ElectronStoreSecretStore();
export function getSecretStore(): SecretStore {
return secretStore;
}
export async function getProviderSecret(accountId: string): Promise<ProviderSecret | null> {
return getSecretStore().get(accountId);
}
export async function setProviderSecret(secret: ProviderSecret): Promise<void> {
await getSecretStore().set(secret);
}
export async function deleteProviderSecret(accountId: string): Promise<void> {
await getSecretStore().delete(accountId);
}
+298
View File
@@ -0,0 +1,298 @@
import type {
ProviderBackendConfig,
ProviderDefinition,
ProviderType,
ProviderTypeInfo,
} from './types';
export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
{
id: 'anthropic',
name: 'Anthropic',
icon: '🤖',
placeholder: 'sk-ant-api03-...',
model: 'Claude',
requiresApiKey: true,
category: 'official',
envVar: 'ANTHROPIC_API_KEY',
defaultModelId: 'claude-opus-4-6',
supportedAuthModes: ['api_key'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
},
{
id: 'openai',
name: 'OpenAI',
icon: '💚',
placeholder: 'sk-proj-...',
model: 'GPT',
requiresApiKey: true,
category: 'official',
envVar: 'OPENAI_API_KEY',
defaultModelId: 'gpt-5.2',
isOAuth: true,
supportsApiKey: true,
supportedAuthModes: ['api_key', 'oauth_browser'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
providerConfig: {
baseUrl: 'https://api.openai.com/v1',
api: 'openai-responses',
apiKeyEnv: 'OPENAI_API_KEY',
},
},
{
id: 'google',
name: 'Google',
icon: '🔷',
placeholder: 'AIza...',
model: 'Gemini',
requiresApiKey: true,
category: 'official',
envVar: 'GEMINI_API_KEY',
defaultModelId: 'gemini-3.1-pro-preview',
isOAuth: true,
supportsApiKey: true,
supportedAuthModes: ['api_key', 'oauth_browser'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
},
{
id: 'openrouter',
name: 'OpenRouter',
icon: '🌐',
placeholder: 'sk-or-v1-...',
model: 'Multi-Model',
requiresApiKey: true,
showModelId: true,
modelIdPlaceholder: 'anthropic/claude-opus-4.6',
defaultModelId: 'anthropic/claude-opus-4.6',
category: 'compatible',
envVar: 'OPENROUTER_API_KEY',
supportedAuthModes: ['api_key'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
providerConfig: {
baseUrl: 'https://openrouter.ai/api/v1',
api: 'openai-completions',
apiKeyEnv: 'OPENROUTER_API_KEY',
headers: {
'HTTP-Referer': 'https://claw-x.com',
'X-Title': 'ClawX',
},
},
},
{
id: 'ark',
name: 'ByteDance Ark',
icon: 'A',
placeholder: 'your-ark-api-key',
model: 'Doubao',
requiresApiKey: true,
defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
showBaseUrl: true,
showModelId: true,
modelIdPlaceholder: 'ep-20260228000000-xxxxx',
category: 'official',
envVar: 'ARK_API_KEY',
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,
providerConfig: {
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
api: 'openai-completions',
apiKeyEnv: 'ARK_API_KEY',
},
},
{
id: 'moonshot',
name: 'Moonshot (CN)',
icon: '🌙',
placeholder: 'sk-...',
model: 'Kimi',
requiresApiKey: true,
defaultBaseUrl: 'https://api.moonshot.cn/v1',
defaultModelId: 'kimi-k2.5',
category: 'official',
envVar: 'MOONSHOT_API_KEY',
supportedAuthModes: ['api_key'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
providerConfig: {
baseUrl: 'https://api.moonshot.cn/v1',
api: 'openai-completions',
apiKeyEnv: 'MOONSHOT_API_KEY',
models: [
{
id: 'kimi-k2.5',
name: 'Kimi K2.5',
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 256000,
maxTokens: 8192,
},
],
},
},
{
id: 'siliconflow',
name: 'SiliconFlow (CN)',
icon: '🌊',
placeholder: 'sk-...',
model: 'Multi-Model',
requiresApiKey: true,
defaultBaseUrl: 'https://api.siliconflow.cn/v1',
showModelId: true,
showModelIdInDevModeOnly: true,
modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3',
defaultModelId: 'deepseek-ai/DeepSeek-V3',
category: 'compatible',
envVar: 'SILICONFLOW_API_KEY',
supportedAuthModes: ['api_key'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
providerConfig: {
baseUrl: 'https://api.siliconflow.cn/v1',
api: 'openai-completions',
apiKeyEnv: 'SILICONFLOW_API_KEY',
},
},
{
id: 'minimax-portal',
name: 'MiniMax (Global)',
icon: '☁️',
placeholder: 'sk-...',
model: 'MiniMax',
requiresApiKey: false,
isOAuth: true,
supportsApiKey: true,
defaultModelId: 'MiniMax-M2.7',
apiKeyUrl: 'https://platform.minimax.io',
category: 'official',
envVar: 'MINIMAX_API_KEY',
supportedAuthModes: ['oauth_device', 'api_key'],
defaultAuthMode: 'oauth_device',
supportsMultipleAccounts: true,
providerConfig: {
baseUrl: 'https://api.minimax.io/anthropic',
api: 'anthropic-messages',
apiKeyEnv: 'MINIMAX_API_KEY',
},
},
{
id: 'minimax-portal-cn',
name: 'MiniMax (CN)',
icon: '☁️',
placeholder: 'sk-...',
model: 'MiniMax',
requiresApiKey: false,
isOAuth: true,
supportsApiKey: true,
defaultModelId: 'MiniMax-M2.7',
apiKeyUrl: 'https://platform.minimaxi.com/',
category: 'official',
envVar: 'MINIMAX_CN_API_KEY',
supportedAuthModes: ['oauth_device', 'api_key'],
defaultAuthMode: 'oauth_device',
supportsMultipleAccounts: true,
providerConfig: {
baseUrl: 'https://api.minimaxi.com/anthropic',
api: 'anthropic-messages',
apiKeyEnv: 'MINIMAX_CN_API_KEY',
},
},
{
id: 'qwen-portal',
name: 'Qwen',
icon: '☁️',
placeholder: 'sk-...',
model: 'Qwen',
requiresApiKey: false,
isOAuth: true,
defaultModelId: 'coder-model',
category: 'official',
envVar: 'QWEN_API_KEY',
supportedAuthModes: ['oauth_device'],
defaultAuthMode: 'oauth_device',
supportsMultipleAccounts: true,
providerConfig: {
baseUrl: 'https://portal.qwen.ai/v1',
api: 'openai-completions',
apiKeyEnv: 'QWEN_API_KEY',
},
},
{
id: 'ollama',
name: 'Ollama',
icon: '🦙',
placeholder: 'Not required',
requiresApiKey: false,
defaultBaseUrl: 'http://localhost:11434/v1',
showBaseUrl: true,
showModelId: true,
modelIdPlaceholder: 'qwen3:latest',
category: 'local',
supportedAuthModes: ['local'],
defaultAuthMode: 'local',
supportsMultipleAccounts: true,
},
{
id: 'custom',
name: 'Custom',
icon: '⚙️',
placeholder: 'API key...',
requiresApiKey: true,
showBaseUrl: true,
showModelId: true,
modelIdPlaceholder: 'your-provider/model-id',
category: 'custom',
envVar: 'CUSTOM_API_KEY',
supportedAuthModes: ['api_key'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
},
];
const PROVIDER_DEFINITION_MAP = new Map(
PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
);
export function getProviderDefinition(
type: ProviderType | string,
): ProviderDefinition | undefined {
return PROVIDER_DEFINITION_MAP.get(type as ProviderType);
}
export function getProviderTypeInfo(
type: ProviderType,
): ProviderTypeInfo | undefined {
return getProviderDefinition(type);
}
export function getProviderEnvVar(type: string): string | undefined {
return getProviderDefinition(type)?.envVar;
}
export function getProviderDefaultModel(type: string): string | undefined {
return getProviderDefinition(type)?.defaultModelId;
}
export function getProviderBackendConfig(
type: string,
): ProviderBackendConfig | undefined {
return getProviderDefinition(type)?.providerConfig;
}
export function getProviderUiInfoList(): ProviderTypeInfo[] {
return PROVIDER_DEFINITIONS;
}
export function getKeyableProviderTypes(): string[] {
return PROVIDER_DEFINITIONS.filter((definition) => definition.envVar).map(
(definition) => definition.id,
);
}
+175
View File
@@ -0,0 +1,175 @@
export const PROVIDER_TYPES = [
'anthropic',
'openai',
'google',
'openrouter',
'ark',
'moonshot',
'siliconflow',
'minimax-portal',
'minimax-portal-cn',
'qwen-portal',
'ollama',
'custom',
] as const;
export const BUILTIN_PROVIDER_TYPES = [
'anthropic',
'openai',
'google',
'openrouter',
'ark',
'moonshot',
'siliconflow',
'minimax-portal',
'minimax-portal-cn',
'qwen-portal',
'ollama',
] as const;
export type ProviderType = (typeof PROVIDER_TYPES)[number];
export type BuiltinProviderType = (typeof BUILTIN_PROVIDER_TYPES)[number];
export const OLLAMA_PLACEHOLDER_API_KEY = 'ollama-local';
export type ProviderProtocol =
| 'openai-completions'
| 'openai-responses'
| 'anthropic-messages';
export type ProviderAuthMode =
| 'api_key'
| 'oauth_device'
| 'oauth_browser'
| 'local';
export type ProviderVendorCategory =
| 'official'
| 'compatible'
| 'local'
| 'custom';
export interface ProviderConfig {
id: string;
name: string;
type: ProviderType;
baseUrl?: string;
apiProtocol?: ProviderProtocol;
headers?: Record<string, string>;
model?: string;
fallbackModels?: string[];
fallbackProviderIds?: string[];
enabled: boolean;
createdAt: string;
updatedAt: string;
}
export interface ProviderWithKeyInfo extends ProviderConfig {
hasKey: boolean;
keyMasked: string | null;
}
export interface ProviderTypeInfo {
id: ProviderType;
name: string;
icon: string;
placeholder: string;
model?: string;
requiresApiKey: boolean;
defaultBaseUrl?: string;
showBaseUrl?: boolean;
showModelId?: boolean;
showModelIdInDevModeOnly?: boolean;
modelIdPlaceholder?: string;
defaultModelId?: string;
isOAuth?: boolean;
supportsApiKey?: boolean;
apiKeyUrl?: string;
codePlanPresetBaseUrl?: string;
codePlanPresetModelId?: string;
codePlanDocsUrl?: string;
}
export interface ProviderModelEntry extends Record<string, unknown> {
id: string;
name: string;
}
export interface ProviderBackendConfig {
baseUrl: string;
api: ProviderProtocol;
apiKeyEnv: string;
models?: ProviderModelEntry[];
headers?: Record<string, string>;
}
export interface ProviderDefinition extends ProviderTypeInfo {
category: ProviderVendorCategory;
envVar?: string;
providerConfig?: ProviderBackendConfig;
supportedAuthModes: ProviderAuthMode[];
defaultAuthMode: ProviderAuthMode;
supportsMultipleAccounts: boolean;
}
export interface ProviderAccount {
id: string;
vendorId: ProviderType;
label: string;
authMode: ProviderAuthMode;
baseUrl?: string;
apiProtocol?: ProviderProtocol;
headers?: Record<string, string>;
model?: string;
fallbackModels?: string[];
fallbackAccountIds?: string[];
enabled: boolean;
isDefault: boolean;
metadata?: {
region?: string;
email?: string;
resourceUrl?: string;
customModels?: string[];
};
createdAt: string;
updatedAt: string;
}
export type ProviderSecret =
| {
type: 'api_key';
accountId: string;
apiKey: string;
}
| {
type: 'oauth';
accountId: string;
accessToken: string;
refreshToken: string;
expiresAt: number;
scopes?: string[];
email?: string;
subject?: string;
}
| {
type: 'local';
accountId: string;
apiKey?: string;
};
export interface ModelSummary {
id: string;
name: string;
vendorId: string;
accountId?: string;
supportsVision?: boolean;
supportsReasoning?: boolean;
contextWindow?: number;
pricing?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
};
source: 'builtin' | 'remote' | 'gateway' | 'custom';
}
+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 });
});
}
+240
View File
@@ -0,0 +1,240 @@
import { EventEmitter } from 'events';
import { BrowserWindow, shell } from 'electron';
import { logger } from './logger';
import { loginGeminiCliOAuth, type GeminiCliOAuthCredentials } from './gemini-cli-oauth';
import { loginOpenAICodexOAuth, type OpenAICodexOAuthCredentials } from './openai-codex-oauth';
import { getProviderService } from '../services/providers/provider-service';
import { getSecretStore } from '../services/secrets/secret-store';
import { saveOAuthTokenToOpenClaw } from './openclaw-auth';
export type BrowserOAuthProviderType = 'google' | 'openai';
const GOOGLE_RUNTIME_PROVIDER_ID = 'google-gemini-cli';
const GOOGLE_OAUTH_DEFAULT_MODEL = 'gemini-3-pro-preview';
const OPENAI_RUNTIME_PROVIDER_ID = 'openai-codex';
const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.3-codex';
class BrowserOAuthManager extends EventEmitter {
private activeProvider: BrowserOAuthProviderType | null = null;
private activeAccountId: string | null = null;
private activeLabel: string | null = null;
private active = false;
private mainWindow: BrowserWindow | null = null;
private pendingManualCodeResolve: ((value: string) => void) | null = null;
private pendingManualCodeReject: ((reason?: unknown) => void) | null = null;
setWindow(window: BrowserWindow) {
this.mainWindow = window;
}
async startFlow(
provider: BrowserOAuthProviderType,
options?: { accountId?: string; label?: string },
): Promise<boolean> {
if (this.active) {
await this.stopFlow();
}
this.active = true;
this.activeProvider = provider;
this.activeAccountId = options?.accountId || provider;
this.activeLabel = options?.label || null;
this.emit('oauth:start', { provider, accountId: this.activeAccountId });
if (provider === 'openai') {
// OpenAI flow may switch to manual callback mode; keep start API non-blocking.
void this.executeFlow(provider);
return true;
}
await this.executeFlow(provider);
return true;
}
private async executeFlow(provider: BrowserOAuthProviderType): Promise<void> {
try {
const token = provider === 'google'
? await loginGeminiCliOAuth({
isRemote: false,
openUrl: async (url) => {
await shell.openExternal(url);
},
log: (message) => logger.info(`[BrowserOAuth] ${message}`),
note: async (message, title) => {
logger.info(`[BrowserOAuth] ${title || 'OAuth note'}: ${message}`);
},
prompt: async () => {
throw new Error('Manual browser OAuth fallback is not implemented in ClawX yet.');
},
progress: {
update: (message) => logger.info(`[BrowserOAuth] ${message}`),
stop: (message) => {
if (message) {
logger.info(`[BrowserOAuth] ${message}`);
}
},
},
})
: await loginOpenAICodexOAuth({
openUrl: async (url) => {
await shell.openExternal(url);
},
onProgress: (message) => logger.info(`[BrowserOAuth] ${message}`),
onManualCodeRequired: ({ authorizationUrl, reason }) => {
const message = reason === 'port_in_use'
? 'OpenAI OAuth callback port 1455 is in use. Complete sign-in, then paste the final callback URL or code.'
: 'OpenAI OAuth callback timed out. Paste the final callback URL or code to continue.';
const payload = {
provider,
mode: 'manual' as const,
authorizationUrl,
message,
};
this.emit('oauth:code', payload);
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:code', payload);
}
},
onManualCodeInput: async () => {
return await new Promise<string>((resolve, reject) => {
this.pendingManualCodeResolve = resolve;
this.pendingManualCodeReject = reject;
});
},
});
await this.onSuccess(provider, token);
} catch (error) {
if (!this.active) {
return;
}
logger.error(`[BrowserOAuth] Flow error for ${provider}:`, error);
this.emitError(error instanceof Error ? error.message : String(error));
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
this.pendingManualCodeResolve = null;
this.pendingManualCodeReject = null;
}
}
async stopFlow(): Promise<void> {
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
if (this.pendingManualCodeReject) {
this.pendingManualCodeReject(new Error('OAuth flow cancelled'));
}
this.pendingManualCodeResolve = null;
this.pendingManualCodeReject = null;
logger.info('[BrowserOAuth] Flow explicitly stopped');
}
submitManualCode(code: string): boolean {
const value = code.trim();
if (!value || !this.pendingManualCodeResolve) {
return false;
}
this.pendingManualCodeResolve(value);
this.pendingManualCodeResolve = null;
this.pendingManualCodeReject = null;
return true;
}
private async onSuccess(
providerType: BrowserOAuthProviderType,
token: GeminiCliOAuthCredentials | OpenAICodexOAuthCredentials,
) {
const accountId = this.activeAccountId || providerType;
const accountLabel = this.activeLabel;
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
this.pendingManualCodeResolve = null;
this.pendingManualCodeReject = null;
logger.info(`[BrowserOAuth] Successfully completed OAuth for ${providerType}`);
const providerService = getProviderService();
const existing = await providerService.getAccount(accountId);
const isGoogle = providerType === 'google';
const runtimeProviderId = isGoogle ? GOOGLE_RUNTIME_PROVIDER_ID : OPENAI_RUNTIME_PROVIDER_ID;
const defaultModel = isGoogle ? GOOGLE_OAUTH_DEFAULT_MODEL : OPENAI_OAUTH_DEFAULT_MODEL;
const accountLabelDefault = isGoogle ? 'Google Gemini' : 'OpenAI Codex';
const oauthTokenEmail = 'email' in token && typeof token.email === 'string' ? token.email : undefined;
const oauthTokenSubject = 'projectId' in token && typeof token.projectId === 'string'
? token.projectId
: ('accountId' in token && typeof token.accountId === 'string' ? token.accountId : undefined);
const normalizedExistingModel = (() => {
const value = existing?.model?.trim();
if (!value) return undefined;
if (isGoogle) {
return value.includes('/') ? value.split('/').pop() : value;
}
// OpenAI OAuth uses openai-codex/* runtime; existing openai/* refs are incompatible.
if (value.startsWith('openai/')) return undefined;
if (value.startsWith('openai-codex/')) return value.split('/').pop();
return value.includes('/') ? value.split('/').pop() : value;
})();
const nextAccount = await providerService.createAccount({
id: accountId,
vendorId: providerType,
label: accountLabel || existing?.label || accountLabelDefault,
authMode: 'oauth_browser',
baseUrl: existing?.baseUrl,
apiProtocol: existing?.apiProtocol,
model: normalizedExistingModel || defaultModel,
fallbackModels: existing?.fallbackModels,
fallbackAccountIds: existing?.fallbackAccountIds,
enabled: existing?.enabled ?? true,
isDefault: existing?.isDefault ?? false,
metadata: {
...existing?.metadata,
email: oauthTokenEmail,
resourceUrl: runtimeProviderId,
},
createdAt: existing?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
await getSecretStore().set({
type: 'oauth',
accountId,
accessToken: token.access,
refreshToken: token.refresh,
expiresAt: token.expires,
email: oauthTokenEmail,
subject: oauthTokenSubject,
});
await saveOAuthTokenToOpenClaw(runtimeProviderId, {
access: token.access,
refresh: token.refresh,
expires: token.expires,
email: oauthTokenEmail,
projectId: oauthTokenSubject,
});
this.emit('oauth:success', { provider: providerType, accountId: nextAccount.id });
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:success', {
provider: providerType,
accountId: nextAccount.id,
success: true,
});
}
}
private emitError(message: string) {
this.emit('oauth:error', { message });
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:error', { message });
}
}
}
export const browserOAuthManager = new BrowserOAuthManager();
+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();
}
}
+3
View File
@@ -12,6 +12,9 @@ export const PORTS = {
/** ClawX GUI production port (for reference) */
CLAWX_GUI: 23333,
/** Local host API server port */
CLAWX_HOST_API: 3210,
/** OpenClaw Gateway port */
OPENCLAW_GATEWAY: 18789,
+147
View File
@@ -0,0 +1,147 @@
/**
* Device identity utilities for OpenClaw Gateway authentication.
*
* OpenClaw Gateway 2026.2.15+ requires a signed device identity in the
* connect handshake to grant scopes (operator.read, operator.write, etc.).
* Without a device, the gateway strips all requested scopes.
*
* All file I/O uses async fs/promises to avoid blocking the main thread.
* Key generation (Ed25519) uses the async crypto.generateKeyPair API.
*/
import crypto from 'crypto';
import { access, readFile, writeFile, mkdir, chmod } from 'fs/promises';
import { constants } from 'fs';
import path from 'path';
export interface DeviceIdentity {
deviceId: string;
publicKeyPem: string;
privateKeyPem: string;
}
export interface DeviceAuthPayloadParams {
deviceId: string;
clientId: string;
clientMode: string;
role: string;
scopes: string[];
signedAtMs: number;
token?: string | null;
nonce?: string | null;
version?: 'v1' | 'v2';
}
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
function base64UrlEncode(buf: Buffer): string {
return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '');
}
function derivePublicKeyRaw(publicKeyPem: string): Buffer {
const spki = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' }) as Buffer;
if (
spki.length === ED25519_SPKI_PREFIX.length + 32 &&
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
) {
return spki.subarray(ED25519_SPKI_PREFIX.length);
}
return spki;
}
function fingerprintPublicKey(publicKeyPem: string): string {
const raw = derivePublicKeyRaw(publicKeyPem);
return crypto.createHash('sha256').update(raw).digest('hex');
}
/** Non-throwing async existence check. */
async function fileExists(p: string): Promise<boolean> {
try { await access(p, constants.F_OK); return true; } catch { return false; }
}
/** Generate a new Ed25519 identity (async key generation). */
async function generateIdentity(): Promise<DeviceIdentity> {
const { publicKey, privateKey } = await new Promise<crypto.KeyPairKeyObjectResult>(
(resolve, reject) => {
crypto.generateKeyPair('ed25519', (err, publicKey, privateKey) => {
if (err) reject(err);
else resolve({ publicKey, privateKey });
});
},
);
const publicKeyPem = (publicKey.export({ type: 'spki', format: 'pem' }) as Buffer).toString();
const privateKeyPem = (privateKey.export({ type: 'pkcs8', format: 'pem' }) as Buffer).toString();
return {
deviceId: fingerprintPublicKey(publicKeyPem),
publicKeyPem,
privateKeyPem,
};
}
/**
* Load device identity from disk, or create and persist a new one.
* The identity file is stored at `filePath` with mode 0o600.
*
* Fully async no synchronous file I/O or crypto.
*/
export async function loadOrCreateDeviceIdentity(filePath: string): Promise<DeviceIdentity> {
try {
if (await fileExists(filePath)) {
const raw = await readFile(filePath, 'utf8');
const parsed = JSON.parse(raw);
if (
parsed?.version === 1 &&
typeof parsed.deviceId === 'string' &&
typeof parsed.publicKeyPem === 'string' &&
typeof parsed.privateKeyPem === 'string'
) {
const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
if (derivedId && derivedId !== parsed.deviceId) {
const updated = { ...parsed, deviceId: derivedId };
await writeFile(filePath, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
return { deviceId: derivedId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
}
return { deviceId: parsed.deviceId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
}
}
} catch {
// fall through to create a new identity
}
const identity = await generateIdentity();
const dir = path.dirname(filePath);
if (!(await fileExists(dir))) await mkdir(dir, { recursive: true });
const stored = { version: 1, ...identity, createdAtMs: Date.now() };
await writeFile(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
try { await chmod(filePath, 0o600); } catch { /* ignore */ }
return identity;
}
/** Sign a string payload with the Ed25519 private key, returns base64url signature. */
export function signDevicePayload(privateKeyPem: string, payload: string): string {
const key = crypto.createPrivateKey(privateKeyPem);
return base64UrlEncode(crypto.sign(null, Buffer.from(payload, 'utf8'), key));
}
/** Encode the raw Ed25519 public key bytes (from PEM) as base64url. */
export function publicKeyRawBase64UrlFromPem(publicKeyPem: string): string {
return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
}
/** Build the canonical payload string that must be signed for device auth. */
export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string {
const version = params.version ?? (params.nonce ? 'v2' : 'v1');
const scopes = params.scopes.join(',');
const token = params.token ?? '';
const base = [
version,
params.deviceId,
params.clientId,
params.clientMode,
params.role,
scopes,
String(params.signedAtMs),
token,
];
if (version === 'v2') base.push(params.nonce ?? '');
return base.join('|');
}
+364
View File
@@ -0,0 +1,364 @@
/**
* Device OAuth Manager
*
* 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:
* - 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
*
* We provide our own callbacks (openUrl/note/progress) that hook into
* the Electron IPC system to display UI in the ClawX frontend.
*/
import { EventEmitter } from 'events';
import { BrowserWindow, shell } from 'electron';
import { logger } from './logger';
import { saveProvider, getProvider, ProviderConfig } from './secure-storage';
import { getProviderDefaultModel } from './provider-registry';
import { 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';
// Re-export types for consumers
export type { MiniMaxRegion, MiniMaxOAuthToken, QwenOAuthToken };
// ─────────────────────────────────────────────────────────────
// DeviceOAuthManager
// ─────────────────────────────────────────────────────────────
class DeviceOAuthManager extends EventEmitter {
private activeProvider: OAuthProviderType | null = null;
private activeAccountId: string | null = null;
private activeLabel: string | null = null;
private active: boolean = false;
private mainWindow: BrowserWindow | null = null;
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;
}
async startFlow(
provider: OAuthProviderType,
region: MiniMaxRegion = 'global',
options?: { accountId?: string; label?: string },
): Promise<boolean> {
if (this.active) {
await this.stopFlow();
}
this.active = true;
this.emit('oauth:start', { provider, accountId: options?.accountId || provider });
this.activeProvider = provider;
this.activeAccountId = options?.accountId || provider;
this.activeLabel = options?.label || null;
try {
if (provider === 'minimax-portal' || provider === 'minimax-portal-cn') {
const actualRegion = provider === 'minimax-portal-cn' ? 'cn' : (region || 'global');
await this.runMiniMaxFlow(actualRegion, provider);
} else if (provider === 'qwen-portal') {
await this.runQwenFlow();
} else {
throw new Error(`Unsupported OAuth provider type: ${provider}`);
}
return true;
} catch (error) {
if (!this.active) {
// Flow was cancelled — not an error
return false;
}
logger.error(`[DeviceOAuth] Flow error for ${provider}:`, error);
this.emitError(error instanceof Error ? error.message : String(error));
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
return false;
}
}
async stopFlow(): Promise<void> {
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
logger.info('[DeviceOAuth] Flow explicitly stopped');
}
// ─────────────────────────────────────────────────────────
// MiniMax flow
// ─────────────────────────────────────────────────────────
private async runMiniMaxFlow(region?: MiniMaxRegion, providerType: OAuthProviderType = 'minimax-portal'): Promise<void> {
const provider = this.activeProvider!;
const token: MiniMaxOAuthToken = await this.runWithProxyAwareFetch(() => loginMiniMaxPortalOAuth({
region,
openUrl: async (url: string) => {
logger.info(`[DeviceOAuth] MiniMax opening browser: ${url}`);
// Open the authorization URL in the system browser
shell.openExternal(url).catch((err: unknown) =>
logger.warn(`[DeviceOAuth] Failed to open browser:`, err)
);
},
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
const { verificationUri, userCode } = this.parseNote(message);
if (verificationUri && userCode) {
this.emitCode({ provider, verificationUri, userCode, expiresIn: 300 });
} else {
logger.info(`[DeviceOAuth] MiniMax note: ${message}`);
}
},
progress: {
update: (msg: string) => logger.info(`[DeviceOAuth] MiniMax progress: ${msg}`),
stop: (msg?: string) => logger.info(`[DeviceOAuth] MiniMax progress done: ${msg ?? ''}`),
},
}));
if (!this.active) return;
await this.onSuccess(providerType, {
access: token.access,
refresh: token.refresh,
expires: token.expires,
// MiniMax returns a per-account resourceUrl as the API base URL
resourceUrl: token.resourceUrl,
// Revert back to anthropic-messages
api: 'anthropic-messages',
region,
});
}
// ─────────────────────────────────────────────────────────
// Qwen flow
// ─────────────────────────────────────────────────────────
private async runQwenFlow(): Promise<void> {
const provider = this.activeProvider!;
const token: QwenOAuthToken = await this.runWithProxyAwareFetch(() => loginQwenPortalOAuth({
openUrl: async (url: string) => {
logger.info(`[DeviceOAuth] Qwen opening browser: ${url}`);
shell.openExternal(url).catch((err: unknown) =>
logger.warn(`[DeviceOAuth] Failed to open browser:`, err)
);
},
note: async (message: string, _title?: string) => {
if (!this.active) return;
const { verificationUri, userCode } = this.parseNote(message);
if (verificationUri && userCode) {
this.emitCode({ provider, verificationUri, userCode, expiresIn: 300 });
} else {
logger.info(`[DeviceOAuth] Qwen note: ${message}`);
}
},
progress: {
update: (msg: string) => logger.info(`[DeviceOAuth] Qwen progress: ${msg}`),
stop: (msg?: string) => logger.info(`[DeviceOAuth] Qwen progress done: ${msg ?? ''}`),
},
}));
if (!this.active) return;
await this.onSuccess('qwen-portal', {
access: token.access,
refresh: token.refresh,
expires: token.expires,
// Qwen returns a per-account resourceUrl as the API base URL
resourceUrl: token.resourceUrl,
// Qwen uses OpenAI Completions API format
api: 'openai-completions',
});
}
// ─────────────────────────────────────────────────────────
// Success handler
// ─────────────────────────────────────────────────────────
private async onSuccess(providerType: OAuthProviderType, token: {
access: string;
refresh: string;
expires: number;
resourceUrl?: string;
api: 'anthropic-messages' | 'openai-completions';
region?: MiniMaxRegion;
}) {
const accountId = this.activeAccountId || providerType;
const accountLabel = this.activeLabel;
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
logger.info(`[DeviceOAuth] Successfully completed OAuth for ${providerType}`);
// 1. Write OAuth token to OpenClaw's auth-profiles.json in native OAuth format.
// (matches what `openclaw models auth login` → upsertAuthProfile writes).
// We save both MiniMax providers to the generic "minimax-portal" profile
// so OpenClaw's gateway auto-refresher knows how to find it.
try {
const tokenProviderId = providerType.startsWith('minimax-portal') ? 'minimax-portal' : providerType;
await saveOAuthTokenToOpenClaw(tokenProviderId, {
access: token.access,
refresh: token.refresh,
expires: token.expires,
});
} catch (err) {
logger.warn(`[DeviceOAuth] Failed to save OAuth token to OpenClaw:`, err);
}
// 2. Write openclaw.json: set default model + provider config (baseUrl/api/models)
// This mirrors what the OpenClaw plugin's configPatch does after CLI login.
// The baseUrl comes from token.resourceUrl (per-account URL from the OAuth server)
// or falls back to the provider's default public endpoint.
const defaultBaseUrl = providerType === 'minimax-portal'
? 'https://api.minimax.io/anthropic'
: (providerType === 'minimax-portal-cn' ? 'https://api.minimaxi.com/anthropic' : 'https://portal.qwen.ai/v1');
let baseUrl = token.resourceUrl || defaultBaseUrl;
// Ensure baseUrl has a protocol prefix
if (baseUrl && !baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://' + baseUrl;
}
// Ensure the base URL ends with /anthropic
if (providerType.startsWith('minimax-portal') && baseUrl) {
baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
} else if (providerType === 'qwen-portal' && baseUrl) {
// Ensure Qwen API gets /v1 at the end
if (!baseUrl.endsWith('/v1')) {
baseUrl = baseUrl.replace(/\/$/, '') + '/v1';
}
}
try {
const tokenProviderId = providerType.startsWith('minimax-portal') ? 'minimax-portal' : providerType;
await setOpenClawDefaultModelWithOverride(tokenProviderId, undefined, {
baseUrl,
api: token.api,
// Tells OpenClaw's anthropic adapter to use `Authorization: Bearer` instead of `x-api-key`
authHeader: providerType.startsWith('minimax-portal') ? true : undefined,
// OAuth placeholder — tells Gateway to resolve credentials
// from auth-profiles.json (type: 'oauth') instead of a static API key.
apiKeyEnv: tokenProviderId === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
});
} catch (err) {
logger.warn(`[DeviceOAuth] Failed to configure openclaw models:`, err);
}
// 3. Save provider record in ClawX's own store so UI shows it as configured
const existing = await getProvider(accountId);
const nameMap: Record<OAuthProviderType, string> = {
'minimax-portal': 'MiniMax (Global)',
'minimax-portal-cn': 'MiniMax (CN)',
'qwen-portal': 'Qwen',
};
const providerConfig: ProviderConfig = {
id: accountId,
name: accountLabel || nameMap[providerType as OAuthProviderType] || providerType,
type: providerType,
enabled: existing?.enabled ?? true,
baseUrl, // Save the dynamically resolved URL (Global vs CN)
model: getProviderDefaultModel(providerType) || existing?.model,
createdAt: existing?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await saveProvider(providerConfig);
// 4. Emit success internally so the main process can restart the Gateway
this.emit('oauth:success', { provider: providerType, accountId });
// 5. Emit success to frontend
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:success', { provider: providerType, accountId, success: true });
}
}
// ─────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────
/**
* Parse user_code and verification_uri from the note message sent by
* the OpenClaw extension's loginXxxPortalOAuth function.
*
* Note format (minimax-portal-auth/oauth.ts):
* "Open https://platform.minimax.io/oauth-authorize?user_code=dyMj_wOhpK&client=... to approve access.\n"
* "If prompted, enter the code dyMj_wOhpK.\n"
* ...
*
* user_code format: mixed-case alphanumeric with underscore, e.g. "dyMj_wOhpK"
*/
private parseNote(message: string): { verificationUri?: string; userCode?: string } {
// Primary: extract URL (everything between "Open " and " to")
const urlMatch = message.match(/Open\s+(https?:\/\/\S+?)\s+to/i);
const verificationUri = urlMatch?.[1];
let userCode: string | undefined;
// Method 1: extract user_code from URL query param (most reliable)
if (verificationUri) {
try {
const parsed = new URL(verificationUri);
const qp = parsed.searchParams.get('user_code');
if (qp) userCode = qp;
} catch {
// fall through to text-based extraction
}
}
// Method 2: text-based extraction — matches mixed-case alnum + underscore/hyphen codes
if (!userCode) {
const codeMatch = message.match(/enter.*?code\s+([A-Za-z0-9][A-Za-z0-9_-]{3,})/i);
if (codeMatch?.[1]) userCode = codeMatch[1].replace(/\.$/, ''); // strip trailing period
}
return { verificationUri, userCode };
}
private emitCode(data: {
provider: string;
verificationUri: string;
userCode: string;
expiresIn: number;
}) {
this.emit('oauth:code', data);
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:code', data);
}
}
private emitError(message: string) {
this.emit('oauth:error', { message });
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:error', { message });
}
}
}
export const deviceOAuthManager = new DeviceOAuthManager();
+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,
};
}
+739
View File
@@ -0,0 +1,739 @@
import { execFile, execFileSync } from 'node:child_process';
import { createHash, randomBytes } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { delimiter, dirname, join } from 'node:path';
import { getClawXConfigDir } from './paths';
import { proxyAwareFetch } from './proxy-fetch';
const CLIENT_ID_KEYS = ['OPENCLAW_GEMINI_OAUTH_CLIENT_ID', 'GEMINI_CLI_OAUTH_CLIENT_ID'];
const CLIENT_SECRET_KEYS = [
'OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET',
'GEMINI_CLI_OAUTH_CLIENT_SECRET',
];
const REDIRECT_URI = 'http://127.0.0.1:8085/oauth2callback';
const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
const USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json';
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
const SCOPES = [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
];
const TIER_FREE = 'free-tier';
const TIER_LEGACY = 'legacy-tier';
const TIER_STANDARD = 'standard-tier';
const LOCAL_GEMINI_DIR = join(getClawXConfigDir(), 'gemini-cli');
export type GeminiCliOAuthCredentials = {
access: string;
refresh: string;
expires: number;
email?: string;
projectId?: string;
};
export type GeminiCliOAuthContext = {
isRemote: boolean;
openUrl: (url: string) => Promise<void>;
log: (msg: string) => void;
note: (message: string, title?: string) => Promise<void>;
prompt: (message: string) => Promise<string>;
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
};
export class DetailedError extends Error {
detail: string;
constructor(message: string, detail: string) {
super(message);
this.name = 'DetailedError';
this.detail = detail;
}
}
let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null;
function resolveEnv(keys: string[]): string | undefined {
for (const key of keys) {
const value = process.env[key]?.trim();
if (value) {
return value;
}
}
return undefined;
}
function findInPath(name: string): string | null {
const exts = process.platform === 'win32' ? ['.cmd', '.bat', '.exe', ''] : [''];
for (const dir of (process.env.PATH ?? '').split(delimiter)) {
if (!dir) continue;
for (const ext of exts) {
const p = join(dir, name + ext);
if (existsSync(p)) {
return p;
}
}
}
return null;
}
function findFile(dir: string, name: string, depth: number): string | null {
if (depth <= 0) {
return null;
}
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const next = join(dir, entry.name);
if (entry.isFile() && entry.name === name) {
return next;
}
if (entry.isDirectory() && !entry.name.startsWith('.')) {
const found = findFile(next, name, depth - 1);
if (found) {
return found;
}
}
}
} catch {
return null;
}
return null;
}
export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null {
if (cachedGeminiCliCredentials) {
return cachedGeminiCliCredentials;
}
try {
const geminiPath = findInPath('gemini');
if (!geminiPath) {
return null;
}
const resolvedPath = realpathSync(geminiPath);
const geminiCliDir = dirname(dirname(resolvedPath));
const searchPaths = [
join(
geminiCliDir,
'node_modules',
'@google',
'gemini-cli-core',
'dist',
'src',
'code_assist',
'oauth2.js',
),
join(
geminiCliDir,
'node_modules',
'@google',
'gemini-cli-core',
'dist',
'code_assist',
'oauth2.js',
),
];
let content: string | null = null;
for (const p of searchPaths) {
if (existsSync(p)) {
content = readFileSync(p, 'utf8');
break;
}
}
if (!content) {
const found = findFile(geminiCliDir, 'oauth2.js', 10);
if (found) {
content = readFileSync(found, 'utf8');
}
}
if (!content) {
return null;
}
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
if (idMatch && secretMatch) {
cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] };
return cachedGeminiCliCredentials;
}
} catch {
return null;
}
return null;
}
function extractFromLocalInstall(): { clientId: string; clientSecret: string } | null {
const coreDir = join(LOCAL_GEMINI_DIR, 'node_modules', '@google', 'gemini-cli-core');
if (!existsSync(coreDir)) {
return null;
}
const searchPaths = [
join(coreDir, 'dist', 'src', 'code_assist', 'oauth2.js'),
join(coreDir, 'dist', 'code_assist', 'oauth2.js'),
];
let content: string | null = null;
for (const p of searchPaths) {
if (existsSync(p)) {
content = readFileSync(p, 'utf8');
break;
}
}
if (!content) {
const found = findFile(coreDir, 'oauth2.js', 10);
if (found) {
content = readFileSync(found, 'utf8');
}
}
if (!content) {
return null;
}
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
if (idMatch && secretMatch) {
return { clientId: idMatch[1], clientSecret: secretMatch[1] };
}
return null;
}
async function installViaNpm(onProgress?: (msg: string) => void): Promise<boolean> {
const npmBin = findInPath('npm');
if (!npmBin) {
return false;
}
onProgress?.('Installing Gemini OAuth helper...');
return await new Promise((resolve) => {
const useShell = process.platform === 'win32';
const child = execFile(
npmBin,
['install', '--prefix', LOCAL_GEMINI_DIR, '@google/gemini-cli'],
{ timeout: 120_000, shell: useShell, env: { ...process.env, NODE_ENV: '' } },
(err) => {
if (err) {
onProgress?.(`Gemini helper install failed, falling back to direct download...`);
resolve(false);
} else {
cachedGeminiCliCredentials = null;
onProgress?.('Gemini OAuth helper installed');
resolve(true);
}
},
);
child.stderr?.on('data', () => {
// Suppress npm noise.
});
});
}
async function installViaDirectDownload(onProgress?: (msg: string) => void): Promise<boolean> {
try {
onProgress?.('Downloading Gemini OAuth helper...');
const metaRes = await proxyAwareFetch('https://registry.npmjs.org/@google/gemini-cli-core/latest');
if (!metaRes.ok) {
onProgress?.(`Failed to fetch Gemini package metadata: ${metaRes.status}`);
return false;
}
const meta = (await metaRes.json()) as { dist?: { tarball?: string } };
const tarballUrl = meta.dist?.tarball;
if (!tarballUrl) {
onProgress?.('Gemini package tarball URL missing');
return false;
}
const tarRes = await proxyAwareFetch(tarballUrl);
if (!tarRes.ok) {
onProgress?.(`Failed to download Gemini package: ${tarRes.status}`);
return false;
}
const buffer = Buffer.from(await tarRes.arrayBuffer());
const targetDir = join(LOCAL_GEMINI_DIR, 'node_modules', '@google', 'gemini-cli-core');
mkdirSync(targetDir, { recursive: true });
const tmpFile = join(LOCAL_GEMINI_DIR, '_tmp_gemini-cli-core.tgz');
writeFileSync(tmpFile, buffer);
try {
execFileSync('tar', ['xzf', tmpFile, '-C', targetDir, '--strip-components=1'], {
timeout: 30_000,
});
} finally {
try {
unlinkSync(tmpFile);
} catch {
// ignore
}
}
cachedGeminiCliCredentials = null;
onProgress?.('Gemini OAuth helper ready');
return true;
} catch (err) {
onProgress?.(`Direct Gemini helper download failed: ${err instanceof Error ? err.message : String(err)}`);
return false;
}
}
async function ensureOAuthClientConfig(
onProgress?: (msg: string) => void,
): Promise<{ clientId: string; clientSecret?: string }> {
const envClientId = resolveEnv(CLIENT_ID_KEYS);
const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS);
if (envClientId) {
return { clientId: envClientId, clientSecret: envClientSecret };
}
const extracted = extractGeminiCliCredentials();
if (extracted) {
return extracted;
}
const localExtracted = extractFromLocalInstall();
if (localExtracted) {
return localExtracted;
}
mkdirSync(LOCAL_GEMINI_DIR, { recursive: true });
const installed = await installViaNpm(onProgress) || await installViaDirectDownload(onProgress);
if (installed) {
const installedExtracted = extractFromLocalInstall();
if (installedExtracted) {
return installedExtracted;
}
}
throw new Error(
'Unable to prepare Gemini OAuth credentials automatically. Set GEMINI_CLI_OAUTH_CLIENT_ID or try again later.',
);
}
function generatePkce(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString('hex');
const challenge = createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
function buildAuthUrl(clientId: string, challenge: string, verifier: string): string {
const params = new URLSearchParams({
client_id: clientId,
response_type: 'code',
redirect_uri: REDIRECT_URI,
scope: SCOPES.join(' '),
code_challenge: challenge,
code_challenge_method: 'S256',
state: verifier,
access_type: 'offline',
prompt: 'consent',
});
return `${AUTH_URL}?${params.toString()}`;
}
async function waitForLocalCallback(params: {
expectedState: string;
timeoutMs: number;
onProgress?: (message: string) => void;
}): Promise<{ code: string; state: string }> {
const port = 8085;
const hostname = '127.0.0.1';
const expectedPath = '/oauth2callback';
return new Promise((resolve, reject) => {
let timeout: NodeJS.Timeout | null = null;
const server = createServer((req, res) => {
try {
const requestUrl = new URL(req.url ?? '/', `http://${hostname}:${port}`);
if (requestUrl.pathname !== expectedPath) {
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain');
res.end('Not found');
return;
}
const error = requestUrl.searchParams.get('error');
const code = requestUrl.searchParams.get('code')?.trim();
const state = requestUrl.searchParams.get('state')?.trim();
if (error) {
res.statusCode = 400;
res.setHeader('Content-Type', 'text/plain');
res.end(`Authentication failed: ${error}`);
finish(new Error(`OAuth error: ${error}`));
return;
}
if (!code || !state) {
res.statusCode = 400;
res.setHeader('Content-Type', 'text/plain');
res.end('Missing code or state');
finish(new Error('Missing OAuth code or state'));
return;
}
if (state !== params.expectedState) {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(
"<!doctype html><html><head><meta charset='utf-8'/></head><body><h2>Session expired</h2><p>This authorization link is from a previous attempt. Please go back to ClawX and try again.</p></body></html>",
);
return;
}
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(
"<!doctype html><html><head><meta charset='utf-8'/></head><body><h2>Gemini CLI OAuth complete</h2><p>You can close this window and return to ClawX.</p></body></html>",
);
finish(undefined, { code, state });
} catch (err) {
finish(err instanceof Error ? err : new Error('OAuth callback failed'));
}
});
const finish = (err?: Error, result?: { code: string; state: string }) => {
if (timeout) {
clearTimeout(timeout);
}
try {
server.close();
} catch {
// ignore
}
if (err) {
reject(err);
} else if (result) {
resolve(result);
}
};
server.once('error', (err) => {
finish(err instanceof Error ? err : new Error('OAuth callback server error'));
});
server.listen(port, hostname, () => {
params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}...`);
});
timeout = setTimeout(() => {
finish(new DetailedError(
'OAuth login timed out. The browser did not redirect back. Check if localhost:8085 is blocked.',
`Waited ${params.timeoutMs / 1000}s for callback on ${hostname}:${port}`,
));
}, params.timeoutMs);
});
}
async function getUserEmail(accessToken: string): Promise<string | undefined> {
try {
const response = await proxyAwareFetch(USERINFO_URL, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (response.ok) {
const data = (await response.json()) as { email?: string };
return data.email;
}
} catch {
// ignore
}
return undefined;
}
function getDefaultTier(
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>,
): { id?: string } | undefined {
if (!allowedTiers?.length) {
return { id: TIER_LEGACY };
}
return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY };
}
function isVpcScAffected(payload: unknown): boolean {
if (!payload || typeof payload !== 'object') {
return false;
}
const error = (payload as { error?: unknown }).error;
if (!error || typeof error !== 'object') {
return false;
}
const details = (error as { details?: unknown[] }).details;
if (!Array.isArray(details)) {
return false;
}
return details.some(
(item) =>
typeof item === 'object'
&& item
&& (item as { reason?: string }).reason === 'SECURITY_POLICY_VIOLATED',
);
}
async function pollOperation(
operationName: string,
headers: Record<string, string>,
): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> {
for (let attempt = 0; attempt < 24; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 5000));
const response = await proxyAwareFetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { headers });
if (!response.ok) {
continue;
}
const data = (await response.json()) as {
done?: boolean;
response?: { cloudaicompanionProject?: { id?: string } };
};
if (data.done) {
return data;
}
}
throw new Error('Operation polling timeout');
}
async function discoverProject(accessToken: string): Promise<string> {
const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID;
const headers = {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'User-Agent': 'google-api-nodejs-client/9.15.1',
'X-Goog-Api-Client': 'gl-node/clawx',
};
const loadBody = {
cloudaicompanionProject: envProject,
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
duetProject: envProject,
},
};
let data: {
currentTier?: { id?: string };
cloudaicompanionProject?: string | { id?: string };
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
} = {};
const response = await proxyAwareFetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
method: 'POST',
headers,
body: JSON.stringify(loadBody),
});
if (!response.ok) {
const errorPayload = await response.json().catch(() => null);
if (isVpcScAffected(errorPayload)) {
data = { currentTier: { id: TIER_STANDARD } };
} else {
throw new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`);
}
} else {
data = (await response.json()) as typeof data;
}
if (data.currentTier) {
const project = data.cloudaicompanionProject;
if (typeof project === 'string' && project) {
return project;
}
if (typeof project === 'object' && project?.id) {
return project.id;
}
if (envProject) {
return envProject;
}
}
const hasExistingTierButNoProject = !!data.currentTier;
const tier = hasExistingTierButNoProject ? { id: TIER_FREE } : getDefaultTier(data.allowedTiers);
const tierId = tier?.id || TIER_FREE;
if (tierId !== TIER_FREE && !envProject) {
throw new DetailedError(
'Your Google account requires a Cloud project. Please create one and set GOOGLE_CLOUD_PROJECT.',
`tierId=${tierId}, currentTier=${JSON.stringify(data.currentTier ?? null)}, allowedTiers=${JSON.stringify(data.allowedTiers)}`,
);
}
const onboardBody: Record<string, unknown> = {
tierId,
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
},
};
if (tierId !== TIER_FREE && envProject) {
onboardBody.cloudaicompanionProject = envProject;
(onboardBody.metadata as Record<string, unknown>).duetProject = envProject;
}
const onboardResponse = await proxyAwareFetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
method: 'POST',
headers,
body: JSON.stringify(onboardBody),
});
if (!onboardResponse.ok) {
const respText = await onboardResponse.text().catch(() => '');
throw new DetailedError(
'Google project provisioning failed. Please try again later.',
`onboardUser ${onboardResponse.status} ${onboardResponse.statusText}: ${respText}`,
);
}
let lro = (await onboardResponse.json()) as {
done?: boolean;
name?: string;
response?: { cloudaicompanionProject?: { id?: string } };
};
if (!lro.done && lro.name) {
lro = await pollOperation(lro.name, headers);
}
const projectId = lro.response?.cloudaicompanionProject?.id;
if (projectId) {
return projectId;
}
if (envProject) {
return envProject;
}
throw new DetailedError(
'Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.',
`tierId=${tierId}, onboardResponse=${JSON.stringify(lro)}, currentTier=${JSON.stringify(data.currentTier ?? null)}`,
);
}
async function exchangeCodeForTokens(
code: string,
verifier: string,
clientConfig: { clientId: string; clientSecret?: string },
): Promise<GeminiCliOAuthCredentials> {
const { clientId, clientSecret } = clientConfig;
const body = new URLSearchParams({
client_id: clientId,
code,
grant_type: 'authorization_code',
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
});
if (clientSecret) {
body.set('client_secret', clientSecret);
}
const response = await proxyAwareFetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${errorText}`);
}
const data = (await response.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
if (!data.refresh_token) {
throw new Error('No refresh token received. Please try again.');
}
const email = await getUserEmail(data.access_token);
const projectId = await discoverProject(data.access_token);
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
return {
refresh: data.refresh_token,
access: data.access_token,
expires: expiresAt,
projectId,
email,
};
}
export async function loginGeminiCliOAuth(
ctx: GeminiCliOAuthContext,
): Promise<GeminiCliOAuthCredentials> {
if (ctx.isRemote) {
throw new Error('Remote/manual Gemini OAuth is not implemented in ClawX yet.');
}
await ctx.note(
[
'Browser will open for Google authentication.',
'Sign in with your Google account for Gemini CLI access.',
'The callback will be captured automatically on 127.0.0.1:8085.',
].join('\n'),
'Gemini CLI OAuth',
);
ctx.progress.update('Preparing Google OAuth...');
const clientConfig = await ensureOAuthClientConfig((msg) => ctx.progress.update(msg));
const { verifier, challenge } = generatePkce();
const authUrl = buildAuthUrl(clientConfig.clientId, challenge, verifier);
ctx.progress.update('Complete sign-in in browser...');
try {
await ctx.openUrl(authUrl);
} catch {
ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`);
}
try {
const { code } = await waitForLocalCallback({
expectedState: verifier,
timeoutMs: 5 * 60 * 1000,
onProgress: (msg) => ctx.progress.update(msg),
});
ctx.progress.update('Exchanging authorization code for tokens...');
return await exchangeCodeForTokens(code, verifier, clientConfig);
} catch (err) {
if (
err instanceof Error
&& (err.message.includes('EADDRINUSE')
|| err.message.includes('port')
|| err.message.includes('listen'))
) {
throw new Error(
'Port 8085 is in use by another process. Close the other application using port 8085 and try again.',
{ cause: err },
);
}
throw err;
}
}
// Best-effort check to help with diagnostics if the user claims gemini is installed but PATH is stale.
export function detectGeminiCliVersion(): string | null {
try {
const geminiPath = findInPath('gemini');
if (!geminiPath) {
return null;
}
return execFileSync(geminiPath, ['--version'], { encoding: 'utf8' }).trim();
} catch {
return null;
}
}
+132 -65
View File
@@ -1,10 +1,16 @@
/**
* Logger Utility
* Centralized logging with levels, file output, and log retrieval for UI
* Centralized logging with levels, file output, and log retrieval for UI.
*
* File writes use an async buffered writer so that high-frequency logging
* (e.g. during gateway startup) never blocks the Electron main thread.
* Only the final `process.on('exit')` handler uses synchronous I/O to
* guarantee the last few messages are flushed before the process exits.
*/
import { app } from 'electron';
import { join } from 'path';
import { existsSync, mkdirSync, appendFileSync, readFileSync, readdirSync, statSync } from 'fs';
import { existsSync, mkdirSync, appendFileSync } from 'fs';
import { appendFile, open, readdir, stat } from 'fs/promises';
/**
* Log levels
@@ -19,7 +25,11 @@ export enum LogLevel {
/**
* Current log level (can be changed at runtime)
*/
let currentLevel = LogLevel.DEBUG; // Default to DEBUG for better diagnostics
// Default to INFO in packaged builds to reduce sync-like overhead from
// high-volume DEBUG logging. In dev mode, keep DEBUG for diagnostics.
// Note: app.isPackaged may not be available before app.isReady(), but the
// logger is initialised after that point so this is safe.
let currentLevel = LogLevel.DEBUG;
/**
* Log file path
@@ -33,11 +43,58 @@ let logDir: string | null = null;
const RING_BUFFER_SIZE = 500;
const recentLogs: string[] = [];
// ── Async write buffer ───────────────────────────────────────────
/** Pending log lines waiting to be flushed to disk. */
let writeBuffer: string[] = [];
/** Timer for the next scheduled flush. */
let flushTimer: NodeJS.Timeout | null = null;
/** Whether a flush is currently in progress. */
let flushing = false;
const FLUSH_INTERVAL_MS = 500;
const FLUSH_SIZE_THRESHOLD = 20;
async function flushBuffer(): Promise<void> {
if (flushing || writeBuffer.length === 0 || !logFilePath) return;
flushing = true;
const batch = writeBuffer.join('');
writeBuffer = [];
try {
await appendFile(logFilePath, batch);
} catch {
// Silently fail if we can't write to file
} finally {
flushing = false;
}
}
/** Synchronous flush for the `exit` handler — guaranteed to write. */
function flushBufferSync(): void {
if (writeBuffer.length === 0 || !logFilePath) return;
try {
appendFileSync(logFilePath, writeBuffer.join(''));
} catch {
// Silently fail
}
writeBuffer = [];
}
// Ensure all buffered data reaches disk before the process exits.
process.on('exit', flushBufferSync);
// ── Initialisation ───────────────────────────────────────────────
/**
* Initialize logger safe to call before app.isReady()
*/
export function initLogger(): void {
try {
// In production, default to INFO to reduce log volume and overhead.
if (app.isPackaged && currentLevel < LogLevel.INFO) {
currentLevel = LogLevel.INFO;
}
logDir = join(app.getPath('userData'), 'logs');
if (!existsSync(logDir)) {
@@ -47,7 +104,7 @@ export function initLogger(): void {
const timestamp = new Date().toISOString().split('T')[0];
logFilePath = join(logDir, `clawx-${timestamp}.log`);
// Write a separator for new session
// Write a separator for new session (sync is OK — happens once at startup)
const sessionHeader = `\n${'='.repeat(80)}\n[${new Date().toISOString()}] === ClawX Session Start (v${app.getVersion()}) ===\n${'='.repeat(80)}\n`;
appendFileSync(logFilePath, sessionHeader);
} catch (error) {
@@ -55,30 +112,22 @@ export function initLogger(): void {
}
}
/**
* Set log level
*/
// ── Level / path accessors ───────────────────────────────────────
export function setLogLevel(level: LogLevel): void {
currentLevel = level;
}
/**
* Get log file directory path
*/
export function getLogDir(): string | null {
return logDir;
}
/**
* Get current log file path
*/
export function getLogFilePath(): string | null {
return logFilePath;
}
/**
* Format log message
*/
// ── Formatting ───────────────────────────────────────────────────
function formatMessage(level: string, message: string, ...args: unknown[]): string {
const timestamp = new Date().toISOString();
const formattedArgs = args.length > 0 ? ' ' + args.map(arg => {
@@ -98,29 +147,36 @@ function formatMessage(level: string, message: string, ...args: unknown[]): stri
return `[${timestamp}] [${level.padEnd(5)}] ${message}${formattedArgs}`;
}
// ── Core write ───────────────────────────────────────────────────
/**
* Write to log file and ring buffer
* Write to ring buffer + schedule an async flush to disk.
*/
function writeLog(formatted: string): void {
// Ring buffer
// Ring buffer (always synchronous — in-memory only)
recentLogs.push(formatted);
if (recentLogs.length > RING_BUFFER_SIZE) {
recentLogs.shift();
}
// File
// Async file write via buffer
if (logFilePath) {
try {
appendFileSync(logFilePath, formatted + '\n');
} catch {
// Silently fail if we can't write to file
writeBuffer.push(formatted + '\n');
if (writeBuffer.length >= FLUSH_SIZE_THRESHOLD) {
// Buffer is large enough — flush immediately (non-blocking)
void flushBuffer();
} else if (!flushTimer) {
// Schedule a flush after a short delay
flushTimer = setTimeout(() => {
flushTimer = null;
void flushBuffer();
}, FLUSH_INTERVAL_MS);
}
}
}
/**
* Log debug message
*/
// ── Public log methods ───────────────────────────────────────────
export function debug(message: string, ...args: unknown[]): void {
if (currentLevel <= LogLevel.DEBUG) {
const formatted = formatMessage('DEBUG', message, ...args);
@@ -129,9 +185,6 @@ export function debug(message: string, ...args: unknown[]): void {
}
}
/**
* Log info message
*/
export function info(message: string, ...args: unknown[]): void {
if (currentLevel <= LogLevel.INFO) {
const formatted = formatMessage('INFO', message, ...args);
@@ -140,9 +193,6 @@ export function info(message: string, ...args: unknown[]): void {
}
}
/**
* Log warning message
*/
export function warn(message: string, ...args: unknown[]): void {
if (currentLevel <= LogLevel.WARN) {
const formatted = formatMessage('WARN', message, ...args);
@@ -151,9 +201,6 @@ export function warn(message: string, ...args: unknown[]): void {
}
}
/**
* Log error message
*/
export function error(message: string, ...args: unknown[]): void {
if (currentLevel <= LogLevel.ERROR) {
const formatted = formatMessage('ERROR', message, ...args);
@@ -162,11 +209,8 @@ export function error(message: string, ...args: unknown[]): void {
}
}
/**
* Get recent logs from ring buffer (for UI display)
* @param count Number of recent log lines to return (default: all)
* @param minLevel Minimum log level to include (default: DEBUG)
*/
// ── Log retrieval (for UI / diagnostics) ─────────────────────────
export function getRecentLogs(count?: number, minLevel?: LogLevel): string[] {
const filtered = minLevel != null
? recentLogs.filter(line => {
@@ -181,41 +225,64 @@ export function getRecentLogs(count?: number, minLevel?: LogLevel): string[] {
}
/**
* Read the current day's log file content (last N lines)
* Read the current day's log file content (last N lines).
* Uses async I/O to avoid blocking.
*/
export function readLogFile(tailLines = 200): string {
if (!logFilePath || !existsSync(logFilePath)) {
return '(No log file found)';
}
export async function readLogFile(tailLines = 200): Promise<string> {
if (!logFilePath) return '(No log file found)';
const safeTailLines = Math.max(1, Math.floor(tailLines));
try {
const content = readFileSync(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})`;
}
}
/**
* List available log files
* List available log files.
* Uses async I/O to avoid blocking.
*/
export function listLogFiles(): Array<{ name: string; path: string; size: number; modified: string }> {
if (!logDir || !existsSync(logDir)) return [];
export async function listLogFiles(): Promise<Array<{ name: string; path: string; size: number; modified: string }>> {
if (!logDir) return [];
try {
return readdirSync(logDir)
.filter(f => f.endsWith('.log'))
.map(f => {
const fullPath = join(logDir!, f);
const stat = statSync(fullPath);
return {
name: f,
path: fullPath,
size: stat.size,
modified: stat.mtime.toISOString(),
};
})
.sort((a, b) => b.modified.localeCompare(a.modified));
const files = await readdir(logDir);
const results: Array<{ name: string; path: string; size: number; modified: string }> = [];
for (const f of files) {
if (!f.endsWith('.log')) continue;
const fullPath = join(logDir, f);
const s = await stat(fullPath);
results.push({
name: f,
path: fullPath,
size: s.size,
modified: s.mtime.toISOString(),
});
}
return results.sort((a, b) => b.modified.localeCompare(a.modified));
} catch {
return [];
}
+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
+343 -21
View File
@@ -1,13 +1,24 @@
/**
* OpenClaw CLI utilities
* OpenClaw CLI utilities cross-platform auto-install
*/
import { app } from 'electron';
import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
import {
appendFileSync,
chmodSync,
existsSync,
mkdirSync,
readFileSync,
symlinkSync,
unlinkSync,
} from 'node:fs';
import { spawn } from 'node:child_process';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { join, dirname } from 'node:path';
import { getOpenClawDir, getOpenClawEntryPath } from './paths';
import { logger } from './logger';
// ── Quoting helpers ──────────────────────────────────────────────────────────
function escapeForDoubleQuotes(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
@@ -20,17 +31,31 @@ 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 {
const entryPath = getOpenClawEntryPath();
const platform = process.platform;
if (platform === 'darwin') {
if (platform === 'darwin' || platform === 'linux') {
const localBinPath = join(homedir(), '.local', 'bin', 'openclaw');
if (existsSync(localBinPath)) {
return quoteForPosix(localBinPath);
}
}
if (platform === 'linux') {
if (existsSync('/usr/local/bin/openclaw')) {
return '/usr/local/bin/openclaw';
}
}
if (!app.isPackaged) {
const openclawDir = getOpenClawDir();
const nodeModulesDir = dirname(openclawDir);
@@ -46,6 +71,19 @@ export function getOpenClawCliCommand(): string {
}
if (app.isPackaged) {
if (platform === 'win32') {
const cliDir = join(process.resourcesPath, 'cli');
const cmdPath = join(cliDir, 'openclaw.cmd');
if (existsSync(cmdPath)) {
return `& ${quoteForPowerShell(cmdPath)}`;
}
const bundledNode = getPackagedWindowsNodePath();
if (bundledNode) {
return `& ${quoteForPowerShell(bundledNode)} ${quoteForPowerShell(entryPath)}`;
}
}
const execPath = process.execPath;
if (platform === 'win32') {
return `$env:ELECTRON_RUN_AS_NODE=1; & ${quoteForPowerShell(execPath)} ${quoteForPowerShell(entryPath)}`;
@@ -60,34 +98,318 @@ export function getOpenClawCliCommand(): string {
return `node ${quoteForPosix(entryPath)}`;
}
export async function installOpenClawCliMac(): Promise<{ success: boolean; path?: string; error?: string }>
{
if (process.platform !== 'darwin') {
return { success: false, error: 'Install is only supported on macOS.' };
// ── Packaged CLI wrapper path ────────────────────────────────────────────────
function getPackagedCliWrapperPath(): string | null {
if (!app.isPackaged) return null;
const platform = process.platform;
if (platform === 'darwin' || platform === 'linux') {
const wrapper = join(process.resourcesPath, 'cli', 'openclaw');
return existsSync(wrapper) ? wrapper : null;
}
if (platform === 'win32') {
const wrapper = join(process.resourcesPath, 'cli', 'openclaw.cmd');
return existsSync(wrapper) ? wrapper : null;
}
return null;
}
function getWindowsPowerShellPath(): string {
const systemRoot = process.env.SystemRoot || 'C:\\Windows';
return join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
}
// ── macOS / Linux install ────────────────────────────────────────────────────
function getCliTargetPath(): string {
return join(homedir(), '.local', 'bin', 'openclaw');
}
export async function installOpenClawCli(): Promise<{
success: boolean; path?: string; error?: string;
}> {
const platform = process.platform;
if (platform === 'win32') {
return { success: false, error: 'Windows CLI is configured by the installer.' };
}
const entryPath = getOpenClawEntryPath();
if (!existsSync(entryPath)) {
return { success: false, error: `OpenClaw entry not found at: ${entryPath}` };
if (!app.isPackaged) {
return { success: false, error: 'CLI install is only available in packaged builds.' };
}
const wrapperSrc = getPackagedCliWrapperPath();
if (!wrapperSrc) {
return { success: false, error: 'CLI wrapper not found in app resources.' };
}
const execPath = process.execPath;
const targetDir = join(homedir(), '.local', 'bin');
const target = join(targetDir, 'openclaw');
const target = getCliTargetPath();
try {
const script = [
'#!/bin/sh',
`ELECTRON_RUN_AS_NODE=1 "${escapeForDoubleQuotes(execPath)}" "${escapeForDoubleQuotes(entryPath)}" "$@"`,
'',
].join('\n');
mkdirSync(targetDir, { recursive: true });
writeFileSync(target, script, { mode: 0o755 });
chmodSync(target, 0o755);
// Remove existing file/symlink to avoid EEXIST
if (existsSync(target)) {
unlinkSync(target);
}
symlinkSync(wrapperSrc, target);
chmodSync(wrapperSrc, 0o755);
logger.info(`OpenClaw CLI symlink created: ${target} -> ${wrapperSrc}`);
return { success: true, path: target };
} catch (error) {
logger.error('Failed to install OpenClaw CLI:', error);
return { success: false, error: String(error) };
}
}
// ── Auto-install on first launch ─────────────────────────────────────────────
function isCliInstalled(): boolean {
const platform = process.platform;
if (platform === 'win32') return true; // handled by NSIS installer
const target = getCliTargetPath();
if (!existsSync(target)) return false;
// Also check /usr/local/bin/openclaw for deb installs
if (platform === 'linux' && existsSync('/usr/local/bin/openclaw')) return true;
return true;
}
function ensureWindowsCliOnPath(): Promise<'updated' | 'already-present'> {
return new Promise((resolve, reject) => {
const cliWrapper = getPackagedCliWrapperPath();
if (!cliWrapper) {
reject(new Error('CLI wrapper not found in app resources.'));
return;
}
const cliDir = dirname(cliWrapper);
const helperPath = join(cliDir, 'update-user-path.ps1');
if (!existsSync(helperPath)) {
reject(new Error(`PATH helper not found at ${helperPath}`));
return;
}
const child = spawn(
getWindowsPowerShellPath(),
[
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-File',
helperPath,
'-Action',
'add',
'-CliDir',
cliDir,
],
{
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
},
);
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', reject);
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(stderr.trim() || `PowerShell exited with code ${code}`));
return;
}
const status = stdout.trim();
if (status === 'updated' || status === 'already-present') {
resolve(status);
return;
}
reject(new Error(`Unexpected PowerShell output: ${status || '(empty)'}`));
});
});
}
function ensureLocalBinInPath(): void {
if (process.platform === 'win32') return;
const localBin = join(homedir(), '.local', 'bin');
const pathEnv = process.env.PATH || '';
if (pathEnv.split(':').includes(localBin)) return;
const shell = process.env.SHELL || '/bin/zsh';
const profileFile = shell.includes('zsh')
? join(homedir(), '.zshrc')
: shell.includes('fish')
? join(homedir(), '.config', 'fish', 'config.fish')
: join(homedir(), '.bashrc');
try {
const marker = '.local/bin';
let content = '';
try {
content = readFileSync(profileFile, 'utf-8');
} catch {
// file doesn't exist yet
}
if (content.includes(marker)) return;
const line = shell.includes('fish')
? '\n# Added by ClawX\nfish_add_path "$HOME/.local/bin"\n'
: '\n# Added by ClawX\nexport PATH="$HOME/.local/bin:$PATH"\n';
appendFileSync(profileFile, line);
logger.info(`Added ~/.local/bin to PATH in ${profileFile}`);
} catch (error) {
logger.warn('Failed to add ~/.local/bin to PATH:', error);
}
}
export async function autoInstallCliIfNeeded(
notify?: (path: string) => void,
): Promise<void> {
if (!app.isPackaged) return;
if (process.platform === 'win32') {
try {
const result = await ensureWindowsCliOnPath();
if (result === 'updated') {
logger.info('Added Windows CLI directory to user PATH.');
}
} catch (error) {
logger.warn('Failed to ensure Windows CLI is on PATH:', error);
}
return;
}
const target = getCliTargetPath();
const wrapperSrc = getPackagedCliWrapperPath();
if (isCliInstalled()) {
if (target && wrapperSrc && existsSync(target)) {
try {
unlinkSync(target);
symlinkSync(wrapperSrc, target);
logger.debug(`Refreshed CLI symlink: ${target} -> ${wrapperSrc}`);
} catch {
// non-critical
}
}
return;
}
logger.info('Auto-installing openclaw CLI...');
const result = await installOpenClawCli();
if (result.success) {
logger.info(`CLI auto-installed at ${result.path}`);
ensureLocalBinInPath();
if (result.path) notify?.(result.path);
} else {
logger.warn(`CLI auto-install failed: ${result.error}`);
}
}
// ── Completion helpers ───────────────────────────────────────────────────────
function getNodeExecForCli(): string {
if (process.platform === 'darwin' && app.isPackaged) {
const appName = app.getName();
const helperName = `${appName} Helper`;
const helperPath = join(
dirname(process.execPath),
'../Frameworks',
`${helperName}.app`,
'Contents/MacOS',
helperName,
);
if (existsSync(helperPath)) return helperPath;
}
return process.execPath;
}
export function generateCompletionCache(): void {
if (!app.isPackaged) return;
const entryPath = getOpenClawEntryPath();
if (!existsSync(entryPath)) return;
const execPath = getNodeExecForCli();
const child = spawn(execPath, [entryPath, 'completion', '--write-state'], {
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
OPENCLAW_NO_RESPAWN: '1',
OPENCLAW_EMBEDDED_IN: 'ClawX',
},
stdio: 'ignore',
detached: false,
windowsHide: true,
});
child.on('close', (code) => {
if (code === 0) {
logger.info('OpenClaw completion cache generated');
} else {
logger.warn(`OpenClaw completion cache generation exited with code ${code}`);
}
});
child.on('error', (err) => {
logger.warn('Failed to generate completion cache:', err);
});
}
export function installCompletionToProfile(): void {
if (!app.isPackaged) return;
if (process.platform === 'win32') return;
const entryPath = getOpenClawEntryPath();
if (!existsSync(entryPath)) return;
const execPath = getNodeExecForCli();
const child = spawn(
execPath,
[entryPath, 'completion', '--install', '-y'],
{
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
OPENCLAW_NO_RESPAWN: '1',
OPENCLAW_EMBEDDED_IN: 'ClawX',
},
stdio: 'ignore',
detached: false,
windowsHide: true,
}
);
child.on('close', (code) => {
if (code === 0) {
logger.info('OpenClaw completion installed to shell profile');
} else {
logger.warn(`OpenClaw completion install exited with code ${code}`);
}
});
child.on('error', (err) => {
logger.warn('Failed to install completion to shell profile:', err);
});
}
+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);
}
+63
View File
@@ -0,0 +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,
options: SyncProxyOptions = {},
): Promise<void> {
return withConfigLock(async () => {
const config = await readOpenClawConfig();
const telegramConfig = config.channels?.telegram;
if (!telegramConfig) {
return;
}
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 (!settings.proxyEnabled && preserveExistingWhenDisabled && currentProxy) {
logger.info('Skipped Telegram proxy sync because ClawX proxy is disabled and preserve mode is enabled');
return;
}
if (!nextProxy && !currentProxy) {
return;
}
if (!config.channels) {
config.channels = {};
}
config.channels.telegram = {
...telegramConfig,
};
if (nextProxy) {
config.channels.telegram.proxy = nextProxy;
} else {
delete config.channels.telegram.proxy;
}
await writeOpenClawConfig(config);
logger.info(`Synced Telegram proxy to OpenClaw config (${nextProxy || 'disabled'})`);
});
}
+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;
+211
View File
@@ -0,0 +1,211 @@
/**
* OpenClaw workspace context utilities.
*
* All file I/O is async (fs/promises) to avoid blocking the Electron
* main thread.
*/
import { access, readFile, writeFile, readdir, mkdir, unlink } from 'fs/promises';
import { constants } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { logger } from './logger';
import { getResourcesDir } from './paths';
const CLAWX_BEGIN = '<!-- clawx:begin -->';
const CLAWX_END = '<!-- clawx:end -->';
// ── Helpers ──────────────────────────────────────────────────────
async function fileExists(p: string): Promise<boolean> {
try { await access(p, constants.F_OK); return true; } catch { return false; }
}
async function ensureDir(dir: string): Promise<void> {
if (!(await fileExists(dir))) {
await mkdir(dir, { recursive: true });
}
}
// ── Pure helpers (no I/O) ────────────────────────────────────────
/**
* Merge a ClawX context section into an existing file's content.
* If markers already exist, replaces the section in-place.
* Otherwise appends it at the end.
*/
export function mergeClawXSection(existing: string, section: string): string {
const wrapped = `${CLAWX_BEGIN}\n${section.trim()}\n${CLAWX_END}`;
const beginIdx = existing.indexOf(CLAWX_BEGIN);
const endIdx = existing.indexOf(CLAWX_END);
if (beginIdx !== -1 && endIdx !== -1) {
return existing.slice(0, beginIdx) + wrapped + existing.slice(endIdx + CLAWX_END.length);
}
return existing.trimEnd() + '\n\n' + wrapped + '\n';
}
// ── Workspace directory resolution ───────────────────────────────
/**
* Collect all unique workspace directories from the openclaw config:
* the defaults workspace, each agent's workspace, and any workspace-*
* directories that already exist under ~/.openclaw/.
*/
async function resolveAllWorkspaceDirs(): Promise<string[]> {
const openclawDir = join(homedir(), '.openclaw');
const dirs = new Set<string>();
const configPath = join(openclawDir, 'openclaw.json');
try {
if (await fileExists(configPath)) {
const config = JSON.parse(await readFile(configPath, 'utf-8'));
const defaultWs = config?.agents?.defaults?.workspace;
if (typeof defaultWs === 'string' && defaultWs.trim()) {
dirs.add(defaultWs.replace(/^~/, homedir()));
}
const agents = config?.agents?.list;
if (Array.isArray(agents)) {
for (const agent of agents) {
const ws = agent?.workspace;
if (typeof ws === 'string' && ws.trim()) {
dirs.add(ws.replace(/^~/, homedir()));
}
}
}
}
} catch {
// ignore config parse errors
}
// 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'));
}
return [...dirs];
}
// ── Bootstrap file repair ────────────────────────────────────────
/**
* Detect and remove bootstrap .md files that contain only ClawX markers
* with no meaningful OpenClaw content outside them.
*/
export async function repairClawXOnlyBootstrapFiles(): Promise<void> {
const workspaceDirs = await resolveAllWorkspaceDirs();
for (const workspaceDir of workspaceDirs) {
if (!(await fileExists(workspaceDir))) continue;
let entries: string[];
try {
entries = (await readdir(workspaceDir)).filter((f) => f.endsWith('.md'));
} catch {
continue;
}
for (const file of entries) {
const filePath = join(workspaceDir, file);
let content: string;
try {
content = await readFile(filePath, 'utf-8');
} catch {
continue;
}
const beginIdx = content.indexOf(CLAWX_BEGIN);
const endIdx = content.indexOf(CLAWX_END);
if (beginIdx === -1 || endIdx === -1) continue;
const before = content.slice(0, beginIdx).trim();
const after = content.slice(endIdx + CLAWX_END.length).trim();
if (before === '' && after === '') {
try {
await unlink(filePath);
logger.info(`Removed ClawX-only bootstrap file for re-seeding: ${file} (${workspaceDir})`);
} catch {
logger.warn(`Failed to remove ClawX-only bootstrap file: ${filePath}`);
}
}
}
}
}
// ── Context merging ──────────────────────────────────────────────
/**
* Merge ClawX context snippets into workspace bootstrap files that
* already exist on disk. Returns the number of target files that were
* skipped because they don't exist yet.
*/
async function mergeClawXContextOnce(): Promise<number> {
const contextDir = join(getResourcesDir(), 'context');
if (!(await fileExists(contextDir))) {
logger.debug('ClawX context directory not found, skipping context merge');
return 0;
}
let files: string[];
try {
files = (await readdir(contextDir)).filter((f) => f.endsWith('.clawx.md'));
} catch {
return 0;
}
const workspaceDirs = await resolveAllWorkspaceDirs();
let skipped = 0;
for (const workspaceDir of workspaceDirs) {
await ensureDir(workspaceDir);
for (const file of files) {
const targetName = file.replace('.clawx.md', '.md');
const targetPath = join(workspaceDir, targetName);
if (!(await fileExists(targetPath))) {
logger.debug(`Skipping ${targetName} in ${workspaceDir} (file does not exist yet, will be seeded by gateway)`);
skipped++;
continue;
}
const section = await readFile(join(contextDir, file), 'utf-8');
const existing = await readFile(targetPath, 'utf-8');
const merged = mergeClawXSection(existing, section);
if (merged !== existing) {
await writeFile(targetPath, merged, 'utf-8');
logger.info(`Merged ClawX context into ${targetName} (${workspaceDir})`);
}
}
}
return skipped;
}
const RETRY_INTERVAL_MS = 2000;
const MAX_RETRIES = 15;
/**
* Ensure ClawX context snippets are merged into the openclaw workspace
* bootstrap files.
*/
export async function ensureClawXContext(): Promise<void> {
let skipped = await mergeClawXContextOnce();
if (skipped === 0) return;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
await new Promise((r) => setTimeout(r, RETRY_INTERVAL_MS));
skipped = await mergeClawXContextOnce();
if (skipped === 0) {
logger.info(`ClawX context merge completed after ${attempt} retry(ies)`);
return;
}
logger.debug(`ClawX context merge: ${skipped} file(s) still missing (retry ${attempt}/${MAX_RETRIES})`);
}
logger.warn(`ClawX context merge: ${skipped} file(s) still missing after ${MAX_RETRIES} retries`);
}

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