Compare commits

...
354 Commits
Author SHA1 Message Date
paisley 4a74b22796 release v0.4.16 (#1155) 2026-07-10 18:26:47 +08:00
paisley bcec47f600 fix: set default compaction reserveTokensFloor to 50000 (#1154) 2026-07-10 18:04:59 +08:00
paisley d7671a42e6 release 0.4.15 (#1151) 2026-07-07 18:56:31 +08:00
paisley f44c2250fb chore: remove unused preinstalled skills (#1150) 2026-07-07 18:09:40 +08:00
paisley d6c770e4a5 chore: bump version to 0.4.14 (#1149) 2026-07-07 17:26:49 +08:00
paisley e84f13c876 fix: seed memorySearch disabled when no user embedding config (#1148) 2026-07-07 16:41:00 +08:00
Felix a0509b9b54 feat(auth): deny subagent tools for ClawX desktop (#1147) 2026-07-07 15:44:45 +08:00
paisley e09dd289db fix: add contextWindow defaults and compaction safeguard for custom providers (#1146) 2026-07-07 15:25:46 +08:00
Felix 2a1e2adde3 docs: surface uv guidance in ClawX agents context (#1142) 2026-07-03 17:17:40 +08:00
paisley 6ea53bdeb1 chore: bump version to 0.4.13 (#1141) 2026-07-02 16:56:46 +08:00
paisley f3689894bf fix: persist trusted plugin install records to SQLite for packaged WhatsApp (#1140) 2026-07-02 11:25:09 +08:00
paisley 33694efe84 Fix whatsapp login (#1139) 2026-07-01 18:54:34 +08:00
paisley 22af7468d7 Upgrade openclaw to 6.10 (#1138) 2026-06-30 18:29:35 +08:00
Felix cb8c3cfd02 chore: bump version to 0.4.12 (#1135) 2026-06-26 13:48:35 +08:00
Felix e42307cbd3 feature: support multi-model provider accounts (#1133) 2026-06-25 14:07:52 +08:00
Felix ffe50ff8a5 fix: deny built-in skill workshop tool (#1131) 2026-06-23 13:30:10 +08:00
Felix d223af5747 chore: bump bundled Windows node to 22.19.0 (#1129) 2026-06-22 12:33:11 +08:00
Haze b0db926874 release: v0.4.11 (#1126) 2026-06-17 10:13:53 +08:00
Haze 05ea50a834 opt(cron): cron job status (#1125) 2026-06-15 19:46:40 +08:00
Haze 808f4a840d feat(cron): opt cron task job (#1124) 2026-06-15 17:54:06 +08:00
ZHUO Xu d9ffbca856 refactor: chat markdown list and heading styles (#1121) 2026-06-15 16:55:27 +08:00
paisley fcfb72b103 release 0.4.10 (#1117) 2026-06-11 18:48:31 +08:00
paisley ae91ba3c05 fix: clear stale agent model refs after deleting custom providers (#1116) 2026-06-11 18:45:26 +08:00
paisley dd92cf400b chore:show provider or custom name in chat model picker labels (#1115) 2026-06-11 18:27:07 +08:00
paisley d246860ef1 fix: migrate OpenAI Codex OAuth to canonical openai provider (#1114) 2026-06-11 18:00:09 +08:00
paisley 66692f1777 fix: use openai-chatgpt-responses for Codex OAuth and hide stale OpenAI API slot after logout (#1113) 2026-06-11 15:46:06 +08:00
paisley 1fd3c50c3e fix(chat): scroll-to-latest no longer cancels smooth scroll to bottom (#1112) 2026-06-11 13:20:47 +08:00
paisley bf038f5ca4 feat:upgrade OpenClaw to 2026.6.5 and fix OAuth provider detection for auth-profile-only configs. (#1109) 2026-06-10 17:52:05 +08:00
paisleyandCursor 07c5c4cfcc fix: reassign default provider when deleting the current default (#1108)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 18:05:56 +08:00
paisley 71ea96a881 fix: preserve custom provider model capabilities on switch (#1107) 2026-06-09 17:18:10 +08:00
paisley 1e2fc7e404 fix(chat): surface image generation replie (#1106) 2026-06-09 13:59:55 +08:00
paisley 65a8a906a6 release 0.4.9 (#1104) 2026-06-08 18:06:55 +08:00
ZHUO Xuandpaisley 581981f203 refactor: Unify Electron IPC Runtime, Shared Contracts, and App UX/DX Fixes (#1102)
Co-authored-by: paisley <8197966+su8su@users.noreply.github.com>
2026-06-06 13:05:25 +08:00
paisley 48d80bf660 release 0.4.8 (#1099) 2026-06-04 14:37:50 +08:00
Felix 277c21c91a Fix/image generation response format (#1098) 2026-06-03 17:19:55 +08:00
Felix 580b213030 fix(chat): settle image runs from delivered media (#1096) 2026-06-03 15:43:25 +08:00
paisley 9e531d1229 fix image load (#1097) 2026-06-03 14:54:09 +08:00
Felix 8ea306d91c Fix/chat runtime event pipeline (#1094) 2026-06-03 13:55:45 +08:00
paisley 840fe62182 fix chat media previews for Windows SVG artifacts (#1095) 2026-06-03 13:25:56 +08:00
Octopusandocto-patch 7435e9ce29 feat: upgrade MiniMax default model to M3 (#1093)
Co-authored-by: octo-patch <octo-patch@github.com>
2026-06-03 11:32:18 +08:00
paisley e47fce4ae4 fix(mac): align traffic lights in sidebar chrome (#1092) 2026-06-02 17:57:27 +08:00
Felix d27faa5a00 refactor: decouple skills page from gateway marketplace (#1089) 2026-06-02 16:15:57 +08:00
paisley 83317210f6 feat: Unify Gateway heartbeat recovery across platforms (#1088) 2026-06-02 15:58:15 +08:00
paisley e91b6774d4 fix(gateway): retry connect when OpenClaw is still starting after cold launch (#1087) 2026-06-02 15:17:18 +08:00
paisley aa7f1f72fc upgrade openclaw to 5.20 (#1085) 2026-06-02 14:45:19 +08:00
paisley c25c7fc432 fix image generate deadloack (#1086) 2026-06-02 14:13:53 +08:00
paisley 73e68f769d Improve agent-browser download logs for CI pipeline visibility (#1084) 2026-06-02 12:47:53 +08:00
paisley 55ab4d5a7c fix(chat): stabilize auto-scroll on tool runs; hide leaked NO_REPLY lines (#1083) 2026-05-29 16:14:05 +08:00
paisley c16b428651 Bundle agent-browser binary like uv and expose it on OpenClaw PATH (#1082) 2026-05-29 14:50:57 +08:00
paisley 6e305e6dba Refine chat history timeout (#1081) 2026-05-29 13:29:56 +08:00
paisley 97dca873a1 Fix image generate failed (#1080) 2026-05-29 12:10:04 +08:00
paisley 5b0c1c9909 release 0.4.7 (#1077) 2026-05-28 13:28:16 +08:00
paisley a87a86df50 fix: pin OpenAI runtime before gateway launch (#1076) 2026-05-28 11:34:03 +08:00
paisley 1830bbe787 fix(installer): skip legacy NSIS uninstall on Windows overwrite upgrades (#1072) 2026-05-27 18:41:12 +08:00
Haze 800932507b refact(i18n/style): opt i18n/style in the project (#1075) 2026-05-27 18:27:20 +08:00
Haze 29de7e15c2 feat(chat): optimize chat and light model (#1074) 2026-05-27 14:38:45 +08:00
Octopusandocto-patch e97af800b3 docs: add Linux system library prerequisites for building from source (#817)
Co-authored-by: octo-patch <octo-patch@github.com>
2026-05-27 14:37:36 +08:00
paisley 1e64cf2230 fix control ui (#1073) 2026-05-27 12:57:09 +08:00
paisley be7f8fc768 Fix image generate (#1068) 2026-05-26 18:24:31 +08:00
paisley 39c8400421 fix: keep macOS artifact preview panel clickable above drag region (#1067) 2026-05-26 13:00:34 +08:00
paisleyandCursor b7d0ca0c53 Feat:support image generate provider (#1066)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 18:51:03 +08:00
paisley 8635f0f5eb fix: default web_fetch SSRF policy for fake-IP proxy DNS (#1065) 2026-05-25 18:28:10 +08:00
paisley e79a4f923e fix: hydrate truncated chat history and unstick thinking on LLM idle timeout (#1064) 2026-05-25 17:57:19 +08:00
paisley d15e2330d5 upgrade openclaw to 5.19 (#1063) 2026-05-25 17:48:12 +08:00
paisley 219db5ff78 fix: clear stuck "thinking" state when run already finished in history (#1059) 2026-05-23 15:20:46 +08:00
Lingxuan Zuo 21c1ba2e33 fix(clawhub): search marketplace through official API (#1058) 2026-05-22 22:31:19 +08:00
Felix d596ed5b50 fix: ensure tar runtime deps (#1057) 2026-05-22 12:30:53 +08:00
paisleyandCursor 0da6a24f55 fix(chat): support dragging folders into the composer on desktop. (#1056)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 16:21:04 +08:00
paisley 3659179fc9 fix(chat): show inbound channel messages on the active main session (#1055) 2026-05-21 15:01:50 +08:00
paisley 94de6fb08e Fix switch session (#1054) 2026-05-21 14:17:34 +08:00
paisley 3178f96c55 release 0.4.6 (#1053) 2026-05-20 15:11:23 +08:00
paisley b4588c5aba fix: drop @openclaw/codex bundle, lazy-load baileys, and harden Windows installer (#1052) 2026-05-20 14:55:48 +08:00
paisley f474bd41ac release 0.4.5 (#1051) 2026-05-20 13:37:43 +08:00
paisley fb2d9e312a fix image and html preview (#1050) 2026-05-20 11:35:53 +08:00
paisley 0fb460efca fix(chat): fold tool steps into execution graph and remove duplicate tool pills (#1048) 2026-05-20 10:48:45 +08:00
paisley d9f42930d7 Fix false "no response" error during tool-call chains when history poll shows progress (#1047) 2026-05-20 10:11:14 +08:00
paisley 2f7466e2dd fix session rename (#1046) 2026-05-19 19:04:17 +08:00
paisley dbe9d3c9bb fix(chat): dedupe optimistic messages and defer transient terminated errors (#1044) 2026-05-19 18:01:12 +08:00
paisley 1aa4776ea5 fix: OpenAI OAuth login and provider list cleanup (#1043) 2026-05-19 15:51:42 +08:00
paisley 0d5323360c fix: ensure maxTokens for anthropic-messages providers in openclaw.json (#1042) 2026-05-19 14:48:58 +08:00
paisley 3de1fed446 release 0.4.4 (#1037) 2026-05-18 19:21:07 +08:00
Haze 035ee8e32c feat(provider): add option to hide OAuth UI in Add Provider dialog (#1036) 2026-05-18 19:17:27 +08:00
paisley 4136b04c42 fix(chat): keep toolbar background draggable (#1035) 2026-05-18 17:54:38 +08:00
566a2d752d Update sidebar history buckets (#1034)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-05-18 16:52:29 +08:00
paisley a417d4ec6b feat(chat): add toolbar question directory (#1032) 2026-05-18 16:37:29 +08:00
8af070a7a0 Remove assistant reply bubble styling (#1033)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-05-18 16:33:38 +08:00
Lingxuan Zuo 8df7eee0c1 [issue follow up] Keep chat runs active across gateway phase end (#1027) 2026-05-18 16:18:14 +08:00
paisley f4d6478f1c feat(chat): add scroll-to-latest button (#1031) 2026-05-18 15:09:08 +08:00
Haze fdc0b0d746 fix(chat): chat bubble loss thinking status error (#1030) 2026-05-18 14:36:53 +08:00
paisley dea38c0c44 feat: add rendered HTML file preview (#1029) 2026-05-18 13:42:38 +08:00
paisley 0ee8f5e202 fix channel degraded misleading info (#1028) 2026-05-18 11:41:04 +08:00
Haze 60b5209cf6 fix(provider): add model provider error (#1025) 2026-05-18 10:21:52 +08:00
Lingxuan Zuo 3edeb8cdfa Reduce packaged app size (#1026) 2026-05-17 23:28:09 +08:00
paisley 34bfae2851 Upgrade openclaw to 5.12 (#1023) 2026-05-15 17:13:43 +08:00
paisley 360b6649ba fix clawx version (#1021) 2026-05-15 11:53:44 +08:00
paisley 75343d58d1 Feat/auto update (#1018) 2026-05-15 11:21:11 +08:00
Kagura 160160ecde feat(chat): add session rename in sidebar (#1016) 2026-05-14 13:12:41 +08:00
paisley 80251a5825 Fix macOS right-side window drag region (#1015) 2026-05-14 11:30:45 +08:00
Haze b0e55692f5 refactor(doc): Update screenshots for English, Japanese, Russian, and Chinese locales (#1014) 2026-05-14 11:02:37 +08:00
Haze ed1525e25b release: v0.4.3 tag commit (#1012) 2026-05-14 11:02:11 +08:00
paisley 4865afc6e6 Fix gateway rpc error (#1010) 2026-05-13 15:29:44 +08:00
Haze 42d62f5002 fix(sytle): windows sidebar and cronjob card front style fix (#1011) 2026-05-13 14:30:36 +08:00
paisley 62c11d2441 fix acpx deps (#1008) 2026-05-13 10:00:21 +08:00
58aa88b3ae feat(session): hard-delete JSONL transcript on conversation delete (#986)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-05-12 19:09:25 +08:00
2327f0ba6a fix(chat): stop splitting message bubble text mid-word (closes #931) (#1001)
Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:05:54 +08:00
Haze 7dc18bb6e7 refact(front): optimize front style (#1007) 2026-05-12 18:37:40 +08:00
paisley 5c1ed5e6ee fix missing deps (#1005) 2026-05-11 19:29:17 +08:00
paisley abce8a8b96 Fix chat load history (#1004) 2026-05-11 19:25:39 +08:00
arianachennn-design 087b38a39a docs: add enterprise inquiry callout to README overview in all locales (#1003) 2026-05-11 17:03:29 +08:00
Tao YipingandHaze cbf70fb299 feat(models): Added a Test button in Provider Settings that validates… (#992)
Co-authored-by: Haze <709547807@qq.com>
2026-05-11 11:51:28 +08:00
Haze 8a8cd73919 release 0.4.2 (#999) 2026-05-11 10:46:59 +08:00
paisley a7f4552be6 fix(openclaw): seed default ClawX identity to avoid bootstrap (#991) 2026-05-11 10:28:02 +08:00
Haze c1ba38c71b refactor(chat): subagent front style (#998) 2026-05-09 18:13:57 +08:00
Haze 294c88f739 fix(chat): read properties of undefined (#997) 2026-05-09 17:06:50 +08:00
Felix 51b86bdded fix(installer): remove stale bundled OpenClaw skills during overwrite installs (#995) 2026-05-09 10:37:42 +08:00
15e8fe8e05 fix: openclaw module loading (#993)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-05-08 15:36:39 +08:00
Lingxuan Zuo c6adf36b57 test(feishu): cover legacy dual-plugin migration (#990) 2026-05-07 20:04:18 +08:00
Haze 05eb54edb6 refactor(headers): update header keys to use 'X-OpenRouter-Title' for consistency (#989) 2026-05-07 16:08:15 +08:00
50e88f6789 Add chat model picker (#924)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-05-07 15:29:08 +08:00
paisley 8ba82e23f9 release v0.4.1 (#988) 2026-05-07 15:20:07 +08:00
paisley 4e4ebd1092 chore: remove Chinese text from file preview components (#987) 2026-05-07 13:39:34 +08:00
Haze 35ae535646 feat(chat): add skill preview functionality in chat input and enhance localization support (#984) 2026-05-07 11:48:30 +08:00
paisley fc13237023 fix: Windows zoom shortcuts and keep chat preview file after workspace tab (#982) 2026-05-07 11:02:02 +08:00
Haze 9ada49afb3 fix(chat): optimize image sending stability (#983) 2026-05-07 10:57:30 +08:00
paisley 0c8dc8cb72 fix(providers): derive API key status from OpenClaw auth-profiles first (#979) 2026-05-07 10:29:00 +08:00
paisley 59579e0852 release v0.4.0 (#980) 2026-05-06 18:44:23 +08:00
paisley 654468b26c feat: pdf and excel file preview (#978) 2026-05-06 16:14:38 +08:00
Haze e516394580 feat(diff-viewer): enhance MonacoDiffViewer with light theme support and background color alignment (#977) 2026-05-06 13:44:01 +08:00
Haze 5bc994ada7 chore(front): optimize front issue (#975) 2026-05-06 12:03:51 +08:00
Tao Yiping 5c2e689cb4 fix(doc): aligin graph (#974) 2026-05-06 10:15:22 +08:00
paisleyandHaze 0c10c729ee Release v0.4.0 alpha (#970)
Co-authored-by: Haze <709547807@qq.com>
2026-05-03 15:43:35 +08:00
paisley 0f0ec50e91 feat: tighten openclaw plugin allowlist reconciliation (#969) 2026-05-03 12:16:41 +08:00
Lingxuan Zuo 8c9b4ea670 fix: integrate OpenClaw Gateway health signals (#957) 2026-05-01 21:49:35 +08:00
Lingxuan Zuo d47101ab4c feat: gate Dreams and add start control (#953) 2026-05-01 15:26:11 +08:00
paisley cc7992e160 Fix package issue (#952) 2026-05-01 12:13:17 +08:00
Lingxuan Zuo 62d83b401f Add native OpenClaw Dreams UI (#950) 2026-05-01 00:54:11 +08:00
Lingxuan Zuo 110b473080 add gateway backend communication harness (#949) 2026-05-01 00:22:30 +08:00
Haze e6c18022a5 feat(chat): add localized error message for model API call and improve error handling in chat history (#947) 2026-04-30 18:46:55 +08:00
Felix e545fa2194 fix: improve error handling by returning specific error messages in skill routes and IPC handlers (#945) 2026-04-30 18:33:03 +08:00
paisley d18985712c feat: overhaul chat artifact previews and file change experience (#937) 2026-04-30 18:21:45 +08:00
Haze f88d6bc17b fix(openclaw): revert update openclaw version (#944) 2026-04-30 17:54:03 +08:00
Haze 1fc1e20ec4 feat(provider): support new provider api in clawx (#938) 2026-04-29 19:42:42 +08:00
Haze 54b75ded19 feat(gateway): disable mDNS/Bonjour advertising for OpenClaw to prevent network collisions (#936) 2026-04-29 11:49:08 +08:00
Haze a74db571d6 fix(openclaw): remove symlink skill in openclaw file (#935) 2026-04-29 11:38:21 +08:00
paisley 5c97044865 fix(gateway): reduce ClawX context merge waits; bundle memory-core chokidar (#930) 2026-04-28 19:06:31 +08:00
9d3a59dc0e feat(chat): Surface terminal model errors in chat (#929)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-04-28 18:47:45 +08:00
Haze 91c6a26115 feat(chat): support skills choose in chat (#928) 2026-04-28 18:16:41 +08:00
Felix ebeb2893a1 feat(chat): enhance chat functionality with beforeSend hooks and composer status components (#927) 2026-04-28 16:10:08 +08:00
Hazeandpaisley 9c930b2198 feat: upgrade openclaw to 4.26 (#899)
Co-authored-by: paisley <8197966+su8su@users.noreply.github.com>
2026-04-28 15:04:19 +08:00
Felix e95c72f79a fix: persist uv mirror config file (#921) 2026-04-26 16:52:03 +08:00
paisley f9361e686a fix(chat): force history sync after internal NO_REPLY final (#919) 2026-04-25 16:08:51 +08:00
Kagura ceb537f7bd fix(chat): filter internal messages (NO_REPLY) in SSE final handler (#904) (#915) 2026-04-25 15:51:09 +08:00
paisley 04e234bbde fix: remove OpenClaw chat-first BOOTSTRAP.md on startup (#917) 2026-04-25 15:43:29 +08:00
paisley 9a360eb43f fix(chat): prevent aborted runs from reactivating conversation state (#916) 2026-04-25 15:09:14 +08:00
89dd765fd6 Adapt MiniMax auth plugin compatibility (#913)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-04-24 18:14:13 +08:00
Haze 4271419abb 0.3.11 (#908) 2026-04-24 15:44:28 +08:00
paisley 01fd010a0e fix(browser): soften aggressive retry hint and add anti-hallucination prompts (#910) 2026-04-24 14:15:47 +08:00
Haze f893f2b21b feat(provider): add DeepSeek provider with configuration and icon support (#907) 2026-04-24 11:41:47 +08:00
Felix ae9af725b2 optimize: skills page structure (#905) 2026-04-24 10:26:33 +08:00
Haze 42a26c41a2 fix(chat): execution graph status error (#903) 2026-04-24 10:03:17 +08:00
31d4531327 fix(chat): dedupe optimistic user message against Gateway-prefixed echo (#887)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-04-23 20:40:59 +08:00
Haze 430d1d0603 style(chat): make reply bubble table headers transparent + bold in light theme (#902) 2026-04-23 19:59:29 +08:00
paisley 5137e706c9 perf: speed up initial chat, channels, skills, and cron loading (#901) 2026-04-23 18:49:47 +08:00
paisley 4b14f977fa chore: remove brave-web-search skill (#900) 2026-04-23 18:20:58 +08:00
paisley 1b75fec71c fix(gateway): only sync configured channel plugins and cleanup unconfigured extensions (#898) 2026-04-23 17:30:53 +08:00
Haze 956e943072 feat(model): update Kimi model references from k2.5 to k2.6 across the application (#897) 2026-04-23 14:23:29 +08:00
Tao Yiping d0c79f6a0a feat(chat): optimize ui for execution graph card (#885) 2026-04-23 11:49:54 +08:00
paisley eda34ad9ce feat: show OpenClaw sidebar entry only in developer mode (#892) 2026-04-22 17:42:27 +08:00
paisley c29ff4dd33 fix(chat): tighten runtime internal-message filtering and add targeted tests (#891) 2026-04-22 17:23:57 +08:00
Felix 36282fce35 feat(dependencies): add tar package to dependencies and update lockfile (#890) 2026-04-22 14:25:12 +08:00
paisley 285f8202c7 Update workspace agent file (#889) 2026-04-22 13:16:48 +08:00
1ed9f77a29 feat(chat): render LaTeX math formulas in assistant messages (#886)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-04-22 12:38:46 +08:00
Haze 92144ab639 feat(release): v0.3.10 tag commit (#881) 2026-04-20 20:57:35 +08:00
Haze 9a1575114d fix(chat): thinking execution graph (#880) 2026-04-20 20:53:26 +08:00
7fa4852c1d fix(chat): separate response and thinking messages (#878)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: paisley <8197966+su8su@users.noreply.github.com>
2026-04-20 15:22:09 +08:00
HazeandHaze 1b2dccee6e refactor(chat): execution graph optimize (#873)
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-04-19 19:36:33 +08:00
paisley 2f03aa1fad fix(channels): restore dingtalk multi-account support (#874) 2026-04-19 17:39:13 +08:00
paisley 3a424ef692 fix(ui): hide recoverable gateway RPC timeouts from chat interface (#875) 2026-04-19 17:38:51 +08:00
24b43335f8 fix(chat): prevent duplicate renderer requests and thinking messages (#870)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-04-18 15:23:16 +08:00
Haze 6d67a77633 feat(openclaw): upgrade openclaw version 4.15 (#866) 2026-04-17 21:05:34 +08:00
4ac4e12aad style(channels): align status indicator with Models card style (#867)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-04-17 19:13:31 +08:00
paisley 48415db990 feat(setup): remove AI provider configuration step from onboarding wizard (#863) 2026-04-16 19:09:15 +08:00
paisley 396e9e0b03 style(channels): simplify channel status indicators (#862) 2026-04-16 17:25:25 +08:00
Haze b884db629e feat(plugin): support enterprise extension (#861) 2026-04-16 17:15:25 +08:00
paisley 2fefbf3aba feat: default browser ssrfPolicy to allow private network access (#860) 2026-04-16 14:57:27 +08:00
Lingxuan Zuo 1f39d1a8a7 Add channel health diagnostics and gateway recovery fixes (#855) 2026-04-15 13:51:02 +08:00
paisley 6acd8acf5a release v0.3.9 (#854) 2026-04-14 17:52:55 +08:00
paisley a20a97ad83 fix feishu plugin conflict (#852) 2026-04-14 17:47:58 +08:00
Hazeandpaisley 30bd8c08f9 feat(gateway): enhance gateway readiness handling and batch sync configuration (#851)
Co-authored-by: paisley <8197966+su8su@users.noreply.github.com>
2026-04-14 15:42:37 +08:00
Tao Yiping 758a8f8c94 support reasoning agentid by accountId or session for cron (#847) 2026-04-14 14:52:47 +08:00
paisley 54ec784545 fix feishu conflict (#850) 2026-04-14 13:31:25 +08:00
Haze 03c40985e1 feat(version): upgrade openclaw version 4.11 (#845) 2026-04-13 19:11:28 +08:00
Octopusandocto-patch 5482acd43d fix(providers): add Moonshot (Global) provider for api.moonshot.ai endpoint (#839)
Co-authored-by: octo-patch <octo-patch@github.com>
2026-04-13 18:17:29 +08:00
Lingxuan Zuo b2c478d554 Fix multi-account channel binding drift for #836 (#841) 2026-04-13 15:30:13 +08:00
Lingxuan Zuo 4ff6861042 Fix startup chat history recovery for Issue #816 (#821) 2026-04-12 15:30:11 +08:00
Tao Yiping 87ab12849c feat(cron): allow users to associate cron jobs with specific agents (#835) 2026-04-12 11:51:29 +08:00
Lingxuan Zuo 49518300dc Fix provider API key validation trimming (#810) 2026-04-10 15:15:29 +08:00
AlexZander85 66b2ddb2dc feat(i18n): add Russian (ru) localization support (#798) 2026-04-10 13:47:42 +08:00
paisley fc9e37c4a3 release 0.3.8 (#813) 2026-04-09 18:55:11 +08:00
paisley 467fcf7e92 Upgrade openclaw to 4.9 (#804) 2026-04-09 18:51:06 +08:00
Haze 96c9f6fe5b release(version): v0.3.7 (#799) 2026-04-08 21:38:11 +08:00
d03902dd4d fix(feishu): feishu connector name validate (#797)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-04-08 19:16:15 +08:00
c1e165d48d fix(channel): support channel names that include numbers; legacy test names containing numbers may still appear (#796)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-04-08 18:38:59 +08:00
19b3ea974b fix(feishu): feishu configuration loss (#795)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-04-08 18:34:07 +08:00
phpoh 3b629e8db4 Fix: remove duplicate IPC channel and extract error code sets (#780) 2026-04-08 15:16:01 +08:00
phpoh 0b3152f7f2 Perf: hoist hidden-usage-source logic out of Models component (#785) 2026-04-08 15:13:09 +08:00
Lingxuan Zuoandzuolingxuan 25b13ab912 Refine chat tool status dedupe (#786)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
2026-04-08 15:05:27 +08:00
32d14b8cf9 fix(gateway): handle Windows OpenClaw process exit error during in-process restarts (#794)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
2026-04-08 12:06:12 +08:00
paisley 3021ad5089 feat(cron): enable WeChat as a supported delivery channel (#789) 2026-04-07 18:56:54 +08:00
Haze 97d29ab23c opt(chat): Enhance error handling for chat.send timeouts and improve logging in gateway RPC calls (#788) 2026-04-07 17:07:57 +08:00
paisley c3a735a49c fix: resolve Windows gateway reconnect race condition and translate comments (#787) 2026-04-07 16:58:18 +08:00
paisley d8750e135b Feat/perf dashboard (#770) 2026-04-07 11:04:57 +08:00
paisley 413244522e fix(providers): resolve provider re-seeding loop on deletion (#772) 2026-04-07 10:12:16 +08:00
paisley 0cdd12cd40 fix: resolve extension dependency missing in shared chunks (#765) (#771) 2026-04-07 10:04:03 +08:00
Lingxuan Zuo c866205eac Add execution graph to chat history (#776) 2026-04-07 01:37:06 +08:00
Lingxuan Zuoandzuolingxuan 91c735c9f4 fix custom provider API key validation fallback (#773)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
2026-04-06 14:35:09 +08:00
paisley e640316382 v0.3.6 (#763) 2026-04-03 15:09:16 +08:00
paisley 83f67e1ed3 fix(gateway): make heartbeat observability-only to prevent false cascade restarts (#762) 2026-04-03 14:50:06 +08:00
Felix 1d2cbf8f26 Revert "fix(gateway): terminate owned process before retry to prevent port conflict on Windows" (#755) 2026-04-02 17:49:16 +08:00
Felix 42d6cec1e0 Revert "fix(gateway): refine process termination and wait logic on retry" (#754) 2026-04-02 17:45:07 +08:00
paisley 28508bc643 fix(gateway): refine process termination and wait logic on retry (#750) 2026-04-02 16:45:23 +08:00
写了吗? 560ae95611 fix(gateway): terminate owned process before retry to prevent port conflict on Windows (#724) 2026-04-02 16:11:03 +08:00
paisley d3112f28d8 v0.3.5 (#748) 2026-04-02 14:51:38 +08:00
paisley b9fd5f6a78 fix: properly sync Ollama provider config to gateway runtime (#747) 2026-04-02 14:42:57 +08:00
Octopus fa2131ab13 fix: sync Ollama provider config to gateway runtime (fixes #448) (#745) 2026-04-02 13:48:11 +08:00
paisley c14bdfa204 fix(cron): replace hardcoded channel name in no-delivery-targets message (#744) 2026-04-02 13:37:49 +08:00
paisley 7e2c4d3835 fix: Host API port conflict crashing startup on Windows (#743) 2026-04-02 12:00:43 +08:00
paisley 06266cb4d2 Upgrade openclaw to 4.1 (#742) 2026-04-02 11:23:24 +08:00
Lingxuan Zuo 5a3da41562 Preserve stable snapshots and stabilize Electron e2e (#734) 2026-04-01 20:35:01 +08:00
paisley 34bbb039d3 v0.3.4 (#739) 2026-04-01 17:45:08 +08:00
paisley ca92d7fa2c fix(gateway): prevent default built-in plugins from being disabled by explicit allowlists (#737) 2026-04-01 17:33:53 +08:00
paisley d34a88e629 Feat/upgrade openclaw (#729) 2026-04-01 14:22:47 +08:00
paisley bf5b089158 v0.3.3 (#723) 2026-03-31 10:53:49 +08:00
paisley eb948d5820 fix(gateway): restore WebSocket heartbeat ping on Windows (#722) 2026-03-31 10:51:30 +08:00
paisley ec8db0be75 fix(gateway): resolve startup hang caused by qqbot plugin manifest ID mismatch & stderr flood (#718) 2026-03-30 18:27:48 +08:00
paisley aa2e4eae14 fix(installer): preserve user .openclaw directory during uninstall (#717) 2026-03-30 16:09:49 +08:00
Lingxuan Zuoandzuolingxuan e988258c59 fix gateway restart handshake timeout (#689)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
2026-03-30 16:09:23 +08:00
Kagura ef3cf64484 fix(chat): hide internal system messages from webchat (#710) 2026-03-30 14:57:26 +08:00
paisley 47c9560b9e Upgrade third party plugin (#715) 2026-03-30 14:46:33 +08:00
paisley 91a86a4a76 fix(installer): resolve upgrade hang caused by locked files in installation directory (#711) 2026-03-30 14:29:48 +08:00
Lingxuan Zuo 870abb99c4 Fix token usage handling and developer proxy save UX (#704) 2026-03-28 21:13:56 +08:00
Lingxuan Zuo 2668082809 add electron e2e harness and regression coverage (#697) 2026-03-28 15:34:20 +08:00
Lingxuan Zuo 514a6c4112 fix: avoid systemd-supervised gateway retry loop in owned launcher (#700) 2026-03-28 15:34:01 +08:00
Lingxuan Zuo 9b56d80d22 fix: clean up deleted provider state correctly (#696) 2026-03-27 23:32:56 +08:00
Lingxuan Zuoandzuolingxuan 07f3c310b5 [codex] fix auth-backed provider discovery (#690)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
2026-03-27 21:12:12 +08:00
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
676 changed files with 98614 additions and 20401 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.
+28
View File
@@ -25,9 +25,17 @@ jobs:
node-version: '24'
cache: 'pnpm'
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate extension bridge
run: pnpm run ext:bridge
- name: Run linter
run: pnpm run lint
@@ -37,6 +45,18 @@ jobs:
- name: Run tests
run: pnpm run test
- name: Run harness checks
run: pnpm run harness:ci
- name: Upload harness artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: harness-artifacts
path: artifacts/harness
if-no-files-found: warn
retention-days: 7
build:
runs-on: windows-latest
env:
@@ -54,8 +74,16 @@ jobs:
node-version: '24'
cache: 'pnpm'
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate extension bridge
run: pnpm run ext:bridge
- name: Build
run: pnpm run build:vite
+57
View File
@@ -0,0 +1,57 @@
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: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run 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
+162
View File
@@ -0,0 +1,162 @@
name: Electron E2E
on:
workflow_dispatch:
pull_request:
branches:
- main
push:
branches:
- main
jobs:
electron-e2e:
name: Electron E2E (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- macos-latest
- windows-latest
env:
CI: 'true'
# Linux runners cannot use Electron's setuid chrome-sandbox; harmless on macOS/Windows.
ELECTRON_DISABLE_SANDBOX: '1'
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'pnpm'
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
shell: bash
run: |
echo "side-effects-cache=false" >> .npmrc
pnpm install --frozen-lockfile
# electron/install.js and extract-zip both leave dist half-extracted on CI
# (114MB zip downloads OK; only LICENSE + LICENSES.chromium.html land in dist).
# Download with @electron/get, extract with the OS unzip tool instead.
- name: Install Electron binary (Unix)
if: runner.os != 'Windows'
shell: bash
env:
force_no_cache: 'true'
run: |
set -euo pipefail
unset ELECTRON_SKIP_BINARY_DOWNLOAD
ELECTRON_DIR="$(node -p "require('path').dirname(require.resolve('electron/package.json'))")"
echo "Electron package dir: $ELECTRON_DIR"
rm -rf "$ELECTRON_DIR/dist" "$ELECTRON_DIR/path.txt"
mkdir -p "$ELECTRON_DIR/dist"
ZIP="$(cd "$ELECTRON_DIR" && node -e "
const { downloadArtifact } = require('@electron/get');
const { version } = require('./package.json');
downloadArtifact({ version, artifactName: 'electron', force: true })
.then((z) => { process.stdout.write(z); process.exit(0); })
.catch((e) => { console.error(e); process.exit(1); });
")"
ZIP_SIZE="$(stat -c%s "$ZIP" 2>/dev/null || stat -f%z "$ZIP")"
echo "Downloaded zip: $ZIP ($ZIP_SIZE bytes)"
unzip -oq "$ZIP" -d "$ELECTRON_DIR/dist"
echo "Extracted top-level entries: $(ls -1 "$ELECTRON_DIR/dist" | wc -l | tr -d ' ')"
if [ -f "$ELECTRON_DIR/dist/electron.d.ts" ]; then
mv "$ELECTRON_DIR/dist/electron.d.ts" "$ELECTRON_DIR/electron.d.ts"
fi
if [ "$(uname -s)" = "Darwin" ]; then
PLATFORM_PATH='Electron.app/Contents/MacOS/Electron'
test -f "$ELECTRON_DIR/dist/Electron.app/Contents/MacOS/Electron"
else
PLATFORM_PATH='electron'
test -f "$ELECTRON_DIR/dist/electron"
chmod +x "$ELECTRON_DIR/dist/electron"
fi
printf '%s' "$PLATFORM_PATH" > "$ELECTRON_DIR/path.txt"
echo "path.txt: $(cat "$ELECTRON_DIR/path.txt")"
- name: Install Electron binary (Windows)
if: runner.os == 'Windows'
shell: pwsh
env:
force_no_cache: 'true'
run: |
$ErrorActionPreference = 'Stop'
Remove-Item Env:ELECTRON_SKIP_BINARY_DOWNLOAD -ErrorAction SilentlyContinue
$electronDir = node -p "require('path').dirname(require.resolve('electron/package.json'))"
Write-Host "Electron package dir: $electronDir"
Remove-Item -Recurse -Force "$electronDir\dist", "$electronDir\path.txt" -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path "$electronDir\dist" | Out-Null
Push-Location $electronDir
try {
$zip = node -e "const { downloadArtifact } = require('@electron/get'); const { version } = require('./package.json'); downloadArtifact({ version, artifactName: 'electron', force: true }).then((z) => { process.stdout.write(z); process.exit(0); }).catch((e) => { console.error(e); process.exit(1); });"
} finally {
Pop-Location
}
$zipSize = (Get-Item -LiteralPath $zip).Length
Write-Host "Downloaded zip: $zip ($zipSize bytes)"
Expand-Archive -LiteralPath $zip -DestinationPath "$electronDir\dist" -Force
$entryCount = (Get-ChildItem -LiteralPath "$electronDir\dist").Count
Write-Host "Extracted top-level entries: $entryCount"
$typeDef = Join-Path $electronDir 'dist\electron.d.ts'
if (Test-Path -LiteralPath $typeDef) {
Move-Item -LiteralPath $typeDef -Destination (Join-Path $electronDir 'electron.d.ts') -Force
}
$exe = Join-Path $electronDir 'dist\electron.exe'
if (-not (Test-Path -LiteralPath $exe)) {
throw "electron.exe missing after extract: $exe"
}
Set-Content -LiteralPath (Join-Path $electronDir 'path.txt') -Value 'electron.exe' -NoNewline
Write-Host "path.txt: electron.exe"
- name: Verify Electron binary
run: pnpm exec electron --version
- name: Generate extension bridge
run: pnpm run ext:bridge
- name: Run Electron E2E on Linux
if: runner.os == 'Linux'
run: xvfb-run -a pnpm run test:e2e
- name: Run Electron E2E on macOS
if: runner.os == 'macOS'
run: pnpm run test:e2e
- name: Run Electron E2E on Windows
if: runner.os == 'Windows'
run: pnpm run test:e2e
- name: Upload Playwright artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-artifacts-${{ matrix.os }}
path: |
playwright-report
test-results
if-no-files-found: warn
retention-days: 7
+54
View File
@@ -0,0 +1,54 @@
name: Harness
on:
workflow_dispatch:
pull_request:
branches:
- main
paths:
- 'harness/**'
- 'tests/unit/harness-specs.test.ts'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.github/workflows/harness.yml'
jobs:
harness:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: '1'
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'pnpm'
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run harness CI checks
run: pnpm run harness:ci
- name: Upload harness artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: harness-artifacts
path: artifacts/harness
if-no-files-found: warn
retention-days: 7
+9 -1
View File
@@ -43,16 +43,24 @@ jobs:
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install
- name: Download uv binaries for Windows
run: pnpm run uv:download:win
- name: Download agent-browser binaries for Windows
run: pnpm run agent-browser:download:win
- name: Build Windows package (no publish)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm run build:vite && pnpm exec zx scripts/bundle-openclaw.mjs && pnpm exec electron-builder --win --publish never
run: pnpm run package:win
- name: Upload Windows Installer (x64)
uses: actions/upload-artifact@v4
+199 -30
View File
@@ -15,9 +15,21 @@ on:
permissions:
contents: write
actions: read
jobs:
# Fails fast on tag pushes if package.json "version" does not match the tag.
validate-release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Assert tag matches package.json
run: node scripts/assert-tag-matches-package.mjs
release:
needs: validate-release
strategy:
matrix:
include:
@@ -32,30 +44,23 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
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: Setup pnpm
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
shell: bash
- name: Prefer HTTPS for public GitHub git dependencies
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-
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install
@@ -72,7 +77,37 @@ jobs:
if: matrix.platform == 'linux'
run: pnpm run uv:download:linux
- name: Download agent-browser binaries for macOS
if: matrix.platform == 'mac'
run: pnpm run agent-browser:download:mac
- name: Download agent-browser binaries for Windows
if: matrix.platform == 'win'
run: pnpm run agent-browser:download:win
- name: Download agent-browser binaries for Linux
if: matrix.platform == 'linux'
run: pnpm run agent-browser:download:linux
# macOS specific steps
- name: Free disk space (macOS)
if: matrix.platform == 'mac'
run: |
echo "=== Disk usage before cleanup ==="
df -h /
# Remove large pre-installed toolchains not needed for Electron builds
sudo rm -rf /usr/local/lib/android || true
sudo rm -rf /usr/share/dotnet || true
sudo rm -rf /usr/local/share/powershell || true
sudo rm -rf /usr/local/share/chromium || true
sudo rm -rf /usr/local/lib/node_modules || true
rm -rf ~/Library/Caches/electron-builder/dmg-builder* || true
# Homebrew cleanup
brew cleanup --prune=all 2>/dev/null || true
echo "=== Disk usage after cleanup ==="
df -h /
# --publish never: prevent electron-builder from auto-publishing to GitHub.
# All artifacts are collected and published atomically in the publish job.
- name: Build macOS
@@ -87,21 +122,159 @@ jobs:
run: |
ulimit -n 65536
echo "File descriptor limit: $(ulimit -n)"
pnpm run build:vite && pnpm exec zx scripts/bundle-openclaw.mjs && pnpm exec electron-builder --mac --publish never
pnpm run package:mac
# Windows specific steps
- name: Build Windows
if: matrix.platform == 'win'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm run build:vite && pnpm exec zx scripts/bundle-openclaw.mjs && pnpm exec electron-builder --win --publish never
run: pnpm run package:win
# Detect release channel from tag to skip code signing for alpha/beta builds
- name: Detect Windows release channel
if: matrix.platform == 'win'
id: win-channel
shell: bash
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
TAG="${GITHUB_REF#refs/tags/v}"
else
TAG="${{ github.event.inputs.version }}"
fi
if [[ "$TAG" =~ (alpha|beta) ]]; then
echo "is_stable=false" >> $GITHUB_OUTPUT
echo "Channel: prerelease ($TAG) — skipping code signing"
else
echo "is_stable=true" >> $GITHUB_OUTPUT
echo "Channel: stable ($TAG) — will sign"
fi
- name: Validate unsigned Windows artifacts before SignPath
if: matrix.platform == 'win' && steps.win-channel.outputs.is_stable == 'true'
shell: pwsh
run: |
$unsignedExeFiles = Get-ChildItem -Path "release" -Filter *.exe -File
if (-not $unsignedExeFiles) {
throw "No unsigned .exe files found in release/ before SignPath upload"
}
$unsignedCount = $unsignedExeFiles.Count
"UNSIGNED_EXE_COUNT=$unsignedCount" | Out-File -FilePath $env:GITHUB_ENV -Append
Write-Host "Found $unsignedCount unsigned .exe file(s):"
$unsignedExeFiles | ForEach-Object { Write-Host " - $($_.Name)" }
- name: Upload unsigned Windows artifacts for SignPath
if: matrix.platform == 'win' && steps.win-channel.outputs.is_stable == 'true'
id: upload-unsigned-windows-artifact
uses: actions/upload-artifact@v4
with:
name: unsigned-win-exe-${{ github.run_id }}-${{ github.run_attempt }}
path: release/*.exe
retention-days: 1
- name: Sign Windows artifacts via SignPath
if: matrix.platform == 'win' && steps.win-channel.outputs.is_stable == 'true'
id: signpath-sign-windows
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: "78e37079-23df-4800-b41c-33312ad7c1e3"
project-slug: "ValueCell"
signing-policy-slug: "ValueCell-sign"
github-artifact-id: ${{ steps.upload-unsigned-windows-artifact.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: release/signed
- name: Replace unsigned executables with signed ones
if: matrix.platform == 'win' && steps.win-channel.outputs.is_stable == 'true'
shell: pwsh
run: |
Write-Host "SignPath GitHub artifact ID: ${{ steps.upload-unsigned-windows-artifact.outputs.artifact-id }}"
$signedExeFiles = Get-ChildItem -Path "release/signed" -Filter *.exe -File -Recurse
if (-not $signedExeFiles) {
throw "No signed .exe files found in release/signed"
}
$signedCount = $signedExeFiles.Count
if ($env:UNSIGNED_EXE_COUNT -and ($signedCount -ne [int]$env:UNSIGNED_EXE_COUNT)) {
throw "Signed .exe count ($signedCount) does not match unsigned count ($env:UNSIGNED_EXE_COUNT)"
}
foreach ($file in $signedExeFiles) {
Copy-Item -Path $file.FullName -Destination "release/$($file.Name)" -Force
}
$finalExeFiles = Get-ChildItem -Path "release" -Filter *.exe -File
if ($env:UNSIGNED_EXE_COUNT -and ($finalExeFiles.Count -ne [int]$env:UNSIGNED_EXE_COUNT)) {
throw "Final release .exe count ($($finalExeFiles.Count)) does not match unsigned count ($env:UNSIGNED_EXE_COUNT)"
}
Write-Host "Signed executables copied to release/ ($($finalExeFiles.Count) file(s))"
# Code signing changes the .exe binary, invalidating the sha512 hash that
# electron-builder wrote into latest.yml during the initial build.
# Recalculate the hash for each signed .exe and patch the yml files so
# electron-updater can verify the download successfully.
#
# Actual latest.yml structure (from electron-builder NSIS):
# files:
# - url: ClawX-0.2.4-win-x64.exe ← files[] entries have url/sha512/size
# sha512: <base64>
# size: 430775882
# path: ClawX-0.2.4-win-arm64.exe ← top-level has path/sha512 (no size!)
# sha512: <base64>
# releaseDate: '...'
- name: Update latest.yml sha512 after code signing
if: matrix.platform == 'win' && steps.win-channel.outputs.is_stable == 'true'
shell: pwsh
run: |
$ymlFiles = Get-ChildItem -Path "release" -Filter "*.yml" -File | Where-Object { $_.Name -ne "builder-debug.yml" }
$exeFiles = Get-ChildItem -Path "release" -Filter "*.exe" -File
foreach ($yml in $ymlFiles) {
$content = Get-Content $yml.FullName -Raw
$modified = $false
foreach ($exe in $exeFiles) {
# Compute new sha512 (base64) for the signed exe
$hash = Get-FileHash -Path $exe.FullName -Algorithm SHA512
$hashBytes = [byte[]]::new($hash.Hash.Length / 2)
for ($i = 0; $i -lt $hashBytes.Length; $i++) {
$hashBytes[$i] = [Convert]::ToByte($hash.Hash.Substring($i * 2, 2), 16)
}
$newSha512 = [Convert]::ToBase64String($hashBytes)
$newSize = (Get-Item $exe.FullName).Length
$escapedName = [Regex]::Escape($exe.Name)
# 1) files[] entries: url: <name>\n sha512: <hash>\n size: <n>
$urlPattern = "(?m)(url:\s*${escapedName}\s*\r?\n\s*sha512:\s*)(\S+)(\s*\r?\n\s*size:\s*)(\d+)"
if ($content -match $urlPattern) {
$content = $content -replace $urlPattern, "`${1}${newSha512}`${3}${newSize}"
$modified = $true
Write-Host "Updated $($yml.Name) files[]: $($exe.Name) sha512=$newSha512 size=$newSize"
}
# 2) Top-level entry: path: <name>\nsha512: <hash>\n (no size field)
$pathPattern = "(?m)(path:\s*${escapedName}\s*\r?\n)sha512:\s*\S+"
if ($content -match $pathPattern) {
$content = $content -replace $pathPattern, "`${1}sha512: ${newSha512}"
$modified = $true
Write-Host "Updated $($yml.Name) top-level: $($exe.Name) sha512=$newSha512"
}
}
if ($modified) {
Set-Content -Path $yml.FullName -Value $content -NoNewline
Write-Host "Saved updated $($yml.Name)"
}
}
Write-Host ""
Write-Host "=== Final yml contents ==="
foreach ($yml in $ymlFiles) {
Write-Host "--- $($yml.Name) ---"
Get-Content $yml.FullName
Write-Host ""
}
# Linux specific steps
- name: Build Linux
if: matrix.platform == 'linux'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm run build:vite && pnpm exec zx scripts/bundle-openclaw.mjs && pnpm exec electron-builder --linux --publish never
run: pnpm run package:linux
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -127,13 +300,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download all artifacts
- name: Download release artifacts only
uses: actions/download-artifact@v4
with:
path: release-artifacts
pattern: release-*
- name: List all downloaded artifacts
run: |
@@ -222,13 +393,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download all artifacts
- name: Download release artifacts only
uses: actions/download-artifact@v4
with:
path: release-artifacts
pattern: release-*
- name: Extract version and channel
id: version
+112
View File
@@ -0,0 +1,112 @@
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: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install
- name: Download uv binaries for Windows
run: pnpm run uv:download:win
- name: Download agent-browser binaries for Windows
run: pnpm run agent-browser:download:win
- name: Build Windows
run: pnpm run package:win
- 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
+23 -1
View File
@@ -35,9 +35,15 @@ yarn-error.log*
# OS files
.DS_Store
Thumbs.db
desktop.ini
# Test coverage
coverage/
playwright-report/
test-results/
# Local session transcript fixtures (may contain private conversation data)
tests/fixtures/transcripts/
# Cache
.cache/
@@ -59,6 +65,22 @@ resources/bin
*.key
build/
artifacts/
.delivery/
docs/pr-session-notes-*.md
.cursor/
.pnpm-store/
.claude/
.pnpm-store/
package-lock.json
# Generated extension bridges (created by scripts/generate-ext-bridge.mjs)
electron/extensions/_ext-bridge.generated.ts
src/extensions/_ext-bridge.generated.ts
# Local playground artifacts
playground/
# ClawX-biz bridge workspace artifacts
resources/enterprise-skills/
resources/openclaw-plugins/skillshub/
-3
View File
@@ -1,6 +1,3 @@
package-import-method=copy
shamefully-hoist=true
strict-peer-dependencies=false
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
+10 -1
View File
@@ -17,6 +17,9 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
| Lint (ESLint, auto-fix) | `pnpm run lint` |
| Type check | `pnpm run typecheck` |
| Unit tests (Vitest) | `pnpm test` |
| Comms replay metrics | `pnpm run comms:replay` |
| Comms baseline refresh | `pnpm run comms:baseline` |
| Comms regression compare | `pnpm run comms:compare` |
| E2E tests (Playwright) | `pnpm run test:e2e` |
| Build frontend only | `pnpm run build:vite` |
@@ -30,12 +33,18 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
- **Gateway startup**: When running `pnpm dev`, the OpenClaw Gateway process starts automatically on port 18789. It takes ~10-30 seconds to become ready. Gateway readiness is not required for UI development—the app functions without it (shows "connecting" state).
- **No database**: The app uses `electron-store` (JSON files) and OS keychain. No database setup is needed.
- **AI Provider keys**: Actual AI chat requires at least one provider API key configured via Settings > AI Providers. The app is fully navigable and testable without keys.
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, scans both configured agents and any runtime agent directories found on disk, and treats normal, `.deleted.jsonl`, and `.jsonl.reset.*` transcripts as valid history sources. It extracts assistant/tool usage records with `message.usage` and aggregates fields such as input/output/cache/total tokens and cost from those structured records.
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, scans both configured agents and any runtime agent directories found on disk, and treats normal, `.deleted.jsonl`, and `.jsonl.reset.*` transcripts as valid history sources. It extracts assistant/tool usage records with `message.usage` and aggregates fields such as input/output/cache/total tokens and cost from those structured records. Note: "Delete conversation" in the sidebar is a hard delete — the Main process unlinks `<id>.jsonl` plus any leftover `<id>.deleted.jsonl` and `<id>.jsonl.reset.*` siblings, *and* OpenClaw's trajectory artefacts (`<id>.trajectory.jsonl` flight recorder + `<id>.trajectory-path.json` pointer); when the pointer references a runtime file outside the agent's `sessions/` folder (the `OPENCLAW_TRAJECTORY_DIR` override), that off-disk file is unlinked too. Deleted conversations stop contributing to this chart — use a fresh session if you want history retained.
- **Models page aggregation**: The 7-day/30-day filters are relative rolling windows, not calendar-month buckets. When grouped by time, the chart should keep all day buckets in the selected window; only model grouping is intentionally capped to the top entries.
- **OpenClaw Doctor in UI**: In Settings > Advanced > Developer, the app exposes both `Run Doctor` (`openclaw doctor --json`) and `Run Doctor Fix` (`openclaw doctor --fix --yes --non-interactive`) through the host-api. Renderer code should call the host route, not spawn CLI processes directly.
- **UI change validation**: Any user-visible UI change should include or update an Electron E2E spec in the same PR so the interaction is covered by Playwright.
- **i18n & styling conventions**: New user-facing features must (1) route all text through `react-i18next` with full locale coverage (`en` / `zh` / `ja` / `ru` under `shared/i18n/locales/<lang>/<ns>.json`) — never hardcode display strings, and (2) use the design tokens and substitution rules documented in `src/styles/globals.css` (surfaces `bg-surface-modal` / `bg-surface-input`, selected state `bg-black/5 dark:bg-white/10`, status colours `text-X-700 dark:text-X-400`, page H1/H2 `font-serif font-normal tracking-tight`, etc.) — see the *Component conventions* block in `globals.css` for the full substitution table.
- **Renderer/Main API boundary (important)**:
- Renderer must use `src/lib/host-api.ts` and `src/lib/api-client.ts` as the single entry for backend calls.
- Do not add new direct `window.electron.ipcRenderer.invoke(...)` calls in pages/components; expose them through host-api/api-client instead.
- 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.
- **Spec-driven harness rule**: AI Coding tasks that touch backend communication must start from a task spec under `harness/specs/tasks/` and reference `gateway-backend-communication` when the change involves renderer/Main/host-api/api-client/Gateway/OpenClaw runtime paths. Run `pnpm harness validate --spec <task-spec>` before implementation review, and `pnpm harness run --spec <task-spec>` or `--dry-run` when checking the selected validation flow.
- **Spec/rule growth rule**: When adding a new feature, user-visible OpenClaw scenario, or recurring AI Coding constraint, add or update the relevant harness scenario spec and rule spec in the same PR so future AI work can validate the behavior instead of relying on tribal knowledge.
- **Harness CI/local parity**: Run `pnpm run harness:ci` to exercise the same baseline harness checks used by GitHub Actions. Real task specs should be validated without `--no-diff`; `--no-diff` is only for structural checks of checked-in examples.
+108 -53
View File
@@ -30,7 +30,7 @@
</p>
<p align="center">
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | 日本語
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | 日本語 | <a href="README.ru-RU.md">Русский</a>
</p>
---
@@ -43,6 +43,8 @@
ClawXはベストプラクティスのモデルプロバイダーが事前設定されており、Windowsおよび多言語設定をネイティブにサポートしています。もちろん、**設定 → 詳細設定 → 開発者モード**から高度な設定を微調整することもできます。
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">完全なエンタープライズ版、専用のサービスサポート、または御社のビジネスシナリオに合わせた導入支援が必要な場合は、<a href="mailto:public@valuecell.ai">public@valuecell.ai</a> までお問い合わせください。</strong></p>
---
## スクリーンショット
@@ -81,6 +83,7 @@ AIエージェントの構築にコマンドラインの習得は不要である
| 複雑なCLIセットアップ | ワンクリックインストールとガイド付きセットアップウィザード |
| 設定ファイル | リアルタイムバリデーション付きのビジュアル設定 |
| プロセス管理 | ゲートウェイライフサイクルの自動管理 |
| アプリ更新 | 起動時に更新を確認し、ダウンロードやインストール前に通知 |
| 複数のAIプロバイダー | 統合プロバイダー設定パネル |
| スキル/プラグインのインストール | 組み込みのスキルマーケットプレイスと管理機能 |
@@ -90,6 +93,8 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
私たちはアップストリームのOpenClawプロジェクトとの厳密な整合性を維持することにコミットしており、公式リリースが提供する最新の機能、安定性の改善、エコシステムの互換性に常にアクセスできることを保証します。
開発者モードを有効にすると、サイドバーにはネイティブの Dreams ページも表示され、ClawX 内で OpenClaw の記憶レビュー、夢日記、基本メンテナンス操作を扱えます。詳細な診断が必要な場合は、そのページから完全版の OpenClaw Dreams UI も開けます。
---
## 機能
@@ -98,27 +103,34 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
インストールから最初のAIインタラクションまで、すべてのセットアップを直感的なグラフィカルインターフェースで完了できます。ターミナルコマンド不要、YAMLファイル不要、環境変数の探索も不要です。
### 💬 インテリジェントチャットインターフェース
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングに加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、MarkdownによるリッチコンテンツレンダリングGitHub 風テーブルや KaTeX による LaTeX 数式 `$インライン$``$$ブロック$$``\(インライン\)``\[ブロック\]` を含む)に加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
コンポーザーから挿入した Skill は `/skill-name` 形式のチップとして表示され、チップをクリックすると右側のプレビュー側欄でその Skill の `SKILL.md` を開けます。
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
各 Agent は `provider/model` の実行時設定を個別に上書きできます。上書きしていない Agent は引き続きグローバルの既定モデルを継承します。
### 📡 マルチチャネル管理
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。
現在は各チャンネルで複数アカウントを扱え、Channels ページでアカウントの Agent 紐付けやデフォルトアカウント切替を直接管理できます。
カスタムのチャンネルアカウント ID には、ルーティング不一致を防ぐため OpenClaw 互換の正規形式(`[a-z0-9_-]`、英小文字、最大 64 文字、先頭は英小文字または数字)を必須にしています。
ClawX には Tencent 公式の個人 WeChat チャンネルプラグインも同梱されており、Channels ページからアプリ内 QR フローで直接 WeChat を連携できます。
### ⏰ Cronベースの自動化
AIタスクを自動的に実行するようスケジュール設定できます。トリガーを定義し、間隔を設定することで、手動介入なしにAIエージェントを24時間稼働させることができます。
定期タスク画面では外部配信を「送信アカウント」と「受信先ターゲット」の 2 段階セレクターで設定できるようになりました。対応チャネルでは、受信先候補をチャネルのディレクトリ機能や既知セッション履歴から自動検出するため、`jobs.json` を手で編集する必要はありません。タスクのメッセージ入力欄でも、メインのチャット入力と同じインライン `/skill` トークン記法でスキルを挿入できるようになりました(選択中のエージェントに応じて読み込み)。スケジュールされたプロンプトから直接スキルを起動できます。スケジュール選択は**繰り返し**と**1回のみ**のタブに分かれました。繰り返しは毎時・毎日・平日・毎週・カスタム(生の cron)の頻度を時刻/曜日コントロール付きで選べ、1回のみは選択した日付(曜日を表示)と時刻に一度だけ実行します。1回のみのタスクは未来の時刻を指定する必要があり、実行後はランタイムにより自動的に削除されます。
### 🧩 拡張可能なスキルシステム
事前構築されたスキルでAIエージェントを拡張できます。統合スキルパネルからスキルの閲覧、インストール、管理が可能です。パッケージマネージャーは不要です。
ClawX はドキュメント処理スキル(`pdf``xlsx``docx``pptx`)もフル内容で同梱し、起動時に管理スキルディレクトリ(既定 `~/.openclaw/skills`)へ自動配備し、初回インストール時に既定で有効化します。追加の同梱スキル(`find-skills``self-improving-agent``tavily-search``brave-web-search``bocha-skill`)も既定で有効化されますが、必要な API キーが未設定の場合は OpenClaw が実行時に設定エラーを表示します。
Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、workspace、追加スキルディレクトリ)から検出されたスキルを表示でき、各スキルの実際のパスを確認して実フォルダを直接開けます。
主な検索スキルで必要な環境変数:
- `BRAVE_SEARCH_API_KEY`: `brave-web-search`
- `TAVILY_API_KEY`: `tavily-search` 用(上流ランタイムで OAuth 対応の場合あり)
- `BOCHA_API_KEY`: `bocha-skill`
事前構築されたスキルでAIエージェントを拡張できます。統合 Skills ページはローカル優先で、管理ディレクトリや workspace のスキルをスキャンし、Gateway に依存せず有効/無効を切り替えられます。エンタープライズ拡張がある場合は、その拡張が提供する marketplace も表示できます。
ClawX はドキュメント処理スキル(`pdf``xlsx``docx``pptx`)もフル内容で同梱し、起動時に管理スキルディレクトリ(既定 `~/.openclaw/skills`)へ自動配備し、初回インストール時に既定で有効化します。
Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、workspace、追加スキルディレクトリ)から検出されたスキルを表示でき、各スキルの実際のパスを確認して実フォルダを直接開けます。OpenClaw 同梱の bundled skill については、コミュニティ版ではパッケージにも表示にも `skill-creator` のみを残し、dev 起動時と packaged 起動時の両方で他の bundled skill を物理的に削除します。さらに、削除済み bundled skill の古い `openclaw.json` エントリも一緒に掃除します。
### 🔐 セキュアなプロバイダー統合
複数のAIプロバイダー(OpenAI、Anthropicなど)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuth(Codex サブスクリプション)の両方に対応しています。
開発者モードでは、専用の Image Generation ページで、独立した OpenAI 互換の画像生成エンドポイント(Base URL、API キー、`gpt-image-2` などのモデル名)を設定でき、画像生成だけ専用の `/v1/images/generations` サービスを使い、チャットは通常の OpenAI Provider のまま継続できます。
OpenAI-compatible ゲートウェイを **Custom プロバイダー** で使う場合、**設定 → AI Providers → Provider 編集** でカスタム `User-Agent` を設定でき、互換性が必要なエンドポイントで有効です。
プロバイダーの編集や切り替え時、ClawX は `input: ["text", "image"]` など既存のモデル単位の能力メタデータを保持します。新しく選択した Custom プロバイダーのモデルには OpenClaw onboarding と同等の画像入力推論を適用し、不明なモデルはテキスト専用として扱います。
Custom プロバイダーのモデル行には明示的な `contextWindow` も書き込まれ(モデルファミリーから推定、例:`gpt-5.x` → 272k)、旧バージョンで保存された行は起動時に自動補完されます。これにより OpenClaw は長いセッションを "Context overflow" エラーになる前に圧縮できます。compaction 未設定の場合は `agents.defaults.compaction.mode = "safeguard"``reserveTokensFloor = 50000` が既定値として設定されますが、ユーザーが自分で設定したモデル行や圧縮設定が変更されることはありません(`reserveTokensFloor` が未設定の場合のみ補完されることがあります)。
互換ゲートウェイで `/models` が認証以外の理由で使えない場合、ClawX は API キー検証時に軽量な `/chat/completions` または `/responses` プローブへ自動フォールバックします。
### 🌙 アダプティブテーマ
ライトモード、ダークモード、またはシステム同期テーマ。ClawXはあなたの好みに自動的に適応します。
@@ -126,6 +138,9 @@ Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、
### 🚀 自動起動設定
**設定 → 通用** から **システム起動時に自動起動** を有効化すると、ログイン後に ClawX が自動的に起動します。
### 🔔 更新通知
ClawX は起動時に新しいバージョンを自動確認できます。更新が見つかるとアプリ内通知を表示し、ダウンロードやインストールはユーザーが選択した後にのみ実行されます。
---
## はじめに
@@ -164,6 +179,8 @@ ClawXを初めて起動すると、**セットアップウィザード**が以
3. **スキルバンドル** – 一般的なユースケース向けの事前設定スキルを選択
4. **検証** – メインインターフェースに入る前に設定をテスト
サポート対象のシステム言語がある場合、ウィザードはその言語を初期選択し、未対応の場合は英語にフォールバックします。
### プロキシ設定
ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネルがローカルプロキシクライアントを介してインターネットにアクセスする必要がある環境向けに、組み込みのプロキシ設定が含まれています。
@@ -188,7 +205,10 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ
- 高度なプロキシフィールドが空の場合、ClawXは`プロキシサーバー`にフォールバックします。
- プロキシ設定を保存すると、Electronのネットワーク設定が即座に再適用され、ゲートウェイが自動的に再起動されます。
- ClawXはTelegramが有効な場合、プロキシをOpenClawのTelegramチャネル設定にも同期します。
- ClawXのプロキシが無効な状態では、Gatewayの通常再起動時に既存のTelegramチャネルプロキシ設定を保持します。
- OpenClaw設定のTelegramプロキシを明示的に消したい場合は、プロキシ無効の状態で一度「保存」を実行してください。
- **設定 → 詳細 → 開発者** では **OpenClaw Doctor** を実行でき、`openclaw doctor --json` の診断出力をアプリ内で確認できます。
- Windows のパッケージ版では、同梱された `openclaw` CLI/TUI は端末入力を安定させるため、同梱の `node.exe` エントリーポイント経由で実行されます。
---
@@ -196,58 +216,71 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ
ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を採用しています。Renderer は単一クライアント抽象を呼び出し、プロトコル選択とライフサイクルは Main が管理します:
```┌─────────────────────────────────────────────────────────────────┐
│ ClawX デスクトップアプリ │
┌────────────────────────────────────────────────────────────┐
│ Electron メインプロセス │
│ │ • ウィンドウ&アプリケーションライフサイクル管理 │ │
│ │ • ゲートウェイプロセスの監視 │ │
│ │ • システム統合(トレイ、通知、キーチェーン) │ │
│ │ • 自動アップデートオーケストレーション │ │
└────────────────────────────────────────────────────────────┘
│ │ IPC(権威ある制御プレーン)
┌────────────────────────────────────────────────────────────┐
│ React レンダラープロセス │
│ │ • モダンなコンポーネントベースUI(React 19) │ │
│ │ • Zustandによるステート管理 │ │
│ │ • 統一 host-api/api-client 呼び出し │ │
│ │ • リッチなMarkdownレンダリング │ │
│ └────────────────────────────────────────────────────────────┘
└──────────────────────────────┬──────────────────────────────────┘
```
┌────────────────────────────────────────────────────────────────────┐
ClawX デスクトップアプリ
┌──────────────────────────────────────────────────────────────┐
│ │ Electron メインプロセス │ │
│ │ • ウィンドウ&アプリケーションライフサイクル管理 │ │
│ │ • ゲートウェイプロセスの監視 │ │
│ │ • システム統合(トレイ、通知、キーチェーン) │ │
│ • 自動アップデートオーケストレーション │
└──────────────────────────────────────────────────────────────┘
│ │
│ IPC(権威ある制御プレーン)
┌──────────────────────────────────────────────────────────────┐
│ │ React レンダラープロセス │ │
│ │ • モダンなコンポーネントベースUI(React 19) │ │
│ │ • Zustandによるステート管理 │ │
│ │ • 統一 host-api/api-client 呼び出し │ │
│ │ • リッチなMarkdownレンダリング │
└──────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬─────────────────────────────────────┘
Main管理のトランスポート戦略
│(WS優先、HTTP次点、IPCフォールバック)
型付き IPC リクエスト
┌─────────────────────────────────────────────────────────────────┐
Host API と Main プロキシ層
│ • hostapi:fetchMainプロキシ、CORS回避)
│ • gateway:httpProxyRendererはGateway HTTPに直アクセスしない)
│ • 統一エラーマッピングとリトライ/バックオフ
Main Host Services と Gateway Manager
│ • host:invoke 型付きサービスディスパッチ
│ • 設定、ファイル、セッション、スキル、プロバイダー、診断サービス
│ • Main が Gateway WebSocket とプロセス監視を所有
└──────────────────────────────┬──────────────────────────────────┘
WS / HTTP / IPC フォールバック
Main 所有 WebSocket
┌─────────────────────────────────────────────────────────────────┐
│ OpenClaw ゲートウェイ │
│ • AIエージェントランタイムとオーケストレーション │
│ • メッセージチャネル管理 │
│ • スキル/プラグイン実行環境 │
│ • プロバイダー抽象化レイヤー │
│ • AIエージェントランタイムとオーケストレーション
│ • メッセージチャネル管理
│ • スキル/プラグイン実行環境
│ • プロバイダー抽象化レイヤー
└─────────────────────────────────────────────────────────────────┘
```
### 設計原則
- **プロセス分離**: AIランタイムは別プロセスで動作し、重い計算処理中でもUIの応答性を確保します
- **フロントエンド呼び出しの単一入口**: Renderer は host-api/api-client を通じて呼び出し、下位プロトコルに依存しません
- **Mainによるトランスポート制御**: WS/HTTP の選択と IPC フォールバックを Main で一元管理します
- **Mainによるトランスポート制御**: Gateway WebSocket は Electron Main のみが所有し、Renderer は型付き IPC で Main と通信します
- **拡張 IPC コントリビューション**: Main プロセス拡張は HTTP route ではなく、型付き IPC レジストリを通じて host-api action を提供します
- **グレースフルリカバリ**: 再接続・タイムアウト・バックオフで一時的障害を自動処理します
- **セキュアストレージ**: APIキーや機密データは、OSのネイティブセキュアストレージ機構を活用します
- **CORSセーフ設計**: ローカルHTTPはMainプロキシ経由とし、Renderer側CORS問題を回避します
- **CORSセーフ設計**: Renderer はローカル Gateway や Host API HTTP エンドポイントを直接呼び出しません
### プロセスモデルと Gateway トラブルシューティング
- ClawX は Electron アプリのため、**1つのアプリインスタンスでも複数プロセス(main/renderer/zygote/utility)が表示される**のが正常です。
- 単一起動保護は Electron のロックに加え、ローカルのプロセスロックファイルも併用し、デスクトップ IPC / セッションバスが不安定な環境でも重複起動を防ぎます。
- ローリングアップグレード中に旧版/新版が混在すると、単一起動保護の挙動が非対称になる場合があります。安定運用のため、デスクトップクライアントは可能な限り同一バージョンへ揃えてください。
- ただし OpenClaw Gateway の待受は常に**単一**であるべきです。`127.0.0.1:18789` を Listen しているプロセスは1つだけです。
- Gateway の readiness は `system-presence``health``status` などの OpenClaw コア信号を基準にし、memory、Dreams、チャネルの失敗はグローバルな Gateway 障害ではなく capability degradation として表示します。
- Listen プロセスの確認例:
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
- Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen`
- ウィンドウの閉じるボタン(`X`)は既定でトレイへ最小化する動作で、完全終了ではありません。完全終了する場合はトレイメニューの **Quit ClawX** を使用してください。
---
@@ -271,16 +304,19 @@ AI を開発ワークフローに統合できます。エージェントを使
### 前提条件
- **Node.js**: 22以上(LTS推奨)
- **Node.js**: 22.19以上(LTS推奨)
- **パッケージマネージャー**: pnpm 9以上(推奨)またはnpm
- **LinuxUbuntu/Debian**: Electron を実行する前に、必要なシステムライブラリをインストールしてください:
```bash
sudo apt-get install -y libnss3 libgtk-3-0 libxss1 libxtst6 libatspi2.0-0 libnotify4 xdg-utils
```
Ubuntu 24.04以降では、一部のパッケージに `t64` サフィックスが付いています。上記コマンドを実行すると `apt` が自動的に適切なバリアントを選択します。
### プロジェクト構成
```ClawX/
├── electron/ # Electron メインプロセス
│ ├── api/ # メイン側 API ルーターとハンドラー
│ │ └── routes/ # RPC/HTTP プロキシのルートモジュール
│ ├── services/ # Provider/Secrets/ランタイムサービス
│ ├── services/ # 型付き Host API、Provider/Secrets/ランタイムサービス
│ │ ├── providers/ # provider/account モデル同期ロジック
│ │ └── secrets/ # OS キーチェーンと秘密情報管理
│ ├── shared/ # 共通 Provider スキーマ/定数
@@ -297,6 +333,7 @@ AI を開発ワークフローに統合できます。エージェントを使
│ ├── i18n/ # ローカライズリソース
│ └── types/ # TypeScript 型定義
├── tests/
│ ├── e2e/ # Playwright による Electron E2E スモークテスト
│ └── unit/ # Vitest ユニット/統合寄りテスト
├── resources/ # 静的アセット(アイコン、画像)
└── scripts/ # ビルド/ユーティリティスクリプト
@@ -305,8 +342,8 @@ AI を開発ワークフローに統合できます。エージェントを使
```bash
# 開発
pnpm run init # 依存関係のインストール + uvのダウンロード
pnpm dev # ホットリロードで起動
pnpm run init # 依存関係のインストール + バンドルバイナリ(uv、agent-browserのダウンロード
pnpm dev # ホットリロードで起動(不足時は同梱スキルを自動準備)
# コード品質
pnpm lint # ESLintを実行
@@ -314,15 +351,33 @@ pnpm typecheck # TypeScriptの型チェック
# テスト
pnpm test # ユニットテストを実行
pnpm run test:e2e # Electron E2E スモークテストを実行
pnpm run test:e2e:headed # 表示付きウィンドウで Electron E2E を実行
pnpm run comms:replay # 通信リプレイ指標を算出
pnpm run comms:baseline # 通信ベースラインを更新
pnpm run comms:compare # リプレイ指標をベースライン閾値と比較
# ビルド&パッケージ
pnpm run build:vite # フロントエンドのみビルド
pnpm build # フルプロダクションビルド(パッケージアセット含む)
pnpm package # 現在のプラットフォーム向けにパッケージ化
pnpm package # 現在のプラットフォーム向けにパッケージ化(同梱プリインストールスキルを含む)
pnpm package:mac # macOS向けにパッケージ化
pnpm package:win # Windows向けにパッケージ化
pnpm package:linux # Linux向けにパッケージ化
```
ヘッドレス Linux では Electron テストに表示サーバーが必要です。`xvfb-run -a pnpm run test:e2e` を利用してください。
### 通信回帰チェック
PR が通信経路(Gateway イベント、Chat 送受信フロー、Channel 配信、トランスポートのフォールバック)に触れる場合は、次を実行してください。
```bash
pnpm run comms:replay
pnpm run comms:compare
```
CI の `comms-regression` が必須シナリオと閾値を検証します。
### 技術スタック
| レイヤー | 技術 |
+124 -48
View File
@@ -30,7 +30,7 @@
</p>
<p align="center">
English | <a href="README.zh-CN.md">简体中文</a> | <a href="README.ja-JP.md">日本語</a>
English | <a href="README.zh-CN.md">简体中文</a> | <a href="README.ja-JP.md">日本語</a> | <a href="README.ru-RU.md">Русский</a>
</p>
---
@@ -43,6 +43,8 @@ Whether you're automating workflows, managing AI-powered channels, or scheduling
ClawX comes pre-configured with best-practice model providers and natively supports Windows as well as multi-language settings. Of course, you can also fine-tune advanced configurations via **Settings → Advanced → Developer Mode**.
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">For a full enterprise edition, dedicated service support, or tailored deployment guidance for your business scenario, contact us at <a href="mailto:public@valuecell.ai">public@valuecell.ai</a>.</strong></p>
---
## Screenshot
@@ -81,8 +83,9 @@ Building AI agents shouldn't require mastering the command line. ClawX was desig
| Complex CLI setup | One-click installation with guided setup wizard |
| Configuration files | Visual settings with real-time validation |
| Process management | Automatic gateway lifecycle management |
| App updates | Startup update checks with a prompt before downloading or installing |
| Multiple AI providers | Unified provider configuration panel |
| Skill/plugin installation | Built-in skill marketplace and management |
| Skill/plugin installation | Local-first skill management with optional extension-provided marketplace |
### OpenClaw Inside
@@ -90,6 +93,8 @@ ClawX is built directly upon the official **OpenClaw** core. Instead of requirin
We are committed to maintaining strict alignment with the upstream OpenClaw project, ensuring that you always have access to the latest capabilities, stability improvements, and ecosystem compatibility provided by the official releases.
When Developer Mode is enabled, the sidebar also provides a native Dreams page for OpenClaw memory review, dream diary inspection, and basic maintenance actions. The full upstream OpenClaw Dreams UI remains available from that page when deeper diagnostics are needed.
---
## Features
@@ -98,28 +103,34 @@ We are committed to maintaining strict alignment with the upstream OpenClaw proj
Complete the entire setup—from installation to your first AI interaction—through an intuitive graphical interface. No terminal commands, no YAML files, no environment variable hunting.
### 💬 Intelligent Chat Interface
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, rich content rendering with Markdown, and direct `@agent` routing in the main composer for multi-agent setups.
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, rich content rendering with Markdown (including GitHub-flavored tables and KaTeX-powered LaTeX math: `$inline$`, `$$block$$`, `\(inline\)`, and `\[block\]`), and direct `@agent` routing in the main composer for multi-agent setups.
Skills you insert from the composer appear as `/skill-name` chips; click a chip to open the preview sidebar and read that skill's `SKILL.md`.
When you target another agent with `@agent`, ClawX switches into that agent's own conversation context directly instead of relaying through the default agent. Agent workspaces stay separate by default, and stronger isolation depends on OpenClaw sandbox settings.
Each agent can also override its own `provider/model` runtime setting; agents without overrides continue inheriting the global default model.
### 📡 Multi-Channel Management
Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks.
Each channel now supports multiple accounts, per-account agent binding, and switching the channel default account directly from the Channels page.
For custom channel account IDs, ClawX enforces OpenClaw-compatible canonical IDs (`[a-z0-9_-]`, lowercase, max 64 chars, must start with a letter/number) to prevent routing mismatches.
ClawX now also bundles Tencent's official personal WeChat channel plugin, so you can link WeChat directly from the Channels page with an in-app QR flow.
### ⏰ Cron-Based Automation
Schedule AI tasks to run automatically. Define triggers, set intervals, and let your AI agents work around the clock without manual intervention.
The Cron page now lets you configure external delivery directly in the task form with separate sender-account and recipient-target selectors. For supported channels, recipient targets are discovered automatically from channel directories or known session history, so you no longer need to edit `jobs.json` by hand. The task message field also supports inserting skills with the same inline `/skill` token syntax as the main chat composer (scoped to the selected agent), so scheduled prompts can trigger skills directly. The schedule picker is split into **Recurring** and **Once** tabs: Recurring offers Hourly, Daily, Weekdays, Weekly, and Custom (raw cron) frequencies with inline time/weekday controls, while Once runs the task a single time at a chosen date (with weekday shown) and time. One-time tasks must be scheduled for a future moment and are automatically removed by the runtime once they finish.
### 🧩 Extensible Skill System
Extend your AI agents with pre-built skills. Browse, install, and manage skills through the integrated skill panel—no package managers required.
ClawX also pre-bundles full document-processing skills (`pdf`, `xlsx`, `docx`, `pptx`), deploys them automatically to the managed skills directory (default `~/.openclaw/skills`) on startup, and enables them by default on first install. Additional bundled skills (`find-skills`, `self-improving-agent`, `tavily-search`, `brave-web-search`, `bocha-skill`) are also enabled by default; if required API keys are missing, OpenClaw will surface configuration errors in runtime.
The Skills page can display skills discovered from multiple OpenClaw sources (managed dir, workspace, and extra skill dirs), and now shows each skill's actual location so you can open the real folder directly.
Environment variables for bundled search skills:
- `BRAVE_SEARCH_API_KEY` for `brave-web-search`
- `TAVILY_API_KEY` for `tavily-search` (OAuth may also be supported by upstream skill runtime)
- `BOCHA_API_KEY` for `bocha-skill`
- `find-skills` and `self-improving-agent` do not require API keys
Extend your AI agents with pre-built skills. The integrated Skills page is local-first: it scans managed/workspace skill directories, lets you enable or disable skills without depending on the Gateway, and can optionally expose an extension-provided marketplace in enterprise builds.
ClawX also pre-bundles full document-processing skills (`pdf`, `xlsx`, `docx`, `pptx`), deploys them automatically to the managed skills directory (default `~/.openclaw/skills`) on startup, and enables them by default on first install.
The Skills page can display skills discovered from multiple OpenClaw sources (managed dir, workspace, and extra skill dirs), and now shows each skill's actual location so you can open the real folder directly. For bundled OpenClaw skills, community builds now ship and expose only `skill-creator`; non-allowlisted bundled skills are physically trimmed in both dev and packaged startup, and any stale `openclaw.json` entries left behind for those removed bundled skills are pruned.
### 🔐 Secure Provider Integration
Connect to multiple AI providers (OpenAI, Anthropic, and more) with credentials stored securely in your system's native keychain. OpenAI supports both API key and browser OAuth (Codex subscription) sign-in.
In developer mode, the dedicated Image Generation page supports an independent OpenAI-compatible image-generation endpoint (Base URL, API key, and model name such as `gpt-image-2`) so image generation can use a dedicated `/v1/images/generations` service while chat continues using the normal OpenAI provider.
For **Custom** providers used with OpenAI-compatible gateways, you can set a custom `User-Agent` in **Settings → AI Providers → Edit Provider** for compatibility-sensitive endpoints.
When you edit or switch providers, ClawX preserves existing per-model capability metadata such as `input: ["text", "image"]`. Newly selected Custom-provider models use OpenClaw onboarding-compatible image-input inference, with unknown models defaulting to text-only.
Custom-provider model rows also receive an explicit `contextWindow` (inferred from the model family, e.g. `gpt-5.x` → 272k), and rows saved by older versions are backfilled on startup, so OpenClaw can compact long sessions before they fail with "Context overflow" errors. When you have no compaction config, ClawX seeds `agents.defaults.compaction.mode = "safeguard"` and `reserveTokensFloor = 50000`; rows or configs you authored yourself are never modified (except a missing `reserveTokensFloor` may be backfilled).
When a compatible gateway rejects `/models` for non-auth reasons, ClawX automatically falls back to a lightweight `/chat/completions` or `/responses` probe during API key validation.
### 🌙 Adaptive Theming
Light mode, dark mode, or system-synchronized themes. ClawX adapts to your preferences automatically.
@@ -127,6 +138,9 @@ Light mode, dark mode, or system-synchronized themes. ClawX adapts to your prefe
### 🚀 Startup Launch Control
In **Settings → General**, you can enable **Launch at system startup** so ClawX starts automatically after login.
### 🔔 Update Prompts
ClawX can automatically check for new versions on startup. When an update is available, it shows an in-app prompt; downloading and installing only happen after you choose the action.
---
## Getting Started
@@ -165,6 +179,8 @@ When you launch ClawX for the first time, the **Setup Wizard** will guide you th
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.
@@ -192,7 +208,10 @@ Notes:
- 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.
---
@@ -200,58 +219,71 @@ Notes:
ClawX employs a **dual-process architecture** with a unified host API layer. The renderer talks to a single client abstraction, while Electron Main owns protocol selection and process lifecycle:
```┌─────────────────────────────────────────────────────────────────┐
```
┌──────────────────────────────────────────────────────────────────┐
│ ClawX Desktop App │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Electron Main Process │ │
│ │ Electron Main Process │ │
│ │ • Window & application lifecycle management │ │
│ │ • Gateway process supervision │ │
│ │ • System integration (tray, notifications, keychain) │ │
│ │ • Auto-update orchestration │ │
│ │ • Gateway process supervision │ │
│ │ • System integration (tray, notifications, keychain) │ │
│ │ • Auto-update orchestration │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ │ IPC (authoritative control plane)
│ ▼
│ │ │
│ │ IPC (authoritative control plane) │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ React Renderer Process │ │
│ │ • Modern component-based UI (React 19) │ │
│ │ • State management with Zustand │ │
│ │ • Unified host-api/api-client calls │ │
│ │ • Rich Markdown rendering │ │
│ │ React Renderer Process │ │
│ │ • Modern component-based UI (React 19) │ │
│ │ • State management with Zustand │ │
│ │ • Unified host-api/api-client calls │ │
│ │ • Rich Markdown rendering │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────────┘
└──────────────────────────────┬──────────────────────────────────
Main-owned transport strategy
│ (WS first, HTTP then IPC fallback)
Typed IPC requests
┌─────────────────────────────────────────────────────────────────┐
Host API & Main Process Proxies
─────────────────────────────────────────────────────────────────┐
Main Host Services & Gateway Manager
│ │
│ • hostapi:fetch (Main proxy, avoids CORS in dev/prod)
│ • gateway:httpProxy (Renderer never calls Gateway HTTP direct)
│ • Unified error mapping & retry/backoff
└──────────────────────────────┬──────────────────────────────────┘
│ • host:invoke typed service dispatcher
│ • Settings, files, sessions, skills, providers, diagnostics
│ • Main-owned Gateway WebSocket and process supervision
└──────────────────────────────┬──────────────────────────────────
WS / HTTP / IPC fallback
Main-owned WebSocket
┌─────────────────────────────────────────────────────────────────┐
─────────────────────────────────────────────────────────────────┐
│ OpenClaw Gateway │
│ │
│ • AI agent runtime and orchestration │
│ • AI agent runtime and orchestration
│ • Message channel management │
│ • Skill/plugin execution environment │
│ • Skill/plugin execution environment
│ • Provider abstraction layer │
└─────────────────────────────────────────────────────────────────┘
─────────────────────────────────────────────────────────────────┘
```
### Design Principles
- **Process Isolation**: The AI runtime operates in a separate process, ensuring UI responsiveness even during heavy computation
- **Single Entry for Frontend Calls**: Renderer requests go through host-api/api-client; protocol details are hidden behind a stable interface
- **Main-Process Transport Ownership**: Electron Main controls WS/HTTP usage and fallback to IPC for reliability
- **Main-Process Transport Ownership**: Electron Main owns the Gateway WebSocket; the renderer talks to Main over typed IPC
- **Extension IPC Contributions**: Main-process extensions contribute host-api actions through the typed IPC registry instead of HTTP routes
- **Graceful Recovery**: Built-in reconnect, timeout, and backoff logic handles transient failures automatically
- **Secure Storage**: API keys and sensitive data leverage the operating system's native secure storage mechanisms
- **CORS-Safe by Design**: Local HTTP access is proxied by Main, preventing renderer-side CORS issues
- **CORS-Safe by Design**: The renderer does not call local Gateway or Host API HTTP endpoints directly
### Process Model & Gateway Troubleshooting
- 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`.
- Gateway readiness is based on OpenClaw core signals such as `system-presence`, `health`, and `status`; memory, Dreams, or channel failures are shown as capability degradation instead of global Gateway failure.
- To verify the active listener:
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
- Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen`
- 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.
---
@@ -275,16 +307,19 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro
### Prerequisites
- **Node.js**: 22+ (LTS recommended)
- **Node.js**: 22.19+ (LTS recommended)
- **Package Manager**: pnpm 9+ (recommended) or npm
- **Linux (Ubuntu/Debian)**: Install required system libraries before running Electron:
```bash
sudo apt-get install -y libnss3 libgtk-3-0 libxss1 libxtst6 libatspi2.0-0 libnotify4 xdg-utils
```
On Ubuntu 24.04+, some packages use a `t64` suffix; run the above command and `apt` will automatically select the correct variant.
### Project Structure
```ClawX/
├── electron/ # Electron Main Process
│ ├── api/ # Main-side API router and handlers
│ │ └── routes/ # RPC/HTTP proxy route modules
│ ├── services/ # Provider, secrets and runtime services
│ ├── services/ # Typed host APIs, provider, secrets and runtime services
│ │ ├── providers/ # Provider/account model sync logic
│ │ └── secrets/ # OS keychain and secret storage
│ ├── shared/ # Shared provider schemas/constants
@@ -301,6 +336,7 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro
│ ├── i18n/ # Localization resources
│ └── types/ # TypeScript type definitions
├── tests/
│ ├── e2e/ # Playwright Electron end-to-end smoke tests
│ └── unit/ # Vitest unit/integration-like tests
├── resources/ # Static assets (icons/images)
└── scripts/ # Build and utility scripts
@@ -309,8 +345,8 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro
```bash
# Development
pnpm run init # Install dependencies + download uv
pnpm dev # Start with hot reload
pnpm run init # Install dependencies + download bundled binaries (uv, agent-browser)
pnpm dev # Start with hot reload (auto-prepares bundled skills if missing)
# Quality
pnpm lint # Run ESLint
@@ -318,15 +354,55 @@ pnpm typecheck # TypeScript validation
# Testing
pnpm test # Run unit tests
pnpm run test:e2e # Run Electron E2E smoke tests with Playwright
pnpm run test:e2e:headed # Run Electron E2E tests with a visible window
pnpm run comms:replay # Compute communication replay metrics
pnpm run comms:baseline # Refresh communication baseline snapshot
pnpm run comms:compare # Compare replay metrics against baseline thresholds
# Build & Package
pnpm run build:vite # Build frontend only
pnpm build # Full production build (with packaging assets)
pnpm package # Package for current platform
pnpm package # Package for current platform (includes bundled preinstalled skills)
pnpm package:mac # Package for macOS
pnpm package:win # Package for Windows
pnpm package:linux # Package for Linux
```
On headless Linux, run Electron tests under a display server such as `xvfb-run -a pnpm run test:e2e`.
### 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.
### Electron E2E Tests
The Playwright Electron suite launches the packaged renderer and main process
from `dist/` and `dist-electron/`, so it does not require manually running
`pnpm dev` first.
`pnpm run test:e2e` automatically:
- builds the renderer and Electron bundles with `pnpm run build:vite`
- starts Electron in an isolated E2E mode with a temporary `HOME`
- uses a temporary ClawX `userData` directory
- skips heavy startup side effects such as gateway auto-start, bundled skill
installation, tray creation, and CLI auto-install
The first two baseline specs cover:
- first-launch setup wizard visibility on a fresh profile
- skipping setup and navigating to the Models page inside the Electron app
Add future Electron flows under `tests/e2e/` and reuse the shared fixture in
`tests/e2e/fixtures/electron.ts`.
### Tech Stack
| Layer | Technology |
+475
View File
@@ -0,0 +1,475 @@
<p align="center">
<img src="src/assets/logo.svg" width="128" height="128" alt="ClawX Logo" />
</p>
<h1 align="center">ClawX</h1>
<p align="center">
<strong>Десктоп-интерфейс для AI-агентов OpenClaw</strong>
</p>
<p align="center">
<a href="#возможности">Возможности</a> •
<a href="#почему-clawx">Почему ClawX</a> •
<a href="#быстрый-старт">Быстрый старт</a> •
<a href="#архитектура">Архитектура</a> •
<a href="#разработка">Разработка</a> •
<a href="#участие">Участие</a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/platform-MacOS%20%7C%20Windows%20%7C%20Linux-blue" alt="Platform" />
<img src="https://img.shields.io/badge/electron-40+-47848F?logo=electron" alt="Electron" />
<img src="https://img.shields.io/badge/react-19-61DAFB?logo=react" alt="React" />
<a href="https://discord.com/invite/84Kex3GGAh" target="_blank">
<img src="https://img.shields.io/discord/1399603591471435907?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb" alt="chat on Discord" />
</a>
<img src="https://img.shields.io/github/downloads/ValueCell-ai/ClawX/total?color=%23027DEB" alt="Downloads" />
<img src="https://img.shields.io/badge/license-MIT-green" alt="License" />
</p>
<p align="center">
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.ja-JP.md">日本語</a> | Русский
</p>
---
## Обзор
**ClawX** — это мост между мощными AI-агентами и повседневными пользователями. Построенный на базе [OpenClaw](https://github.com/OpenClaw), он превращает управление AI через командную строку в доступный и красивый десктоп-опыт — терминал не нужен.
Автоматизация рабочих процессов, управление AI-каналами или планирование интеллектуальных задач — ClawX предоставляет интерфейс для эффективного использования AI-агентов.
ClawX поставляется с предустановленными лучшими практиками для провайдеров моделей и нативно поддерживает Windows, а также многоязычные настройки. Вы можете тонко настроить расширенные параметры через **Настройки → Дополнительно → Режим разработчика**.
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">Для получения полной корпоративной версии, специализированной поддержки или индивидуального сопровождения внедрения под ваш бизнес-сценарий, свяжитесь с нами по адресу <a href="mailto:public@valuecell.ai">public@valuecell.ai</a>.</strong></p>
---
## Скриншоты
<p align="center">
<img src="resources/screenshot/ru/chat.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/ru/cron.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/ru/skills.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/ru/channels.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/ru/models.png" style="width: 100%; height: auto;">
</p>
<p align="center">
<img src="resources/screenshot/ru/settings.png" style="width: 100%; height: auto;">
</p>
---
## Почему ClawX
Создание AI-агентов не должно требовать владения командной строкой. Философия ClawX проста: **мощные технологии заслуживают интерфейса, который уважает ваше время.**
| Проблема | Решение ClawX |
|----------|---------------|
| Сложная настройка через CLI | Установка в один клик с мастером настройки |
| Редактирование конфигурационных файлов | Визуальные настройки с проверкой в реальном времени |
| Управление процессами | Автоматическое управление жизненным циклом шлюза |
| Несколько AI-провайдеров | Единая панель настройки провайдеров |
| Установка навыков/плагинов | Встроенный маркетплейс и управление навыками |
### OpenClaw внутри
ClawX построен непосредственно на официальном ядре **OpenClaw**. Вместо отдельной установки мы встраиваем среду выполнения в приложение для бесшовного опыта "всё включено".
Мы стремимся поддерживать строгое соответствие с проектом OpenClaw, чтобы вы всегда имели доступ к новейшим возможностям, улучшениям стабильности и совместимости с экосистемой.
---
## Возможности
### 🎯 Нулевой порог настройки
Весь процесс — от установки до первого взаимодействия с AI — выполняется через интуитивный графический интерфейс. Без терминальных команд, без YAML-файлов, без поиска переменных окружения.
### 💬 Интеллектуальный интерфейс чата
Общайтесь с AI-агентами через современный чат. Поддержка нескольких контекстов разговора, истории сообщений, рендеринга Markdown (включая таблицы GitHub-flavored и математические формулы LaTeX через KaTeX: `$строчные$`, `$$блочные$$`, `\(строчные\)` и `\[блочные\]`) и прямая маршрутизация через `@agent` в главном поле ввода для мультиагентных конфигураций.
Навыки, вставляемые из поля ввода, отображаются как чипы `/skill-name`; нажмите на чип, чтобы открыть боковую панель предпросмотра и прочитать `SKILL.md` соответствующего навыка.
При выборе другого агента через `@agent` ClawX переключается непосредственно в контекст этого агента вместо ретрансляции через агента по умолчанию. Рабочие пространства агентов по умолчанию разделены, но более строгая изоляция зависит от настроек песочницы OpenClaw.
Каждый агент может переопределить свои настройки `provider/model`; агенты без переопределения продолжают наследовать глобальную модель по умолчанию.
### 📡 Управление несколькими каналами
Настраивайте и отслеживайте несколько AI-каналов одновременно. Каждый канал работает независимо, позволяя запускать специализированных агентов для разных задач.
Каждый канал теперь поддерживает несколько учётных записей, привязку агента к учётной записи и переключение канала по умолчанию прямо на странице Каналы.
Для пользовательских идентификаторов учётных записей каналов ClawX требует совместимый с OpenClaw канонический формат (`[a-z0-9_-]`, строчные буквы, максимум 64 символа, должен начинаться с буквы или цифры) для предотвращения ошибок маршрутизации.
ClawX также включает официальный плагин личного WeChat от Tencent, позволяя подключить WeChat напрямую со страницы Каналы через встроенный QR-код.
### ⏰ Автоматизация по расписанию
Планируйте автоматический запуск AI-задач. Определяйте триггеры, устанавливайте интервалы и позволяйте AI-агентам работать круглосуточно без ручного вмешательства.
На странице Cron теперь можно настроить внешнюю доставку непосредственно в форме задачи с отдельными селекторами учётной записи отправителя и цели получателя. Для поддерживаемых каналов цели получателей автоматически обнаруживаются из каталогов каналов или известной истории сессий, поэтому больше не нужно редактировать `jobs.json` вручную. Поле сообщения задачи также поддерживает вставку навыков с помощью того же синтаксиса встроенных токенов `/skill`, что и в основном окне чата (с учётом выбранного агента), поэтому запланированные подсказки могут запускать навыки напрямую. Выбор расписания разделён на вкладки **Повтор** и **Однократно**: повтор предлагает частоты «Ежечасно», «Ежедневно», «По будням», «Еженедельно» и «Свой» (произвольный cron) со встроенными элементами выбора времени/дня недели, а однократно запускает задачу один раз в выбранную дату (с показом дня недели) и время. Однократные задачи должны быть запланированы на будущее и автоматически удаляются средой выполнения после завершения.
### 🧩 Расширяемая система навыков
Расширяйте возможности AI-агентов готовыми навыками. Просматривайте, устанавливайте и управляйте навыками через встроенную панель — менеджеры пакетов не нужны.
ClawX также предварительно упаковывает полные навыки обработки документов (`pdf`, `xlsx`, `docx`, `pptx`), автоматически развёртывает их в управляемый каталог навыков (по умолчанию `~/.openclaw/skills`) при запуске и включает по умолчанию при первой установке.
На странице Навыки отображаются навыки из нескольких источников OpenClaw (управляемый каталог, workspace и дополнительные каталоги навыков), а также показывается фактическое расположение каждого навыка для прямого открытия папки.
### 🔐 Безопасная интеграция провайдеров
Подключайтесь к нескольким AI-провайдерам (OpenAI, Anthropic и др.) с учётными данными, безопасно хранящимися в системной связке ключей. OpenAI поддерживает как API-ключи, так и OAuth через браузер (подписка Codex).
Для провайдеров **Custom**, используемых с OpenAI-совместимыми шлюзами, вы можете установить пользовательский `User-Agent` в **Настройки → AI Провайдеры → Редактировать провайдера** для совместимости с чувствительными эндпоинтами.
Когда совместимый шлюз отклоняет `/models` по причинам, не связанным с аутентификацией, ClawX автоматически переключается на легковесный зонд `/chat/completions` или `/responses` при проверке API-ключа.
### 🌙 Адаптивные темы
Светлая тема, тёмная тема или синхронизация с системой. ClawX автоматически адаптируется к вашим предпочтениям.
### 🚀 Управление автозапуском
В **Настройки → Общие** вы можете включить **Запускать при старте системы**, чтобы ClawX автоматически запускался после входа в систему.
---
## Быстрый старт
### Системные требования
- **Операционная система**: macOS 11+, Windows 10+ или Linux (Ubuntu 20.04+)
- **Память**: минимум 4 ГБ RAM (рекомендуется 8 ГБ)
- **Хранилище**: 1 ГБ свободного места на диске
### Установка
#### Готовые релизы (рекомендуется)
Скачайте последний релиз для вашей платформы со страницы [Releases](https://github.com/ValueCell-ai/ClawX/releases).
#### Сборка из исходников
```bash
# Клонирование репозитория
git clone https://github.com/ValueCell-ai/ClawX.git
cd ClawX
# Инициализация проекта
pnpm run init
# Запуск в режиме разработки
pnpm dev
```
### Первый запуск
При первом запуске ClawX **Мастер настройки** проведёт вас через:
1. **Язык и регион** — настройка предпочтительного языка и региона
2. **AI-провайдер** — добавление провайдеров с API-ключами или OAuth (для провайдеров, поддерживающих вход через браузер/устройство)
3. **Пакеты навыков** — выбор предустановленных навыков для распространённых сценариев
4. **Проверка** — тестирование конфигурации перед входом в основной интерфейс
Мастер предварительно выбирает системный язык, если он поддерживается, иначе переключается на английский.
### Настройки прокси
ClawX включает встроенные настройки прокси для сред, где Electron, шлюз OpenClaw или каналы вроде Telegram должны выходить в интернет через локальный прокси-клиент.
Откройте **Настройки → Шлюз → Прокси** и настройте:
- **Прокси-сервер**: прокси по умолчанию для всех запросов
- **Правила обхода**: хосты, которые должны подключаться напрямую, разделённые точкой с запятой, запятыми или новыми строками
- В **Режиме разработчика** можно дополнительно переопределить:
- **HTTP Прокси**
- **HTTPS Прокси**
- **ALL_PROXY / SOCKS**
Рекомендуемые примеры локальных настроек:
```text
Прокси-сервер: http://127.0.0.1:7890
```
Примечания:
- Значение `host:port` рассматривается как HTTP.
- Если расширенные поля прокси пусты, ClawX использует `Прокси-сервер`.
- Сохранение настроек прокси немедленно повторно применяет сеть Electron и автоматически перезапускает шлюз.
- ClawX также синхронизирует прокси с конфигурацией канала Telegram в OpenClaw, когда Telegram включён.
- При перезапуске шлюза существующий прокси канала Telegram сохраняется, если прокси ClawX отключен.
- Чтобы явно очистить прокси Telegram из конфигурации OpenClaw, сохраните настройки прокси с отключенным прокси.
- В **Настройки → Дополнительно → Разработчик** можно запустить **OpenClaw Doctor** для выполнения `openclaw doctor --json` и просмотра диагностического вывода, не покидая приложение.
- В упакованных сборках Windows встроенный `openclaw` CLI/TUI запускается через поставляемый `node.exe` для стабильного поведения ввода в терминале.
---
## Архитектура
ClawX использует **двухпроцессную архитектуру с унифицированным уровнем Host API**. Рендерер обращается к единой абстракции клиента, а Electron Main управляет выбором протокола и жизненным циклом процессов:
```
┌─────────────────────────────────────────────────────────────────┐
│ Десктоп-приложение ClawX │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Главный процесс Electron │ │
│ │ • Управление жизненным циклом окна и приложения │ │
│ │ • Наблюдение за процессом шлюза │ │
│ │ • Интеграция с системой (трей, уведомления, связка ключей)│ │
│ │ • Оркестрация автообновлений │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ IPC (авторитетная плоскость управления) │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Процесс рендерера React │ │
│ │ • Современный UI на компонентах (React 19) │ │
│ │ • Управление состоянием с Zustand │ │
│ │ • Унифицированные вызовы host-api/api-client │ │
│ │ • Рендеринг Markdown │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────────┘
Стратегия транспорта, управляемая Main
(Сначала WS, затем HTTP, затем IPC)
┌──────────────────────────────────────────────────────────────────┐
│ Host API и прокси-уровень Main │
│ │
│ • hostapi:fetch (прокси Main, избегает CORS в dev/prod) │
│ • gateway:httpProxy (Рендерер не вызывает Gateway HTTP напрямую)│
│ • Унифицированное отображение ошибок и повторные попытки │
└──────────────────────────────┬───────────────────────────────────┘
Резерв WS / HTTP / IPC
┌─────────────────────────────────────────────────────────────────┐
│ Шлюз OpenClaw │
│ │
│ • Среда выполнения AI-агентов и оркестрация │
│ • Управление каналами сообщений │
│ • Среда выполнения навыков/плагинов │
│ • Уровень абстракции провайдеров │
└─────────────────────────────────────────────────────────────────┘
```
### Принципы проектирования
- **Изоляция процессов**: Среда выполнения AI работает в отдельном процессе, обеспечивая отзывчивость UI даже при тяжёлых вычислениях
- **Единая точка входа для фронтенда**: Запросы рендерера проходят через host-api/api-client; детали протокола скрыты за стабильным интерфейсом
- **Владение транспортом в Main**: Electron Main управляет использованием WS/HTTP и откатом к IPC для надёжности
- **Корректное восстановление**: Встроенная логика переподключения, таймаутов и отката автоматически обрабатывает временные сбои
- **Безопасное хранение**: API-ключи и конфиденциальные данные используют нативные механизмы безопасного хранения ОС
- **Безопасность CORS**: Локальный HTTP-доступ проксируется через Main, предотвращая CORS-проблемы на стороне рендерера
### Модель процессов и устранение неполадок шлюза
- ClawX — это приложение Electron, поэтому **один экземпляр приложения обычно отображается как несколько процессов ОС** (main/renderer/zygote/utility). Это нормально.
- Защита единственного экземпляра использует блокировку Electron плюс локальный файл блокировки процессов, предотвращая дублирование запуска приложения в средах с нестабильным IPC/сессионной шиной.
- При последовательных обновлениях смешанные старые/новые версии могут иметь асимметричное поведение защиты. Для лучшей надёжности обновите все десктоп-клиенты до одной версии.
- Слушатель шлюза OpenClaw должен быть **единственным владельцем**: только один процесс должен слушать `127.0.0.1:18789`.
- Для проверки активного слушателя:
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
- Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen`
- Нажатие кнопки закрытия окна (`X`) скрывает ClawX в трей; это **не** полностью закрывает приложение. Используйте меню трея **Quit ClawX** для полного завершения.
---
## Варианты использования
### 🤖 Персональный AI-ассистент
Настройте универсального AI-агента, который может отвечать на вопросы, составлять письма, резюмировать документы и помогать с повседневными задачами — всё через чистый десктоп-интерфейс.
### 📊 Автоматизированный мониторинг
Настройте запланированных агентов для отслеживания новостных лент, цен или определённых событий. Результаты доставляются в ваш предпочтительный канал уведомлений.
### 💻 Производительность разработчика
Интегрируйте AI в рабочий процесс разработки. Используйте агентов для проверки кода, генерации документации или автоматизации повторяющихся задач кодирования.
### 🔄 Автоматизация рабочих процессов
Связывайте несколько навыков для создания сложных конвейеров автоматизации. Обрабатывайте данные, преобразовывайте контент и запускайте действия — всё визуально оркестрируется.
---
## Разработка
### Требования
- **Node.js**: 22.19+ (рекомендуется LTS)
- **Менеджер пакетов**: pnpm 9+ (рекомендуется) или npm
### Структура проекта
```
ClawX/
├── electron/ # Главный процесс Electron
│ ├── api/ # Маршрутизатор API и обработчики Main
│ │ └── routes/ # Модули маршрутов RPC/HTTP прокси
│ ├── services/ # Службы провайдеров, секретов и среды выполнения
│ │ ├── providers/ # Логика синхронизации моделей provider/account
│ │ └── secrets/ # Связка ключей ОС и хранилище секретов
│ ├── shared/ # Общие схемы провайдеров и константы
│ │ └── providers/
│ ├── main/ # Точка входа приложения, окна, регистрация IPC
│ ├── gateway/ # Менеджер процесса шлюза OpenClaw
│ ├── preload/ # Безопасный IPC-мост
│ └── utils/ # Утилиты (хранилище, аутентификация, пути)
├── src/ # Процесс рендерера React
│ ├── lib/ # Унифицированный фронтенд API и модель ошибок
│ ├── stores/ # Хранилища Zustand (settings/chat/gateway)
│ ├── components/ # Переиспользуемые UI-компоненты
│ ├── pages/ # Setup/Dashboard/Chat/Channels/Skills/Cron/Settings
│ ├── i18n/ # Ресурсы локализации
│ └── types/ # Определения типов TypeScript
├── tests/
│ ├── e2e/ # Сквозные дымовые тесты Playwright Electron
│ └── unit/ # Модульные/интеграционные тесты Vitest
├── resources/ # Статические ресуры (иконки, изображения)
└── scripts/ # Скрипты сборки и утилит
```
### Доступные команды
```bash
# Разработка
pnpm run init # Установить зависимости + скачать uv
pnpm dev # Запуск с горячей перезагрузкой (автоподготовка упакованных навыков при отсутствии)
# Качество кода
pnpm lint # Запустить ESLint
pnpm typecheck # Проверка типов TypeScript
# Тестирование
pnpm test # Запустить модульные тесты
pnpm run test:e2e # Запустить E2E дымовые тесты Electron с Playwright
pnpm run test:e2e:headed # Запустить E2E тесты Electron с видимым окном
pnpm run comms:replay # Вычислить метрики повторного воспроизведения коммуникаций
pnpm run comms:baseline # Обновить базовый снимок коммуникаций
pnpm run comms:compare # Сравнить метрики воспроизведения с базовыми порогами
# Сборка и упаковка
pnpm run build:vite # Собрать только фронтенд
pnpm build # Полная production-сборка (с ресурсами упаковки)
pnpm package # Упаковать для текущей платформы (включает предустановленные навыки)
pnpm package:mac # Упаковать для macOS
pnpm package:win # Упаковать для Windows
pnpm package:linux # Упаковать для Linux
```
На headless Linux запускайте тесты Electron под сервером отображения, например `xvfb-run -a pnpm run test:e2e`.
### Проверка регрессии коммуникаций
Когда PR изменяет пути коммуникации (события шлюза, поток отправки/получения чата, доставка каналов или откат транспорта), запустите:
```bash
pnpm run comms:replay
pnpm run comms:compare
```
`comms-regression` в CI проверяет обязательные сценарии и пороги.
### E2E-тесты Electron
Сьют Playwright Electron запускает упакованный рендерер и главный процесс из `dist/` и `dist-electron/`, поэтому не требует предварительного ручного запуска `pnpm dev`.
`pnpm run test:e2e` автоматически:
- собирает рендерер и пакеты Electron с `pnpm run build:vite`
- запускает Electron в изолированном режиме E2E с временным `HOME`
- использует временный каталог `userData` ClawX
- пропускает тяжёлые побочные эффекты запуска, такие как автозапуск шлюза, установку упакованных навыков, создание трея и автоустановку CLI
Первые два базовых спецификации покрывают:
- видимость мастера настройки при первом запуске на чистом профиле
- пропуск настройки и навигация на страницу Models внутри приложения Electron
Добавляйте будущие потоки Electron в `tests/e2e/` и переиспользуйте общий fixture в `tests/e2e/fixtures/electron.ts`.
### Технологический стек
| Уровень | Технология |
|----------------|-------------------------------|
| Среда выполнения | Electron 40+ |
| UI-фреймворк | React 19 + TypeScript |
| Стилизация | Tailwind CSS + shadcn/ui |
| Состояние | Zustand |
| Сборка | Vite + electron-builder |
| Тестирование | Vitest + Playwright |
| Анимация | Framer Motion |
| Иконки | Lucide React |
---
## Участие
Мы приветствуем вклад сообщества! Исправления багов, новые функции, улучшения документации или переводы — каждый вклад делает ClawX лучше.
### Как внести вклад
1. **Сделайте форк** репозитория
2. **Создайте** ветку функции (`git checkout -b feature/amazing-feature`)
3. **Зафиксируйте** изменения с понятными сообщениями
4. **Отправьте** в свою ветку
5. **Откройте** Pull Request
### Руководящие принципы
- Следуйте существующему стилю кода (ESLint + Prettier)
- Пишите тесты для нового функционала
- Обновляйте документацию по мере необходимости
- Держите коммиты атомарными и описательными
---
## Благодарности
ClawX построен на плечах отличных проектов с открытым исходным кодом:
- [OpenClaw](https://github.com/OpenClaw) – Среда выполнения AI-агентов
- [Electron](https://www.electronjs.org/) – Кроссплатформенный десктоп-фреймворк
- [React](https://react.dev/) – Библиотека UI-компонентов
- [shadcn/ui](https://ui.shadcn.com/) – Красиво спроектированные компоненты
- [Zustand](https://github.com/pmndrs/zustand) – Легковесное управление состоянием
---
## Сообщество
Присоединяйтесь к нашему сообществу, чтобы общаться с другими пользователями, получать поддержку и делиться опытом.
| WeChat Enterprise | Feishu Group | Discord |
| :---: | :---: | :---: |
| <img src="src/assets/community/wecom-qr.png" width="150" alt="WeChat QR Code" /> | <img src="src/assets/community/feishu-qr.png" width="150" alt="Feishu QR Code" /> | <img src="src/assets/community/20260212-185822.png" width="150" alt="Discord QR Code" /> |
### Партнёрская программа ClawX 🚀
Мы запускаем Партнёрскую программу ClawX и ищем партнёров, которые могут помочь представить ClawX большему числу клиентов, особенно тем, у кого есть потребности в кастомных AI-агентах или автоматизации.
Партнёры помогают связывать нас с потенциальными пользователями и проектами, а команда ClawX предоставляет полную техническую поддержку, кастомизацию и интеграцию.
Если вы работаете с клиентами, заинтересованными в AI-инструментах или автоматизации, мы будем рады сотрудничеству.
Напишите нам в DM или на [public@valuecell.ai](mailto:public@valuecell.ai) для получения дополнительной информации.
---
## История звёзд
<p align="center">
<img src="https://api.star-history.com/svg?repos=ValueCell-ai/ClawX&type=Date" alt="Star History Chart" />
</p>
---
## Лицензия
ClawX выпускается под [лицензией MIT](LICENSE). Вы можете свободно использовать, модифицировать и распространять это программное обеспечение.
---
<p align="center">
<sub>Создано с ❤️ командой ValueCell</sub>
</p>
+100 -45
View File
@@ -30,7 +30,7 @@
</p>
<p align="center">
<a href="README.md">English</a> | 简体中文 | <a href="README.ja-JP.md">日本語</a>
<a href="README.md">English</a> | 简体中文 | <a href="README.ja-JP.md">日本語</a> | <a href="README.ru-RU.md">Русский</a>
</p>
---
@@ -43,6 +43,8 @@
ClawX 预置了最佳实践的模型供应商配置,原生支持 Windows 平台以及多语言设置。当然,你也可以通过 **设置 → 高级 → 开发者模式** 来进行精细的高级配置。
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">如需完整的企业版、专属服务支持或面向您业务场景的定制化落地辅导,请联系 <a href="mailto:public@valuecell.ai">public@valuecell.ai</a>。</strong></p>
---
## 截图预览
@@ -82,6 +84,7 @@ ClawX 预置了最佳实践的模型供应商配置,原生支持 Windows 平
| 复杂的命令行配置 | 一键安装,配合引导式设置向导 |
| 手动编辑配置文件 | 可视化设置界面,实时校验 |
| 进程管理繁琐 | 自动管理网关生命周期 |
| 应用更新 | 启动时检查新版本,并在下载或安装前提示确认 |
| 多 AI 供应商切换 | 统一的供应商配置面板 |
| 技能/插件安装复杂 | 内置技能市场与管理界面 |
@@ -91,6 +94,8 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
我们致力于与上游 OpenClaw 项目保持严格同步,确保你始终可以使用官方发布的最新功能、稳定性改进和生态兼容性。
打开开发者模式后,侧边栏还会提供原生 Dreams 页面,可在 ClawX 内查看 OpenClaw 记忆回顾、梦境日记,并执行基础维护操作;需要更深诊断时仍可从该页面打开完整 OpenClaw Dreams UI。
---
## 功能特性
@@ -99,27 +104,34 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
从安装到第一次 AI 对话,全程通过直观的图形界面完成。无需终端命令,无需 YAML 文件,无需到处寻找环境变量。
### 💬 智能聊天界面
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、Markdown 富文本渲染,以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、Markdown 富文本渲染(包括 GitHub 风格表格以及由 KaTeX 渲染的 LaTeX 数学公式:`$行内$``$$块级$$``\(行内\)``\[块级\]`,以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。
从输入框插入的技能会以 `/技能名` 卡片形式显示;点击卡片可在右侧预览栏打开并阅读该技能的 `SKILL.md`
当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。
### 📡 多频道管理
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。
现在每个频道支持多个账号,并可在 Channels 页面直接完成账号绑定到 Agent 与默认账号切换。
对于自定义频道账号 ID,ClawX 现在会强制校验 OpenClaw 兼容的规范格式(`[a-z0-9_-]`、小写、最长 64 位、且必须以字母或数字开头),避免路由匹配异常。
ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Channels 页面通过内置二维码流程完成微信连接。
### ⏰ 定时任务自动化
调度 AI 任务自动执行。定义触发器、设置时间间隔,让 AI 智能体 7×24 小时不间断工作。
现在定时任务页面已经可以直接配置外部投递,统一拆成“发送账号”和“接收目标”两个下拉选择。对于已支持的通道,接收目标会从通道目录能力或已知会话历史中自动发现,不需要再手动修改 `jobs.json`。任务的消息输入框也支持像主对话框那样以内联 `/skill` 令牌的方式插入技能(按所选智能体范围加载),让定时提示词可以直接触发技能。调度选择器现在分为**周期**和**单次**两个选项卡:周期支持每小时、每天、工作日、每周、自定义(原始 cron)等频率,并内置时间/星期选择;单次则在所选日期(显示星期)和时间执行一次。单次任务必须设置为未来时间,并会在执行完成后由运行时自动清除。
### 🧩 可扩展技能系统
通过预构建的技能扩展 AI 智能体的能力。集成的技能面板中浏览、安装和管理技能——无需包管理器
ClawX 还会内置预装完整的文档处理技能(`pdf``xlsx``docx``pptx`),在启动时自动部署到托管技能目录(默认 `~/.openclaw/skills`),并在首次安装时默认启用。额外预装技能(`find-skills``self-improving-agent``tavily-search``brave-web-search``bocha-skill`)也会默认启用;若缺少必需的 API Key,OpenClaw 会在运行时给出配置错误提示。
Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、workspace、额外技能目录),并显示每个技能的实际路径,便于直接打开真实安装位置。
重点搜索技能所需环境变量:
- `BRAVE_SEARCH_API_KEY`:用于 `brave-web-search`
- `TAVILY_API_KEY`:用于 `tavily-search`(上游运行时也可能支持 OAuth
- `BOCHA_API_KEY`:用于 `bocha-skill`
通过预构建的技能扩展 AI 智能体的能力。集成的 Skills 页面采用“本地优先”方式:会扫描托管目录与 workspace 技能目录,并且无需依赖 Gateway 即可启用或停用技能;在企业扩展接管时,也可以显示扩展提供的 marketplace
ClawX 还会内置预装完整的文档处理技能(`pdf``xlsx``docx``pptx`),在启动时自动部署到托管技能目录(默认 `~/.openclaw/skills`),并在首次安装时默认启用。
Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、workspace、额外技能目录),并显示每个技能的实际路径,便于直接打开真实安装位置。对于 OpenClaw 自带的 bundled skills,社区版现在在打包产物里只保留并展示 `skill-creator`;开发模式和打包版启动时都会直接清理其它 bundled skill,同时把这些已删除 bundled skill 在 `openclaw.json` 中残留的旧配置一并移除。
### 🔐 安全的供应商集成
连接多个 AI 供应商(OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuthCodex 订阅)登录。
在开发者模式下,独立的“图像生成”页面支持配置 OpenAI 兼容生图端点(Base URL、API Key 和模型名,例如 `gpt-image-2`),生图请求会走专用的 `/v1/images/generations` 服务,聊天仍继续使用正常的 OpenAI Provider。
如果你通过 **自定义(CustomProvider** 对接 OpenAI-compatible 网关,可以在 **设置 → AI Providers → 编辑 Provider** 中配置自定义 `User-Agent`,以提高兼容性。
编辑或切换 Provider 时,ClawX 会保留已有的模型级能力元数据,例如 `input: ["text", "image"]`。新选择的自定义 Provider 模型会使用与 OpenClaw onboarding 一致的图片输入能力推断;未知模型默认按纯文本模型处理。
自定义 Provider 的模型行还会写入显式的 `contextWindow`(按模型系列推断,例如 `gpt-5.x` → 272k),旧版本保存的模型行会在启动时自动回填,使 OpenClaw 能在长会话超限前主动压缩上下文,避免出现 "Context overflow" 报错。当你没有配置 compaction 时,ClawX 会默认写入 `agents.defaults.compaction.mode = "safeguard"``reserveTokensFloor = 50000`;你手动配置过的模型行或压缩配置永远不会被修改(仅可能回填缺失的 `reserveTokensFloor`)。
如果兼容网关的 `/models` 因非鉴权原因不可用,ClawX 会在校验 API Key 时自动降级为轻量的 `/chat/completions``/responses` 探测。
### 🌙 自适应主题
支持浅色模式、深色模式或跟随系统主题。ClawX 自动适应你的偏好设置。
@@ -127,6 +139,9 @@ Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、wor
### 🚀 开机启动控制
**设置 → 通用** 中,你可以开启 **开机自动启动**,让 ClawX 在系统登录后自动启动。
### 🔔 更新提示
ClawX 可以在启动时自动检查新版本。发现更新后会显示应用内提示;只有在你选择操作后,才会下载或安装更新。
---
## 快速上手
@@ -165,6 +180,8 @@ pnpm dev
3. **技能包** 选择适用于常见场景的预配置技能
4. **验证** 在进入主界面前测试你的配置
如果系统语言在支持列表中,向导会默认选中该语言;否则回退到英文。
> MoonshotKimi)说明:ClawX 默认保持开启 Kimi 的 web search。
> 当配置 Moonshot 后,ClawX 也会将 OpenClaw 配置中的 Kimi web search 同步到中国区端点(`https://api.moonshot.cn/v1`)。
@@ -192,7 +209,10 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问
- 高级代理项留空时,会自动回退到“代理服务器”。
- 保存代理设置后,Electron 网络层会立即重新应用代理,并自动重启 Gateway。
- 如果启用了 TelegramClawX 还会把代理同步到 OpenClaw 的 Telegram 频道配置中。
- 当 ClawX 代理处于关闭状态时,Gateway 的常规重启会保留已有的 Telegram 频道代理配置。
- 如果你要明确清空 OpenClaw 中的 Telegram 代理,请在关闭代理后点一次“保存代理设置”。
-**设置 → 高级 → 开发者** 中,可以直接运行 **OpenClaw Doctor**,执行 `openclaw doctor --json` 并在应用内查看诊断输出。
- 在 Windows 打包版本中,内置的 `openclaw` CLI/TUI 会通过随包分发的 `node.exe` 入口运行,以保证终端输入行为稳定。
---
@@ -200,47 +220,47 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问
ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用统一客户端抽象,协议选择与进程生命周期由 Electron 主进程统一管理:
```┌─────────────────────────────────────────────────────────────────┐
│ ClawX 桌面应用 │
┌────────────────────────────────────────────────────────────┐
```
┌───────────────────────────────────────────────────────────────────┐
ClawX 桌面应用
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Electron 主进程 │ │
│ │ • 窗口与应用生命周期管理 │ │
│ │ • 窗口与应用生命周期管理 │ │
│ │ • 网关进程监控 │ │
│ │ • 系统集成(托盘、通知、密钥链) │ │
│ │ • 自动更新编排 │ │
│ └────────────────────────────────────────────────────────────┘ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ IPC权威控制面
│ │ IPC (权威控制面)
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ React 渲染进程 │ │
│ │ • 现代组件化 UI(React 19 │ │
│ │ • Zustand 状态管理 │ │
│ │ • 统一 host-api/api-client 调用 │ │
│ │ • Markdown 富文本渲染 │ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ React 渲染进程 │ │
│ │ • 现代组件化 UI(React 19 │ │
│ │ • Zustand 状态管理 │ │
│ │ • 统一 host-api/api-client 调用 │ │
│ │ • Markdown 富文本渲染 │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────────┘
└──────────────────────────────┬──────────────────────────────────
主进程统一传输策略
│(WS 优先,HTTP 次之,IPC 回退)
类型化 IPC 请求
┌─────────────────────────────────────────────────────────────────┐
│ Host API 与主进程代理层
│ • hostapi:fetch(主进程代理,规避开发/生产 CORS)
│ • gateway:httpProxy(渲染进程不直连 Gateway HTTP
│ • 统一错误映射与重试/退避策略
主进程 Host Services 与 Gateway Manager
│ • host:invoke 类型化服务分发
│ • 设置、文件、会话、技能、供应商、诊断服务
│ • 主进程持有 Gateway WebSocket 并负责进程监控
└──────────────────────────────┬──────────────────────────────────┘
WS / HTTP / IPC 回退
主进程持有 WebSocket
┌─────────────────────────────────────────────────────────────────┐
│ OpenClaw 网关
│ • AI 智能体运行时与编排 │
│ OpenClaw 网关 │
│ • AI 智能体运行时与编排
│ • 消息频道管理 │
│ • 技能/插件执行环境 │
│ • 技能/插件执行环境
│ • 供应商抽象层 │
└─────────────────────────────────────────────────────────────────┘
```
@@ -248,10 +268,23 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
- **进程隔离**:AI 运行时在独立进程中运行,确保即使在高负载计算期间 UI 也能保持响应
- **前端调用单一入口**:渲染层统一走 host-api/api-client,不感知底层协议细节
- **主进程掌控传输策略**WS/HTTP 选择与 IPC 回退在主进程集中处理,提升稳定性
- **主进程掌控传输策略**Gateway WebSocket 只由 Electron Main 持有,渲染进程通过类型化 IPC 调用 Main
- **扩展 IPC 贡献点**:主进程扩展通过类型化 IPC 注册表贡献 host-api action,而不是挂载 HTTP route
- **优雅恢复**:内置重连、超时、退避逻辑,自动处理瞬时故障
- **安全存储**:API 密钥和敏感数据利用操作系统原生的安全存储机制
- **CORS 安全**本地 HTTP 请求由主进程代理,避免渲染进程跨域问题
- **CORS 安全**渲染进程不直接请求本地 Gateway 或 Host API HTTP 端点
### 进程模型与 Gateway 排障
- ClawX 基于 Electron,**单个应用实例出现多个系统进程是正常现象**main/renderer/zygote/utility)。
- 单实例保护同时使用 Electron 自带锁与本地进程文件锁回退机制,可在桌面会话总线异常时避免重复启动。
- 滚动升级期间若新旧版本混跑,单实例保护仍可能出现不对称行为。为保证稳定性,建议桌面客户端尽量统一升级到同一版本。
- 但 OpenClaw Gateway 监听应始终保持**单实例**:`127.0.0.1:18789` 只能有一个监听者。
- Gateway readiness 以 OpenClaw 的 `system-presence``health``status` 等核心信号为准;memory、Dreams 或频道失败会显示为能力降级,而不是全局 Gateway 故障。
- 可用以下命令确认监听进程:
- macOS/Linux`lsof -nP -iTCP:18789 -sTCP:LISTEN`
- WindowsPowerShell):`Get-NetTCPConnection -LocalPort 18789 -State Listen`
- 点击窗口关闭按钮(`X`)默认只是最小化到托盘,并不会完全退出应用。请在托盘菜单中选择 **Quit ClawX** 执行完整退出。
---
@@ -275,16 +308,19 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
### 前置要求
- **Node.js**22+(推荐 LTS 版本)
- **Node.js**22.19+(推荐 LTS 版本)
- **包管理器**pnpm 9+(推荐)或 npm
- **LinuxUbuntu/Debian**:运行 Electron 前,请先安装所需系统库:
```bash
sudo apt-get install -y libnss3 libgtk-3-0 libxss1 libxtst6 libatspi2.0-0 libnotify4 xdg-utils
```
在 Ubuntu 24.04+ 上,部分软件包使用 `t64` 后缀,运行上述命令后 `apt` 会自动选择正确版本。
### 项目结构
```ClawX/
├── electron/ # Electron 主进程
│ ├── api/ # 主进程 API 路由与处理器
│ │ └── routes/ # RPC/HTTP 代理路由模块
│ ├── services/ # Provider、Secrets 与运行时服务
│ ├── services/ # 类型化 Host API、Provider、Secrets 与运行时服务
│ │ ├── providers/ # Provider/account 模型同步逻辑
│ │ └── secrets/ # 系统钥匙串与密钥存储
│ ├── shared/ # 共享 Provider schema/常量
@@ -301,6 +337,7 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
│ ├── i18n/ # 国际化资源
│ └── types/ # TypeScript 类型定义
├── tests/
│ ├── e2e/ # Playwright Electron 端到端冒烟测试
│ └── unit/ # Vitest 单元/集成型测试
├── resources/ # 静态资源(图标、图片)
└── scripts/ # 构建与工具脚本
@@ -309,8 +346,8 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
```bash
# 开发
pnpm run init # 安装依赖并下载 uv
pnpm dev # 以热重载模式启动
pnpm run init # 安装依赖并下载捆绑二进制(uv、agent-browser
pnpm dev # 以热重载模式启动(若缺失会自动准备预装技能包)
# 代码质量
pnpm lint # 运行 ESLint 检查
@@ -318,15 +355,33 @@ pnpm typecheck # TypeScript 类型检查
# 测试
pnpm test # 运行单元测试
pnpm run test:e2e # 运行 Electron E2E 冒烟测试
pnpm run test:e2e:headed # 以可见窗口运行 Electron E2E 测试
pnpm run comms:replay # 计算通信回放指标
pnpm run comms:baseline # 刷新通信基线快照
pnpm run comms:compare # 将回放指标与基线阈值对比
# 构建与打包
pnpm run build:vite # 仅构建前端
pnpm build # 完整生产构建(含打包资源)
pnpm package # 为当前平台打包
pnpm package # 为当前平台打包(包含预装技能资源)
pnpm package:mac # 为 macOS 打包
pnpm package:win # 为 Windows 打包
pnpm package:linux # 为 Linux 打包
```
在无头 Linux 环境下,Electron 测试需要显示服务;可使用 `xvfb-run -a pnpm run test:e2e`。
### 通信回归检查
当 PR 涉及通信链路(Gateway 事件、Chat 收发流程、Channel 投递、传输回退)时,建议执行:
```bash
pnpm run comms:replay
pnpm run comms:compare
```
CI 中的 `comms-regression` 会校验必选场景与阈值。
### 技术栈
| 层级 | 技术 |
+8
View File
@@ -0,0 +1,8 @@
{
"extensions": {
"main": [
"builtin/diagnostics"
],
"renderer": []
}
}
+9 -3
View File
@@ -38,6 +38,10 @@ afterPack: ./scripts/after-pack.cjs
asar: true
asarUnpack:
- "**/*.node"
# lru-cache CJS/ESM interop: older CJS versions (v5, v6, v7) don't export
# `LRUCache` as a named property, breaking `import { LRUCache }` in Node.js
# 22+ (Electron 40+). Unpacking lets afterPack patch them in place.
- "**/node_modules/lru-cache/**"
# Disable native module rebuilding.
# The Electron renderer/main process has no native (.node) dependencies.
@@ -86,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
@@ -114,9 +122,7 @@ win:
icon: resources/icons/icon.ico
target:
- target: nsis
arch:
- x64
- arm64
arch: x64
nsis:
oneClick: false
-11
View File
@@ -1,11 +0,0 @@
import type { BrowserWindow } from 'electron';
import type { GatewayManager } from '../gateway/manager';
import type { ClawHubService } from '../gateway/clawhub';
import type { HostEventBus } from './event-bus';
export interface HostApiContext {
gatewayManager: GatewayManager;
clawHubService: ClawHubService;
eventBus: HostEventBus;
mainWindow: BrowserWindow | null;
}
-36
View File
@@ -1,36 +0,0 @@
import type { ServerResponse } from 'http';
type EventPayload = unknown;
export class HostEventBus {
private readonly clients = new Set<ServerResponse>();
addSseClient(res: ServerResponse): void {
this.clients.add(res);
res.on('close', () => {
this.clients.delete(res);
});
}
emit(eventName: string, payload: EventPayload): void {
const message = `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`;
for (const client of this.clients) {
try {
client.write(message);
} catch {
this.clients.delete(client);
}
}
}
closeAll(): void {
for (const client of this.clients) {
try {
client.end();
} catch {
// Ignore individual client close failures.
}
}
this.clients.clear();
}
}
-39
View File
@@ -1,39 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
export async function parseJsonBody<T>(req: IncomingMessage): Promise<T> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const raw = Buffer.concat(chunks).toString('utf8').trim();
if (!raw) {
return {} as T;
}
return JSON.parse(raw) as T;
}
export function setCorsHeaders(res: ServerResponse): void {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
}
export function sendJson(res: ServerResponse, statusCode: number, payload: unknown): void {
setCorsHeaders(res);
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(payload));
}
export function sendNoContent(res: ServerResponse): void {
setCorsHeaders(res);
res.statusCode = 204;
res.end();
}
export function sendText(res: ServerResponse, statusCode: number, text: string): void {
setCorsHeaders(res);
res.statusCode = statusCode;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end(text);
}
-110
View File
@@ -1,110 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import {
assignChannelToAgent,
clearChannelBinding,
createAgent,
deleteAgentConfig,
listAgentsSnapshot,
resolveAccountIdForAgent,
updateAgentName,
} from '../../utils/agent-config';
import { deleteChannelAccountConfig } from '../../utils/channel-config';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
function scheduleGatewayReload(ctx: HostApiContext, reason: string): void {
if (ctx.gatewayManager.getStatus().state !== 'stopped') {
ctx.gatewayManager.debouncedReload();
return;
}
void reason;
}
export async function handleAgentRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/agents' && req.method === 'GET') {
sendJson(res, 200, { success: true, ...(await listAgentsSnapshot()) });
return true;
}
if (url.pathname === '/api/agents' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ name: string }>(req);
const snapshot = await createAgent(body.name);
scheduleGatewayReload(ctx, 'create-agent');
sendJson(res, 200, { success: true, ...snapshot });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/agents/') && req.method === 'PUT') {
const suffix = url.pathname.slice('/api/agents/'.length);
const parts = suffix.split('/').filter(Boolean);
if (parts.length === 1) {
try {
const body = await parseJsonBody<{ name: string }>(req);
const agentId = decodeURIComponent(parts[0]);
const snapshot = await updateAgentName(agentId, body.name);
scheduleGatewayReload(ctx, 'update-agent');
sendJson(res, 200, { success: true, ...snapshot });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (parts.length === 3 && parts[1] === 'channels') {
try {
const agentId = decodeURIComponent(parts[0]);
const channelType = decodeURIComponent(parts[2]);
const snapshot = await assignChannelToAgent(agentId, channelType);
scheduleGatewayReload(ctx, 'assign-channel');
sendJson(res, 200, { success: true, ...snapshot });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
}
if (url.pathname.startsWith('/api/agents/') && req.method === 'DELETE') {
const suffix = url.pathname.slice('/api/agents/'.length);
const parts = suffix.split('/').filter(Boolean);
if (parts.length === 1) {
try {
const agentId = decodeURIComponent(parts[0]);
const snapshot = await deleteAgentConfig(agentId);
scheduleGatewayReload(ctx, 'delete-agent');
sendJson(res, 200, { success: true, ...snapshot });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (parts.length === 3 && parts[1] === 'channels') {
try {
const agentId = decodeURIComponent(parts[0]);
const channelType = decodeURIComponent(parts[2]);
const accountId = resolveAccountIdForAgent(agentId);
await deleteChannelAccountConfig(channelType, accountId);
const snapshot = await clearChannelBinding(channelType, accountId);
scheduleGatewayReload(ctx, 'remove-agent-channel');
sendJson(res, 200, { success: true, ...snapshot });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
}
return false;
}
-41
View File
@@ -1,41 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import type { HostApiContext } from '../context';
import { parseJsonBody } from '../route-utils';
import { setCorsHeaders, sendJson, sendNoContent } from '../route-utils';
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../../utils/openclaw-doctor';
export async function handleAppRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/events' && req.method === 'GET') {
setCorsHeaders(res);
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
});
res.write(': connected\n\n');
ctx.eventBus.addSseClient(res);
// Send a current-state snapshot immediately so renderer subscribers do not
// miss lifecycle transitions that happened before the SSE connection opened.
res.write(`event: gateway:status\ndata: ${JSON.stringify(ctx.gatewayManager.getStatus())}\n\n`);
return true;
}
if (url.pathname === '/api/app/openclaw-doctor' && req.method === 'POST') {
const body = await parseJsonBody<{ mode?: 'diagnose' | 'fix' }>(req);
const mode = body.mode === 'fix' ? 'fix' : 'diagnose';
sendJson(res, 200, mode === 'fix' ? await runOpenClawDoctorFix() : await runOpenClawDoctor());
return true;
}
if (req.method === 'OPTIONS') {
sendNoContent(res);
return true;
}
return false;
}
-325
View File
@@ -1,325 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { app } from 'electron';
import { existsSync, cpSync, mkdirSync, rmSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import {
deleteChannelConfig,
getChannelFormValues,
listConfiguredChannels,
saveChannelConfig,
setChannelEnabled,
validateChannelConfig,
validateChannelCredentials,
} from '../../utils/channel-config';
import { clearAllBindingsForChannel } from '../../utils/agent-config';
import { whatsAppLoginManager } from '../../utils/whatsapp-login';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
function scheduleGatewayChannelRestart(ctx: HostApiContext, reason: string): void {
if (ctx.gatewayManager.getStatus().state === 'stopped') {
return;
}
ctx.gatewayManager.debouncedRestart();
void reason;
}
async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk');
const targetManifest = join(targetDir, 'openclaw.plugin.json');
if (existsSync(targetManifest)) {
return { installed: true };
}
const candidateSources = app.isPackaged
? [
join(process.resourcesPath, 'openclaw-plugins', 'dingtalk'),
join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', 'dingtalk'),
join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', 'dingtalk'),
]
: [
join(app.getAppPath(), 'build', 'openclaw-plugins', 'dingtalk'),
join(process.cwd(), 'build', 'openclaw-plugins', 'dingtalk'),
join(__dirname, '../../../build/openclaw-plugins/dingtalk'),
];
const sourceDir = candidateSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json')));
if (!sourceDir) {
return {
installed: false,
warning: `Bundled DingTalk plugin mirror not found. Checked: ${candidateSources.join(' | ')}`,
};
}
try {
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
rmSync(targetDir, { recursive: true, force: true });
cpSync(sourceDir, targetDir, { recursive: true, dereference: true });
if (!existsSync(targetManifest)) {
return { installed: false, warning: 'Failed to install DingTalk plugin mirror (manifest missing).' };
}
return { installed: true };
} catch {
return { installed: false, warning: 'Failed to install bundled DingTalk plugin mirror' };
}
}
async function ensureWeComPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
const targetDir = join(homedir(), '.openclaw', 'extensions', 'wecom');
const targetManifest = join(targetDir, 'openclaw.plugin.json');
if (existsSync(targetManifest)) {
return { installed: true };
}
const candidateSources = app.isPackaged
? [
join(process.resourcesPath, 'openclaw-plugins', 'wecom'),
join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', 'wecom'),
join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', 'wecom'),
]
: [
join(app.getAppPath(), 'build', 'openclaw-plugins', 'wecom'),
join(process.cwd(), 'build', 'openclaw-plugins', 'wecom'),
join(__dirname, '../../../build/openclaw-plugins/wecom'),
];
const sourceDir = candidateSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json')));
if (!sourceDir) {
return {
installed: false,
warning: `Bundled WeCom plugin mirror not found. Checked: ${candidateSources.join(' | ')}`,
};
}
try {
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
rmSync(targetDir, { recursive: true, force: true });
cpSync(sourceDir, targetDir, { recursive: true, dereference: true });
if (!existsSync(targetManifest)) {
return { installed: false, warning: 'Failed to install WeCom plugin mirror (manifest missing).' };
}
return { installed: true };
} catch {
return { installed: false, warning: 'Failed to install bundled WeCom plugin mirror' };
}
}
async function ensureFeishuPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
const targetDir = join(homedir(), '.openclaw', 'extensions', 'feishu-openclaw-plugin');
const targetManifest = join(targetDir, 'openclaw.plugin.json');
if (existsSync(targetManifest)) {
return { installed: true };
}
const candidateSources = app.isPackaged
? [
join(process.resourcesPath, 'openclaw-plugins', 'feishu-openclaw-plugin'),
join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', 'feishu-openclaw-plugin'),
join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', 'feishu-openclaw-plugin'),
]
: [
join(app.getAppPath(), 'build', 'openclaw-plugins', 'feishu-openclaw-plugin'),
join(process.cwd(), 'build', 'openclaw-plugins', 'feishu-openclaw-plugin'),
join(__dirname, '../../../build/openclaw-plugins/feishu-openclaw-plugin'),
];
const sourceDir = candidateSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json')));
if (!sourceDir) {
return {
installed: false,
warning: `Bundled Feishu plugin mirror not found. Checked: ${candidateSources.join(' | ')}`,
};
}
try {
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
rmSync(targetDir, { recursive: true, force: true });
cpSync(sourceDir, targetDir, { recursive: true, dereference: true });
if (!existsSync(targetManifest)) {
return { installed: false, warning: 'Failed to install Feishu plugin mirror (manifest missing).' };
}
return { installed: true };
} catch {
return { installed: false, warning: 'Failed to install bundled Feishu plugin mirror' };
}
}
async function ensureQQBotPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
const targetDir = join(homedir(), '.openclaw', 'extensions', 'qqbot');
const targetManifest = join(targetDir, 'openclaw.plugin.json');
if (existsSync(targetManifest)) {
return { installed: true };
}
const candidateSources = app.isPackaged
? [
join(process.resourcesPath, 'openclaw-plugins', 'qqbot'),
join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', 'qqbot'),
join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', 'qqbot'),
]
: [
join(app.getAppPath(), 'build', 'openclaw-plugins', 'qqbot'),
join(process.cwd(), 'build', 'openclaw-plugins', 'qqbot'),
join(__dirname, '../../../build/openclaw-plugins/qqbot'),
];
const sourceDir = candidateSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json')));
if (!sourceDir) {
return {
installed: false,
warning: `Bundled QQ Bot plugin mirror not found. Checked: ${candidateSources.join(' | ')}`,
};
}
try {
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
rmSync(targetDir, { recursive: true, force: true });
cpSync(sourceDir, targetDir, { recursive: true, dereference: true });
if (!existsSync(targetManifest)) {
return { installed: false, warning: 'Failed to install QQ Bot plugin mirror (manifest missing).' };
}
return { installed: true };
} catch {
return { installed: false, warning: 'Failed to install bundled QQ Bot plugin mirror' };
}
}
export async function handleChannelRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/channels/configured' && req.method === 'GET') {
sendJson(res, 200, { success: true, channels: await listConfiguredChannels() });
return true;
}
if (url.pathname === '/api/channels/config/validate' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ channelType: string }>(req);
sendJson(res, 200, { success: true, ...(await validateChannelConfig(body.channelType)) });
} catch (error) {
sendJson(res, 500, { success: false, valid: false, errors: [String(error)], warnings: [] });
}
return true;
}
if (url.pathname === '/api/channels/credentials/validate' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ channelType: string; config: Record<string, string> }>(req);
sendJson(res, 200, { success: true, ...(await validateChannelCredentials(body.channelType, body.config)) });
} catch (error) {
sendJson(res, 500, { success: false, valid: false, errors: [String(error)], warnings: [] });
}
return true;
}
if (url.pathname === '/api/channels/whatsapp/start' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ accountId: string }>(req);
await whatsAppLoginManager.start(body.accountId);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/channels/whatsapp/cancel' && req.method === 'POST') {
try {
await whatsAppLoginManager.stop();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/channels/config' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ channelType: string; config: Record<string, unknown>; accountId?: string }>(req);
if (body.channelType === 'dingtalk') {
const installResult = await ensureDingTalkPluginInstalled();
if (!installResult.installed) {
sendJson(res, 500, { success: false, error: installResult.warning || 'DingTalk plugin install failed' });
return true;
}
}
if (body.channelType === 'wecom') {
const installResult = await ensureWeComPluginInstalled();
if (!installResult.installed) {
sendJson(res, 500, { success: false, error: installResult.warning || 'WeCom plugin install failed' });
return true;
}
}
if (body.channelType === 'qqbot') {
const installResult = await ensureQQBotPluginInstalled();
if (!installResult.installed) {
sendJson(res, 500, { success: false, error: installResult.warning || 'QQ Bot plugin install failed' });
return true;
}
}
if (body.channelType === 'feishu') {
const installResult = await ensureFeishuPluginInstalled();
if (!installResult.installed) {
sendJson(res, 500, { success: false, error: installResult.warning || 'Feishu plugin install failed' });
return true;
}
}
await saveChannelConfig(body.channelType, body.config, body.accountId);
scheduleGatewayChannelRestart(ctx, `channel:saveConfig:${body.channelType}`);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/channels/config/enabled' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{ channelType: string; enabled: boolean }>(req);
await setChannelEnabled(body.channelType, body.enabled);
scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${body.channelType}`);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/channels/config/') && req.method === 'GET') {
try {
const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length));
const accountId = url.searchParams.get('accountId') || undefined;
sendJson(res, 200, {
success: true,
values: await getChannelFormValues(channelType, accountId),
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/channels/config/') && req.method === 'DELETE') {
try {
const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length));
await deleteChannelConfig(channelType);
await clearAllBindingsForChannel(channelType);
scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${channelType}`);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
void ctx;
return false;
}
-448
View File
@@ -1,448 +0,0 @@
import { readFile } from 'node:fs/promises';
import type { IncomingMessage, ServerResponse } from 'http';
import { join } from 'node:path';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import { getOpenClawConfigDir } from '../../utils/paths';
interface GatewayCronJob {
id: string;
name: string;
description?: string;
enabled: boolean;
createdAtMs: number;
updatedAtMs: number;
schedule: { kind: string; expr?: string; everyMs?: number; at?: string; tz?: string };
payload: { kind: string; message?: string; text?: string };
delivery?: { mode: string; channel?: string; to?: string };
sessionTarget?: string;
state: {
nextRunAtMs?: number;
runningAtMs?: number;
lastRunAtMs?: number;
lastStatus?: string;
lastError?: string;
lastDurationMs?: number;
};
}
interface CronRunLogEntry {
jobId?: string;
action?: string;
status?: string;
error?: string;
summary?: string;
sessionId?: string;
sessionKey?: string;
ts?: number;
runAtMs?: number;
durationMs?: number;
model?: string;
provider?: string;
}
interface CronSessionKeyParts {
agentId: string;
jobId: string;
runSessionId?: string;
}
interface CronSessionFallbackMessage {
id: string;
role: 'assistant' | 'system';
content: string;
timestamp: number;
isError?: boolean;
}
function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
if (!sessionKey.startsWith('agent:')) return null;
const parts = sessionKey.split(':');
if (parts.length < 4 || parts[2] !== 'cron') return null;
const agentId = parts[1] || 'main';
const jobId = parts[3];
if (!jobId) return null;
if (parts.length === 4) {
return { agentId, jobId };
}
if (parts.length === 6 && parts[4] === 'run' && parts[5]) {
return { agentId, jobId, runSessionId: parts[5] };
}
return null;
}
function normalizeTimestampMs(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value < 1e12 ? value * 1000 : value;
}
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return undefined;
}
function formatDuration(durationMs: number | undefined): string | null {
if (!durationMs || !Number.isFinite(durationMs)) return null;
if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
if (durationMs < 10_000) return `${(durationMs / 1000).toFixed(1)}s`;
return `${Math.round(durationMs / 1000)}s`;
}
function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSessionFallbackMessage | null {
const timestamp = normalizeTimestampMs(entry.ts) ?? normalizeTimestampMs(entry.runAtMs);
if (!timestamp) return null;
const status = typeof entry.status === 'string' ? entry.status.toLowerCase() : '';
const summary = typeof entry.summary === 'string' ? entry.summary.trim() : '';
const error = typeof entry.error === 'string' ? entry.error.trim() : '';
let content = summary || error;
if (!content) {
content = status === 'error'
? 'Scheduled task failed.'
: 'Scheduled task completed.';
}
if (status === 'error' && !content.toLowerCase().startsWith('run failed:')) {
content = `Run failed: ${content}`;
}
const meta: string[] = [];
const duration = formatDuration(entry.durationMs);
if (duration) meta.push(`Duration: ${duration}`);
if (entry.provider && entry.model) {
meta.push(`Model: ${entry.provider}/${entry.model}`);
} else if (entry.model) {
meta.push(`Model: ${entry.model}`);
}
if (meta.length > 0) {
content = `${content}\n\n${meta.join(' | ')}`;
}
return {
id: `cron-run-${entry.sessionId ?? entry.ts ?? index}`,
role: status === 'error' ? 'system' : 'assistant',
content,
timestamp,
...(status === 'error' ? { isError: true } : {}),
};
}
async function readCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
const logPath = join(getOpenClawConfigDir(), 'cron', 'runs', `${jobId}.jsonl`);
const raw = await readFile(logPath, 'utf8').catch(() => '');
if (!raw.trim()) return [];
const entries: CronRunLogEntry[] = [];
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const entry = JSON.parse(trimmed) as CronRunLogEntry;
if (!entry || entry.jobId !== jobId) continue;
if (entry.action && entry.action !== 'finished') continue;
entries.push(entry);
} catch {
// Ignore malformed log lines so one bad entry does not hide the rest.
}
}
return entries;
}
async function readSessionStoreEntry(
agentId: string,
sessionKey: string,
): Promise<Record<string, unknown> | undefined> {
const storePath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json');
const raw = await readFile(storePath, 'utf8').catch(() => '');
if (!raw.trim()) return undefined;
try {
const store = JSON.parse(raw) as Record<string, unknown>;
const directEntry = store[sessionKey];
if (directEntry && typeof directEntry === 'object') {
return directEntry as Record<string, unknown>;
}
const sessions = (store as { sessions?: unknown }).sessions;
if (Array.isArray(sessions)) {
const arrayEntry = sessions.find((entry) => {
if (!entry || typeof entry !== 'object') return false;
const record = entry as Record<string, unknown>;
return record.key === sessionKey || record.sessionKey === sessionKey;
});
if (arrayEntry && typeof arrayEntry === 'object') {
return arrayEntry as Record<string, unknown>;
}
}
} catch {
return undefined;
}
return undefined;
}
export function buildCronSessionFallbackMessages(params: {
sessionKey: string;
job?: Pick<GatewayCronJob, 'name' | 'payload' | 'state'>;
runs: CronRunLogEntry[];
sessionEntry?: { label?: string; updatedAt?: number };
limit?: number;
}): CronSessionFallbackMessage[] {
const parsed = parseCronSessionKey(params.sessionKey);
if (!parsed) return [];
const matchingRuns = params.runs
.filter((entry) => {
if (!parsed.runSessionId) return true;
return entry.sessionId === parsed.runSessionId
|| entry.sessionKey === `${params.sessionKey}`;
})
.sort((a, b) => {
const left = normalizeTimestampMs(a.ts) ?? normalizeTimestampMs(a.runAtMs) ?? 0;
const right = normalizeTimestampMs(b.ts) ?? normalizeTimestampMs(b.runAtMs) ?? 0;
return left - right;
});
const messages: CronSessionFallbackMessage[] = [];
const prompt = params.job?.payload?.message || params.job?.payload?.text || '';
const taskName = params.job?.name?.trim()
|| params.sessionEntry?.label?.replace(/^Cron:\s*/, '').trim()
|| '';
const firstRelevantTimestamp = matchingRuns.length > 0
? (normalizeTimestampMs(matchingRuns[0]?.runAtMs) ?? normalizeTimestampMs(matchingRuns[0]?.ts))
: (normalizeTimestampMs(params.job?.state?.runningAtMs) ?? params.sessionEntry?.updatedAt);
if (taskName || prompt) {
const lines = [taskName ? `Scheduled task: ${taskName}` : 'Scheduled task'];
if (prompt) lines.push(`Prompt: ${prompt}`);
messages.push({
id: `cron-meta-${parsed.jobId}`,
role: 'system',
content: lines.join('\n'),
timestamp: Math.max(0, (firstRelevantTimestamp ?? Date.now()) - 1),
});
}
matchingRuns.forEach((entry, index) => {
const message = buildCronRunMessage(entry, index);
if (message) messages.push(message);
});
if (matchingRuns.length === 0) {
const runningAt = normalizeTimestampMs(params.job?.state?.runningAtMs);
if (runningAt) {
messages.push({
id: `cron-running-${parsed.jobId}`,
role: 'system',
content: 'This scheduled task is still running in OpenClaw, but no chat transcript is available yet.',
timestamp: runningAt,
});
} else if (messages.length === 0) {
messages.push({
id: `cron-empty-${parsed.jobId}`,
role: 'system',
content: 'No chat transcript is available for this scheduled task yet.',
timestamp: params.sessionEntry?.updatedAt ?? Date.now(),
});
}
}
const limit = typeof params.limit === 'number' && Number.isFinite(params.limit)
? Math.max(1, Math.floor(params.limit))
: messages.length;
return messages.slice(-limit);
}
function transformCronJob(job: GatewayCronJob) {
const message = job.payload?.message || job.payload?.text || '';
const channelType = job.delivery?.channel;
const target = channelType
? { channelType, channelId: channelType, channelName: channelType }
: undefined;
const lastRun = job.state?.lastRunAtMs
? {
time: new Date(job.state.lastRunAtMs).toISOString(),
success: job.state.lastStatus === 'ok',
error: job.state.lastError,
duration: job.state.lastDurationMs,
}
: undefined;
const nextRun = job.state?.nextRunAtMs
? new Date(job.state.nextRunAtMs).toISOString()
: undefined;
return {
id: job.id,
name: job.name,
message,
schedule: job.schedule,
target,
enabled: job.enabled,
createdAt: new Date(job.createdAtMs).toISOString(),
updatedAt: new Date(job.updatedAtMs).toISOString(),
lastRun,
nextRun,
};
}
export async function handleCronRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/cron/session-history' && req.method === 'GET') {
const sessionKey = url.searchParams.get('sessionKey')?.trim() || '';
const parsedSession = parseCronSessionKey(sessionKey);
if (!parsedSession) {
sendJson(res, 400, { success: false, error: `Invalid cron sessionKey: ${sessionKey}` });
return true;
}
const rawLimit = Number(url.searchParams.get('limit') || '200');
const limit = Number.isFinite(rawLimit)
? Math.min(Math.max(Math.floor(rawLimit), 1), 200)
: 200;
try {
const [jobsResult, runs, sessionEntry] = await Promise.all([
ctx.gatewayManager.rpc('cron.list', { includeDisabled: true })
.catch(() => ({ jobs: [] as GatewayCronJob[] })),
readCronRunLog(parsedSession.jobId),
readSessionStoreEntry(parsedSession.agentId, sessionKey),
]);
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
const job = jobs.find((item) => item.id === parsedSession.jobId);
const messages = buildCronSessionFallbackMessages({
sessionKey,
job,
runs,
sessionEntry: sessionEntry ? {
label: typeof sessionEntry.label === 'string' ? sessionEntry.label : undefined,
updatedAt: normalizeTimestampMs(sessionEntry.updatedAt),
} : undefined,
limit,
});
sendJson(res, 200, { messages });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/cron/jobs' && req.method === 'GET') {
try {
const result = await ctx.gatewayManager.rpc('cron.list', { includeDisabled: true });
const data = result as { jobs?: GatewayCronJob[] };
const jobs = data?.jobs ?? [];
for (const job of jobs) {
const isIsolatedAgent =
(job.sessionTarget === 'isolated' || !job.sessionTarget) &&
job.payload?.kind === 'agentTurn';
const needsRepair =
isIsolatedAgent &&
job.delivery?.mode === 'announce' &&
!job.delivery?.channel;
if (needsRepair) {
try {
await ctx.gatewayManager.rpc('cron.update', {
id: job.id,
patch: { delivery: { mode: 'none' } },
});
job.delivery = { mode: 'none' };
if (job.state?.lastError?.includes('Channel is required')) {
job.state.lastError = undefined;
job.state.lastStatus = 'ok';
}
} catch {
// ignore per-job repair failure
}
}
}
sendJson(res, 200, jobs.map(transformCronJob));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/cron/jobs' && req.method === 'POST') {
try {
const input = await parseJsonBody<{ name: string; message: string; schedule: string; enabled?: boolean }>(req);
const result = await ctx.gatewayManager.rpc('cron.add', {
name: input.name,
schedule: { kind: 'cron', expr: input.schedule },
payload: { kind: 'agentTurn', message: input.message },
enabled: input.enabled ?? true,
wakeMode: 'next-heartbeat',
sessionTarget: 'isolated',
delivery: { mode: 'none' },
});
sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/cron/jobs/') && req.method === 'PUT') {
try {
const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length));
const input = await parseJsonBody<Record<string, unknown>>(req);
const patch = { ...input };
if (typeof patch.schedule === 'string') {
patch.schedule = { kind: 'cron', expr: patch.schedule };
}
if (typeof patch.message === 'string') {
patch.payload = { kind: 'agentTurn', message: patch.message };
delete patch.message;
}
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.update', { id, patch }));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/cron/jobs/') && req.method === 'DELETE') {
try {
const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length));
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.remove', { id }));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/cron/toggle' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ id: string; enabled: boolean }>(req);
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.update', { id: body.id, patch: { enabled: body.enabled } }));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/cron/trigger' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ id: string }>(req);
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.run', { id: body.id, mode: 'force' }));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
-200
View File
@@ -1,200 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { dialog, nativeImage } from 'electron';
import crypto from 'node:crypto';
import { extname, join } from 'node:path';
import { homedir } from 'node:os';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
const EXT_MIME_MAP: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
'.mkv': 'video/x-matroska',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
'.pdf': 'application/pdf',
'.zip': 'application/zip',
'.gz': 'application/gzip',
'.tar': 'application/x-tar',
'.7z': 'application/x-7z-compressed',
'.rar': 'application/vnd.rar',
'.json': 'application/json',
'.xml': 'application/xml',
'.csv': 'text/csv',
'.txt': 'text/plain',
'.md': 'text/markdown',
'.html': 'text/html',
'.css': 'text/css',
'.js': 'text/javascript',
'.ts': 'text/typescript',
'.py': 'text/x-python',
};
function getMimeType(ext: string): string {
return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream';
}
function mimeToExt(mimeType: string): string {
for (const [ext, mime] of Object.entries(EXT_MIME_MAP)) {
if (mime === mimeType) return ext;
}
return '';
}
const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound');
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
try {
const img = nativeImage.createFromPath(filePath);
if (img.isEmpty()) return null;
const size = img.getSize();
const maxDim = 512;
if (size.width > maxDim || size.height > maxDim) {
const resized = size.width >= size.height
? img.resize({ width: maxDim })
: img.resize({ height: maxDim });
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
}
const { readFile } = await import('node:fs/promises');
const buf = await readFile(filePath);
return `data:${mimeType};base64,${buf.toString('base64')}`;
} catch {
return null;
}
}
export async function handleFileRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/files/stage-paths' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ filePaths: string[] }>(req);
const fsP = await import('node:fs/promises');
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
const results = [];
for (const filePath of body.filePaths) {
const id = crypto.randomUUID();
const ext = extname(filePath);
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
await fsP.copyFile(filePath, stagedPath);
const s = await fsP.stat(stagedPath);
const mimeType = getMimeType(ext);
const fileName = filePath.split(/[\\/]/).pop() || 'file';
const preview = mimeType.startsWith('image/')
? await generateImagePreview(stagedPath, mimeType)
: null;
results.push({ id, fileName, mimeType, fileSize: s.size, stagedPath, preview });
}
sendJson(res, 200, results);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/files/stage-buffer' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ base64: string; fileName: string; mimeType: string }>(req);
const fsP = await import('node:fs/promises');
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
const id = crypto.randomUUID();
const ext = extname(body.fileName) || mimeToExt(body.mimeType);
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
const buffer = Buffer.from(body.base64, 'base64');
await fsP.writeFile(stagedPath, buffer);
const mimeType = body.mimeType || getMimeType(ext);
const preview = mimeType.startsWith('image/')
? await generateImagePreview(stagedPath, mimeType)
: null;
sendJson(res, 200, {
id,
fileName: body.fileName,
mimeType,
fileSize: buffer.length,
stagedPath,
preview,
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/files/thumbnails' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ paths: Array<{ filePath: string; mimeType: string }> }>(req);
const fsP = await import('node:fs/promises');
const results: Record<string, { preview: string | null; fileSize: number }> = {};
for (const { filePath, mimeType } of body.paths) {
try {
const s = await fsP.stat(filePath);
const preview = mimeType.startsWith('image/')
? await generateImagePreview(filePath, mimeType)
: null;
results[filePath] = { preview, fileSize: s.size };
} catch {
results[filePath] = { preview: null, fileSize: 0 };
}
}
sendJson(res, 200, results);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/files/save-image' && req.method === 'POST') {
try {
const body = await parseJsonBody<{
base64?: string;
mimeType?: string;
filePath?: string;
defaultFileName: string;
}>(req);
const ext = body.defaultFileName.includes('.')
? body.defaultFileName.split('.').pop()!
: (body.mimeType?.split('/')[1] || 'png');
const result = await dialog.showSaveDialog({
defaultPath: join(homedir(), 'Downloads', body.defaultFileName),
filters: [
{ name: 'Images', extensions: [ext, 'png', 'jpg', 'jpeg', 'webp', 'gif'] },
{ name: 'All Files', extensions: ['*'] },
],
});
if (result.canceled || !result.filePath) {
sendJson(res, 200, { success: false });
return true;
}
const fsP = await import('node:fs/promises');
if (body.filePath) {
await fsP.copyFile(body.filePath, result.filePath);
} else if (body.base64) {
await fsP.writeFile(result.filePath, Buffer.from(body.base64, 'base64'));
} else {
sendJson(res, 400, { success: false, error: 'No image data provided' });
return true;
}
sendJson(res, 200, { success: true, savedPath: result.filePath });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
-129
View File
@@ -1,129 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { PORTS } from '../../utils/config';
import { getSetting } from '../../utils/store';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
export async function handleGatewayRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/app/gateway-info' && req.method === 'GET') {
const status = ctx.gatewayManager.getStatus();
const token = await getSetting('gatewayToken');
const port = status.port || PORTS.OPENCLAW_GATEWAY;
sendJson(res, 200, {
wsUrl: `ws://127.0.0.1:${port}/ws`,
token,
port,
});
return true;
}
if (url.pathname === '/api/gateway/status' && req.method === 'GET') {
sendJson(res, 200, ctx.gatewayManager.getStatus());
return true;
}
if (url.pathname === '/api/gateway/health' && req.method === 'GET') {
const health = await ctx.gatewayManager.checkHealth();
sendJson(res, 200, health);
return true;
}
if (url.pathname === '/api/gateway/start' && req.method === 'POST') {
try {
await ctx.gatewayManager.start();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/gateway/stop' && req.method === 'POST') {
try {
await ctx.gatewayManager.stop();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/gateway/restart' && req.method === 'POST') {
try {
await ctx.gatewayManager.restart();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/gateway/control-ui' && req.method === 'GET') {
try {
const status = ctx.gatewayManager.getStatus();
const token = await getSetting('gatewayToken');
const port = status.port || PORTS.OPENCLAW_GATEWAY;
const urlValue = `http://127.0.0.1:${port}/?token=${encodeURIComponent(token)}`;
sendJson(res, 200, { success: true, url: urlValue, token, port });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/chat/send-with-media' && req.method === 'POST') {
try {
const body = await parseJsonBody<{
sessionKey: string;
message: string;
deliver?: boolean;
idempotencyKey: string;
media?: Array<{ filePath: string; mimeType: string; fileName: string }>;
}>(req);
const VISION_MIME_TYPES = new Set([
'image/png', 'image/jpeg', 'image/bmp', 'image/webp',
]);
const imageAttachments: Array<{ content: string; mimeType: string; fileName: string }> = [];
const fileReferences: string[] = [];
if (body.media && body.media.length > 0) {
const fsP = await import('node:fs/promises');
for (const m of body.media) {
fileReferences.push(`[media attached: ${m.filePath} (${m.mimeType}) | ${m.filePath}]`);
if (VISION_MIME_TYPES.has(m.mimeType)) {
const fileBuffer = await fsP.readFile(m.filePath);
imageAttachments.push({
content: fileBuffer.toString('base64'),
mimeType: m.mimeType,
fileName: m.fileName,
});
}
}
}
const message = fileReferences.length > 0
? [body.message, ...fileReferences].filter(Boolean).join('\n')
: body.message;
const rpcParams: Record<string, unknown> = {
sessionKey: body.sessionKey,
message,
deliver: body.deliver ?? false,
idempotencyKey: body.idempotencyKey,
};
if (imageAttachments.length > 0) {
rpcParams.attachments = imageAttachments;
}
const result = await ctx.gatewayManager.rpc('chat.send', rpcParams, 120000);
sendJson(res, 200, { success: true, result });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
-29
View File
@@ -1,29 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { logger } from '../../utils/logger';
import type { HostApiContext } from '../context';
import { sendJson } from '../route-utils';
export async function handleLogRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/logs' && req.method === 'GET') {
const tailLines = Number(url.searchParams.get('tailLines') || '100');
sendJson(res, 200, { content: await logger.readLogFile(Number.isFinite(tailLines) ? tailLines : 100) });
return true;
}
if (url.pathname === '/api/logs/dir' && req.method === 'GET') {
sendJson(res, 200, { dir: logger.getLogDir() });
return true;
}
if (url.pathname === '/api/logs/files' && req.method === 'GET') {
sendJson(res, 200, { files: await logger.listLogFiles() });
return true;
}
return false;
}
-324
View File
@@ -1,324 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import {
type ProviderConfig,
} from '../../utils/secure-storage';
import {
getProviderConfig,
} from '../../utils/provider-registry';
import { deviceOAuthManager, type OAuthProviderType } from '../../utils/device-oauth';
import { browserOAuthManager, type BrowserOAuthProviderType } from '../../utils/browser-oauth';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import {
syncDefaultProviderToRuntime,
syncDeletedProviderApiKeyToRuntime,
syncDeletedProviderToRuntime,
syncProviderApiKeyToRuntime,
syncSavedProviderToRuntime,
syncUpdatedProviderToRuntime,
} from '../../services/providers/provider-runtime-sync';
import { validateApiKeyWithProvider } from '../../services/providers/provider-validation';
import { getProviderService } from '../../services/providers/provider-service';
import { providerAccountToConfig } from '../../services/providers/provider-store';
import type { ProviderAccount } from '../../shared/providers/types';
import { logger } from '../../utils/logger';
const legacyProviderRoutesWarned = new Set<string>();
export async function handleProviderRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
const providerService = getProviderService();
const logLegacyProviderRoute = (route: string): void => {
if (legacyProviderRoutesWarned.has(route)) return;
legacyProviderRoutesWarned.add(route);
logger.warn(
`[provider-migration] Legacy HTTP route "${route}" is deprecated. Prefer /api/provider-accounts endpoints.`,
);
};
if (url.pathname === '/api/provider-vendors' && req.method === 'GET') {
sendJson(res, 200, await providerService.listVendors());
return true;
}
if (url.pathname === '/api/provider-accounts' && req.method === 'GET') {
sendJson(res, 200, await providerService.listAccounts());
return true;
}
if (url.pathname === '/api/provider-accounts' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ account: ProviderAccount; apiKey?: string }>(req);
const account = await providerService.createAccount(body.account, body.apiKey);
await syncSavedProviderToRuntime(providerAccountToConfig(account), body.apiKey, ctx.gatewayManager);
sendJson(res, 200, { success: true, account });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/provider-accounts/default' && req.method === 'GET') {
sendJson(res, 200, { accountId: await providerService.getDefaultAccountId() ?? null });
return true;
}
if (url.pathname === '/api/provider-accounts/default' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{ accountId: string }>(req);
await providerService.setDefaultAccount(body.accountId);
await syncDefaultProviderToRuntime(body.accountId, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'GET') {
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
sendJson(res, 200, await providerService.getAccount(accountId));
return true;
}
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'PUT') {
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
try {
const body = await parseJsonBody<{ updates: Partial<ProviderAccount>; apiKey?: string }>(req);
const existing = await providerService.getAccount(accountId);
if (!existing) {
sendJson(res, 404, { success: false, error: 'Provider account not found' });
return true;
}
const nextAccount = await providerService.updateAccount(accountId, body.updates, body.apiKey);
await syncUpdatedProviderToRuntime(providerAccountToConfig(nextAccount), body.apiKey, ctx.gatewayManager);
sendJson(res, 200, { success: true, account: nextAccount });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'DELETE') {
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
try {
const existing = await providerService.getAccount(accountId);
const runtimeProviderKey = existing?.authMode === 'oauth_browser'
? (existing.vendorId === 'google'
? 'google-gemini-cli'
: (existing.vendorId === 'openai' ? 'openai-codex' : undefined))
: undefined;
if (url.searchParams.get('apiKeyOnly') === '1') {
await providerService.deleteLegacyProviderApiKey(accountId);
await syncDeletedProviderApiKeyToRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
runtimeProviderKey,
);
sendJson(res, 200, { success: true });
return true;
}
await providerService.deleteAccount(accountId);
await syncDeletedProviderToRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
ctx.gatewayManager,
runtimeProviderKey,
);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers' && req.method === 'GET') {
logLegacyProviderRoute('GET /api/providers');
sendJson(res, 200, await providerService.listLegacyProvidersWithKeyInfo());
return true;
}
if (url.pathname === '/api/providers/default' && req.method === 'GET') {
logLegacyProviderRoute('GET /api/providers/default');
sendJson(res, 200, { providerId: await providerService.getDefaultLegacyProvider() ?? null });
return true;
}
if (url.pathname === '/api/providers/default' && req.method === 'PUT') {
logLegacyProviderRoute('PUT /api/providers/default');
try {
const body = await parseJsonBody<{ providerId: string }>(req);
await providerService.setDefaultLegacyProvider(body.providerId);
await syncDefaultProviderToRuntime(body.providerId, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers/validate' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers/validate');
try {
const body = await parseJsonBody<{ providerId: string; apiKey: string; options?: { baseUrl?: string; apiProtocol?: string } }>(req);
const provider = await providerService.getLegacyProvider(body.providerId);
const providerType = provider?.type || body.providerId;
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
const resolvedBaseUrl = body.options?.baseUrl || provider?.baseUrl || registryBaseUrl;
const resolvedProtocol = body.options?.apiProtocol || provider?.apiProtocol;
sendJson(res, 200, await validateApiKeyWithProvider(providerType, body.apiKey, { baseUrl: resolvedBaseUrl, apiProtocol: resolvedProtocol as any }));
} catch (error) {
sendJson(res, 500, { valid: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers/oauth/start' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers/oauth/start');
try {
const body = await parseJsonBody<{
provider: OAuthProviderType | BrowserOAuthProviderType;
region?: 'global' | 'cn';
accountId?: string;
label?: string;
}>(req);
if (body.provider === 'google' || body.provider === 'openai') {
await browserOAuthManager.startFlow(body.provider, {
accountId: body.accountId,
label: body.label,
});
} else {
await deviceOAuthManager.startFlow(body.provider, body.region, {
accountId: body.accountId,
label: body.label,
});
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers/oauth/cancel' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers/oauth/cancel');
try {
await deviceOAuthManager.stopFlow();
await browserOAuthManager.stopFlow();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers/oauth/submit' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers/oauth/submit');
try {
const body = await parseJsonBody<{ code: string }>(req);
const accepted = browserOAuthManager.submitManualCode(body.code || '');
if (!accepted) {
sendJson(res, 400, { success: false, error: 'No active manual OAuth input pending' });
return true;
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers');
try {
const body = await parseJsonBody<{ config: ProviderConfig; apiKey?: string }>(req);
const config = body.config;
await providerService.saveLegacyProvider(config);
if (body.apiKey !== undefined) {
const trimmedKey = body.apiKey.trim();
if (trimmedKey) {
await providerService.setLegacyProviderApiKey(config.id, trimmedKey);
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
}
}
await syncSavedProviderToRuntime(config, body.apiKey, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/providers/') && req.method === 'GET') {
logLegacyProviderRoute('GET /api/providers/:id');
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
if (providerId.endsWith('/api-key')) {
const actualId = providerId.slice(0, -('/api-key'.length));
sendJson(res, 200, { apiKey: await providerService.getLegacyProviderApiKey(actualId) });
return true;
}
if (providerId.endsWith('/has-api-key')) {
const actualId = providerId.slice(0, -('/has-api-key'.length));
sendJson(res, 200, { hasKey: await providerService.hasLegacyProviderApiKey(actualId) });
return true;
}
sendJson(res, 200, await providerService.getLegacyProvider(providerId));
return true;
}
if (url.pathname.startsWith('/api/providers/') && req.method === 'PUT') {
logLegacyProviderRoute('PUT /api/providers/:id');
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
try {
const body = await parseJsonBody<{ updates: Partial<ProviderConfig>; apiKey?: string }>(req);
const existing = await providerService.getLegacyProvider(providerId);
if (!existing) {
sendJson(res, 404, { success: false, error: 'Provider not found' });
return true;
}
const nextConfig: ProviderConfig = { ...existing, ...body.updates, updatedAt: new Date().toISOString() };
await providerService.saveLegacyProvider(nextConfig);
if (body.apiKey !== undefined) {
const trimmedKey = body.apiKey.trim();
if (trimmedKey) {
await providerService.setLegacyProviderApiKey(providerId, trimmedKey);
await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey);
} else {
await providerService.deleteLegacyProviderApiKey(providerId);
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
}
}
await syncUpdatedProviderToRuntime(nextConfig, body.apiKey, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/providers/') && req.method === 'DELETE') {
logLegacyProviderRoute('DELETE /api/providers/:id');
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
try {
const existing = await providerService.getLegacyProvider(providerId);
if (url.searchParams.get('apiKeyOnly') === '1') {
await providerService.deleteLegacyProviderApiKey(providerId);
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
sendJson(res, 200, { success: true });
return true;
}
await providerService.deleteLegacyProvider(providerId);
await syncDeletedProviderToRuntime(existing, providerId, ctx.gatewayManager);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
-96
View File
@@ -1,96 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { join } from 'node:path';
import { getOpenClawConfigDir } from '../../utils/paths';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
export async function handleSessionRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/sessions/delete' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ sessionKey: string }>(req);
const sessionKey = body.sessionKey;
if (!sessionKey || !sessionKey.startsWith('agent:')) {
sendJson(res, 400, { success: false, error: `Invalid sessionKey: ${sessionKey}` });
return true;
}
const parts = sessionKey.split(':');
if (parts.length < 3) {
sendJson(res, 400, { success: false, error: `sessionKey has too few parts: ${sessionKey}` });
return true;
}
const agentId = parts[1];
const sessionsDir = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions');
const sessionsJsonPath = join(sessionsDir, 'sessions.json');
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
const sessionsJson = JSON.parse(raw) as Record<string, unknown>;
let uuidFileName: string | undefined;
let resolvedSrcPath: string | undefined;
if (Array.isArray(sessionsJson.sessions)) {
const entry = (sessionsJson.sessions as Array<Record<string, unknown>>)
.find((s) => s.key === sessionKey || s.sessionKey === sessionKey);
if (entry) {
uuidFileName = (entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (!uuidFileName && typeof entry.id === 'string') {
uuidFileName = `${entry.id}.jsonl`;
}
}
}
if (!uuidFileName && sessionsJson[sessionKey] != null) {
const val = sessionsJson[sessionKey];
if (typeof val === 'string') {
uuidFileName = val;
} else if (typeof val === 'object' && val !== null) {
const entry = val as Record<string, unknown>;
const absFile = (entry.sessionFile ?? entry.file ?? entry.fileName ?? entry.path) as string | undefined;
if (absFile) {
if (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/)) {
resolvedSrcPath = absFile;
} else {
uuidFileName = absFile;
}
} else {
const uuidVal = (entry.id ?? entry.sessionId) as string | undefined;
if (uuidVal) uuidFileName = uuidVal.endsWith('.jsonl') ? uuidVal : `${uuidVal}.jsonl`;
}
}
}
if (!uuidFileName && !resolvedSrcPath) {
sendJson(res, 404, { success: false, error: `Cannot resolve file for session: ${sessionKey}` });
return true;
}
if (!resolvedSrcPath) {
if (!uuidFileName!.endsWith('.jsonl')) uuidFileName = `${uuidFileName}.jsonl`;
resolvedSrcPath = join(sessionsDir, uuidFileName!);
}
const dstPath = resolvedSrcPath.replace(/\.jsonl$/, '.deleted.jsonl');
try {
await fsP.access(resolvedSrcPath);
await fsP.rename(resolvedSrcPath, dstPath);
} catch {
// Non-fatal; still try to update sessions.json.
}
const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8');
const json2 = JSON.parse(raw2) as Record<string, unknown>;
if (Array.isArray(json2.sessions)) {
json2.sessions = (json2.sessions as Array<Record<string, unknown>>)
.filter((s) => s.key !== sessionKey && s.sessionKey !== sessionKey);
} else if (json2[sessionKey]) {
delete json2[sessionKey];
}
await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8');
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
-110
View File
@@ -1,110 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { applyProxySettings } from '../../main/proxy';
import { syncLaunchAtStartupSettingFromStore } from '../../main/launch-at-startup';
import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../../utils/store';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
async function handleProxySettingsChange(ctx: HostApiContext): Promise<void> {
const settings = await getAllSettings();
await applyProxySettings(settings);
if (ctx.gatewayManager.getStatus().state === 'running') {
await ctx.gatewayManager.restart();
}
}
function patchTouchesProxy(patch: Partial<AppSettings>): boolean {
return Object.keys(patch).some((key) => (
key === 'proxyEnabled' ||
key === 'proxyServer' ||
key === 'proxyHttpServer' ||
key === 'proxyHttpsServer' ||
key === 'proxyAllServer' ||
key === 'proxyBypassRules'
));
}
function patchTouchesLaunchAtStartup(patch: Partial<AppSettings>): boolean {
return Object.prototype.hasOwnProperty.call(patch, 'launchAtStartup');
}
export async function handleSettingsRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/settings' && req.method === 'GET') {
sendJson(res, 200, await getAllSettings());
return true;
}
if (url.pathname === '/api/settings' && req.method === 'PUT') {
try {
const patch = await parseJsonBody<Partial<AppSettings>>(req);
const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>;
for (const [key, value] of entries) {
await setSetting(key, value);
}
if (patchTouchesProxy(patch)) {
await handleProxySettingsChange(ctx);
}
if (patchTouchesLaunchAtStartup(patch)) {
await syncLaunchAtStartupSettingFromStore();
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/settings/') && req.method === 'GET') {
const key = url.pathname.slice('/api/settings/'.length) as keyof AppSettings;
try {
sendJson(res, 200, { value: await getSetting(key) });
} catch (error) {
sendJson(res, 404, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/settings/') && req.method === 'PUT') {
const key = url.pathname.slice('/api/settings/'.length) as keyof AppSettings;
try {
const body = await parseJsonBody<{ value: AppSettings[keyof AppSettings] }>(req);
await setSetting(key, body.value);
if (
key === 'proxyEnabled' ||
key === 'proxyServer' ||
key === 'proxyHttpServer' ||
key === 'proxyHttpsServer' ||
key === 'proxyAllServer' ||
key === 'proxyBypassRules'
) {
await handleProxySettingsChange(ctx);
}
if (key === 'launchAtStartup') {
await syncLaunchAtStartupSettingFromStore();
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/settings/reset' && req.method === 'POST') {
try {
await resetSettings();
await handleProxySettingsChange(ctx);
await syncLaunchAtStartupSettingFromStore();
sendJson(res, 200, { success: true, settings: await getAllSettings() });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
-101
View File
@@ -1,101 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { getAllSkillConfigs, updateSkillConfig } from '../../utils/skill-config';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
export async function handleSkillRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/skills/configs' && req.method === 'GET') {
sendJson(res, 200, await getAllSkillConfigs());
return true;
}
if (url.pathname === '/api/skills/config' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{
skillKey: string;
apiKey?: string;
env?: Record<string, string>;
}>(req);
sendJson(res, 200, await updateSkillConfig(body.skillKey, {
apiKey: body.apiKey,
env: body.env,
}));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/search' && req.method === 'POST') {
try {
const body = await parseJsonBody<Record<string, unknown>>(req);
sendJson(res, 200, {
success: true,
results: await ctx.clawHubService.search(body),
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/install' && req.method === 'POST') {
try {
const body = await parseJsonBody<Record<string, unknown>>(req);
await ctx.clawHubService.install(body);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/uninstall' && req.method === 'POST') {
try {
const body = await parseJsonBody<Record<string, unknown>>(req);
await ctx.clawHubService.uninstall(body);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/list' && req.method === 'GET') {
try {
sendJson(res, 200, { success: true, results: await ctx.clawHubService.listInstalled() });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/open-readme' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ slug?: string; skillKey?: string; baseDir?: string }>(req);
await ctx.clawHubService.openSkillReadme(body.skillKey || body.slug || '', body.slug, body.baseDir);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/clawhub/open-path' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ slug?: string; skillKey?: string; baseDir?: string }>(req);
await ctx.clawHubService.openSkillPath(body.skillKey || body.slug || '', body.slug, body.baseDir);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}
-26
View File
@@ -1,26 +0,0 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { getRecentTokenUsageHistory } from '../../utils/token-usage';
import type { HostApiContext } from '../context';
import { sendJson } from '../route-utils';
export async function handleUsageRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/usage/recent-token-history' && req.method === 'GET') {
const rawLimit = url.searchParams.get('limit');
let limit: number | undefined;
if (rawLimit != null && rawLimit.trim() !== '') {
const parsedLimit = Number(rawLimit);
if (Number.isFinite(parsedLimit)) {
limit = Math.max(Math.floor(parsedLimit), 1);
}
}
sendJson(res, 200, await getRecentTokenUsageHistory(limit));
return true;
}
return false;
}
-62
View File
@@ -1,62 +0,0 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { PORTS } from '../utils/config';
import { logger } from '../utils/logger';
import type { HostApiContext } from './context';
import { handleAppRoutes } from './routes/app';
import { handleGatewayRoutes } from './routes/gateway';
import { handleSettingsRoutes } from './routes/settings';
import { handleProviderRoutes } from './routes/providers';
import { handleAgentRoutes } from './routes/agents';
import { handleChannelRoutes } from './routes/channels';
import { handleLogRoutes } from './routes/logs';
import { handleUsageRoutes } from './routes/usage';
import { handleSkillRoutes } from './routes/skills';
import { handleFileRoutes } from './routes/files';
import { handleSessionRoutes } from './routes/sessions';
import { handleCronRoutes } from './routes/cron';
import { sendJson } from './route-utils';
type RouteHandler = (
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
) => Promise<boolean>;
const routeHandlers: RouteHandler[] = [
handleAppRoutes,
handleGatewayRoutes,
handleSettingsRoutes,
handleProviderRoutes,
handleAgentRoutes,
handleChannelRoutes,
handleSkillRoutes,
handleFileRoutes,
handleSessionRoutes,
handleCronRoutes,
handleLogRoutes,
handleUsageRoutes,
];
export function startHostApiServer(ctx: HostApiContext, port = PORTS.CLAWX_HOST_API): Server {
const server = createServer(async (req, res) => {
try {
const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`);
for (const handler of routeHandlers) {
if (await handler(req, res, requestUrl, ctx)) {
return;
}
}
sendJson(res, 404, { success: false, error: `No route for ${req.method} ${requestUrl.pathname}` });
} catch (error) {
logger.error('Host API request failed:', error);
sendJson(res, 500, { success: false, error: String(error) });
}
});
server.listen(port, '127.0.0.1', () => {
logger.info(`Host API server listening on http://127.0.0.1:${port}`);
});
return server;
}
@@ -0,0 +1,40 @@
import type {
Extension,
ExtensionContext,
MarketplaceProviderExtension,
MarketplaceCapability,
} from '../types';
import type {
MarketplaceSearchParams,
MarketplaceInstallParams,
MarketplaceSkillResult,
} from '../../gateway/clawhub';
class ClawHubMarketplaceExtension implements MarketplaceProviderExtension {
readonly id = 'builtin/clawhub-marketplace';
setup(_ctx: ExtensionContext): void {
// Built-in public ClawHub marketplace is disabled in community builds.
}
async getCapability(): Promise<MarketplaceCapability> {
return {
mode: 'local-only',
canSearch: false,
canInstall: false,
reason: 'marketplace-disabled',
};
}
async search(_params: MarketplaceSearchParams): Promise<MarketplaceSkillResult[]> {
throw new Error('Marketplace search is disabled');
}
async install(_params: MarketplaceInstallParams): Promise<void> {
throw new Error('Marketplace install is disabled');
}
}
export function createClawHubMarketplaceExtension(): Extension {
return new ClawHubMarketplaceExtension();
}
@@ -0,0 +1,25 @@
import { createDiagnosticsApi } from '../../services/diagnostics-api';
import type {
Extension,
ExtensionContext,
HostApiProviderExtension,
} from '../types';
class DiagnosticsExtension implements HostApiProviderExtension {
readonly id = 'builtin/diagnostics';
setup(_ctx: ExtensionContext): void {
// Diagnostics are exposed through host IPC contributions.
}
getHostApiContributions(ctx: ExtensionContext) {
return [{
module: 'diagnostics',
actions: createDiagnosticsApi({ gatewayManager: ctx.gatewayManager }),
}];
}
}
export function createDiagnosticsExtension(): Extension {
return new DiagnosticsExtension();
}
+8
View File
@@ -0,0 +1,8 @@
import { registerBuiltinExtension } from '../loader';
import { createClawHubMarketplaceExtension } from './clawhub-marketplace';
import { createDiagnosticsExtension } from './diagnostics';
export function registerAllBuiltinExtensions(): void {
registerBuiltinExtension('builtin/clawhub-marketplace', createClawHubMarketplaceExtension);
registerBuiltinExtension('builtin/diagnostics', createDiagnosticsExtension);
}
+16
View File
@@ -0,0 +1,16 @@
export { extensionRegistry } from './registry';
export { registerBuiltinExtension, loadExtensionsFromManifest } from './loader';
export type {
Extension,
ExtensionContext,
HostApiProviderExtension,
MarketplaceProviderExtension,
MarketplaceCapability,
AuthProviderExtension,
AuthStatus,
} from './types';
export {
isHostApiProviderExtension,
isMarketplaceProviderExtension,
isAuthProviderExtension,
} from './types';
+76
View File
@@ -0,0 +1,76 @@
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { app } from 'electron';
import { logger } from '../utils/logger';
import { extensionRegistry } from './registry';
import type { Extension } from './types';
interface ExtensionManifest {
extensions?: {
main?: string[];
};
}
const builtinModules = new Map<string, () => Extension>();
export function registerBuiltinExtension(id: string, factory: () => Extension): void {
builtinModules.set(id, factory);
}
function resolveManifestPath(): string {
if (app.isPackaged) {
return join(process.resourcesPath, 'clawx-extensions.json');
}
return join(app.getAppPath(), 'clawx-extensions.json');
}
export async function loadExtensionsFromManifest(): Promise<void> {
const manifestPath = resolveManifestPath();
let manifest: ExtensionManifest = {};
if (existsSync(manifestPath)) {
try {
manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as ExtensionManifest;
logger.info(`[extensions] Loaded manifest from ${manifestPath}`);
} catch (err) {
logger.warn(`[extensions] Failed to parse ${manifestPath}, using defaults:`, err);
}
} else {
logger.debug('[extensions] No clawx-extensions.json found, loading all builtin extensions');
}
const mainExtensions = manifest.extensions?.main;
if (!mainExtensions || mainExtensions.length === 0) {
for (const [id, factory] of builtinModules) {
extensionRegistry.register(factory());
logger.debug(`[extensions] Auto-registered builtin extension "${id}"`);
}
return;
}
for (const extensionId of mainExtensions) {
if (builtinModules.has(extensionId)) {
extensionRegistry.register(builtinModules.get(extensionId)!());
continue;
}
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mod = require(extensionId) as { default?: Extension; extension?: Extension };
const ext = mod.default ?? mod.extension;
if (ext && typeof ext.setup === 'function') {
extensionRegistry.register(ext);
} else {
logger.warn(`[extensions] Module "${extensionId}" does not export a valid Extension`);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('Cannot find module')) {
logger.debug(`[extensions] "${extensionId}" not loadable at runtime (expected when using ext-bridge)`);
} else {
logger.warn(`[extensions] Failed to load extension "${extensionId}": ${message}`);
}
}
}
}
+93
View File
@@ -0,0 +1,93 @@
import { logger } from '../utils/logger';
import type {
Extension,
ExtensionContext,
MarketplaceProviderExtension,
} from './types';
import {
isHostApiProviderExtension,
isMarketplaceProviderExtension,
} from './types';
class ExtensionRegistry {
private extensions = new Map<string, Extension>();
private ctx: ExtensionContext | null = null;
private hostApiUnregisters = new Map<string, () => void>();
async initialize(ctx: ExtensionContext): Promise<void> {
this.ctx = ctx;
for (const ext of this.extensions.values()) {
try {
await ext.setup(ctx);
this.registerHostApiContributions(ext, ctx);
logger.info(`[extensions] Extension "${ext.id}" initialized`);
} catch (err) {
logger.error(`[extensions] Extension "${ext.id}" failed to initialize:`, err);
}
}
}
register(extension: Extension): void {
if (this.extensions.has(extension.id)) {
logger.warn(`[extensions] Extension "${extension.id}" is already registered; skipping duplicate`);
return;
}
this.extensions.set(extension.id, extension);
logger.debug(`[extensions] Registered extension "${extension.id}"`);
if (this.ctx) {
void Promise.resolve(extension.setup(this.ctx))
.then(() => {
if (this.ctx) {
this.registerHostApiContributions(extension, this.ctx);
}
})
.catch((err) => {
logger.error(`[extensions] Late-registered extension "${extension.id}" failed to initialize:`, err);
});
}
}
get(id: string): Extension | undefined {
return this.extensions.get(id);
}
getAll(): Extension[] {
return [...this.extensions.values()];
}
getMarketplaceProvider(): MarketplaceProviderExtension | undefined {
return this.getAll().find(isMarketplaceProviderExtension) as MarketplaceProviderExtension | undefined;
}
async teardownAll(): Promise<void> {
for (const ext of this.extensions.values()) {
try {
this.hostApiUnregisters.get(ext.id)?.();
this.hostApiUnregisters.delete(ext.id);
await ext.teardown?.();
} catch (err) {
logger.warn(`[extensions] Extension "${ext.id}" teardown failed:`, err);
}
}
this.extensions.clear();
this.ctx = null;
}
private registerHostApiContributions(ext: Extension, ctx: ExtensionContext): void {
this.hostApiUnregisters.get(ext.id)?.();
this.hostApiUnregisters.delete(ext.id);
if (!isHostApiProviderExtension(ext)) {
return;
}
const contributions = ext.getHostApiContributions(ctx);
if (contributions.length === 0) {
return;
}
this.hostApiUnregisters.set(ext.id, ctx.hostApi.register(ext.id, contributions));
}
}
export const extensionRegistry = new ExtensionRegistry();
+68
View File
@@ -0,0 +1,68 @@
import type { BrowserWindow } from 'electron';
import type { GatewayManager } from '../gateway/manager';
import type { HostApiContribution, HostApiContributionRegistrar } from '../main/ipc/host-contract';
import type {
MarketplaceSearchParams,
MarketplaceInstallParams,
MarketplaceSkillResult,
ClawHubSearchParams,
ClawHubInstallParams,
ClawHubSkillResult,
} from '../gateway/clawhub';
export interface ExtensionContext {
gatewayManager: GatewayManager;
getMainWindow: () => BrowserWindow | null;
hostApi: HostApiContributionRegistrar;
}
export interface Extension {
id: string;
setup(ctx: ExtensionContext): void | Promise<void>;
teardown?(): void | Promise<void>;
}
export interface MarketplaceCapability {
mode: string;
canSearch: boolean;
canInstall: boolean;
reason?: string;
}
export interface MarketplaceProviderExtension extends Extension {
getCapability(): Promise<MarketplaceCapability>;
search(params: MarketplaceSearchParams): Promise<MarketplaceSkillResult[]>;
install(params: MarketplaceInstallParams): Promise<void>;
}
export interface HostApiProviderExtension extends Extension {
getHostApiContributions(ctx: ExtensionContext): HostApiContribution[];
}
export type LegacyMarketplaceSearchParams = ClawHubSearchParams;
export type LegacyMarketplaceInstallParams = ClawHubInstallParams;
export type LegacyMarketplaceSkillResult = ClawHubSkillResult;
export interface AuthStatus {
authenticated: boolean;
expired: boolean;
user: { username: string; displayName: string; email: string } | null;
}
export interface AuthProviderExtension extends Extension {
getAuthStatus(): Promise<AuthStatus>;
onStartup?(mainWindow: BrowserWindow): Promise<void>;
}
export function isMarketplaceProviderExtension(ext: Extension): ext is MarketplaceProviderExtension {
return 'getCapability' in ext && 'search' in ext && 'install' in ext;
}
export function isHostApiProviderExtension(ext: Extension): ext is HostApiProviderExtension {
return 'getHostApiContributions' in ext
&& typeof (ext as HostApiProviderExtension).getHostApiContributions === 'function';
}
export function isAuthProviderExtension(ext: Extension): ext is AuthProviderExtension {
return 'getAuthStatus' in ext && typeof (ext as AuthProviderExtension).getAuthStatus === 'function';
}
+141
View File
@@ -0,0 +1,141 @@
import type {
GatewayDiagnosticsSnapshot,
GatewayHealthSummary,
GatewayStatus,
} from './manager';
import type { GatewayRuntimePayload } from '@shared/types/gateway';
export type GatewayCapabilityName = 'openclawHealth' | 'openclawStatus' | 'channels' | 'memory';
export interface GatewayCapabilityProbe {
state: 'unknown' | 'healthy' | 'degraded';
checkedAt?: number;
durationMs?: number;
error?: string;
payload?: GatewayRuntimePayload;
}
export interface GatewayCoreProbe {
ok: boolean;
checkedAt: number;
durationMs?: number;
error?: string;
}
export interface GatewayCapabilitySnapshot {
core: {
process: GatewayStatus['state'];
transport: 'connected' | 'disconnected';
rpcRouter: 'unknown' | 'ready' | 'blocked';
lastProbe?: GatewayCoreProbe;
};
openclawHealth: GatewayCapabilityProbe;
openclawStatus: GatewayCapabilityProbe;
presence: GatewayCapabilityProbe;
channels: GatewayCapabilityProbe;
memory: GatewayCapabilityProbe;
diagnostics: GatewayDiagnosticsSnapshot;
summary?: GatewayHealthSummary;
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function capabilityFromPayload(payload: GatewayRuntimePayload, checkedAt = Date.now()): GatewayCapabilityProbe {
return {
state: 'healthy',
checkedAt,
payload,
};
}
function capabilityFromError(error: unknown, checkedAt = Date.now()): GatewayCapabilityProbe {
return {
state: 'degraded',
checkedAt,
error: formatError(error),
};
}
const UNKNOWN_CAPABILITY: GatewayCapabilityProbe = { state: 'unknown' };
export class GatewayCapabilityMonitor {
private openclawHealth: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
private openclawStatus: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
private presence: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
private channels: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
private memory: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
private lastCoreProbe: GatewayCoreProbe | undefined;
recordOpenClawHealth(payload: GatewayRuntimePayload): void {
this.openclawHealth = capabilityFromPayload(payload);
}
recordOpenClawStatus(payload: GatewayRuntimePayload): void {
this.openclawStatus = capabilityFromPayload(payload);
}
recordPresence(payload: GatewayRuntimePayload): void {
this.presence = capabilityFromPayload(payload);
}
recordCoreProbe(probe: GatewayCoreProbe): void {
this.lastCoreProbe = probe;
}
recordCapabilitySuccess(name: GatewayCapabilityName, payload: GatewayRuntimePayload, durationMs?: number): void {
const probe: GatewayCapabilityProbe = {
state: 'healthy',
checkedAt: Date.now(),
durationMs,
payload,
};
this.setCapability(name, probe);
}
recordCapabilityFailure(name: GatewayCapabilityName, error: unknown, durationMs?: number): void {
const probe = capabilityFromError(error);
probe.durationMs = durationMs;
this.setCapability(name, probe);
}
buildSnapshot(params: {
status: GatewayStatus;
transportConnected: boolean;
diagnostics: GatewayDiagnosticsSnapshot;
summary?: GatewayHealthSummary;
}): GatewayCapabilitySnapshot {
return {
core: {
process: params.status.state,
transport: params.transportConnected ? 'connected' : 'disconnected',
rpcRouter: this.lastCoreProbe?.ok === false
? 'blocked'
: params.status.gatewayReady === true || this.lastCoreProbe?.ok === true
? 'ready'
: 'unknown',
lastProbe: this.lastCoreProbe,
},
openclawHealth: this.openclawHealth,
openclawStatus: this.openclawStatus,
presence: this.presence,
channels: this.channels,
memory: this.memory,
diagnostics: params.diagnostics,
summary: params.summary,
};
}
private setCapability(name: GatewayCapabilityName, probe: GatewayCapabilityProbe): void {
if (name === 'openclawHealth') {
this.openclawHealth = probe;
} else if (name === 'openclawStatus') {
this.openclawStatus = probe;
} else if (name === 'channels') {
this.channels = probe;
} else if (name === 'memory') {
this.memory = probe;
}
}
}
+211
View File
@@ -0,0 +1,211 @@
import type { ChatRuntimeEvent } from '../../shared/chat-runtime-events';
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
}
function readString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value : undefined;
}
function readNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
type ChatRuntimeEventType = ChatRuntimeEvent['type'];
type ChatRuntimeEventFor<T extends ChatRuntimeEventType> = Extract<ChatRuntimeEvent, { type: T }>;
type ChatRuntimeEventBaseFor<T extends ChatRuntimeEventType> = Pick<
ChatRuntimeEventFor<T>,
'type' | 'runId' | 'sessionKey' | 'seq' | 'ts'
>;
function withBase<T extends ChatRuntimeEventType>(
type: T,
payload: Record<string, unknown>,
): ChatRuntimeEventBaseFor<T> | null {
const runId = readString(payload.runId);
if (!runId) return null;
return {
type,
runId,
sessionKey: readString(payload.sessionKey),
seq: readNumber(payload.seq),
ts: readNumber(payload.ts),
} as ChatRuntimeEventBaseFor<T>;
}
export function normalizeGatewayChatRuntimeEvent(payload: unknown): ChatRuntimeEvent | null {
const raw = asRecord(payload);
if (!raw) return null;
const stream = readString(raw.stream);
const data = asRecord(raw.data) ?? raw;
if (stream === 'lifecycle') {
const phase = readString(data.phase);
if (phase === 'start') {
const base = withBase('run.started', raw);
return base
? {
...base,
startedAt: readNumber(data.startedAt),
}
: null;
}
if (phase === 'completed' || phase === 'done' || phase === 'finished') {
const base = withBase('run.ended', raw);
return base
? {
...base,
status: 'completed',
endedAt: readNumber(data.endedAt),
livenessState: readString(data.livenessState),
replayInvalid: typeof data.replayInvalid === 'boolean' ? data.replayInvalid : undefined,
stopReason: readString(data.stopReason),
}
: null;
}
if (phase === 'error' || phase === 'failed') {
const base = withBase('run.ended', raw);
return base
? {
...base,
status: 'error',
endedAt: readNumber(data.endedAt),
error: readString(data.error),
livenessState: readString(data.livenessState),
replayInvalid: typeof data.replayInvalid === 'boolean' ? data.replayInvalid : undefined,
stopReason: readString(data.stopReason),
}
: null;
}
if (phase === 'aborted' || phase === 'cancelled') {
const base = withBase('run.ended', raw);
return base
? {
...base,
status: 'aborted',
endedAt: readNumber(data.endedAt),
error: readString(data.error),
stopReason: readString(data.stopReason),
}
: null;
}
return null;
}
if (stream === 'assistant') {
const base = withBase('assistant.delta', raw);
return base
? {
...base,
text: readString(data.text),
delta: readString(data.delta),
replace: typeof data.replace === 'boolean' ? data.replace : undefined,
phase: readString(data.phase),
mediaUrls: Array.isArray(data.mediaUrls)
? data.mediaUrls.filter((value): value is string => typeof value === 'string' && value.length > 0)
: undefined,
}
: null;
}
if (stream === 'thinking') {
const base = withBase('thinking.delta', raw);
return base
? {
...base,
text: readString(data.text),
delta: readString(data.delta),
}
: null;
}
if (stream === 'tool') {
const phase = readString(data.phase);
const toolCallId = readString(data.toolCallId);
const name = readString(data.name);
if (!toolCallId || !name) return null;
if (phase === 'start') {
const base = withBase('tool.started', raw);
return base ? { ...base, toolCallId, name, args: data.args } : null;
}
if (phase === 'update') {
const base = withBase('tool.updated', raw);
return base ? { ...base, toolCallId, name, partialResult: data.partialResult } : null;
}
if (phase === 'result' || phase === 'end') {
const base = withBase('tool.completed', raw);
return base
? {
...base,
toolCallId,
name,
result: data.result,
meta: data.meta,
isError: typeof data.isError === 'boolean' ? data.isError : undefined,
}
: null;
}
return null;
}
if (stream === 'command_output') {
const base = withBase('command.output', raw);
return base
? {
...base,
itemId: readString(data.itemId),
toolCallId: readString(data.toolCallId),
name: readString(data.name),
title: readString(data.title),
output: readString(data.output),
status: readString(data.status),
phase: readString(data.phase),
exitCode: readNumber(data.exitCode),
durationMs: readNumber(data.durationMs),
cwd: readString(data.cwd),
}
: null;
}
if (stream === 'patch') {
const base = withBase('patch.completed', raw);
return base
? {
...base,
itemId: readString(data.itemId),
toolCallId: readString(data.toolCallId),
name: readString(data.name),
title: readString(data.title),
summary: readString(data.summary),
added: readNumber(data.added),
modified: readNumber(data.modified),
deleted: readNumber(data.deleted),
}
: null;
}
if (stream === 'approval') {
const base = withBase('approval.updated', raw);
return base
? {
...base,
itemId: readString(data.itemId),
toolCallId: readString(data.toolCallId),
title: readString(data.title),
kind: readString(data.kind),
phase: readString(data.phase),
status: readString(data.status),
message: readString(data.message),
}
: null;
}
return null;
}
+148 -293
View File
@@ -1,29 +1,29 @@
/**
* ClawHub Service
* Manages interactions with the ClawHub CLI for skills management
* Maintains marketplace-provider compatibility and managed skill uninstall/open helpers.
*/
import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { app, shell } from 'electron';
import { getOpenClawConfigDir, ensureDir, getClawHubCliBinPath, getClawHubCliEntryPath, quoteForCmd } from '../utils/paths';
import { shell } from 'electron';
import { getOpenClawConfigDir, ensureDir } from '../utils/paths';
import { removeSkillConfig } from '../utils/skill-config';
export interface ClawHubSearchParams {
export interface MarketplaceSearchParams {
query: string;
limit?: number;
}
export interface ClawHubInstallParams {
export interface MarketplaceInstallParams {
slug: string;
version?: string;
force?: boolean;
}
export interface ClawHubUninstallParams {
export interface MarketplaceUninstallParams {
slug: string;
}
export interface ClawHubSkillResult {
export interface MarketplaceSkillResult {
slug: string;
name: string;
description: string;
@@ -33,6 +33,11 @@ export interface ClawHubSkillResult {
stars?: number;
}
export type ClawHubSearchParams = MarketplaceSearchParams;
export type ClawHubInstallParams = MarketplaceInstallParams;
export type ClawHubUninstallParams = MarketplaceUninstallParams;
export type ClawHubSkillResult = MarketplaceSkillResult;
export interface ClawHubInstalledSkillResult {
slug: string;
version: string;
@@ -40,44 +45,154 @@ export interface ClawHubInstalledSkillResult {
baseDir?: string;
}
export interface MarketplaceProvider {
getCapability(): Promise<{ mode: string; canSearch: boolean; canInstall: boolean; reason?: string }>;
search(params: MarketplaceSearchParams): Promise<MarketplaceSkillResult[]>;
install(params: MarketplaceInstallParams): Promise<void>;
}
export class ClawHubService {
private workDir: string;
private cliPath: string;
private cliEntryPath: string;
private useNodeRunner: boolean;
private ansiRegex: RegExp;
private marketplaceProvider: MarketplaceProvider | null = null;
constructor() {
// Use the user's OpenClaw config directory (~/.openclaw) for skill management
// This avoids installing skills into the project's openclaw submodule
this.workDir = getOpenClawConfigDir();
ensureDir(this.workDir);
const binPath = getClawHubCliBinPath();
const entryPath = getClawHubCliEntryPath();
this.cliEntryPath = entryPath;
if (!app.isPackaged && fs.existsSync(binPath)) {
this.cliPath = binPath;
this.useNodeRunner = false;
} else {
this.cliPath = process.execPath;
this.useNodeRunner = true;
}
const esc = String.fromCharCode(27);
const csi = String.fromCharCode(155);
const pattern = `(?:${esc}|${csi})[[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]`;
this.ansiRegex = new RegExp(pattern, 'g');
}
private stripAnsi(line: string): string {
return line.replace(this.ansiRegex, '').trim();
setMarketplaceProvider(provider: MarketplaceProvider): void {
this.marketplaceProvider = provider;
}
async getMarketplaceCapability(): Promise<{ mode: string; canSearch: boolean; canInstall: boolean; reason?: string }> {
if (this.marketplaceProvider) {
return this.marketplaceProvider.getCapability();
}
return {
mode: 'local-only',
canSearch: false,
canInstall: false,
reason: 'marketplace-disabled',
};
}
/**
* Search for skills via an extension-provided marketplace.
*/
async search(params: MarketplaceSearchParams): Promise<MarketplaceSkillResult[]> {
if (this.marketplaceProvider) {
return this.marketplaceProvider.search(params);
}
throw new Error('Marketplace search is disabled');
}
/**
* Explore marketplace skills via the registered marketplace provider.
*/
async explore(params: { limit?: number } = {}): Promise<MarketplaceSkillResult[]> {
if (this.marketplaceProvider) {
return this.marketplaceProvider.search({ query: '', limit: params.limit });
}
throw new Error('Marketplace search is disabled');
}
/**
* Install a skill through an extension-provided marketplace.
*/
async install(params: MarketplaceInstallParams): Promise<void> {
if (this.marketplaceProvider) {
return this.marketplaceProvider.install(params);
}
throw new Error('Marketplace install is disabled');
}
/**
* Uninstall a managed skill and remove its stored config.
*/
async uninstall(params: ClawHubUninstallParams): Promise<void> {
const fsPromises = fs.promises;
const skillDir = path.join(this.workDir, 'skills', params.slug);
if (fs.existsSync(skillDir)) {
console.log(`Deleting skill directory: ${skillDir}`);
await fsPromises.rm(skillDir, { recursive: true, force: true });
}
const lockFile = path.join(this.workDir, '.clawhub', 'lock.json');
if (fs.existsSync(lockFile)) {
try {
const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf8')) as {
skills?: Record<string, unknown>;
};
if (lockData.skills && lockData.skills[params.slug]) {
console.log(`Removing ${params.slug} from lock.json`);
delete lockData.skills[params.slug];
await fsPromises.writeFile(lockFile, JSON.stringify(lockData, null, 2));
}
} catch (err) {
console.error('Failed to update ClawHub lock file:', err);
}
}
await removeSkillConfig(params.slug);
}
/**
* List installed managed skills from the filesystem.
*/
async listInstalled(): Promise<ClawHubInstalledSkillResult[]> {
const skillsRoot = path.join(this.workDir, 'skills');
if (!fs.existsSync(skillsRoot)) {
return [];
}
try {
const entries = await fs.promises.readdir(skillsRoot, { withFileTypes: true });
const items = await Promise.all(entries
.filter((entry) => entry.isDirectory())
.map(async (entry) => {
const skillDir = path.join(skillsRoot, entry.name);
const manifestPath = path.join(skillDir, 'SKILL.md');
if (!fs.existsSync(manifestPath)) return null;
let version = 'unknown';
const manifestJsonPath = path.join(skillDir, 'manifest.json');
if (fs.existsSync(manifestJsonPath)) {
try {
const manifestJson = JSON.parse(await fs.promises.readFile(manifestJsonPath, 'utf8')) as { version?: string };
version = manifestJson.version?.trim() || version;
} catch {
// Ignore malformed manifest.json
}
}
const originJsonPath = path.join(skillDir, '.clawhub', 'origin.json');
if (fs.existsSync(originJsonPath)) {
try {
const originJson = JSON.parse(await fs.promises.readFile(originJsonPath, 'utf8')) as { installedVersion?: string };
version = originJson.installedVersion?.trim() || version;
} catch {
// Ignore malformed origin.json
}
}
return {
slug: entry.name,
version,
source: 'openclaw-managed',
baseDir: skillDir,
};
}));
return items.filter((item): item is NonNullable<typeof item> => item !== null);
} catch (error) {
console.error('ClawHub list error:', error);
return [];
}
}
private extractFrontmatterName(skillManifestPath: string): string | null {
try {
const raw = fs.readFileSync(skillManifestPath, 'utf8');
// Match the first frontmatter block and read `name: ...`
const frontmatterMatch = raw.match(/^---\s*\n([\s\S]*?)\n---/);
if (!frontmatterMatch) return null;
const body = frontmatterMatch[1];
@@ -123,257 +238,6 @@ export class ClawHubService {
return null;
}
/**
* Run a ClawHub CLI command
*/
private async runCommand(args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
if (this.useNodeRunner && !fs.existsSync(this.cliEntryPath)) {
reject(new Error(`ClawHub CLI entry not found at: ${this.cliEntryPath}`));
return;
}
if (!this.useNodeRunner && !fs.existsSync(this.cliPath)) {
reject(new Error(`ClawHub CLI not found at: ${this.cliPath}`));
return;
}
const commandArgs = this.useNodeRunner ? [this.cliEntryPath, ...args] : args;
const displayCommand = [this.cliPath, ...commandArgs].join(' ');
console.log(`Running ClawHub command: ${displayCommand}`);
const isWin = process.platform === 'win32';
const useShell = isWin && !this.useNodeRunner;
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
const env = {
...baseEnv,
CI: 'true',
FORCE_COLOR: '0',
};
if (this.useNodeRunner) {
env.ELECTRON_RUN_AS_NODE = '1';
}
const spawnCmd = useShell ? quoteForCmd(this.cliPath) : this.cliPath;
const spawnArgs = useShell ? commandArgs.map(a => quoteForCmd(a)) : commandArgs;
const child = spawn(spawnCmd, spawnArgs, {
cwd: this.workDir,
shell: useShell,
env: {
...env,
CLAWHUB_WORKDIR: this.workDir,
},
windowsHide: true,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('error', (error) => {
console.error('ClawHub process error:', error);
reject(error);
});
child.on('close', (code) => {
if (code !== 0 && code !== null) {
console.error(`ClawHub command failed with code ${code}`);
console.error('Stderr:', stderr);
reject(new Error(`Command failed: ${stderr || stdout}`));
} else {
resolve(stdout.trim());
}
});
});
}
/**
* Search for skills
*/
async search(params: ClawHubSearchParams): Promise<ClawHubSkillResult[]> {
try {
// If query is empty, use 'explore' to show trending skills
if (!params.query || params.query.trim() === '') {
return this.explore({ limit: params.limit });
}
const args = ['search', params.query];
if (params.limit) {
args.push('--limit', String(params.limit));
}
const output = await this.runCommand(args);
if (!output || output.includes('No skills found')) {
return [];
}
const lines = output.split('\n').filter(l => l.trim());
return lines.map(line => {
const cleanLine = this.stripAnsi(line);
// Format could be: slug vversion description (score)
// Or sometimes: slug vversion description
let match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)\s+(.+)$/);
if (match) {
const slug = match[1];
const version = match[2];
let description = match[3];
// Clean up score if present at the end
description = description.replace(/\(\d+\.\d+\)$/, '').trim();
return {
slug,
name: slug,
version,
description,
};
}
// Fallback for new clawhub search format without version:
// slug name/description (score)
match = cleanLine.match(/^(\S+)\s+(.+)$/);
if (match) {
const slug = match[1];
let description = match[2];
// Clean up score if present at the end
description = description.replace(/\(\d+\.\d+\)$/, '').trim();
return {
slug,
name: slug,
version: 'latest', // Fallback version since it's not provided
description,
};
}
return null;
}).filter((s): s is ClawHubSkillResult => s !== null);
} catch (error) {
console.error('ClawHub search error:', error);
throw error;
}
}
/**
* Explore trending skills
*/
async explore(params: { limit?: number } = {}): Promise<ClawHubSkillResult[]> {
try {
const args = ['explore'];
if (params.limit) {
args.push('--limit', String(params.limit));
}
const output = await this.runCommand(args);
if (!output) return [];
const lines = output.split('\n').filter(l => l.trim());
return lines.map(line => {
const cleanLine = this.stripAnsi(line);
// Format: slug vversion time description
// Example: my-skill v1.0.0 2 hours ago A great skill
const match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)\s+(.+? ago|just now|yesterday)\s+(.+)$/i);
if (match) {
return {
slug: match[1],
name: match[1],
version: match[2],
description: match[4],
};
}
return null;
}).filter((s): s is ClawHubSkillResult => s !== null);
} catch (error) {
console.error('ClawHub explore error:', error);
throw error;
}
}
/**
* Install a skill
*/
async install(params: ClawHubInstallParams): Promise<void> {
const args = ['install', params.slug];
if (params.version) {
args.push('--version', params.version);
}
if (params.force) {
args.push('--force');
}
await this.runCommand(args);
}
/**
* Uninstall a skill
*/
async uninstall(params: ClawHubUninstallParams): Promise<void> {
const fsPromises = fs.promises;
// 1. Delete the skill directory
const skillDir = path.join(this.workDir, 'skills', params.slug);
if (fs.existsSync(skillDir)) {
console.log(`Deleting skill directory: ${skillDir}`);
await fsPromises.rm(skillDir, { recursive: true, force: true });
}
// 2. Remove from lock.json
const lockFile = path.join(this.workDir, '.clawhub', 'lock.json');
if (fs.existsSync(lockFile)) {
try {
const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
if (lockData.skills && lockData.skills[params.slug]) {
console.log(`Removing ${params.slug} from lock.json`);
delete lockData.skills[params.slug];
await fsPromises.writeFile(lockFile, JSON.stringify(lockData, null, 2));
}
} catch (err) {
console.error('Failed to update ClawHub lock file:', err);
}
}
}
/**
* List installed skills
*/
async listInstalled(): Promise<ClawHubInstalledSkillResult[]> {
try {
const output = await this.runCommand(['list']);
if (!output || output.includes('No installed skills')) {
return [];
}
const lines = output.split('\n').filter(l => l.trim());
return lines.map(line => {
const cleanLine = this.stripAnsi(line);
const match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)/);
if (match) {
const slug = match[1];
return {
slug,
version: match[2],
source: 'openclaw-managed',
baseDir: path.join(this.workDir, 'skills', slug),
};
}
return null;
}).filter((s): s is ClawHubInstalledSkillResult => s !== null);
} catch (error) {
console.error('ClawHub list error:', error);
return [];
}
}
private resolveSkillDir(skillKeyOrSlug: string, fallbackSlug?: string, preferredBaseDir?: string): string | null {
const candidates = [skillKeyOrSlug, fallbackSlug]
.filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
@@ -388,13 +252,9 @@ export class ClawHubService {
return directSkillDir || this.resolveSkillDirByManifestName(uniqueCandidates);
}
/**
* Open skill README/manual in default editor
*/
async openSkillReadme(skillKeyOrSlug: string, fallbackSlug?: string, preferredBaseDir?: string): Promise<boolean> {
const skillDir = this.resolveSkillDir(skillKeyOrSlug, fallbackSlug, preferredBaseDir);
// Try to find documentation file
const possibleFiles = ['SKILL.md', 'README.md', 'skill.md', 'readme.md'];
let targetFile = '';
@@ -409,7 +269,6 @@ export class ClawHubService {
}
if (!targetFile) {
// If no md file, just open the directory
if (skillDir) {
targetFile = skillDir;
} else {
@@ -418,7 +277,6 @@ export class ClawHubService {
}
try {
// Open file with default application
await shell.openPath(targetFile);
return true;
} catch (error) {
@@ -427,9 +285,6 @@ export class ClawHubService {
}
}
/**
* Open skill path in file explorer
*/
async openSkillPath(skillKeyOrSlug: string, fallbackSlug?: string, preferredBaseDir?: string): Promise<boolean> {
const skillDir = this.resolveSkillDir(skillKeyOrSlug, fallbackSlug, preferredBaseDir);
if (!skillDir) {
+22
View File
@@ -0,0 +1,22 @@
export const SUPERVISED_SYSTEMD_ENV_KEYS = [
'OPENCLAW_SYSTEMD_UNIT',
'INVOCATION_ID',
'SYSTEMD_EXEC_PID',
'JOURNAL_STREAM',
] as const;
export type GatewayEnv = Record<string, string | undefined>;
/**
* OpenClaw CLI treats certain environment variables as systemd supervisor hints.
* When present in ClawX-owned child-process launches, it can mistakenly enter
* a supervised process retry loop. Strip those variables so startup follows
* ClawX lifecycle.
*/
export function stripSystemdSupervisorEnv(env: GatewayEnv): GatewayEnv {
const next = { ...env };
for (const key of SUPERVISED_SYSTEMD_ENV_KEYS) {
delete next[key];
}
return next;
}
+547 -20
View File
@@ -1,17 +1,51 @@
import { app } from 'electron';
import path from 'path';
import { existsSync } from 'fs';
import { existsSync, readFileSync, mkdirSync, readdirSync, rmSync, symlinkSync } 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 {
getOpenClawConfigDir,
getOpenClawDir,
getOpenClawEntryPath,
getOpenClawResolvedDir,
getOpenClawSkillsDir,
isOpenClawPresent,
} from '../utils/paths';
import { getUvMirrorEnv } from '../utils/uv-env';
import { listConfiguredChannels } from '../utils/channel-config';
import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, sanitizeOpenClawConfig } from '../utils/openclaw-auth';
import { cleanupDanglingWeChatPluginState, listConfiguredChannelsFromConfig, readOpenClawConfig } from '../utils/channel-config';
import { sanitizeOpenClawConfig, batchSyncConfigFields } from '../utils/openclaw-auth';
import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
import { logger } from '../utils/logger';
import { prependPathEntry } from '../utils/env-path';
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe, buildCandidateSources, repairTrustedOfficialPluginInstallRecords, syncTrustedOfficialPluginInstallRecord, resolvePluginNpmPackagePath } from '../utils/plugin-install';
import { CLAWX_OPENAI_IMAGE_PROVIDER_KEY } from '../utils/openclaw-image-relay-constants';
import { stripSystemdSupervisorEnv } from './config-sync-env';
import { cleanupAgentsSymlinkedSkills, cleanupStalePluginRuntimeDeps } from './skills-symlink-cleanup';
import {
buildPrelaunchMaintenanceCacheKey,
directoryChildrenSignature,
pathSignature,
runCachedPrelaunchMaintenanceTask,
type PrelaunchMaintenanceRunResult,
type PrelaunchMaintenanceTaskName,
} from './prelaunch-maintenance-cache';
export interface GatewayLaunchContext {
appSettings: Awaited<ReturnType<typeof getAllSettings>>;
@@ -26,28 +60,491 @@ export interface GatewayLaunchContext {
channelStartupSummary: string;
}
export async function syncGatewayConfigBeforeLaunch(
appSettings: Awaited<ReturnType<typeof getAllSettings>>,
): Promise<void> {
await syncProxyConfigToOpenClaw(appSettings);
export interface GatewayPrelaunchSyncSummary {
timingsMs: Record<string, number>;
maintenance: Partial<Record<PrelaunchMaintenanceTaskName, PrelaunchMaintenanceRunResult>>;
configuredChannels: string[];
}
// ── Auto-upgrade bundled plugins on startup ──────────────────────
const CHANNEL_PLUGIN_MAP: Record<string, { dirName: string; npmName: string }> = {
dingtalk: { dirName: 'dingtalk', npmName: '@soimy/dingtalk' },
wecom: { dirName: 'wecom', npmName: '@wecom/wecom-openclaw-plugin' },
feishu: { dirName: 'feishu-openclaw-plugin', npmName: '@larksuite/openclaw-lark' },
discord: { dirName: 'discord', npmName: '@openclaw/discord' },
qqbot: { dirName: 'qqbot', npmName: '@openclaw/qqbot' },
whatsapp: { dirName: 'whatsapp', npmName: '@openclaw/whatsapp' },
'openclaw-weixin': { dirName: 'openclaw-weixin', npmName: '@tencent-weixin/openclaw-weixin' },
[CLAWX_OPENAI_IMAGE_PROVIDER_KEY]: { dirName: CLAWX_OPENAI_IMAGE_PROVIDER_KEY, npmName: 'clawx-openai-image-plugin' },
};
/**
* OpenClaw ships some channel plugins as bundled extensions under
* dist/extensions/. If ClawX previously mirrored one of those ids into
* ~/.openclaw/extensions/, the stale copy overrides the bundled plugin.
* Only remove extension copies whose id is actually bundled in the
* currently resolved OpenClaw runtime (e.g. telegram in 2026.6.10).
*/
function listBundledOpenClawExtensionPluginIds(): string[] {
const extensionsDir = join(getOpenClawResolvedDir(), 'dist', 'extensions');
if (!existsSync(fsPath(extensionsDir))) {
return [];
}
const pluginIds: string[] = [];
for (const entry of readdirSync(fsPath(extensionsDir), { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const manifestPath = join(extensionsDir, entry.name, 'openclaw.plugin.json');
if (!existsSync(fsPath(manifestPath))) continue;
try {
const parsed = JSON.parse(readFileSync(fsPath(manifestPath), 'utf-8')) as { id?: unknown };
if (typeof parsed.id === 'string' && parsed.id.trim()) {
pluginIds.push(parsed.id.trim());
}
} catch {
// ignore malformed manifests
}
}
return pluginIds;
}
function cleanupStaleBuiltInExtensions(): void {
for (const ext of listBundledOpenClawExtensionPluginIds()) {
const extDir = join(homedir(), '.openclaw', 'extensions', ext);
if (existsSync(fsPath(extDir))) {
logger.info(`[plugin] Removing stale built-in extension copy: ${ext}`);
try {
rmSync(fsPath(extDir), { recursive: true, force: true });
} 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 measureSync<T>(timings: Record<string, number>, key: string, fn: () => T): T {
const startedAt = Date.now();
try {
return fn();
} finally {
timings[key] = Date.now() - startedAt;
}
}
async function measureAsync<T>(timings: Record<string, number>, key: string, fn: () => Promise<T>): Promise<T> {
const startedAt = Date.now();
try {
return await fn();
} finally {
timings[key] = Date.now() - startedAt;
}
}
function appVersionForCache(): string {
try {
return app.getVersion();
} catch {
return 'unknown';
}
}
/**
* 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[]): boolean {
let succeeded = true;
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 = buildCandidateSources(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);
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
} catch (err) {
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin:`, err);
succeeded = false;
}
} else if (isInstalled) {
// Same version already installed — still patch manifest ID in case it was
// never corrected (e.g. installed before MANIFEST_ID_FIXES included this plugin).
fixupPluginManifest(targetDir);
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
}
continue;
}
// Dev mode fallback: copy from node_modules/ with pnpm dep resolution
if (!app.isPackaged) {
const npmPkgPath = resolvePluginNpmPackagePath(npmName);
if (npmPkgPath && existsSync(fsPath(join(npmPkgPath, 'openclaw.plugin.json')))) {
const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json'));
if (!sourceVersion) continue;
// Skip only if installed AND same version — but still patch manifest ID.
if (isInstalled && installedVersion && sourceVersion === installedVersion) {
fixupPluginManifest(targetDir);
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
continue;
}
logger.info(`[plugin] ${isInstalled ? 'Auto-upgrading' : 'Installing'} ${channelType} plugin${isInstalled ? `: ${installedVersion}${sourceVersion}` : `: ${sourceVersion}`} (dev/node_modules)`);
try {
mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true });
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
fixupPluginManifest(targetDir);
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
} catch (err) {
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin from node_modules:`, err);
succeeded = false;
}
}
}
}
return succeeded;
}
/**
* Remove channel plugin extensions from ~/.openclaw/extensions/ when their
* corresponding channel is no longer configured. This prevents the Gateway
* from scanning residual plugin manifests that were installed by a previous
* configuration but are no longer needed.
*/
function cleanupUnconfiguredChannelPlugins(configuredChannels: string[]): boolean {
let succeeded = true;
const configuredSet = new Set(configuredChannels);
for (const [channelType, pluginInfo] of Object.entries(CHANNEL_PLUGIN_MAP)) {
if (configuredSet.has(channelType)) continue;
const { dirName } = pluginInfo;
const targetDir = join(homedir(), '.openclaw', 'extensions', dirName);
if (!existsSync(fsPath(targetDir))) continue;
logger.info(`[plugin] Removing unconfigured channel plugin: ${channelType} (${dirName})`);
try {
rmSync(fsPath(targetDir), { recursive: true, force: true });
} catch (err) {
logger.warn(`[plugin] Failed to remove unconfigured channel plugin ${channelType}:`, err);
succeeded = false;
}
}
return succeeded;
}
function resolveImageGenerationPrimary(config: unknown): string | null {
if (!config || typeof config !== 'object') return null;
const agents = (config as { agents?: unknown }).agents;
if (!agents || typeof agents !== 'object') return null;
const defaults = (agents as { defaults?: unknown }).defaults;
if (!defaults || typeof defaults !== 'object') return null;
const imageGenerationModel = (defaults as { imageGenerationModel?: unknown }).imageGenerationModel;
if (typeof imageGenerationModel === 'string') return imageGenerationModel.trim() || null;
if (imageGenerationModel && typeof imageGenerationModel === 'object') {
const primary = (imageGenerationModel as { primary?: unknown }).primary;
return typeof primary === 'string' && primary.trim() ? primary.trim() : null;
}
return null;
}
function withConfiguredImageGenerationPlugins(configuredChannels: string[], rawConfig: unknown): string[] {
const next = [...configuredChannels];
const primary = resolveImageGenerationPrimary(rawConfig);
const provider = primary?.includes('/') ? primary.slice(0, primary.indexOf('/')).trim() : primary;
if (provider === CLAWX_OPENAI_IMAGE_PROVIDER_KEY && !next.includes(CLAWX_OPENAI_IMAGE_PROVIDER_KEY)) {
next.push(CLAWX_OPENAI_IMAGE_PROVIDER_KEY);
}
return next;
}
function buildPluginSourceSignatures(configuredChannels: string[]): Record<string, unknown> {
const signatures: Record<string, unknown> = {};
for (const channelType of [...configuredChannels].sort()) {
const pluginInfo = CHANNEL_PLUGIN_MAP[channelType];
if (!pluginInfo) continue;
const bundledSources = buildCandidateSources(pluginInfo.dirName);
const bundledDir = bundledSources.find((dir) => existsSync(fsPath(join(dir, 'openclaw.plugin.json'))));
const devPkgPath = join(process.cwd(), 'node_modules', ...pluginInfo.npmName.split('/'));
const sourceDir = bundledDir || (!app.isPackaged ? devPkgPath : '');
signatures[channelType] = sourceDir
? {
sourceDir,
manifest: pathSignature(join(sourceDir, 'openclaw.plugin.json')),
packageJson: pathSignature(join(sourceDir, 'package.json')),
}
: 'missing';
}
return signatures;
}
function buildPluginMaintenanceCacheKey(openclawDir: string, configuredChannels: string[]): string {
return buildPrelaunchMaintenanceCacheKey({
task: 'plugin-maintenance',
appVersion: appVersionForCache(),
openclawDir,
cwd: process.cwd(),
configuredChannels: [...configuredChannels].sort(),
extensionsDir: directoryChildrenSignature(join(homedir(), '.openclaw', 'extensions')),
sourceSignatures: buildPluginSourceSignatures(configuredChannels),
});
}
function buildSkillsSymlinkCleanupCacheKey(openclawDir: string): string {
const workspaceSkillsDir = join(getOpenClawConfigDir(), 'workspace', 'skills');
return buildPrelaunchMaintenanceCacheKey({
task: 'skills-symlink-cleanup',
appVersion: appVersionForCache(),
openclawDir,
skillsDir: getOpenClawSkillsDir(),
skillsDirSignature: directoryChildrenSignature(getOpenClawSkillsDir()),
workspaceSkillsDir,
workspaceSkillsDirSignature: directoryChildrenSignature(workspaceSkillsDir),
});
}
function buildRuntimeDepsCleanupCacheKey(openclawDir: string): string {
const runtimeDepsDir = join(getOpenClawConfigDir(), 'plugin-runtime-deps');
return buildPrelaunchMaintenanceCacheKey({
task: 'runtime-deps-cleanup',
appVersion: appVersionForCache(),
openclawDir,
currentOpenClawDir: getOpenClawResolvedDir(),
runtimeDepsDir,
runtimeDepsDirSignature: directoryChildrenSignature(runtimeDepsDir),
});
}
/**
* Ensure extension-specific packages are resolvable from shared dist/ chunks.
*
* OpenClaw's Rollup bundler creates shared chunks in dist/ (e.g.
* sticker-cache-*.js) that eagerly `import "grammy"`. ESM bare specifier
* resolution walks from the importing file's directory upward:
* dist/node_modules/ → openclaw/node_modules/ → …
* It does NOT search `dist/extensions/telegram/node_modules/`.
*
* NODE_PATH only works for CJS require(), NOT for ESM import statements.
*
* Fix: create symlinks in openclaw/node_modules/ pointing to packages in
* dist/extensions/<ext>/node_modules/. This makes the standard ESM
* resolution algorithm find them. Skip-if-exists avoids overwriting
* openclaw's own deps (they take priority).
*/
let _extensionDepsLinked = false;
/**
* Reset the extension-deps-linked cache so the next
* ensureExtensionDepsResolvable() call re-scans and links.
* Called before each Gateway launch to pick up newly installed extensions.
*/
export function resetExtensionDepsLinked(): void {
_extensionDepsLinked = false;
}
function ensureExtensionDepsResolvable(openclawDir: string): void {
if (_extensionDepsLinked) return;
const extDir = join(openclawDir, 'dist', 'extensions');
const topNM = join(openclawDir, 'node_modules');
let linkedCount = 0;
try {
await sanitizeOpenClawConfig();
if (!existsSync(extDir)) return;
for (const ext of readdirSync(extDir, { withFileTypes: true })) {
if (!ext.isDirectory()) continue;
const extNM = join(extDir, ext.name, 'node_modules');
if (!existsSync(extNM)) continue;
for (const pkg of readdirSync(extNM, { withFileTypes: true })) {
if (pkg.name === '.bin') continue;
if (pkg.name.startsWith('@')) {
// Scoped package — iterate sub-entries
const scopeDir = join(extNM, pkg.name);
let scopeEntries;
try { scopeEntries = readdirSync(scopeDir, { withFileTypes: true }); } catch { continue; }
for (const sub of scopeEntries) {
if (!sub.isDirectory()) continue;
const dest = join(topNM, pkg.name, sub.name);
if (existsSync(dest)) continue;
try {
mkdirSync(join(topNM, pkg.name), { recursive: true });
symlinkSync(join(scopeDir, sub.name), dest);
linkedCount++;
} catch { /* skip on error — non-fatal */ }
}
} else {
const dest = join(topNM, pkg.name);
if (existsSync(dest)) continue;
try {
mkdirSync(topNM, { recursive: true });
symlinkSync(join(extNM, pkg.name), dest);
linkedCount++;
} catch { /* skip on error — non-fatal */ }
}
}
}
} catch {
// extensions dir may not exist or be unreadable — non-fatal
}
if (linkedCount > 0) {
logger.info(`[extension-deps] Linked ${linkedCount} extension packages into ${topNM}`);
}
_extensionDepsLinked = true;
}
// ── Pre-launch sync ──────────────────────────────────────────────
export async function syncGatewayConfigBeforeLaunch(
appSettings: Awaited<ReturnType<typeof getAllSettings>>,
openclawDir: string,
): Promise<GatewayPrelaunchSyncSummary> {
const timingsMs: Record<string, number> = {};
const maintenance: GatewayPrelaunchSyncSummary['maintenance'] = {};
let configuredChannels: string[] = [];
// Reset the extension-deps cache so that newly installed extensions
// (e.g. user added a channel while the app was running) get their
// node_modules linked on the next Gateway spawn.
resetExtensionDepsLinked();
await measureAsync(timingsMs, 'proxySyncMs', async () => {
await syncProxyConfigToOpenClaw(appSettings, { preserveExistingWhenDisabled: true });
});
try {
await measureAsync(timingsMs, 'sanitizeMs', sanitizeOpenClawConfig);
} catch (err) {
logger.warn('Failed to sanitize openclaw.json:', err);
}
try {
await syncGatewayTokenToConfig(appSettings.gatewayToken);
await measureAsync(timingsMs, 'wechatStateCleanupMs', cleanupDanglingWeChatPluginState);
} catch (err) {
logger.warn('Failed to sync gateway token to openclaw.json:', 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 {
await syncBrowserConfigToOpenClaw();
measureSync(timingsMs, 'staleBuiltinExtensionCleanupMs', cleanupStaleBuiltInExtensions);
} catch (err) {
logger.warn('Failed to sync browser config to openclaw.json:', err);
logger.warn('Failed to clean stale built-in extensions:', err);
}
// Remove stray symlinks under ~/.openclaw/skills whose realpath resolves
// inside ~/.agents/skills. OpenClaw's hardened skill loader rejects these
// on every launch (reason=symlink-escape) and the underlying skills are
// still discovered via the agents-skills-personal source, so the symlinks
// are pure log noise. Transitional workaround for openclaw/openclaw#59219.
try {
const result = measureSync(timingsMs, 'skillsCleanupMs', () => runCachedPrelaunchMaintenanceTask(
'skills-symlink-cleanup',
() => buildSkillsSymlinkCleanupCacheKey(openclawDir),
() => (cleanupAgentsSymlinkedSkills().failed ?? 0) === 0,
));
maintenance['skills-symlink-cleanup'] = result;
} catch (err) {
logger.warn('Failed to clean .agents/skills-targeted skill symlinks:', err);
}
// Remove stale OpenClaw runtime-deps cache roots that point at an older
// worktree/package. Those symlink trees can make Gateway plugin setup spend
// a long time in synchronous fs.open/copy calls before the RPC router is
// responsive.
try {
const result = measureSync(timingsMs, 'runtimeDepsCleanupMs', () => runCachedPrelaunchMaintenanceTask(
'runtime-deps-cleanup',
() => buildRuntimeDepsCleanupCacheKey(openclawDir),
() => (cleanupStalePluginRuntimeDeps().failed ?? 0) === 0,
));
maintenance['runtime-deps-cleanup'] = result;
} catch (err) {
logger.warn('Failed to clean stale OpenClaw plugin runtime deps:', err);
}
// Auto-upgrade installed plugins before Gateway starts so that
// the plugin manifest ID matches what sanitize wrote to the config.
// Only install/upgrade plugins for channels that are actually configured
// in openclaw.json — do NOT expand the list from plugins.allow.
try {
configuredChannels = await measureAsync(timingsMs, 'configuredChannelsMs', async () => {
const rawCfg = await readOpenClawConfig();
return withConfiguredImageGenerationPlugins(
await listConfiguredChannelsFromConfig(rawCfg),
rawCfg,
);
});
const result = measureSync(timingsMs, 'pluginMaintenanceMs', () => runCachedPrelaunchMaintenanceTask(
'plugin-maintenance',
() => buildPluginMaintenanceCacheKey(openclawDir, configuredChannels),
() => {
const upgradeOk = ensureConfiguredPluginsUpgraded(configuredChannels);
const cleanupOk = cleanupUnconfiguredChannelPlugins(configuredChannels);
return upgradeOk && cleanupOk;
},
));
maintenance['plugin-maintenance'] = result;
// Always refresh trusted install metadata through ClawX — this must not
// be skipped when plugin-maintenance is cache-hit, otherwise official
// external plugins like WhatsApp fail openKeyedStore at runtime.
measureSync(timingsMs, 'trustedPluginInstallSyncMs', repairTrustedOfficialPluginInstallRecords);
} catch (err) {
logger.warn('Failed to auto-upgrade plugins:', err);
}
// Batch gateway token, browser config, and session idle into one read+write cycle.
try {
await measureAsync(timingsMs, 'configFieldSyncMs', async () => {
await batchSyncConfigFields(appSettings.gatewayToken);
});
} catch (err) {
logger.warn('Failed to batch-sync config fields to openclaw.json:', err);
}
return {
timingsMs,
maintenance,
configuredChannels,
};
}
async function loadProviderEnv(): Promise<{ providerEnv: Record<string, string>; loadedProviderKeyCount: number }> {
@@ -96,7 +593,8 @@ async function resolveChannelStartupPolicy(): Promise<{
channelStartupSummary: string;
}> {
try {
const configuredChannels = await listConfiguredChannels();
const rawCfg = await readOpenClawConfig();
const configuredChannels = await listConfiguredChannelsFromConfig(rawCfg);
if (configuredChannels.length === 0) {
return {
skipChannels: true,
@@ -118,6 +616,8 @@ async function resolveChannelStartupPolicy(): Promise<{
}
export async function prepareGatewayLaunchContext(port: number): Promise<GatewayLaunchContext> {
const timingsMs: Record<string, number> = {};
const totalStartedAt = Date.now();
const openclawDir = getOpenClawDir();
const entryScript = getOpenClawEntryPath();
@@ -125,8 +625,10 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
throw new Error(`OpenClaw package not found at: ${openclawDir}`);
}
const appSettings = await getAllSettings();
await syncGatewayConfigBeforeLaunch(appSettings);
const appSettings = await measureAsync(timingsMs, 'settingsMs', getAllSettings);
const prelaunchSummary = await measureAsync(timingsMs, 'prelaunchSyncMs', async () => (
await syncGatewayConfigBeforeLaunch(appSettings, openclawDir)
));
if (!existsSync(entryScript)) {
throw new Error(`OpenClaw entry script not found at: ${entryScript}`);
@@ -143,9 +645,13 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
: path.join(process.cwd(), 'resources', 'bin', target);
const binPathExists = existsSync(binPath);
const { providerEnv, loadedProviderKeyCount } = await loadProviderEnv();
const { skipChannels, channelStartupSummary } = await resolveChannelStartupPolicy();
const uvEnv = await getUvMirrorEnv();
const { providerEnv, loadedProviderKeyCount } = await measureAsync(timingsMs, 'providerEnvMs', loadProviderEnv);
const { skipChannels, channelStartupSummary } = await measureAsync(
timingsMs,
'channelStartupPolicyMs',
resolveChannelStartupPolicy,
);
const uvEnv = await measureAsync(timingsMs, 'uvEnvMs', getUvMirrorEnv);
const proxyEnv = buildProxyEnv(appSettings);
const resolvedProxy = resolveProxySettings(appSettings);
const proxySummary = appSettings.proxyEnabled
@@ -158,7 +664,7 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
? prependPathEntry(baseEnvRecord, binPath).env
: baseEnvRecord;
const forkEnv: Record<string, string | undefined> = {
...baseEnvPatched,
...stripSystemdSupervisorEnv(baseEnvPatched),
...providerEnv,
...uvEnv,
...proxyEnv,
@@ -166,8 +672,29 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
OPENCLAW_SKIP_CHANNELS: skipChannels ? '1' : '',
CLAWDBOT_SKIP_CHANNELS: skipChannels ? '1' : '',
OPENCLAW_NO_RESPAWN: '1',
// Disable OpenClaw's interactive-shell env snapshot. When the Gateway runs
// as an Electron utilityProcess, `process.execPath` is the Electron binary,
// and OpenClaw captures the shell env by spawning `process.execPath -e
// <script>` inside a sanitized login shell that strips ELECTRON_RUN_AS_NODE.
// Electron then treats the script as an app path and pops up "Unable to find
// Electron app at <cwd>/const safe = new Set(...)". Turning the snapshot off
// avoids that broken spawn; exec tools fall back to the Gateway launch env.
OPENCLAW_EXEC_SHELL_SNAPSHOT: '0',
};
// Ensure extension-specific packages (e.g. grammy from the telegram
// extension) are resolvable by shared dist/ chunks via symlinks in
// openclaw/node_modules/. NODE_PATH does NOT work for ESM imports.
measureSync(timingsMs, 'extensionDepsMs', () => ensureExtensionDepsResolvable(openclawDir));
timingsMs.totalMs = Date.now() - totalStartedAt;
logger.info('[metric] gateway.prelaunch', {
...prelaunchSummary.timingsMs,
...timingsMs,
maintenance: prelaunchSummary.maintenance,
configuredChannelCount: prelaunchSummary.configuredChannels.length,
});
return {
appSettings,
openclawDir,
+67 -2
View File
@@ -1,21 +1,78 @@
import { logger } from '../utils/logger';
type HealthResult = { ok: boolean; error?: string };
type HeartbeatAliveReason = 'pong' | 'message';
type PingOptions = {
sendPing: () => void;
onHeartbeatTimeout: (context: { consecutiveMisses: number; timeoutMs: number }) => void;
intervalMs?: number;
timeoutMs?: number;
maxConsecutiveMisses?: number;
};
export class GatewayConnectionMonitor {
private pingInterval: NodeJS.Timeout | null = null;
private healthCheckInterval: NodeJS.Timeout | null = null;
private lastPingAt = 0;
private waitingForAlive = false;
private consecutiveMisses = 0;
private timeoutTriggered = false;
startPing(options: PingOptions): void {
const intervalMs = options.intervalMs ?? 30000;
const timeoutMs = options.timeoutMs ?? 10000;
const maxConsecutiveMisses = Math.max(1, options.maxConsecutiveMisses ?? 3);
this.resetHeartbeatState();
startPing(sendPing: () => void, intervalMs = 30000): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
}
this.pingInterval = setInterval(() => {
sendPing();
const now = Date.now();
if (this.waitingForAlive && now - this.lastPingAt >= timeoutMs) {
this.waitingForAlive = false;
this.consecutiveMisses += 1;
logger.warn(
`Gateway heartbeat missed (${this.consecutiveMisses}/${maxConsecutiveMisses}, timeout=${timeoutMs}ms)`,
);
if (this.consecutiveMisses >= maxConsecutiveMisses && !this.timeoutTriggered) {
this.timeoutTriggered = true;
options.onHeartbeatTimeout({
consecutiveMisses: this.consecutiveMisses,
timeoutMs,
});
return;
}
}
options.sendPing();
this.waitingForAlive = true;
this.lastPingAt = now;
}, intervalMs);
}
markAlive(reason: HeartbeatAliveReason): void {
// Only log true recovery cases to avoid steady-state heartbeat log spam.
if (this.consecutiveMisses > 0) {
logger.debug(`Gateway heartbeat recovered via ${reason} (misses=${this.consecutiveMisses})`);
}
this.waitingForAlive = false;
this.consecutiveMisses = 0;
this.timeoutTriggered = false;
}
// Backward-compatible alias for old callers.
handlePong(): void {
this.markAlive('pong');
}
getConsecutiveMisses(): number {
return this.consecutiveMisses;
}
startHealthCheck(options: {
shouldCheck: () => boolean;
checkHealth: () => Promise<HealthResult>;
@@ -55,5 +112,13 @@ export class GatewayConnectionMonitor {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
this.resetHeartbeatState();
}
private resetHeartbeatState(): void {
this.lastPingAt = 0;
this.waitingForAlive = false;
this.consecutiveMisses = 0;
this.timeoutTriggered = false;
}
}
+29 -14
View File
@@ -1,5 +1,11 @@
import { GatewayEventType, type JsonRpcNotification } from './protocol';
import { logger } from '../utils/logger';
import { normalizeGatewayChatRuntimeEvent } from './chat-runtime-events';
import type {
GatewayChannelStatusEvent,
GatewayChatMessageEvent,
GatewayRuntimePayload,
} from '@shared/host-events/contract';
type GatewayEventEmitter = {
emit: (event: string, payload: unknown) => boolean;
@@ -17,23 +23,26 @@ export function dispatchProtocolEvent(
emitter.emit('chat:message', { message: payload });
break;
case 'agent': {
const p = payload as Record<string, unknown>;
const data = (p.data && typeof p.data === 'object') ? p.data as Record<string, unknown> : {};
const chatEvent: Record<string, unknown> = {
...data,
runId: p.runId ?? data.runId,
sessionKey: p.sessionKey ?? data.sessionKey,
state: p.state ?? data.state,
message: p.message ?? data.message,
};
if (chatEvent.state || chatEvent.message) {
emitter.emit('chat:message', { message: chatEvent });
const normalized = normalizeGatewayChatRuntimeEvent(payload);
if (normalized) {
emitter.emit('chat:runtime-event', normalized);
}
emitter.emit('notification', { method: event, params: payload });
break;
}
case 'channel.status':
emitter.emit('channel:status', payload as { channelId: string; status: string });
case 'channel.status_changed':
emitter.emit('channel:status', payload as GatewayChannelStatusEvent);
break;
case 'gateway.ready':
case 'ready':
emitter.emit('gateway:ready', payload);
break;
case 'health':
emitter.emit('gateway:health', payload as GatewayRuntimePayload);
break;
case 'presence':
emitter.emit('gateway:presence', payload as GatewayRuntimePayload);
break;
default:
emitter.emit('notification', { method: event, params: payload });
@@ -45,12 +54,18 @@ export function dispatchJsonRpcNotification(
notification: JsonRpcNotification,
): void {
emitter.emit('notification', notification);
if (notification.method === 'agent') {
const normalized = normalizeGatewayChatRuntimeEvent(notification.params);
if (normalized) {
emitter.emit('chat:runtime-event', normalized);
}
}
switch (notification.method) {
case GatewayEventType.CHANNEL_STATUS_CHANGED:
emitter.emit('channel:status', notification.params as { channelId: string; status: string });
emitter.emit('channel:status', notification.params as GatewayChannelStatusEvent);
break;
case GatewayEventType.MESSAGE_RECEIVED:
emitter.emit('chat:message', notification.params as { message: unknown });
emitter.emit('chat:message', notification.params as GatewayChatMessageEvent);
break;
case GatewayEventType.ERROR: {
const errorData = notification.params as { message?: string };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,160 @@
import { app } from 'electron';
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, join } from 'node:path';
const CACHE_SCHEMA_VERSION = 1;
const CACHE_FILE_NAME = 'gateway-prelaunch-maintenance-cache.json';
export type PrelaunchMaintenanceTaskName =
| 'plugin-maintenance'
| 'runtime-deps-cleanup'
| 'skills-symlink-cleanup';
export interface PrelaunchMaintenanceRunResult {
executed: boolean;
reason: 'cache-hit' | 'cache-miss' | 'cache-unavailable' | 'task-failed';
}
type CacheKeyInput = string | (() => string);
type MaintenanceTask = () => void | boolean;
interface CacheEntry {
key: string;
updatedAt: string;
}
interface CacheFile {
schemaVersion: number;
tasks: Partial<Record<PrelaunchMaintenanceTaskName, CacheEntry>>;
}
function getDefaultCachePath(): string {
return join(app.getPath('userData'), CACHE_FILE_NAME);
}
function emptyCache(): CacheFile {
return {
schemaVersion: CACHE_SCHEMA_VERSION,
tasks: {},
};
}
function readCache(cachePath: string): CacheFile | null {
try {
if (!existsSync(cachePath)) return emptyCache();
const parsed = JSON.parse(readFileSync(cachePath, 'utf-8')) as CacheFile;
if (parsed.schemaVersion !== CACHE_SCHEMA_VERSION || !parsed.tasks) {
return emptyCache();
}
return parsed;
} catch {
return null;
}
}
function writeCache(cachePath: string, cache: CacheFile): boolean {
try {
mkdirSync(dirname(cachePath), { recursive: true });
writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`, 'utf-8');
return true;
} catch {
return false;
}
}
export function stableJson(value: unknown): string {
if (value == null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) {
return `[${value.map((item) => stableJson(item)).join(',')}]`;
}
const entries = Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableJson(entryValue)}`);
return `{${entries.join(',')}}`;
}
export function pathSignature(path: string): string {
try {
const stat = statSync(path);
return `${stat.isDirectory() ? 'dir' : 'file'}:${Math.round(stat.mtimeMs)}:${stat.size}`;
} catch {
return 'missing';
}
}
export function directoryChildrenSignature(path: string, maxEntries = 200): string {
try {
const entries = readdirSync(path, { withFileTypes: true, encoding: 'utf8' })
.sort((left, right) => left.name.localeCompare(right.name))
.slice(0, maxEntries)
.map((entry) => {
const childPath = join(path, entry.name);
return [
entry.name,
entry.isDirectory() ? 'dir' : entry.isSymbolicLink() ? 'symlink' : 'file',
pathSignature(childPath),
].join(':');
});
return stableJson(entries);
} catch {
return 'missing';
}
}
export function buildPrelaunchMaintenanceCacheKey(parts: Record<string, unknown>): string {
return stableJson({
schemaVersion: CACHE_SCHEMA_VERSION,
...parts,
});
}
export function runCachedPrelaunchMaintenanceTask(
taskName: PrelaunchMaintenanceTaskName,
cacheKey: CacheKeyInput,
task: MaintenanceTask,
options: { cachePath?: string } = {},
): PrelaunchMaintenanceRunResult {
const readCacheKey = (): string => (typeof cacheKey === 'function' ? cacheKey() : cacheKey);
const cachePath = options.cachePath ?? getDefaultCachePath();
const cache = readCache(cachePath);
if (!cache) {
task();
return { executed: true, reason: 'cache-unavailable' };
}
let initialCacheKey: string;
try {
initialCacheKey = readCacheKey();
} catch {
task();
return { executed: true, reason: 'cache-unavailable' };
}
if (cache.tasks[taskName]?.key === initialCacheKey) {
return { executed: false, reason: 'cache-hit' };
}
const taskResult = task();
if (taskResult === false) {
return { executed: true, reason: 'task-failed' };
}
let finalCacheKey: string;
try {
finalCacheKey = readCacheKey();
} catch {
return { executed: true, reason: 'cache-unavailable' };
}
cache.tasks[taskName] = {
key: finalCacheKey,
updatedAt: new Date().toISOString(),
};
writeCache(cachePath, cache);
return { executed: true, reason: 'cache-miss' };
}
+36 -5
View File
@@ -32,8 +32,10 @@ const GATEWAY_FETCH_PRELOAD_SOURCE = `'use strict';
delete flat['HTTP-Referer'];
delete flat['x-title'];
delete flat['X-Title'];
delete flat['x-openrouter-title'];
delete flat['X-OpenRouter-Title'];
flat['HTTP-Referer'] = 'https://claw-x.com';
flat['X-Title'] = 'ClawX';
flat['X-OpenRouter-Title'] = 'ClawX';
init.headers = flat;
}
return _f.call(globalThis, input, init);
@@ -117,6 +119,29 @@ export async function launchGatewayProcess(options: {
const lastSpawnSummary = `mode=${mode}, entry="${entryScript}", args="${options.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}"`;
const runtimeEnv = { ...forkEnv };
// Disable OpenClaw's mDNS/Bonjour gateway advertiser unconditionally.
//
// The OpenClaw gateway advertises `_openclaw-gw._tcp.local` on every
// active network interface using a hardcoded `openclaw.local` hostname,
// which causes:
// - cross-machine name collisions when multiple OpenClaw/ClawX peers
// share a LAN (each falls back to "<name> (OpenClaw) (2)")
// - self-collisions on multi-homed hosts (Wi-Fi + Tailscale + utun ...)
// - "ghost" record collisions after an unclean ClawX exit, because
// SIGKILL prevents ciao from emitting the mDNS goodbye record.
//
// ClawX has no UI for LAN gateway discovery today, so the advertiser is
// pure log noise. `OPENCLAW_DISABLE_BONJOUR=1` short-circuits
// `startGatewayBonjourAdvertiser()` (openclaw `src/infra/bonjour.ts`,
// `isDisabledByEnv()`). Set after the `forkEnv` spread so any
// pre-existing value inherited from the user shell cannot re-enable it.
runtimeEnv.OPENCLAW_DISABLE_BONJOUR = '1';
// Only apply the fetch/child_process preload in dev mode.
// In packaged builds Electron's UtilityProcess rejects NODE_OPTIONS
// with --require, logging "Most NODE_OPTIONs are not supported in
// packaged apps" and the preload never loads.
if (!app.isPackaged) {
try {
const preloadPath = ensureGatewayFetchPreload();
@@ -151,14 +176,20 @@ export async function launchGatewayProcess(options: {
reject(error);
};
child.on('error', (error) => {
child.on('error', (error: unknown) => {
const normalizedError = error instanceof Error ? error : new Error(String(error));
logger.error('Gateway process spawn error:', error);
options.onError(error);
rejectOnce(error);
options.onError(normalizedError);
rejectOnce(normalizedError);
});
child.on('exit', (code: number) => {
const expectedExit = !options.getShouldReconnect() || options.getCurrentState() === 'stopped';
// Only check shouldReconnect — not current state. On Windows the WS
// close handler fires before the process exit handler and sets state to
// 'stopped', which would make an unexpected crash look like a planned
// shutdown in logs. shouldReconnect is the reliable indicator: stop()
// sets it to false (expected), crashes leave it true (unexpected).
const expectedExit = !options.getShouldReconnect();
const level = expectedExit ? logger.info : logger.warn;
level(`Gateway process exited (code=${code}, expected=${expectedExit ? 'yes' : 'no'})`);
options.onExit(child, code);
+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 };
}
}
+22
View File
@@ -16,6 +16,8 @@ type DeferredRestartContext = RestartDeferralState & {
export class GatewayRestartController {
private deferredRestartPending = false;
private deferredRestartRequestedAt = 0;
private lastRestartCompletedAt = 0;
private restartDebounceTimer: NodeJS.Timeout | null = null;
isRestartDeferred(context: RestartDeferralState): boolean {
@@ -33,6 +35,13 @@ export class GatewayRestartController {
);
}
this.deferredRestartPending = true;
if (this.deferredRestartRequestedAt === 0) {
this.deferredRestartRequestedAt = Date.now();
}
}
recordRestartCompleted(): void {
this.lastRestartCompletedAt = Date.now();
}
flushDeferredRestart(
@@ -55,7 +64,9 @@ export class GatewayRestartController {
return;
}
const requestedAt = this.deferredRestartRequestedAt;
this.deferredRestartPending = false;
this.deferredRestartRequestedAt = 0;
if (action === 'drop') {
logger.info(
`Dropping deferred Gateway restart (${trigger}) because lifecycle already recovered (state=${context.state}, shouldReconnect=${context.shouldReconnect})`,
@@ -63,6 +74,16 @@ export class GatewayRestartController {
return;
}
// If a restart already completed after this deferred request was made,
// the current process is already running with the latest config —
// skip the redundant restart to avoid "just started then restart" loops.
if (requestedAt > 0 && this.lastRestartCompletedAt >= requestedAt) {
logger.info(
`Dropping deferred Gateway restart (${trigger}): a restart already completed after the request (requested=${requestedAt}, completed=${this.lastRestartCompletedAt})`,
);
return;
}
logger.info(`Executing deferred Gateway restart now (${trigger})`);
executeRestart();
}
@@ -87,5 +108,6 @@ export class GatewayRestartController {
resetDeferredRestart(): void {
this.deferredRestartPending = false;
this.deferredRestartRequestedAt = 0;
}
}
+94
View File
@@ -0,0 +1,94 @@
export type RestartDecision =
| { allow: true }
| {
allow: false;
reason: 'cooldown_active';
retryAfterMs: number;
};
type RestartGovernorOptions = {
/** Minimum interval between consecutive restarts (ms). */
cooldownMs: number;
};
const DEFAULT_OPTIONS: RestartGovernorOptions = {
cooldownMs: 2500,
};
/**
* Lightweight restart rate-limiter.
*
* Prevents rapid-fire restarts by enforcing a simple cooldown between
* consecutive restart executions. Nothing more no circuit breakers,
* no sliding-window budgets, no exponential back-off. Those mechanisms
* were previously present but removed because:
*
* 1. The root causes of infinite restart loops (stale ownedPid, port
* contention, leaked WebSocket connections) have been fixed at their
* source.
* 2. A 10-minute circuit-breaker lockout actively hurt the user
* experience: legitimate config changes were silently dropped.
* 3. The complexity made the restart path harder to reason about during
* debugging.
*/
export class GatewayRestartGovernor {
private readonly options: RestartGovernorOptions;
private lastRestartAt = 0;
private suppressedTotal = 0;
private executedTotal = 0;
constructor(options?: Partial<RestartGovernorOptions>) {
this.options = { ...DEFAULT_OPTIONS, ...options };
}
/** No-op kept for interface compatibility with callers. */
onRunning(_now = Date.now()): void {
// Previously used to track "stable running" for exponential back-off
// reset. No longer needed with the simplified cooldown model.
}
decide(now = Date.now()): RestartDecision {
if (this.lastRestartAt > 0) {
const sinceLast = now - this.lastRestartAt;
if (sinceLast < this.options.cooldownMs) {
this.suppressedTotal = this.safeIncrement(this.suppressedTotal);
return {
allow: false,
reason: 'cooldown_active',
retryAfterMs: this.options.cooldownMs - sinceLast,
};
}
}
return { allow: true };
}
recordExecuted(now = Date.now()): void {
this.executedTotal = this.safeIncrement(this.executedTotal);
this.lastRestartAt = 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: 0, // Always 0 — no circuit breaker
};
}
private safeIncrement(current: number): number {
if (current >= Number.MAX_SAFE_INTEGER) return 0;
return current + 1;
}
}
+101
View File
@@ -0,0 +1,101 @@
type GatewayRpcRunner = (method: string, params?: unknown, timeoutMs?: number) => Promise<unknown>;
type QueuedRpc = {
run: () => Promise<void>;
};
function stableStringify(value: unknown): string {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(',')}]`;
}
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map((key) => (
`${JSON.stringify(key)}:${stableStringify(record[key])}`
)).join(',')}}`;
}
export interface GatewayRpcBackpressureOptions {
maxConcurrentHistory?: number;
}
/**
* Prevents renderer fan-out from forwarding an unbounded number of expensive
* chat.history RPCs to OpenClaw. The Gateway still owns the canonical response;
* this class only coalesces duplicate in-flight history calls and runs distinct
* history requests through a small FIFO queue.
*/
export class GatewayRpcBackpressure {
private readonly maxConcurrentHistory: number;
private readonly inFlightHistory = new Map<string, Promise<unknown>>();
private readonly queue: QueuedRpc[] = [];
private activeHistory = 0;
constructor(options: GatewayRpcBackpressureOptions = {}) {
this.maxConcurrentHistory = Math.max(1, options.maxConcurrentHistory ?? 2);
}
run(
method: string,
params: unknown,
timeoutMs: number | undefined,
runner: GatewayRpcRunner,
): Promise<unknown> {
if (method !== 'chat.history') {
return runner(method, params, timeoutMs);
}
const key = `${method}:${stableStringify(params)}:${timeoutMs ?? 'default'}`;
const existing = this.inFlightHistory.get(key);
if (existing) return existing;
const promise = this.enqueueHistory(() => runner(method, params, timeoutMs))
.finally(() => {
if (this.inFlightHistory.get(key) === promise) {
this.inFlightHistory.delete(key);
}
});
this.inFlightHistory.set(key, promise);
return promise;
}
getDiagnostics(): { activeHistory: number; queuedHistory: number; inFlightHistory: number } {
return {
activeHistory: this.activeHistory,
queuedHistory: this.queue.length,
inFlightHistory: this.inFlightHistory.size,
};
}
private enqueueHistory(work: () => Promise<unknown>): Promise<unknown> {
return new Promise((resolve, reject) => {
const queued: QueuedRpc = {
run: async () => {
this.activeHistory += 1;
try {
resolve(await work());
} catch (error) {
reject(error);
} finally {
this.activeHistory -= 1;
this.drain();
}
},
};
this.queue.push(queued);
this.drain();
});
}
private drain(): void {
while (this.activeHistory < this.maxConcurrentHistory) {
const next = this.queue.shift();
if (!next) return;
void next.run();
}
}
}
+388
View File
@@ -0,0 +1,388 @@
/**
* Pre-launch cleanup for stray skill symlinks under OpenClaw skill roots.
*
* Background: since openclaw commit 253e159700 ("fix: harden workspace skill
* path containment"), the Gateway rejects any candidate under a skills root
* whose realpath escapes that root, logging a noisy
* `Skipping escaped skill path outside its configured root.
* reason=symlink-escape source=openclaw-managed ...`
* warning per offending entry on every start.
*
* Common offenders are one-shot install scripts that drop symlinks into:
* - ~/.openclaw/skills/<name> -> ~/.agents/skills/<name>
* - ~/.openclaw/workspace/skills/<name> -> ~/.openclaw/workspace/.agents/skills/<name>
* - ~/.openclaw/skills/<name> -> ~/workspace/<repo>/skills/<name>
* The hardened loader rejects these because their realpath escapes the
* configured managed root, so they are pure log noise entries that the
* loader can never accept from this root.
*
* This helper is invoked before each Gateway launch to remove those
* specific symlinks. Scope is intentionally narrow:
* - source dirs: ~/.openclaw/skills and ~/.openclaw/workspace/skills
* - target dirs: anything outside the matching managed skills root
* Symlinks whose realpath stays inside the same managed skills root are left
* untouched.
*
* Removal uses fs.rmSync({ force: true, recursive: true }) rather than
* fs.unlinkSync so that directory symlinks and Windows junctions (the form
* that non-admin Windows installs end up creating) are deleted correctly.
* unlinkSync raises EPERM on those on Windows, and rmSync without recursive
* can reject directory symlinks on some platforms.
*
* This is a transitional workaround. Once openclaw/openclaw#59219 lands and
* the loader stops rejecting managed-source symlinks whose realpath escapes
* the managed root, this helper can be removed entirely.
*/
import {
existsSync,
lstatSync,
readlinkSync,
readdirSync,
realpathSync,
rmSync,
type Dirent,
} from 'node:fs';
import { homedir } from 'node:os';
import path from 'node:path';
import { getOpenClawConfigDir, getOpenClawResolvedDir, getOpenClawSkillsDir } from '../utils/paths';
import { logger } from '../utils/logger';
export interface CleanupOptions {
/** Override for ~/.openclaw/skills (mainly for tests). */
skillsDir?: string;
/** Override for ~/.agents/skills (mainly for tests/log context). */
agentsDir?: string;
/** Override for ~/.openclaw/workspace/skills (mainly for tests). */
workspaceSkillsDir?: string;
/** Override for ~/.openclaw/workspace/.agents/skills (mainly for tests). */
workspaceAgentsDir?: string;
}
export interface CleanupResult {
/** Symlink names that were unlinked from the skills dir. */
removed: string[];
/** Total number of symlink entries that were inspected. */
examined: number;
/** Cleanup operations that could not be completed and should be retried later. */
failed?: number;
}
export interface PluginRuntimeDepsCleanupOptions {
/** Override for ~/.openclaw/plugin-runtime-deps (mainly for tests). */
runtimeDepsDir?: string;
/** Override for the current bundled OpenClaw package dir (mainly for tests). */
currentOpenClawDir?: string;
}
function defaultSkillsDir(): string {
return getOpenClawSkillsDir();
}
function recordCleanupFailure(result: CleanupResult): void {
result.failed = (result.failed ?? 0) + 1;
}
function defaultAgentsDir(): string {
return path.join(homedir(), '.agents', 'skills');
}
function defaultWorkspaceSkillsDir(): string {
return path.join(getOpenClawConfigDir(), 'workspace', 'skills');
}
function defaultWorkspaceAgentsDir(): string {
return path.join(getOpenClawConfigDir(), 'workspace', '.agents', 'skills');
}
function defaultPluginRuntimeDepsDir(): string {
return path.join(getOpenClawConfigDir(), 'plugin-runtime-deps');
}
/**
* Resolve the agents skills directory to its real path. When the directory
* itself does not exist yet (fresh install), fall back to realpath'ing its
* parent and re-appending the basename so a `~/.agents -> /opt/agents`
* indirection is still honored. As a final fallback returns the lexical
* resolved path.
*/
function resolveAgentsRealRoot(agentsDir: string): string {
if (existsSync(agentsDir)) {
try {
return realpathSync(agentsDir);
} catch {
// fall through
}
}
const parent = path.dirname(agentsDir);
const tail = path.basename(agentsDir);
if (parent && parent !== agentsDir && existsSync(parent)) {
try {
return path.join(realpathSync(parent), tail);
} catch {
// fall through
}
}
return path.resolve(agentsDir);
}
/**
* Lower-case path strings on Win32 only so the `path.relative` byte-wise
* comparison aligns with NTFS case-insensitive semantics. No-op elsewhere.
*/
function normalizeForCompare(p: string): string {
return process.platform === 'win32' ? p.toLowerCase() : p;
}
function isInside(parent: string, child: string): boolean {
const rel = path.relative(normalizeForCompare(parent), normalizeForCompare(child));
if (rel === '') return true;
return !rel.startsWith('..') && !path.isAbsolute(rel);
}
function resolveSymlinkTarget(linkPath: string): string | null {
try {
const target = readlinkSync(linkPath);
return path.resolve(path.dirname(linkPath), target);
} catch {
return null;
}
}
function looksLikeOpenClawPackagePath(candidate: string): boolean {
const normalized = candidate.replace(/\\/g, '/');
return /\/node_modules(?:\/\.pnpm\/[^/]+\/node_modules)?\/openclaw(?:\/|$)/.test(normalized);
}
function resolveCurrentOpenClawRoots(currentOpenClawDir: string): string[] {
const roots = new Set<string>([path.resolve(currentOpenClawDir)]);
try {
roots.add(realpathSync(currentOpenClawDir));
} catch {
// fall through
}
return Array.from(roots);
}
export function cleanupAgentsSymlinkedSkills(opts: CleanupOptions = {}): CleanupResult {
const hasMainOverrides = opts.skillsDir !== undefined || opts.agentsDir !== undefined;
const hasWorkspaceOverrides =
opts.workspaceSkillsDir !== undefined || opts.workspaceAgentsDir !== undefined;
const roots = [
{
skillsDir: opts.skillsDir ?? defaultSkillsDir(),
agentsDir: opts.agentsDir ?? defaultAgentsDir(),
},
];
if (!hasMainOverrides || hasWorkspaceOverrides) {
roots.push({
skillsDir: opts.workspaceSkillsDir ?? defaultWorkspaceSkillsDir(),
agentsDir: opts.workspaceAgentsDir ?? defaultWorkspaceAgentsDir(),
});
}
const result: CleanupResult = { removed: [], examined: 0 };
const seenRoots = new Set<string>();
for (const root of roots) {
const rootKey = `${path.resolve(root.skillsDir)}\0${path.resolve(root.agentsDir)}`;
if (seenRoots.has(rootKey)) continue;
seenRoots.add(rootKey);
const rootResult = cleanupSkillsDir(root.skillsDir, root.agentsDir);
result.removed.push(...rootResult.removed);
result.examined += rootResult.examined;
if (rootResult.failed) {
result.failed = (result.failed ?? 0) + rootResult.failed;
}
}
return result;
}
/**
* Remove stale OpenClaw plugin runtime dependency cache roots.
*
* OpenClaw can materialize `~/.openclaw/plugin-runtime-deps/openclaw-*` as a
* symlink tree back into the package's `dist` files. After app upgrades or
* worktree switches those symlinks can point at an old `node_modules/openclaw`
* path. The Gateway may then spend a long time synchronously opening/copying
* old runtime files during plugin setup, which blocks RPC readiness.
*
* Scope is intentionally narrow: only immediate cache roots named `openclaw-*`
* are removed, and only when a symlink inside points at an OpenClaw package
* path outside the current bundled package. The cache is regenerated by
* OpenClaw on demand.
*/
export function cleanupStalePluginRuntimeDeps(
opts: PluginRuntimeDepsCleanupOptions = {},
): CleanupResult {
const runtimeDepsDir = opts.runtimeDepsDir ?? defaultPluginRuntimeDepsDir();
const currentRoots = resolveCurrentOpenClawRoots(opts.currentOpenClawDir ?? getOpenClawResolvedDir());
const result: CleanupResult = { removed: [], examined: 0 };
if (!existsSync(runtimeDepsDir)) {
return result;
}
let entries: Dirent[];
try {
entries = readdirSync(runtimeDepsDir, { withFileTypes: true, encoding: 'utf8' });
} catch (err) {
logger.warn(`[plugin-runtime-deps-cleanup] Failed to list ${runtimeDepsDir}:`, err);
recordCleanupFailure(result);
return result;
}
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith('openclaw-')) {
continue;
}
const cacheRoot = path.join(runtimeDepsDir, entry.name);
const scan = scanRuntimeDepsRootForStaleOpenClawSymlink(cacheRoot, currentRoots);
result.examined += scan.examined;
if (!scan.stale) {
continue;
}
try {
rmSync(cacheRoot, { force: true, recursive: true });
result.removed.push(entry.name);
} catch (err) {
logger.warn(`[plugin-runtime-deps-cleanup] Failed to remove ${cacheRoot}:`, err);
recordCleanupFailure(result);
}
}
if (result.removed.length > 0) {
logger.info(
`[plugin-runtime-deps-cleanup] Removed ${result.removed.length} stale OpenClaw runtime cache root(s): ` +
result.removed.join(', '),
);
}
return result;
}
function scanRuntimeDepsRootForStaleOpenClawSymlink(
cacheRoot: string,
currentOpenClawRoots: string[],
): { stale: boolean; examined: number } {
const stack = [cacheRoot];
let examined = 0;
const maxEntries = 5000;
while (stack.length > 0 && examined < maxEntries) {
const dir = stack.pop()!;
let entries: Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' });
} catch {
continue;
}
for (const entry of entries) {
if (examined >= maxEntries) break;
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
stack.push(entryPath);
continue;
}
let isSymlink = entry.isSymbolicLink();
if (!isSymlink) {
try {
isSymlink = lstatSync(entryPath).isSymbolicLink();
} catch {
continue;
}
}
if (!isSymlink) continue;
examined++;
const target = resolveSymlinkTarget(entryPath);
if (!target || !looksLikeOpenClawPackagePath(target)) {
continue;
}
const pointsAtCurrentOpenClaw = currentOpenClawRoots.some((root) => isInside(root, target));
if (!pointsAtCurrentOpenClaw) {
return { stale: true, examined };
}
}
}
return { stale: false, examined };
}
function cleanupSkillsDir(skillsDir: string, agentsDir: string): CleanupResult {
const result: CleanupResult = { removed: [], examined: 0 };
if (!existsSync(skillsDir)) {
return result;
}
let entries: Dirent[];
try {
entries = readdirSync(skillsDir, { withFileTypes: true, encoding: 'utf8' });
} catch (err) {
logger.warn(`[skills-cleanup] Failed to list ${skillsDir}:`, err);
recordCleanupFailure(result);
return result;
}
const agentsRealRoot = resolveAgentsRealRoot(agentsDir);
const skillsRealRoot = resolveAgentsRealRoot(skillsDir);
for (const entry of entries) {
const entryPath = path.join(skillsDir, entry.name);
let isSymlink = entry.isSymbolicLink();
if (!isSymlink) {
try {
isSymlink = lstatSync(entryPath).isSymbolicLink();
} catch {
continue;
}
}
if (!isSymlink) continue;
result.examined++;
let realTarget: string;
try {
realTarget = realpathSync(entryPath);
} catch {
continue;
}
if (isInside(skillsRealRoot, realTarget)) continue;
try {
// rmSync handles file symlinks, directory symlinks, and Windows
// junctions uniformly. unlinkSync would raise EPERM on directory
// symlinks/junctions on Windows.
rmSync(entryPath, { force: true, recursive: true });
result.removed.push(entry.name);
} catch (err) {
logger.warn(`[skills-cleanup] Failed to remove ${entryPath}:`, err);
recordCleanupFailure(result);
}
}
if (result.removed.length > 0) {
logger.info(
`[skills-cleanup] Removed ${result.removed.length} stray skill symlink(s) ` +
`under ${skillsDir} that escaped managed root ${skillsRealRoot} ` +
`(workaround for openclaw/openclaw#59219): ` +
result.removed.join(', '),
);
} else if (result.examined > 0) {
logger.debug(
`[skills-cleanup] Examined ${result.examined} symlink(s) under ${skillsDir}; ` +
`none escaped managed root (agents context: ${agentsRealRoot})`,
);
}
return result;
}
+40 -6
View File
@@ -1,6 +1,6 @@
import { logger } from '../utils/logger';
import { LifecycleSupersededError } from './lifecycle-controller';
import { getGatewayStartupRecoveryAction } from './startup-recovery';
import { connectGatewayWithStartupRetry, getGatewayStartupRecoveryAction } from './startup-recovery';
export interface ExistingGatewayInfo {
port: number;
@@ -9,13 +9,15 @@ export interface ExistingGatewayInfo {
type StartupHooks = {
port: number;
ownedPid?: number;
ownedPid?: never; // Removed: pid is now read dynamically in findExistingGateway to avoid stale-snapshot bug
shouldWaitForPortFree: boolean;
maxStartAttempts?: number;
/** Returns true when the manager still owns a living Gateway process (e.g. after a code-1012 in-process restart). */
hasOwnedProcess: () => boolean;
resetStartupStderrLines: () => void;
getStartupStderrLines: () => string[];
assertLifecycle: (phase: string) => void;
findExistingGateway: (port: number, ownedPid?: number) => Promise<ExistingGatewayInfo | null>;
findExistingGateway: (port: number) => Promise<ExistingGatewayInfo | null>;
connect: (port: number, externalToken?: string) => Promise<void>;
onConnectedToExistingGateway: () => void;
waitForPortFree: (port: number) => Promise<void>;
@@ -27,6 +29,22 @@ type StartupHooks = {
delay: (ms: number) => Promise<void>;
};
async function connectWithStartupRetry(
hooks: StartupHooks,
port: number,
externalToken?: string,
): Promise<void> {
await connectGatewayWithStartupRetry({
connect: hooks.connect,
port,
externalToken,
delay: hooks.delay,
beforeAttempt: () => hooks.assertLifecycle('start/connect-retry'),
logWarn: (message) => logger.warn(message),
logInfo: (message) => logger.info(message),
});
}
export async function runGatewayStartupSequence(hooks: StartupHooks): Promise<void> {
let configRepairAttempted = false;
let startAttempts = 0;
@@ -39,16 +57,32 @@ export async function runGatewayStartupSequence(hooks: StartupHooks): Promise<vo
try {
logger.debug('Checking for existing Gateway...');
const existing = await hooks.findExistingGateway(hooks.port, hooks.ownedPid);
const existing = await hooks.findExistingGateway(hooks.port);
hooks.assertLifecycle('start/find-existing');
if (existing) {
logger.debug(`Found existing Gateway on port ${existing.port}`);
await hooks.connect(existing.port, existing.externalToken);
await connectWithStartupRetry(hooks, existing.port, existing.externalToken);
hooks.assertLifecycle('start/connect-existing');
hooks.onConnectedToExistingGateway();
return;
}
// When the Gateway did an in-process restart (WS close 1012), the
// UtilityProcess is still alive but its WS server may be mid-rebuild,
// so findExistingGateway's quick probe returns null. Rather than
// waiting for the port to free (it never will — the process holds it)
// and then spawning a duplicate, wait for the existing process to
// become ready and reconnect to it.
if (hooks.hasOwnedProcess()) {
logger.info('Owned Gateway process still alive (likely in-process restart); waiting for it to become ready');
await hooks.waitForReady(hooks.port);
hooks.assertLifecycle('start/wait-ready-owned');
await connectWithStartupRetry(hooks, hooks.port);
hooks.assertLifecycle('start/connect-owned');
hooks.onConnectedToExistingGateway();
return;
}
logger.debug('No existing Gateway found, starting new process...');
if (hooks.shouldWaitForPortFree) {
@@ -62,7 +96,7 @@ export async function runGatewayStartupSequence(hooks: StartupHooks): Promise<vo
await hooks.waitForReady(hooks.port);
hooks.assertLifecycle('start/wait-ready');
await hooks.connect(hooks.port);
await connectWithStartupRetry(hooks, hooks.port);
hooks.assertLifecycle('start/connect');
hooks.onConnectedToManagedGateway();
+54
View File
@@ -18,8 +18,15 @@ const TRANSIENT_START_ERROR_PATTERNS: RegExp[] = [
/Gateway process exited before becoming ready/i,
/Timed out waiting for connect\.challenge/i,
/Connect handshake timeout/i,
// OpenClaw can emit connect.challenge before the connect RPC is accepted.
/gateway starting/i,
// Port occupied after orphan kill: transient, worth retrying with backoff
/Port \d+ still occupied after \d+ms/i,
];
/** Backoff between connect() attempts when the Gateway rejects with "still starting". */
export const GATEWAY_CONNECT_STARTUP_RETRY_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000, 8_000] as const;
function normalizeLogLine(value: string): string {
return value.trim();
}
@@ -73,6 +80,53 @@ export function isTransientGatewayStartError(error: unknown): boolean {
return TRANSIENT_START_ERROR_PATTERNS.some((pattern) => pattern.test(errorText));
}
export function isGatewayStillStartingError(error: unknown): boolean {
const errorText = error instanceof Error
? error.message
: String(error ?? '');
return /gateway starting/i.test(errorText);
}
export async function connectGatewayWithStartupRetry(options: {
connect: (port: number, externalToken?: string) => Promise<void>;
port: number;
externalToken?: string;
delay: (ms: number) => Promise<void>;
retryDelaysMs?: readonly number[];
beforeAttempt?: () => void;
logWarn?: (message: string) => void;
logInfo?: (message: string) => void;
}): Promise<void> {
const retryDelaysMs = options.retryDelaysMs ?? GATEWAY_CONNECT_STARTUP_RETRY_DELAYS_MS;
const logWarn = options.logWarn ?? (() => {});
const logInfo = options.logInfo ?? (() => {});
let lastError: unknown;
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) {
options.beforeAttempt?.();
try {
await options.connect(options.port, options.externalToken);
if (attempt > 0) {
logInfo(`Gateway connect succeeded after ${attempt + 1} attempt(s)`);
}
return;
} catch (error) {
lastError = error;
if (!isGatewayStillStartingError(error) || attempt >= retryDelaysMs.length) {
throw error;
}
const delayMs = retryDelaysMs[attempt] ?? retryDelaysMs[retryDelaysMs.length - 1]!;
logWarn(
`Gateway connect rejected while still starting (${String(error)}); `
+ `retrying in ${delayMs}ms (${attempt + 1}/${retryDelaysMs.length})`,
);
await options.delay(delayMs);
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError ?? 'Gateway connect failed'));
}
export type GatewayStartupRecoveryAction = 'repair' | 'retry' | 'fail';
export function getGatewayStartupRecoveryAction(options: {
+9
View File
@@ -18,12 +18,21 @@ export function classifyGatewayStderrMessage(message: string): GatewayStderrClas
if (msg.includes('closed before connect') && msg.includes('token mismatch')) {
return { level: 'drop', normalized: msg };
}
if (msg.includes('[ws] closed before connect') && msg.includes('code=1005')) {
return { level: 'debug', normalized: msg };
}
if (msg.includes('security warning: dangerous config flags enabled')) {
return { level: 'debug', normalized: msg };
}
// Downgrade frequent non-fatal noise.
if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg };
if (msg.includes('DeprecationWarning')) return { level: 'debug', normalized: msg };
if (msg.includes('Debugger attached')) return { level: 'debug', normalized: msg };
// Gateway config warnings (e.g. stale plugin entries) are informational, not actionable.
if (msg.includes('Config warnings:')) return { level: 'debug', normalized: msg };
// Electron restricts NODE_OPTIONS in packaged apps; this is expected and harmless.
if (msg.includes('node: --require is not allowed in NODE_OPTIONS')) {
return { level: 'debug', normalized: msg };
+40 -33
View File
@@ -1,12 +1,12 @@
import { app, utilityProcess } from 'electron';
import path from 'path';
import { existsSync } from 'fs';
import WebSocket from 'ws';
import { getOpenClawDir, getOpenClawEntryPath } from '../utils/paths';
import { getUvMirrorEnv } from '../utils/uv-env';
import { isPythonReady, setupManagedPython } from '../utils/uv-setup';
import { logger } from '../utils/logger';
import { prependPathEntry } from '../utils/env-path';
import { probeGatewayReady } from './ws-client';
export function warmupManagedPythonReadiness(): void {
void isPythonReady().then((pythonReady) => {
@@ -22,39 +22,58 @@ export function warmupManagedPythonReadiness(): void {
}
export async function terminateOwnedGatewayProcess(child: Electron.UtilityProcess): Promise<void> {
let exited = false;
const terminateWindowsProcessTree = async (pid: number): Promise<void> => {
const cp = await import('child_process');
await new Promise<void>((resolve) => {
cp.exec(`taskkill /F /PID ${pid} /T`, { timeout: 5000, windowsHide: true }, () => resolve());
});
};
await new Promise<void>((resolve) => {
let exited = false;
// Register a single exit listener before any kill attempt to avoid
// the race where exit fires between two separate `once('exit')` calls.
child.once('exit', () => {
exited = true;
clearTimeout(timeout);
resolve();
});
const pid = child.pid;
logger.info(`Sending kill to Gateway process (pid=${pid ?? 'unknown'})`);
try {
child.kill();
} catch {
// ignore if already exited
if (process.platform === 'win32' && pid) {
void terminateWindowsProcessTree(pid).catch((err) => {
logger.warn(`Windows process-tree kill failed for Gateway pid=${pid}:`, err);
});
} else {
try {
child.kill();
} catch {
// ignore if already exited
}
}
const timeout = setTimeout(() => {
if (!exited) {
logger.warn(`Gateway did not exit in time, force-killing (pid=${pid ?? 'unknown'})`);
if (pid) {
try {
process.kill(pid, 'SIGKILL');
} catch {
// ignore
if (process.platform === 'win32') {
void terminateWindowsProcessTree(pid).catch((err) => {
logger.warn(`Forced Windows process-tree kill failed for Gateway pid=${pid}:`, err);
});
} else {
try {
process.kill(pid, 'SIGKILL');
} catch {
// ignore
}
}
}
}
resolve();
}, 5000);
child.once('exit', () => {
clearTimeout(timeout);
});
});
}
@@ -137,7 +156,8 @@ export async function waitForPortFree(port: number, timeoutMs = 30000): Promise<
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
logger.warn(`Port ${port} still occupied after ${timeoutMs}ms, proceeding anyway`);
logger.error(`Port ${port} still occupied after ${timeoutMs}ms; aborting startup to avoid port conflict`);
throw new Error(`Port ${port} still occupied after ${timeoutMs}ms`);
}
async function getListeningProcessIds(port: number): Promise<string[]> {
@@ -226,30 +246,17 @@ export async function findExistingGatewayProcess(options: {
const pids = await getListeningProcessIds(port);
if (pids.length > 0 && (!ownedPid || !pids.includes(String(ownedPid)))) {
await terminateOrphanedProcessIds(port, pids);
if (process.platform === 'win32') {
await waitForPortFree(port, 10000);
}
return null;
}
} catch (err) {
logger.warn('Error checking for existing process on port:', err);
}
return await new Promise<{ port: number; externalToken?: string } | null>((resolve) => {
const testWs = new WebSocket(`ws://localhost:${port}/ws`);
const timeout = setTimeout(() => {
testWs.close();
resolve(null);
}, 2000);
testWs.on('open', () => {
clearTimeout(timeout);
testWs.close();
resolve({ port });
});
testWs.on('error', () => {
clearTimeout(timeout);
resolve(null);
});
});
const ready = await probeGatewayReady(port, 5000);
return ready ? { port } : null;
} catch {
return null;
}
+44 -8
View File
@@ -7,6 +7,14 @@ import {
signDevicePayload,
} from '../utils/device-identity';
import { logger } from '../utils/logger';
import {
isGatewayWsTraceEnabled,
redactGatewayFrameForTrace,
summarizeGatewayFrameForTrace,
} from './ws-trace';
export const GATEWAY_CHALLENGE_TIMEOUT_MS = 10_000;
export const GATEWAY_CONNECT_HANDSHAKE_TIMEOUT_MS = 20_000;
export async function probeGatewayReady(
port: number,
@@ -21,7 +29,10 @@ export async function probeGatewayReady(
settled = true;
clearTimeout(timeout);
try {
testWs.close();
// Use terminate() (TCP RST) instead of close() (WS close handshake)
// to avoid leaving TIME_WAIT connections on Windows. These probe
// WebSockets are short-lived and don't need a graceful close.
testWs.terminate();
} catch {
// ignore
}
@@ -96,6 +107,8 @@ export async function waitForGatewayReady(options: {
throw new Error(`Gateway failed to start after ${retries} retries (port ${options.port})`);
}
const GATEWAY_PROTOCOL_VERSION = 4;
export function buildGatewayConnectFrame(options: {
challengeNonce: string;
token: string;
@@ -139,8 +152,8 @@ export function buildGatewayConnectFrame(options: {
id: connectId,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
minProtocol: GATEWAY_PROTOCOL_VERSION,
maxProtocol: GATEWAY_PROTOCOL_VERSION,
client: {
id: clientId,
displayName: 'ClawX',
@@ -151,7 +164,7 @@ export function buildGatewayConnectFrame(options: {
auth: {
token: options.token,
},
caps: [],
caps: ['tool-events'],
role,
scopes,
device,
@@ -168,9 +181,13 @@ export async function connectGatewaySocket(options: {
getToken: () => Promise<string>;
onHandshakeComplete: (ws: WebSocket) => void;
onMessage: (message: unknown) => void;
onCloseAfterHandshake: () => void;
onCloseAfterHandshake: (code: number) => void;
challengeTimeoutMs?: number;
connectTimeoutMs?: number;
}): Promise<WebSocket> {
logger.debug(`Connecting Gateway WebSocket (ws://localhost:${options.port}/ws)`);
const challengeTimeoutMs = options.challengeTimeoutMs ?? GATEWAY_CHALLENGE_TIMEOUT_MS;
const connectTimeoutMs = options.connectTimeoutMs ?? GATEWAY_CONNECT_HANDSHAKE_TIMEOUT_MS;
return await new Promise<WebSocket>((resolve, reject) => {
const wsUrl = `ws://localhost:${options.port}/ws`;
@@ -211,6 +228,13 @@ export async function connectGatewaySocket(options: {
if (settled) return;
settled = true;
cleanupHandshakeRequest();
if (!handshakeComplete) {
try {
ws.terminate();
} catch {
// ignore cleanup errors during failed startup handshakes
}
}
reject(error instanceof Error ? error : new Error(String(error)));
};
@@ -226,6 +250,12 @@ export async function connectGatewaySocket(options: {
});
connectId = connectPayload.connectId;
if (isGatewayWsTraceEnabled()) {
logger.debug('[gateway-ws-trace] send', {
summary: summarizeGatewayFrameForTrace(connectPayload.frame),
frame: redactGatewayFrameForTrace(connectPayload.frame),
});
}
ws.send(JSON.stringify(connectPayload.frame));
const requestTimeout = setTimeout(() => {
@@ -234,7 +264,7 @@ export async function connectGatewaySocket(options: {
ws.close();
rejectOnce(new Error('Connect handshake timeout'));
}
}, 10000);
}, connectTimeoutMs);
handshakeTimeout = requestTimeout;
options.pendingRequests.set(connectId, {
@@ -258,7 +288,7 @@ export async function connectGatewaySocket(options: {
ws.close();
rejectOnce(new Error('Timed out waiting for connect.challenge from Gateway'));
}
}, 10000);
}, challengeTimeoutMs);
ws.on('open', () => {
logger.debug('Gateway WebSocket opened, waiting for connect.challenge...');
@@ -267,6 +297,12 @@ export async function connectGatewaySocket(options: {
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
if (isGatewayWsTraceEnabled()) {
logger.debug('[gateway-ws-trace] recv', {
summary: summarizeGatewayFrameForTrace(message),
frame: redactGatewayFrameForTrace(message),
});
}
if (
!challengeReceived &&
typeof message === 'object' && message !== null &&
@@ -301,7 +337,7 @@ export async function connectGatewaySocket(options: {
return;
}
cleanupHandshakeRequest();
options.onCloseAfterHandshake();
options.onCloseAfterHandshake(code);
});
ws.on('error', (error) => {
+51
View File
@@ -0,0 +1,51 @@
const SECRET_KEYS = new Set([
'token',
'authorization',
'apikey',
'api_key',
'signature',
'cookie',
'set-cookie',
'accesstoken',
'refreshtoken',
]);
export function isGatewayWsTraceEnabled(): boolean {
return process.env.CLAWX_GATEWAY_WS_TRACE === '1';
}
export function redactGatewayFrameForTrace(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => redactGatewayFrameForTrace(item));
}
if (!value || typeof value !== 'object') {
return value;
}
const result: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
const normalizedKey = key.toLowerCase();
result[key] = SECRET_KEYS.has(normalizedKey)
? '[redacted]'
: redactGatewayFrameForTrace(item);
}
return result;
}
export function summarizeGatewayFrameForTrace(value: unknown): string {
if (!value || typeof value !== 'object') return typeof value;
const frame = value as Record<string, unknown>;
if (frame.type === 'req') {
return `req id=${String(frame.id ?? '-')} method=${String(frame.method ?? '-')}`;
}
if (frame.type === 'res') {
return `res id=${String(frame.id ?? '-')} ok=${String(frame.ok ?? !frame.error)}`;
}
if (frame.type === 'event') {
return `event ${String(frame.event ?? '-')}`;
}
if (typeof frame.method === 'string') {
return `jsonrpc method=${frame.method}`;
}
return 'unknown gateway frame';
}
+438 -143
View File
@@ -3,12 +3,13 @@
* Manages window creation, system tray, and IPC handlers
*/
import { app, BrowserWindow, nativeImage, session, shell } from 'electron';
import type { Server } from 'node:http';
import { join } from 'path';
import { GatewayManager } from '../gateway/manager';
import { registerIpcHandlers } from './ipc-handlers';
import { HostApiRegistry } from './ipc/host-invoke';
import { createTray } from './tray';
import { createMenu } from './menu';
import { registerZoomShortcuts } from './zoom-shortcuts';
import { appUpdater, registerUpdateHandlers } from './updater';
import { logger } from '../utils/logger';
@@ -16,20 +17,54 @@ import { warmupNetworkOptimization } from '../utils/uv-env';
import { initTelemetry } from '../utils/telemetry';
import { ClawHubService } from '../gateway/clawhub';
import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace';
import { extensionRegistry } from '../extensions/registry';
import { loadExtensionsFromManifest } from '../extensions/loader';
import { registerAllBuiltinExtensions } from '../extensions/builtin';
import { loadExternalMainExtensions } from '../extensions/_ext-bridge.generated';
import {
ensureClawXContext,
ensureClawXDefaultIdentity,
repairClawXOnlyBootstrapFiles,
} from '../utils/openclaw-workspace';
import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToProfile } from '../utils/openclaw-cli';
import { isQuitting, setQuitting } from './app-state';
import { getMacTrafficLightPosition, syncMacTrafficLightPosition } from './traffic-light-layout';
import { getSetting } from '../utils/store';
import { applyProxySettings } from './proxy';
import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup';
import { getSetting } from '../utils/store';
import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '../utils/skill-config';
import { startHostApiServer } from '../api/server';
import { HostEventBus } from '../api/event-bus';
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 { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled, trimBundledOpenClawSkillsAndConfigs } from '../utils/skill-config';
import { deviceOAuthManager } from '../utils/device-oauth';
import { browserOAuthManager } from '../utils/browser-oauth';
import { whatsAppLoginManager } from '../utils/whatsapp-login';
import { syncAllProviderAuthToRuntime } from '../services/providers/provider-runtime-sync';
const WINDOWS_APP_USER_MODEL_ID = 'app.clawx.desktop';
const isE2EMode = process.env.CLAWX_E2E === '1';
const requestedUserDataDir = process.env.CLAWX_USER_DATA_DIR?.trim();
const requestedRemoteDebuggingPort = process.env.CLAWX_REMOTE_DEBUGGING_PORT?.trim();
if (requestedRemoteDebuggingPort) {
app.commandLine.appendSwitch('remote-debugging-port', requestedRemoteDebuggingPort);
}
if (isE2EMode && requestedUserDataDir) {
app.setPath('userData', requestedUserDataDir);
}
// Disable GPU hardware acceleration globally for maximum stability across
// all GPU configurations (no GPU, integrated, discrete).
//
@@ -51,24 +86,61 @@ app.disableHardwareAcceleration();
// on X11 it supplements the StartupWMClass matching.
// Must be called before app.whenReady() / before any window is created.
if (process.platform === 'linux') {
app.setDesktopName('clawx.desktop');
const linuxApp = app as typeof app & { setDesktopName?: (desktopName: string) => void };
linuxApp.setDesktopName?.('clawx.desktop');
}
// Prevent multiple instances of the app from running simultaneously.
// Without this, two instances each spawn their own gateway process on the
// same port, then each treats the other's gateway as "orphaned" and kills
// it — creating an infinite kill/restart loop on Windows.
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
// The losing process must exit immediately so it never reaches Gateway startup.
const gotElectronLock = isE2EMode ? true : app.requestSingleInstanceLock();
if (!gotElectronLock) {
console.info('[ClawX] Another instance already holds the single-instance lock; exiting duplicate process');
app.exit(0);
}
let releaseProcessInstanceFileLock: () => void = () => {};
let gotFileLock = true;
if (gotElectronLock && !isE2EMode) {
try {
const fileLock = acquireProcessInstanceFileLock({
userDataDir: app.getPath('userData'),
lockName: 'clawx',
force: true, // Electron lock already guarantees exclusivity; force-clean orphan/recycled-PID locks
});
gotFileLock = fileLock.acquired;
releaseProcessInstanceFileLock = fileLock.release;
if (!fileLock.acquired) {
const ownerDescriptor = fileLock.ownerPid
? `${fileLock.ownerFormat ?? 'legacy'} pid=${fileLock.ownerPid}`
: fileLock.ownerFormat === 'unknown'
? 'unknown lock format/content'
: 'unknown owner';
console.info(
`[ClawX] Another instance already holds process lock (${fileLock.lockPath}, ${ownerDescriptor}); exiting duplicate process`,
);
app.exit(0);
}
} catch (error) {
console.warn('[ClawX] Failed to acquire process instance file lock; continuing with Electron single-instance lock only', error);
}
}
const gotTheLock = gotElectronLock && gotFileLock;
// Global references
let mainWindow: BrowserWindow | null = null;
const gatewayManager = new GatewayManager();
const clawHubService = new ClawHubService();
const hostEventBus = new HostEventBus();
let hostApiServer: Server | null = null;
let gatewayManager!: GatewayManager;
let clawHubService!: ClawHubService;
const hostApiRegistry = new HostApiRegistry();
const mainWindowFocusState = createMainWindowFocusState();
const quitLifecycleState = createQuitLifecycleState();
function sendMainWindowEvent(channel: string, payload: unknown): void {
const win = mainWindow;
if (!win || win.isDestroyed()) return;
win.webContents.send(channel, payload);
}
/**
* Resolve the icons directory path (works in both dev and packaged mode)
@@ -102,6 +174,9 @@ function getAppIcon(): Electron.NativeImage | undefined {
*/
function createWindow(): BrowserWindow {
const isMac = process.platform === 'darwin';
const isWindows = process.platform === 'win32';
const useCustomTitleBar = isWindows;
const shouldSkipSetupForE2E = process.env.CLAWX_E2E_SKIP_SETUP === '1';
const win = new BrowserWindow({
width: 1280,
@@ -116,34 +191,115 @@ function createWindow(): BrowserWindow {
sandbox: false,
webviewTag: true, // Enable <webview> for embedding OpenClaw Control UI
},
titleBarStyle: isMac ? 'hiddenInset' : 'hidden',
trafficLightPosition: isMac ? { x: 16, y: 16 } : undefined,
frame: isMac,
titleBarStyle: isMac ? 'hiddenInset' : useCustomTitleBar ? 'hidden' : 'default',
trafficLightPosition: isMac
? getMacTrafficLightPosition(false)
: undefined,
frame: isMac || !useCustomTitleBar,
show: false,
});
// Show window when ready to prevent visual flash
win.once('ready-to-show', () => {
win.show();
});
registerZoomShortcuts(win);
// 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' };
});
// Load the app
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(process.env.VITE_DEV_SERVER_URL);
win.webContents.openDevTools();
const rendererUrl = new URL(process.env.VITE_DEV_SERVER_URL);
if (shouldSkipSetupForE2E) {
rendererUrl.searchParams.set('e2eSkipSetup', '1');
}
win.loadURL(rendererUrl.toString());
if (!isE2EMode) {
win.webContents.openDevTools();
}
} else {
win.loadFile(join(__dirname, '../../dist/index.html'));
win.loadFile(join(__dirname, '../../dist/index.html'), {
query: shouldSkipSetupForE2E
? { e2eSkipSetup: '1' }
: undefined,
});
}
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;
}
if (process.platform === 'darwin') {
void getSetting('sidebarCollapsed').then((sidebarCollapsed) => {
syncMacTrafficLightPosition(win, sidebarCollapsed);
});
}
const action = consumeMainWindowReady(mainWindowFocusState);
if (action === 'focus') {
focusWindow(win);
return;
}
win.show();
});
win.on('close', (event) => {
if (!isQuitting() && !isE2EMode) {
event.preventDefault();
win.hide();
}
});
win.on('closed', () => {
if (mainWindow === win) {
mainWindow = null;
}
});
mainWindow = win;
return win;
}
/**
* Initialize the application
*/
@@ -152,27 +308,33 @@ 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();
if (!isE2EMode) {
// Warm up network optimization (non-blocking)
void warmupNetworkOptimization();
// Initialize Telemetry early
await initTelemetry();
// Initialize Telemetry early
await initTelemetry();
// Apply persisted proxy settings before creating windows or network requests.
await applyProxySettings();
await syncLaunchAtStartupSettingFromStore();
// Apply persisted proxy settings before creating windows or network requests.
await applyProxySettings();
await syncLaunchAtStartupSettingFromStore();
} else {
logger.info('Running in E2E mode: startup side effects minimized');
}
// Set application menu
createMenu();
await createMenu();
// Create the main window
mainWindow = createWindow();
const window = createMainWindow();
// Create system tray
createTray(mainWindow);
if (!isE2EMode) {
createTray(window);
}
// Override security headers ONLY for the OpenClaw Gateway Control UI.
// The URL filter ensures this callback only fires for gateway requests,
@@ -198,58 +360,90 @@ async function initialize(): Promise<void> {
);
// Register IPC handlers
registerIpcHandlers(gatewayManager, clawHubService, mainWindow);
registerIpcHandlers(gatewayManager, clawHubService, window, hostApiRegistry);
hostApiServer = startHostApiServer({
// Initialize extension system
await extensionRegistry.initialize({
gatewayManager,
clawHubService,
eventBus: hostEventBus,
mainWindow,
getMainWindow: () => mainWindow,
hostApi: {
register: (extensionId, contributions) => (
hostApiRegistry.registerExtensionContributions(extensionId, contributions)
),
},
});
// Wire marketplace provider to ClawHubService if an extension provides one
const marketplaceProvider = extensionRegistry.getMarketplaceProvider();
if (marketplaceProvider) {
clawHubService.setMarketplaceProvider(marketplaceProvider);
}
// Register update handlers
registerUpdateHandlers(appUpdater, mainWindow);
registerUpdateHandlers(appUpdater, window);
// Note: Auto-check for updates is driven by the renderer (update store init)
// so it respects the user's "Auto-check for updates" setting.
// Minimize to tray on close instead of quitting (macOS & Windows)
mainWindow.on('close', (event) => {
if (!isQuitting()) {
event.preventDefault();
mainWindow?.hide();
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
// Seed a stable default IDENTITY.md before the Gateway initializes the
// workspace so ClawX desktop sessions skip OpenClaw's chat-first bootstrap.
if (!isE2EMode) {
void ensureClawXDefaultIdentity().catch((error) => {
logger.warn('Failed to seed default ClawX identity:', error);
});
}
// Repair any bootstrap files that only contain ClawX markers (no OpenClaw
// template content). This fixes a race condition where ensureClawXContext()
// previously created the file before the gateway could seed the full template.
void repairClawXOnlyBootstrapFiles().catch((error) => {
logger.warn('Failed to repair bootstrap files:', error);
});
if (!isE2EMode) {
void repairClawXOnlyBootstrapFiles().catch((error) => {
logger.warn('Failed to repair bootstrap files:', error);
});
}
// Pre-deploy built-in skills (feishu-doc, feishu-drive, feishu-perm, feishu-wiki)
// to ~/.openclaw/skills/ so they are immediately available without manual install.
void ensureBuiltinSkillsInstalled().catch((error) => {
logger.warn('Failed to install built-in skills:', error);
});
if (!isE2EMode) {
void ensureBuiltinSkillsInstalled().catch((error) => {
logger.warn('Failed to install built-in skills:', error);
});
}
// Keep community builds aligned with Clawx-biz by physically trimming
// bundled OpenClaw consumer skills on startup (dev + packaged), keeping only
// `skill-creator`. This also prunes stale openclaw.json entries for trimmed
// bundled skills so we do not keep `enabled: false` config for skills that no
// longer exist.
if (!isE2EMode) {
void trimBundledOpenClawSkillsAndConfigs().then(({ removed, removedConfigs, kept }) => {
if (removed > 0 || removedConfigs > 0) {
logger.info(
`Trimmed bundled OpenClaw skills: removed ${removed}, pruned configs ${removedConfigs}, kept ${kept.join(', ')}`,
);
}
});
}
// Pre-deploy bundled third-party skills from resources/preinstalled-skills.
// This installs full skill directories (not only SKILL.md) in an idempotent,
// non-destructive way and never blocks startup.
void ensurePreinstalledSkillsInstalled().catch((error) => {
logger.warn('Failed to install preinstalled skills:', error);
});
if (!isE2EMode) {
void ensurePreinstalledSkillsInstalled().catch((error) => {
logger.warn('Failed to install preinstalled skills:', error);
});
}
// Plugin installation is now configuration-driven:
// - When a channel is added via UI: ensureXxxPluginInstalled() in IPC handlers
// - When Gateway starts: ensureConfiguredPluginsUpgraded() in config-sync.ts
// No need to pre-install all bundled plugins at app startup.
// Bridge gateway and host-side events before any auto-start logic runs, so
// renderer subscribers observe the full startup lifecycle.
gatewayManager.on('status', (status: { state: string }) => {
hostEventBus.emit('gateway:status', status);
if (status.state === 'running') {
sendMainWindowEvent('gateway:status-changed', status);
if (status.state === 'running' && !isE2EMode) {
void ensureClawXContext().catch((error) => {
logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error);
});
@@ -257,72 +451,76 @@ async function initialize(): Promise<void> {
});
gatewayManager.on('error', (error) => {
hostEventBus.emit('gateway:error', { message: error.message });
sendMainWindowEvent('gateway:error', { message: error.message });
});
gatewayManager.on('notification', (notification) => {
hostEventBus.emit('gateway:notification', notification);
sendMainWindowEvent('gateway:notification', notification);
});
gatewayManager.on('gateway:health', (data) => {
sendMainWindowEvent('gateway:health-changed', data);
});
gatewayManager.on('gateway:presence', (data) => {
sendMainWindowEvent('gateway:presence-changed', data);
});
gatewayManager.on('chat:message', (data) => {
hostEventBus.emit('gateway:chat-message', data);
sendMainWindowEvent('gateway:chat-message', data);
});
gatewayManager.on('chat:runtime-event', (data) => {
sendMainWindowEvent('chat:runtime-event', data);
});
gatewayManager.on('channel:status', (data) => {
hostEventBus.emit('gateway:channel-status', data);
sendMainWindowEvent('gateway:channel-status', data);
});
gatewayManager.on('exit', (code) => {
hostEventBus.emit('gateway:exit', { code });
sendMainWindowEvent('gateway:exit', { code });
});
deviceOAuthManager.on('oauth:code', (payload) => {
hostEventBus.emit('oauth:code', payload);
});
deviceOAuthManager.on('oauth:start', (payload) => {
hostEventBus.emit('oauth:start', payload);
sendMainWindowEvent('oauth:code', payload);
});
deviceOAuthManager.on('oauth:success', (payload) => {
hostEventBus.emit('oauth:success', { ...payload, success: true });
sendMainWindowEvent('oauth:success', { ...payload, success: true });
});
deviceOAuthManager.on('oauth:error', (error) => {
hostEventBus.emit('oauth:error', error);
});
browserOAuthManager.on('oauth:start', (payload) => {
hostEventBus.emit('oauth:start', payload);
sendMainWindowEvent('oauth:error', error);
});
browserOAuthManager.on('oauth:code', (payload) => {
hostEventBus.emit('oauth:code', payload);
sendMainWindowEvent('oauth:code', payload);
});
browserOAuthManager.on('oauth:success', (payload) => {
hostEventBus.emit('oauth:success', { ...payload, success: true });
sendMainWindowEvent('oauth:success', { ...payload, success: true });
});
browserOAuthManager.on('oauth:error', (error) => {
hostEventBus.emit('oauth:error', error);
sendMainWindowEvent('oauth:error', error);
});
whatsAppLoginManager.on('qr', (data) => {
hostEventBus.emit('channel:whatsapp-qr', data);
sendMainWindowEvent('channel:whatsapp-qr', data);
});
whatsAppLoginManager.on('success', (data) => {
hostEventBus.emit('channel:whatsapp-success', data);
sendMainWindowEvent('channel:whatsapp-success', data);
});
whatsAppLoginManager.on('error', (error) => {
hostEventBus.emit('channel:whatsapp-error', error);
sendMainWindowEvent('channel:whatsapp-error', error);
});
// Start Gateway automatically (this seeds missing bootstrap files with full templates)
const gatewayAutoStart = await getSetting('gatewayAutoStart');
if (gatewayAutoStart) {
if (!isE2EMode && gatewayAutoStart) {
try {
await syncAllProviderAuthToRuntime();
logger.debug('Auto-starting Gateway...');
@@ -332,6 +530,8 @@ async function initialize(): Promise<void> {
logger.error('Gateway auto-start failed:', error);
mainWindow?.webContents.send('gateway:error', String(error));
}
} else if (isE2EMode) {
logger.info('Gateway auto-start skipped in E2E mode');
} else {
logger.info('Gateway auto-start disabled in settings');
}
@@ -339,65 +539,160 @@ async function initialize(): Promise<void> {
// Merge ClawX context snippets into the workspace bootstrap files.
// The gateway seeds workspace files asynchronously after its HTTP server
// is ready, so ensureClawXContext will retry until the target files appear.
void ensureClawXContext().catch((error) => {
logger.warn('Failed to merge ClawX context into workspace:', error);
});
if (!isE2EMode) {
void ensureClawXContext().catch((error) => {
logger.warn('Failed to merge ClawX context into workspace:', error);
});
}
// Auto-install openclaw CLI and shell completions (non-blocking).
void autoInstallCliIfNeeded((installedPath) => {
mainWindow?.webContents.send('openclaw:cli-installed', installedPath);
}).then(() => {
generateCompletionCache();
installCompletionToProfile();
}).catch((error) => {
logger.warn('CLI auto-install failed:', error);
if (!isE2EMode) {
void autoInstallCliIfNeeded((installedPath) => {
mainWindow?.webContents.send('openclaw:cli-installed', installedPath);
}).then(() => {
generateCompletionCache();
installCompletionToProfile();
}).catch((error) => {
logger.warn('CLI auto-install failed:', error);
});
}
}
if (gotTheLock) {
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();
// Register builtin extensions and load manifest
registerAllBuiltinExtensions();
loadExternalMainExtensions();
void loadExtensionsFromManifest().catch((err) => {
logger.warn('Failed to load extensions from manifest:', err);
});
// When a second instance is launched, focus the existing window instead.
app.on('second-instance', () => {
logger.info('Second ClawX instance detected; redirecting to the existing window');
const focusRequest = requestSecondInstanceFocus(
mainWindowFocusState,
Boolean(mainWindow && !mainWindow.isDestroyed()),
);
if (focusRequest === 'focus-now') {
focusMainWindow();
return;
}
logger.debug('Main window is not ready yet; deferring second-instance focus until ready-to-show');
});
// Application lifecycle
app.whenReady().then(() => {
void initialize().catch((error) => {
logger.error('Application initialization failed:', error);
});
// Register activate handler AFTER app is ready to prevent
// "Cannot create BrowserWindow before app is ready" on macOS.
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
} else {
focusMainWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin' || isE2EMode) {
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;
}
void extensionRegistry.teardownAll();
const stopPromise = gatewayManager.stop().catch((err) => {
logger.warn('gatewayManager.stop() error during quit:', err);
});
const timeoutPromise = new Promise<'timeout'>((resolve) => {
setTimeout(() => resolve('timeout'), 5000);
});
void Promise.race([stopPromise.then(() => 'stopped' as const), timeoutPromise]).then((result) => {
if (result === 'timeout') {
logger.warn('Gateway shutdown timed out during app quit; proceeding with forced quit');
void gatewayManager.forceTerminateOwnedProcessForQuit().then((terminated) => {
if (terminated) {
logger.warn('Forced gateway process termination completed after quit timeout');
}
}).catch((err) => {
logger.warn('Forced gateway termination failed after quit timeout:', err);
});
}
markQuitCleanupCompleted(quitLifecycleState);
app.quit();
});
});
// Best-effort Gateway cleanup on unexpected crashes.
// These handlers attempt to terminate the Gateway child process within a
// short timeout before force-exiting, preventing orphaned processes.
const emergencyGatewayCleanup = (reason: string, error: unknown): void => {
logger.error(`${reason}:`, error);
try {
void gatewayManager?.stop().catch(() => { /* ignore */ });
} catch {
// ignore — stop() may not be callable if state is corrupted
}
// Give Gateway stop a brief window, then force-exit.
setTimeout(() => {
process.exit(1);
}, 3000).unref();
};
process.on('uncaughtException', (error) => {
emergencyGatewayCleanup('Uncaught exception in main process', error);
});
process.on('unhandledRejection', (reason) => {
emergencyGatewayCleanup('Unhandled promise rejection in main process', reason);
});
}
// When a second instance is launched, focus the existing window instead.
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
});
// Application lifecycle
app.whenReady().then(() => {
void initialize().catch((error) => {
logger.error('Application initialization failed:', error);
});
// Register activate handler AFTER app is ready to prevent
// "Cannot create BrowserWindow before app is ready" on macOS.
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow();
} else if (mainWindow && !mainWindow.isDestroyed()) {
// On macOS, clicking the dock icon should show the window if it's hidden
mainWindow.show();
mainWindow.focus();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('before-quit', () => {
setQuitting();
hostEventBus.closeAll();
hostApiServer?.close();
// Fire-and-forget: do not await gatewayManager.stop() here.
// Awaiting inside before-quit can stall Electron's quit sequence.
void gatewayManager.stop().catch((err) => {
logger.warn('gatewayManager.stop() error during quit:', err);
});
});
// Export for testing
export { mainWindow, gatewayManager };
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
import type { HostApiContract } from '@shared/host-api/contract';
export type HostRequest = {
id: string;
module: string;
action: string;
payload?: unknown;
};
export type HostErrorCode = 'VALIDATION' | 'UNSUPPORTED' | 'INTERNAL';
export type HostResponse<T = unknown> =
| { id?: string; ok: true; data: T }
| { id?: string; ok: false; error: { code: HostErrorCode; message: string; details?: unknown } };
export type RuntimeHostAction = (payload?: unknown) => Promise<unknown> | unknown;
type MaybePromise<T> = T | Promise<T>;
type HostServiceFunction<TFunction> = TFunction extends (...args: infer Args) => infer Result
? (...args: Args) => MaybePromise<Awaited<Result>>
: never;
type HostServiceModule<TModule> = {
[A in keyof TModule]: HostServiceFunction<TModule[A]>;
};
export type HostServiceRegistry = {
[M in keyof HostApiContract]?: Partial<HostServiceModule<HostApiContract[M]>>;
};
export type CompleteHostServiceRegistry = {
[M in keyof HostApiContract]: HostServiceModule<HostApiContract[M]>;
};
export type HostApiContribution = {
module: string;
actions: Record<string, RuntimeHostAction>;
};
export type HostApiContributionRegistrar = {
register: (extensionId: string, contributions: HostApiContribution[]) => () => void;
};
export function isHostRequest(value: unknown): value is HostRequest {
if (!value || typeof value !== 'object') return false;
const record = value as Record<string, unknown>;
return typeof record.id === 'string'
&& record.id.length > 0
&& typeof record.module === 'string'
&& record.module.length > 0
&& typeof record.action === 'string'
&& record.action.length > 0;
}
+134
View File
@@ -0,0 +1,134 @@
import { ipcMain } from 'electron';
import {
type HostApiContribution,
type HostResponse,
type HostServiceRegistry,
type RuntimeHostAction,
isHostRequest,
} from './host-contract';
type RegisteredHostAction = {
action: RuntimeHostAction;
ownerId: string;
};
function assertValidContributionKey(kind: 'module' | 'action', value: string): void {
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(value)) {
throw new Error(`Invalid host API ${kind}: ${value}`);
}
}
export class HostApiRegistry {
private modules = new Map<string, Map<string, RegisteredHostAction>>();
registerCoreServices(services: HostServiceRegistry): void {
for (const [moduleName, actions] of Object.entries(services)) {
if (!actions || typeof actions !== 'object') continue;
for (const [actionName, action] of Object.entries(actions)) {
if (typeof action !== 'function') continue;
this.registerAction(moduleName, actionName, action as RuntimeHostAction, 'core');
}
}
}
registerExtensionContributions(extensionId: string, contributions: HostApiContribution[]): () => void {
const registered: Array<{ module: string; action: string }> = [];
for (const contribution of contributions) {
assertValidContributionKey('module', contribution.module);
for (const [actionName, action] of Object.entries(contribution.actions)) {
assertValidContributionKey('action', actionName);
this.registerAction(contribution.module, actionName, action, extensionId);
registered.push({ module: contribution.module, action: actionName });
}
}
return () => {
for (const { module, action } of registered) {
const moduleActions = this.modules.get(module);
const registeredAction = moduleActions?.get(action);
if (registeredAction?.ownerId === extensionId) {
moduleActions?.delete(action);
}
if (moduleActions?.size === 0) {
this.modules.delete(module);
}
}
};
}
resolve(moduleName: string, actionName: string): RuntimeHostAction | undefined {
return this.modules.get(moduleName)?.get(actionName)?.action;
}
private registerAction(
moduleName: string,
actionName: string,
action: RuntimeHostAction,
ownerId: string,
): void {
const moduleActions = this.modules.get(moduleName) ?? new Map<string, RegisteredHostAction>();
if (moduleActions.has(actionName)) {
throw new Error(`Host API action already registered: ${moduleName}.${actionName}`);
}
moduleActions.set(actionName, { action, ownerId });
this.modules.set(moduleName, moduleActions);
}
}
function toHostApiRegistry(registryOrServices: HostApiRegistry | HostServiceRegistry): HostApiRegistry {
if (registryOrServices instanceof HostApiRegistry) {
return registryOrServices;
}
const registry = new HostApiRegistry();
registry.registerCoreServices(registryOrServices);
return registry;
}
export function createHostInvokeDispatcher(registryOrServices: HostApiRegistry | HostServiceRegistry) {
const registry = toHostApiRegistry(registryOrServices);
return async function dispatchHostRequest(request: unknown): Promise<HostResponse> {
const requestId = request && typeof request === 'object'
? String((request as Record<string, unknown>).id ?? '')
: undefined;
if (!isHostRequest(request)) {
return {
id: requestId,
ok: false,
error: { code: 'VALIDATION', message: 'Invalid host request format' },
};
}
const action = registry.resolve(request.module, request.action);
if (typeof action !== 'function') {
return {
id: request.id,
ok: false,
error: {
code: 'UNSUPPORTED',
message: `Unsupported host request: ${request.module}.${request.action}`,
},
};
}
try {
const data = await action(request.payload);
return { id: request.id, ok: true, data };
} catch (error) {
return {
id: request.id,
ok: false,
error: {
code: 'INTERNAL',
message: error instanceof Error ? error.message : String(error),
},
};
}
};
}
export function registerHostInvokeHandler(registry: HostApiRegistry): void {
const dispatch = createHostInvokeDispatcher(registry);
ipcMain.handle('host:invoke', async (_event, request: unknown) => dispatch(request));
}
+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';
}
+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;
}
+81 -56
View File
@@ -3,12 +3,33 @@
* Creates the native application menu for macOS/Windows/Linux
*/
import { Menu, app, shell, BrowserWindow } from 'electron';
import { MENU_LABELS } from '@shared/i18n/resources';
import { resolveSupportedLanguage, type LanguageCode } from '@shared/language';
import { getSetting } from '../utils/store';
function applyAppName(label: string): string {
return label.replaceAll('{{appName}}', app.name);
}
async function resolveMenuLanguage(language?: string): Promise<LanguageCode> {
if (language) return resolveSupportedLanguage(language);
try {
return resolveSupportedLanguage(await getSetting('language'));
} catch {
return resolveSupportedLanguage(app.getLocale());
}
}
function getMenuTargetWindow(): BrowserWindow | null {
return BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? null;
}
/**
* Create application menu
*/
export function createMenu(): void {
export async function createMenu(language?: string): Promise<void> {
const isMac = process.platform === 'darwin';
const labels = MENU_LABELS[await resolveMenuLanguage(language)];
const template: Electron.MenuItemConstructorOptions[] = [
// App menu (macOS only)
@@ -17,24 +38,24 @@ export function createMenu(): void {
{
label: app.name,
submenu: [
{ role: 'about' as const },
{ role: 'about' as const, label: applyAppName(labels.app.about) },
{ type: 'separator' as const },
{
label: 'Preferences...',
label: labels.app.preferences,
accelerator: 'Cmd+,',
click: () => {
const win = BrowserWindow.getFocusedWindow();
const win = getMenuTargetWindow();
win?.webContents.send('navigate', '/settings');
},
},
{ type: 'separator' as const },
{ role: 'services' as const },
{ role: 'services' as const, label: labels.app.services },
{ type: 'separator' as const },
{ role: 'hide' as const },
{ role: 'hideOthers' as const },
{ role: 'unhide' as const },
{ role: 'hide' as const, label: applyAppName(labels.app.hide) },
{ role: 'hideOthers' as const, label: labels.app.hideOthers },
{ role: 'unhide' as const, label: labels.app.unhide },
{ type: 'separator' as const },
{ role: 'quit' as const },
{ role: 'quit' as const, label: applyAppName(labels.app.quit) },
],
},
]
@@ -42,110 +63,113 @@ export function createMenu(): void {
// File menu
{
label: 'File',
label: labels.file.label,
submenu: [
{
label: 'New Chat',
id: 'new-chat',
label: labels.file.newChat,
accelerator: 'CmdOrCtrl+N',
click: () => {
const win = BrowserWindow.getFocusedWindow();
win?.webContents.send('navigate', '/chat');
const win = getMenuTargetWindow();
win?.webContents.send('new-chat');
},
},
{ type: 'separator' },
isMac ? { role: 'close' } : { role: 'quit' },
isMac
? { role: 'close', label: labels.file.close }
: { role: 'quit', label: applyAppName(labels.app.quit) },
],
},
// Edit menu
{
label: 'Edit',
label: labels.edit.label,
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ role: 'undo', label: labels.edit.undo },
{ role: 'redo', label: labels.edit.redo },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'cut', label: labels.edit.cut },
{ role: 'copy', label: labels.edit.copy },
{ role: 'paste', label: labels.edit.paste },
...(isMac
? [
{ role: 'pasteAndMatchStyle' as const },
{ role: 'delete' as const },
{ role: 'selectAll' as const },
{ role: 'pasteAndMatchStyle' as const, label: labels.edit.pasteAndMatchStyle },
{ role: 'delete' as const, label: labels.edit.delete },
{ role: 'selectAll' as const, label: labels.edit.selectAll },
]
: [
{ role: 'delete' as const },
{ role: 'delete' as const, label: labels.edit.delete },
{ type: 'separator' as const },
{ role: 'selectAll' as const },
{ role: 'selectAll' as const, label: labels.edit.selectAll },
]),
],
},
// View menu
{
label: 'View',
label: labels.view.label,
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ role: 'reload', label: labels.view.reload },
{ role: 'forceReload', label: labels.view.forceReload },
{ role: 'toggleDevTools', label: labels.view.toggleDevTools },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ role: 'resetZoom', label: labels.view.resetZoom },
{ role: 'zoomIn', label: labels.view.zoomIn },
{ role: 'zoomOut', label: labels.view.zoomOut },
{ type: 'separator' },
{ role: 'togglefullscreen' },
{ role: 'togglefullscreen', label: labels.view.toggleFullscreen },
],
},
// Navigate menu
{
label: 'Navigate',
label: labels.navigate.label,
submenu: [
{
label: 'Dashboard',
label: labels.navigate.dashboard,
accelerator: 'CmdOrCtrl+1',
click: () => {
const win = BrowserWindow.getFocusedWindow();
const win = getMenuTargetWindow();
win?.webContents.send('navigate', '/');
},
},
{
label: 'Chat',
label: labels.navigate.chat,
accelerator: 'CmdOrCtrl+2',
click: () => {
const win = BrowserWindow.getFocusedWindow();
win?.webContents.send('navigate', '/chat');
const win = getMenuTargetWindow();
win?.webContents.send('navigate', '/');
},
},
{
label: 'Channels',
label: labels.navigate.channels,
accelerator: 'CmdOrCtrl+3',
click: () => {
const win = BrowserWindow.getFocusedWindow();
const win = getMenuTargetWindow();
win?.webContents.send('navigate', '/channels');
},
},
{
label: 'Skills',
label: labels.navigate.skills,
accelerator: 'CmdOrCtrl+4',
click: () => {
const win = BrowserWindow.getFocusedWindow();
const win = getMenuTargetWindow();
win?.webContents.send('navigate', '/skills');
},
},
{
label: 'Cron Tasks',
label: labels.navigate.cronTasks,
accelerator: 'CmdOrCtrl+5',
click: () => {
const win = BrowserWindow.getFocusedWindow();
const win = getMenuTargetWindow();
win?.webContents.send('navigate', '/cron');
},
},
{
label: 'Settings',
label: labels.navigate.settings,
accelerator: isMac ? 'Cmd+,' : 'Ctrl+,',
click: () => {
const win = BrowserWindow.getFocusedWindow();
const win = getMenuTargetWindow();
win?.webContents.send('navigate', '/settings');
},
},
@@ -154,40 +178,41 @@ export function createMenu(): void {
// Window menu
{
label: 'Window',
label: labels.window.label,
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
{ role: 'minimize', label: labels.window.minimize },
{ role: 'zoom', label: labels.window.zoom },
...(isMac
? [
{ type: 'separator' as const },
{ role: 'front' as const },
{ role: 'front' as const, label: labels.window.front },
{ type: 'separator' as const },
{ role: 'window' as const },
{ role: 'window' as const, label: labels.window.label },
]
: [{ role: 'close' as const }]),
: [{ role: 'close' as const, label: labels.window.close }]),
],
},
// Help menu
{
role: 'help',
label: labels.help.label,
submenu: [
{
label: 'Documentation',
label: labels.help.documentation,
click: async () => {
await shell.openExternal('https://claw-x.com');
},
},
{
label: 'Report Issue',
label: labels.help.reportIssue,
click: async () => {
await shell.openExternal('https://github.com/ValueCell-ai/ClawX/issues');
},
},
{ type: 'separator' },
{
label: 'OpenClaw Documentation',
label: labels.help.openClawDocumentation,
click: async () => {
await shell.openExternal('https://docs.openclaw.ai');
},
+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
},
};
}
+6 -2
View File
@@ -1,6 +1,10 @@
import { getProviderConfig } from '../utils/provider-registry';
import { getOpenClawProviderKeyForType, isOAuthProviderType } from '../utils/provider-keys';
import type { ProviderConfig } from '../utils/secure-storage';
import {
piAiModelsJsonModelEntry,
type PiAiModelCostRates,
} from '../shared/pi-ai-model-cost';
export interface AgentProviderUpdatePayload {
providerKey: string;
@@ -8,7 +12,7 @@ export interface AgentProviderUpdatePayload {
baseUrl: string;
api: string;
apiKey: string | undefined;
models: Array<{ id: string; name: string }>;
models: Array<{ id: string; name: string; cost: PiAiModelCostRates }>;
};
}
@@ -42,7 +46,7 @@ export function buildNonOAuthAgentProviderUpdate(
baseUrl,
api,
apiKey: meta?.apiKeyEnv,
models: modelId ? [{ id: modelId, name: modelId }] : [],
models: modelId ? [piAiModelsJsonModelEntry(modelId)] : [],
},
};
}
+8 -1
View File
@@ -4,7 +4,14 @@ import { buildElectronProxyConfig } from '../utils/proxy';
import { logger } from '../utils/logger';
export async function applyProxySettings(
partialSettings?: Pick<AppSettings, 'proxyEnabled' | 'proxyServer' | 'proxyBypassRules'>
partialSettings?: Pick<AppSettings,
| 'proxyEnabled'
| 'proxyServer'
| 'proxyHttpServer'
| 'proxyHttpsServer'
| 'proxyAllServer'
| 'proxyBypassRules'
>,
): Promise<void> {
const settings = partialSettings ?? await getAllSettings();
const config = buildElectronProxyConfig(settings);
+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();
};
}
+40
View File
@@ -0,0 +1,40 @@
import { release } from 'node:os';
import type { BrowserWindow } from 'electron';
const MAC_SIDEBAR_CHROME_HEIGHT = 28;
const MAC_TRAFFIC_LIGHT_GAP = 8;
const MAC_TRAFFIC_LIGHT_FRAME_HEIGHT = 16;
const MAC_TRAFFIC_LIGHT_FRAME_HEIGHT_TAHOE = 14;
function getMacTrafficLightFrameHeight(darwinMajor: number): number {
return darwinMajor >= 25
? MAC_TRAFFIC_LIGHT_FRAME_HEIGHT_TAHOE
: MAC_TRAFFIC_LIGHT_FRAME_HEIGHT;
}
function getMacTrafficLightChromeOffset(buttonFrameHeight: number): number {
return Math.floor((MAC_SIDEBAR_CHROME_HEIGHT - buttonFrameHeight) / 2);
}
export function getMacTrafficLightPosition(sidebarCollapsed: boolean): { x: number; y: number } {
const darwinMajor = Number.parseInt(release().split('.')[0] ?? '0', 10);
const buttonFrameHeight = getMacTrafficLightFrameHeight(darwinMajor);
const offset = getMacTrafficLightChromeOffset(buttonFrameHeight);
if (sidebarCollapsed) {
return { x: MAC_TRAFFIC_LIGHT_GAP, y: Math.max(MAC_TRAFFIC_LIGHT_GAP, offset) };
}
return { x: offset + 1, y: offset };
}
export function syncMacTrafficLightPosition(
win: BrowserWindow,
sidebarCollapsed: boolean,
): void {
if (process.platform !== 'darwin' || win.isDestroyed()) {
return;
}
win.setWindowButtonPosition(getMacTrafficLightPosition(sidebarCollapsed));
}
+1 -9
View File
@@ -90,20 +90,12 @@ export function createTray(mainWindow: BrowserWindow): Tray {
{
label: 'Quick Actions',
submenu: [
{
label: 'Open Dashboard',
click: () => {
if (mainWindow.isDestroyed()) return;
mainWindow.show();
mainWindow.webContents.send('navigate', '/');
},
},
{
label: 'Open Chat',
click: () => {
if (mainWindow.isDestroyed()) return;
mainWindow.show();
mainWindow.webContents.send('navigate', '/chat');
mainWindow.webContents.send('navigate', '/');
},
},
{
+10 -9
View File
@@ -60,7 +60,7 @@ export class AppUpdater extends EventEmitter {
});
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.logger = {
info: (msg: string) => logger.info('[Updater]', msg),
@@ -131,10 +131,6 @@ export class AppUpdater extends EventEmitter {
autoUpdater.on('update-downloaded', (event: UpdateDownloadedEvent) => {
this.updateStatus({ status: 'downloaded', info: event });
this.emit('update-downloaded', event);
if (autoUpdater.autoDownload) {
this.startAutoInstallCountdown();
}
});
autoUpdater.on('error', (error: Error) => {
@@ -234,7 +230,7 @@ export class AppUpdater extends EventEmitter {
* Start a countdown that auto-installs the downloaded update.
* Sends `update:auto-install-countdown` events to the renderer each second.
*/
private startAutoInstallCountdown(): void {
startAutoInstallCountdown(): void {
this.clearAutoInstallTimer();
this.autoInstallCountdown = AppUpdater.AUTO_INSTALL_DELAY_SECONDS;
this.sendToRenderer('update:auto-install-countdown', { seconds: this.autoInstallCountdown });
@@ -270,10 +266,15 @@ export class AppUpdater extends EventEmitter {
}
/**
* Set auto-download preference
* Set auto-download preference.
*
* ClawX uses a prompt-first update flow: finding an update shows a UI prompt,
* and downloads/installations only start after the user chooses an action.
* Keep this legacy IPC method as a no-op-compatible setter so stale renderer
* settings cannot re-enable electron-updater's implicit auto-download path.
*/
setAutoDownload(enable: boolean): void {
autoUpdater.autoDownload = enable;
setAutoDownload(_enable: boolean): void {
autoUpdater.autoDownload = false;
}
/**
+47
View File
@@ -0,0 +1,47 @@
import type { BrowserWindow } from 'electron';
export type ZoomShortcutAction = 'in' | 'out' | 'reset';
type ZoomShortcutInput = Pick<Electron.Input, 'key' | 'code' | 'control' | 'meta' | 'alt'>;
export function getZoomShortcutAction(input: ZoomShortcutInput): ZoomShortcutAction | null {
if ((!input.control && !input.meta) || input.alt) {
return null;
}
const key = input.key.toLowerCase();
if (key === '+' || key === '=' || input.code === 'Equal' || input.code === 'NumpadAdd') {
return 'in';
}
if (key === '-' || input.code === 'Minus' || input.code === 'NumpadSubtract') {
return 'out';
}
if (key === '0' || input.code === 'Digit0' || input.code === 'Numpad0') {
return 'reset';
}
return null;
}
export function registerZoomShortcuts(win: BrowserWindow): void {
win.webContents.on('before-input-event', (event, input) => {
const action = getZoomShortcutAction(input);
if (!action) {
return;
}
event.preventDefault();
if (action === 'reset') {
win.webContents.setZoomLevel(0);
return;
}
const delta = action === 'in' ? 1 : -1;
win.webContents.setZoomLevel(win.webContents.getZoomLevel() + delta);
});
}
+34 -119
View File
@@ -2,7 +2,20 @@
* Preload Script
* Exposes safe APIs to the renderer process via contextBridge
*/
import { contextBridge, ipcRenderer } from 'electron';
import { contextBridge, ipcRenderer, webUtils } from 'electron';
import type { HostRequest } from '@shared/host-api/types';
import { HOST_EVENT_CHANNELS } from '@shared/host-events/contract';
const validStaticEventChannels: Set<string> = new Set(
Object.values(HOST_EVENT_CHANNELS).flatMap((moduleChannels) => Object.values(moduleChannels)),
);
const DYNAMIC_CHANNEL_EVENT_RE = /^channel:[a-z0-9_-]+-(?:qr|success|error)$/i;
function isValidEventChannel(channel: string): boolean {
return validStaticEventChannels.has(channel)
|| DYNAMIC_CHANNEL_EVENT_RE.test(channel)
|| channel.startsWith('ext:');
}
/**
* IPC renderer methods exposed to the renderer process
@@ -16,39 +29,26 @@ const electronAPI = {
const validChannels = [
// Gateway
'gateway:status',
'gateway:isConnected',
'gateway:start',
'gateway:stop',
'gateway:restart',
'gateway:rpc',
'gateway:httpProxy',
'hostapi:fetch',
'gateway:health',
'gateway:getControlUiUrl',
// OpenClaw
'openclaw:status',
'openclaw:isReady',
// Shell
'shell:openExternal',
'shell:showItemInFolder',
'shell:openPath',
// Dialog
'dialog:open',
'dialog:save',
'dialog:message',
// App
'app:version',
'app:name',
'app:getPath',
'app:platform',
'app:quit',
'app:relaunch',
'app:request',
// Window controls
'window:minimize',
'window:maximize',
'window:close',
'window:isMaximized',
'window:syncTrafficLightPosition',
// Settings
'settings:get',
'settings:set',
@@ -82,59 +82,14 @@ const electronAPI = {
'provider:setDefault',
'provider:getDefault',
'provider:validateKey',
'provider:requestOAuth',
'provider:cancelOAuth',
// Cron
'cron:list',
'cron:create',
'cron:update',
'cron:delete',
'cron:toggle',
'cron:trigger',
// Channel Config
'channel:saveConfig',
'channel:getConfig',
'channel:getFormValues',
'channel:deleteConfig',
'channel:listConfigured',
'channel:setEnabled',
'channel:validate',
'channel:validate',
'channel:validateCredentials',
// WhatsApp
'channel:requestWhatsAppQr',
'channel:cancelWhatsAppQr',
// ClawHub
'clawhub:search',
'clawhub:install',
'clawhub:uninstall',
'clawhub:list',
'clawhub:openSkillReadme',
// UV
'uv:check',
'uv:install-all',
// Skill config (direct file access)
'skill:updateConfig',
'skill:getConfig',
'skill:getAllConfigs',
// Logs
'log:getRecent',
'log:readFile',
'log:getFilePath',
'log:getDir',
'log:listFiles',
// File staging & media
'file:stage',
'file:stageBuffer',
'media:getThumbnails',
'media:saveImage',
// Chat send with media (reads staged files in main process)
'chat:sendWithMedia',
// Session management
'session:delete',
// File preview (sandboxed read/write/list/tree)
'file:readText',
'file:readBinary',
'file:writeText',
'file:stat',
'file:listDir',
'file:listTree',
// OpenClaw extras
'openclaw:getDir',
'openclaw:getConfigDir',
'openclaw:getSkillsDir',
'openclaw:getCliCommand',
];
@@ -150,35 +105,7 @@ const electronAPI = {
* Listen for events from main process
*/
on: (channel: string, callback: (...args: unknown[]) => void) => {
const validChannels = [
'gateway:status-changed',
'gateway:message',
'gateway:notification',
'gateway:channel-status',
'gateway:chat-message',
'channel:whatsapp-qr',
'channel:whatsapp-success',
'channel:whatsapp-error',
'gateway:exit',
'gateway:error',
'navigate',
'update:status-changed',
'update:checking',
'update:available',
'update:not-available',
'update:progress',
'update:downloaded',
'update:error',
'update:auto-install-countdown',
'cron:updated',
'oauth:code',
'oauth:success',
'oauth:error',
'openclaw:cli-installed',
];
if (validChannels.includes(channel)) {
// Wrap the callback to strip the event
if (isValidEventChannel(channel)) {
const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => {
callback(...args);
};
@@ -197,29 +124,7 @@ const electronAPI = {
* Listen for a single event from main process
*/
once: (channel: string, callback: (...args: unknown[]) => void) => {
const validChannels = [
'gateway:status-changed',
'gateway:message',
'gateway:notification',
'gateway:channel-status',
'gateway:chat-message',
'gateway:exit',
'gateway:error',
'navigate',
'update:status-changed',
'update:checking',
'update:available',
'update:not-available',
'update:progress',
'update:downloaded',
'update:error',
'update:auto-install-countdown',
'oauth:code',
'oauth:success',
'oauth:error',
];
if (validChannels.includes(channel)) {
if (isValidEventChannel(channel)) {
ipcRenderer.once(channel, (_event, ...args) => callback(...args));
return;
}
@@ -247,6 +152,11 @@ const electronAPI = {
return ipcRenderer.invoke('shell:openExternal', url);
},
/**
* Resolve the on-disk path for a native drag/drop or <input type="file"> File.
*/
getPathForFile: (file: File) => webUtils.getPathForFile(file),
/**
* Get current platform
*/
@@ -258,8 +168,13 @@ const electronAPI = {
isDev: process.env.NODE_ENV === 'development' || !!process.env.VITE_DEV_SERVER_URL,
};
const clawxAPI = {
hostInvoke: (request: HostRequest) => ipcRenderer.invoke('host:invoke', request),
};
// Expose the API to the renderer process
contextBridge.exposeInMainWorld('electron', electronAPI);
contextBridge.exposeInMainWorld('clawx', clawxAPI);
// Type declarations for the renderer process
export type ElectronAPI = typeof electronAPI;
+129
View File
@@ -0,0 +1,129 @@
import type { GatewayManager } from '../gateway/manager';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import {
assignChannelToAgent,
clearChannelBinding,
createAgent,
deleteAgentConfig,
listAgentsSnapshot,
removeAgentWorkspaceDirectory,
resolveAccountIdForAgent,
updateAgentModel,
updateAgentName,
} from '../utils/agent-config';
import { deleteChannelAccountConfig } from '../utils/channel-config';
import { ensureClawXContext } from '../utils/openclaw-workspace';
import { isRecord } from './payload-utils';
import { syncAgentModelOverrideToRuntime, syncAllProviderAuthToRuntime } from './providers/provider-runtime-sync';
type AgentsApiContext = {
gatewayManager: GatewayManager;
};
function requireString(payload: unknown, key: string): string {
if (!isRecord(payload) || typeof payload[key] !== 'string' || !payload[key].trim()) {
throw new Error(`${key} is required`);
}
return payload[key].trim();
}
function scheduleGatewayReload(ctx: AgentsApiContext, reason: string): void {
if (ctx.gatewayManager.getStatus().state !== 'stopped') {
ctx.gatewayManager.debouncedReload();
return;
}
void reason;
}
async function restartGatewayForAgentDeletion(ctx: AgentsApiContext): Promise<void> {
try {
await ctx.gatewayManager.restart();
console.log('[agents] Gateway restart completed after agent deletion');
} catch (err) {
console.warn('[agents] Gateway restart after agent deletion failed:', err);
}
}
export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegistry['agents'] {
return {
list: async () => ({ success: true, ...(await listAgentsSnapshot()) }),
create: async (payload) => {
const name = requireString(payload, 'name');
const inheritWorkspace = isRecord(payload) ? payload.inheritWorkspace === true : undefined;
const snapshot = await createAgent(name, { inheritWorkspace });
syncAllProviderAuthToRuntime().catch((err) => {
console.warn('[agents] Failed to sync provider auth after agent creation:', err);
});
scheduleGatewayReload(ctx, 'create-agent');
void ensureClawXContext({ waitForAllConfiguredWorkspaces: true }).catch((err) => {
console.warn('[agents] Failed to ensure ClawX context after agent creation:', err);
});
return { success: true, ...snapshot };
},
update: async (payload) => {
const agentId = requireString(payload, 'id');
const name = requireString(payload, 'name');
const snapshot = await updateAgentName(agentId, name);
scheduleGatewayReload(ctx, 'update-agent');
return { success: true, ...snapshot };
},
updateModel: async (payload) => {
const agentId = requireString(payload, 'id');
const modelRef = isRecord(payload) && typeof payload.modelRef === 'string' ? payload.modelRef : null;
const snapshot = await updateAgentModel(agentId, modelRef);
try {
await syncAllProviderAuthToRuntime();
await syncAgentModelOverrideToRuntime(agentId);
} catch (syncError) {
console.warn('[agents] Failed to sync runtime after updating agent model:', syncError);
}
// Agent model changes must be picked up by the running Gateway before
// the next send; otherwise the UI can show the new selection while the
// active runtime still answers with the previous model.
scheduleGatewayReload(ctx, 'update-agent-model');
return { success: true, ...snapshot };
},
delete: async (payload) => {
const agentId = requireString(payload, 'id');
const { snapshot, removedEntry } = await deleteAgentConfig(agentId);
await restartGatewayForAgentDeletion(ctx);
await removeAgentWorkspaceDirectory(removedEntry).catch((err) => {
console.warn('[agents] Failed to remove workspace after agent deletion:', err);
});
return { success: true, ...snapshot };
},
assignChannel: async (payload) => {
const agentId = requireString(payload, 'id');
const channelType = requireString(payload, 'channelType');
const snapshot = await assignChannelToAgent(agentId, channelType);
scheduleGatewayReload(ctx, 'assign-channel');
return { success: true, ...snapshot };
},
removeChannel: async (payload) => {
const agentId = requireString(payload, 'id');
const channelType = requireString(payload, 'channelType');
const ownerId = agentId.trim().toLowerCase();
const snapshotBefore = await listAgentsSnapshot();
const ownedAccountIds = Object.entries(snapshotBefore.channelAccountOwners)
.filter(([channelAccountKey, owner]) => {
if (owner !== ownerId) return false;
return channelAccountKey.startsWith(`${channelType}:`);
})
.map(([channelAccountKey]) => channelAccountKey.slice(channelAccountKey.indexOf(':') + 1));
if (ownedAccountIds.length === 0) {
const legacyAccountId = resolveAccountIdForAgent(agentId);
if (snapshotBefore.channelAccountOwners[`${channelType}:${legacyAccountId}`] === ownerId) {
ownedAccountIds.push(legacyAccountId);
}
}
for (const accountId of ownedAccountIds) {
await deleteChannelAccountConfig(channelType, accountId);
await clearChannelBinding(channelType, accountId);
}
const snapshot = await listAgentsSnapshot();
scheduleGatewayReload(ctx, 'remove-agent-channel');
return { success: true, ...snapshot };
},
};
}
+16
View File
@@ -0,0 +1,16 @@
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../utils/openclaw-doctor';
import { isRecord } from './payload-utils';
type OpenClawDoctorPayload = {
mode?: unknown;
};
export function createAppApi(): CompleteHostServiceRegistry['app'] {
return {
openClawDoctor: async (payload) => {
const body = isRecord(payload) ? payload as OpenClawDoctorPayload : {};
return body.mode === 'fix' ? runOpenClawDoctorFix() : runOpenClawDoctor();
},
};
}
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
import type { GatewayManager } from '../gateway/manager';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { logger } from '../utils/logger';
import { isRecord } from './payload-utils';
const VISION_MIME_TYPES = new Set([
'image/png',
'image/jpeg',
'image/bmp',
'image/webp',
]);
type ChatSendWithMediaPayload = {
sessionKey?: unknown;
message?: unknown;
deliver?: unknown;
idempotencyKey?: unknown;
media?: unknown;
};
type MediaPayload = {
filePath?: unknown;
mimeType?: unknown;
fileName?: unknown;
};
function normalizeMedia(media: unknown): Array<{ filePath: string; mimeType: string; fileName: string }> {
if (!Array.isArray(media)) return [];
return media.flatMap((entry): Array<{ filePath: string; mimeType: string; fileName: string }> => {
if (!isRecord(entry)) return [];
const item = entry as MediaPayload;
if (typeof item.filePath !== 'string' || !item.filePath) return [];
return [{
filePath: item.filePath,
mimeType: typeof item.mimeType === 'string' && item.mimeType ? item.mimeType : 'application/octet-stream',
fileName: typeof item.fileName === 'string' && item.fileName ? item.fileName : item.filePath.split(/[\\/]/).pop() || 'file',
}];
});
}
export function createChatApi({ gatewayManager }: { gatewayManager: GatewayManager }): CompleteHostServiceRegistry['chat'] {
return {
sendWithMedia: async (payload) => {
const body = isRecord(payload) ? payload as ChatSendWithMediaPayload : {};
const sessionKey = typeof body.sessionKey === 'string' ? body.sessionKey : '';
const idempotencyKey = typeof body.idempotencyKey === 'string' ? body.idempotencyKey : '';
if (!sessionKey || !idempotencyKey) {
return { success: false, error: 'Invalid chat send payload' };
}
try {
let message = typeof body.message === 'string' ? body.message : '';
const imageAttachments: Array<Record<string, unknown>> = [];
const fileReferences: string[] = [];
const media = normalizeMedia(body.media);
if (media.length > 0) {
const fsP = await import('node:fs/promises');
for (const item of media) {
const exists = await fsP.access(item.filePath).then(() => true, () => false);
logger.info(
`[chat:sendWithMedia] Processing file: ${item.fileName} (${item.mimeType}), path: ${item.filePath}, exists: ${exists}, isVision: ${VISION_MIME_TYPES.has(item.mimeType)}`,
);
fileReferences.push(
`[media attached: ${item.filePath} (${item.mimeType}) | ${item.filePath}]`,
);
if (VISION_MIME_TYPES.has(item.mimeType)) {
const fileBuffer = await fsP.readFile(item.filePath);
const base64Data = fileBuffer.toString('base64');
logger.info(`[chat:sendWithMedia] Read ${fileBuffer.length} bytes, base64 length: ${base64Data.length}`);
imageAttachments.push({
content: base64Data,
mimeType: item.mimeType,
fileName: item.fileName,
});
}
}
}
if (fileReferences.length > 0) {
const refs = fileReferences.join('\n');
message = message ? `${message}\n\n${refs}` : refs;
}
const rpcParams: Record<string, unknown> = {
sessionKey,
message,
deliver: body.deliver ?? false,
idempotencyKey,
};
if (imageAttachments.length > 0) {
rpcParams.attachments = imageAttachments;
}
logger.info(
`[chat:sendWithMedia] Sending: message="${message.substring(0, 100)}", attachments=${imageAttachments.length}, fileRefs=${fileReferences.length}`,
);
const result = await gatewayManager.rpc('chat.send', rpcParams, 120000);
logger.info(`[chat:sendWithMedia] RPC result: ${JSON.stringify(result)}`);
const response = isRecord(result) && typeof result.runId === 'string'
? { runId: result.runId }
: undefined;
return { success: true, ...(response ? { result: response } : {}) };
} catch (error) {
logger.error(`[chat:sendWithMedia] Error: ${String(error)}`);
return { success: false, error: String(error) };
}
},
};
}
+592
View File
@@ -0,0 +1,592 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { CronJob, CronJobDelivery, CronSchedule } from '@shared/types/cron';
import type { GatewayManager } from '../gateway/manager';
import { getOpenClawConfigDir } from '../utils/paths';
import { resolveAgentIdFromChannel } from '../utils/agent-config';
import { toOpenClawChannelType, toUiChannelType } from '../utils/channel-alias';
import { resolveAccountIdFromSessionHistory } from '../utils/session-util';
import { isRecord } from './payload-utils';
interface GatewayCronJob {
id: string;
name: string;
description?: string;
enabled: boolean;
createdAtMs: number;
updatedAtMs: number;
schedule: { kind: string; expr?: string; everyMs?: number; at?: string; tz?: string };
payload: { kind: string; message?: string; text?: string };
delivery?: { mode: string; channel?: string; to?: string; accountId?: string };
sessionTarget?: string;
state: {
nextRunAtMs?: number;
runningAtMs?: number;
lastRunAtMs?: number;
lastStatus?: string;
lastError?: string;
lastDurationMs?: number;
};
}
interface CronRunLogEntry {
jobId?: string;
action?: string;
status?: string;
error?: string;
summary?: string;
sessionId?: string;
sessionKey?: string;
ts?: number;
runAtMs?: number;
durationMs?: number;
model?: string;
provider?: string;
}
interface CronSessionKeyParts {
agentId: string;
jobId: string;
runSessionId?: string;
}
interface CronSessionFallbackMessage {
id: string;
role: 'assistant' | 'system';
content: string;
timestamp: number;
isError?: boolean;
}
type JsonRecord = Record<string, unknown>;
function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
if (!sessionKey.startsWith('agent:')) return null;
const parts = sessionKey.split(':');
if (parts.length < 4 || parts[2] !== 'cron') return null;
const agentId = parts[1] || 'main';
const jobId = parts[3];
if (!jobId) return null;
if (parts.length === 4) return { agentId, jobId };
if (parts.length === 6 && parts[4] === 'run' && parts[5]) {
return { agentId, jobId, runSessionId: parts[5] };
}
return null;
}
function normalizeTimestampMs(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value < 1e12 ? value * 1000 : value;
}
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function formatDuration(durationMs: number | undefined): string | null {
if (!durationMs || !Number.isFinite(durationMs)) return null;
if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
if (durationMs < 10_000) return `${(durationMs / 1000).toFixed(1)}s`;
return `${Math.round(durationMs / 1000)}s`;
}
function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSessionFallbackMessage | null {
const timestamp = normalizeTimestampMs(entry.ts) ?? normalizeTimestampMs(entry.runAtMs);
if (!timestamp) return null;
const status = typeof entry.status === 'string' ? entry.status.toLowerCase() : '';
const summary = typeof entry.summary === 'string' ? entry.summary.trim() : '';
const error = typeof entry.error === 'string' ? entry.error.trim() : '';
let content = summary || error;
if (!content) {
content = status === 'error' ? 'Scheduled task failed.' : 'Scheduled task completed.';
}
if (status === 'error' && !content.toLowerCase().startsWith('run failed:')) {
content = `Run failed: ${content}`;
}
const meta: string[] = [];
const duration = formatDuration(entry.durationMs);
if (duration) meta.push(`Duration: ${duration}`);
if (entry.provider && entry.model) meta.push(`Model: ${entry.provider}/${entry.model}`);
else if (entry.model) meta.push(`Model: ${entry.model}`);
if (meta.length > 0) content = `${content}\n\n${meta.join(' | ')}`;
return {
id: `cron-run-${entry.sessionId ?? entry.ts ?? index}`,
role: status === 'error' ? 'system' : 'assistant',
content,
timestamp,
...(status === 'error' ? { isError: true } : {}),
};
}
async function readCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
const logPath = join(getOpenClawConfigDir(), 'cron', 'runs', `${jobId}.jsonl`);
const raw = await readFile(logPath, 'utf8').catch(() => '');
if (!raw.trim()) return [];
const entries: CronRunLogEntry[] = [];
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const entry = JSON.parse(trimmed) as CronRunLogEntry;
if (!entry || entry.jobId !== jobId) continue;
if (entry.action && entry.action !== 'finished') continue;
entries.push(entry);
} catch {
// Ignore malformed log lines.
}
}
return entries;
}
async function readSessionStoreEntry(
agentId: string,
sessionKey: string,
): Promise<Record<string, unknown> | undefined> {
const storePath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json');
const raw = await readFile(storePath, 'utf8').catch(() => '');
if (!raw.trim()) return undefined;
try {
const store = JSON.parse(raw) as Record<string, unknown>;
const directEntry = store[sessionKey];
if (directEntry && typeof directEntry === 'object') return directEntry as Record<string, unknown>;
const sessions = (store as { sessions?: unknown }).sessions;
if (Array.isArray(sessions)) {
const arrayEntry = sessions.find((entry) => {
if (!entry || typeof entry !== 'object') return false;
const record = entry as Record<string, unknown>;
return record.key === sessionKey || record.sessionKey === sessionKey;
});
if (arrayEntry && typeof arrayEntry === 'object') return arrayEntry as Record<string, unknown>;
}
} catch {
return undefined;
}
return undefined;
}
function buildCronSessionFallbackMessages(params: {
sessionKey: string;
job?: Pick<GatewayCronJob, 'name' | 'payload' | 'state'>;
runs: CronRunLogEntry[];
sessionEntry?: { label?: string; updatedAt?: number };
limit?: number;
}): CronSessionFallbackMessage[] {
const parsed = parseCronSessionKey(params.sessionKey);
if (!parsed) return [];
const matchingRuns = params.runs
.filter((entry) => {
if (!parsed.runSessionId) return true;
return entry.sessionId === parsed.runSessionId || entry.sessionKey === `${params.sessionKey}`;
})
.sort((a, b) => {
const left = normalizeTimestampMs(a.ts) ?? normalizeTimestampMs(a.runAtMs) ?? 0;
const right = normalizeTimestampMs(b.ts) ?? normalizeTimestampMs(b.runAtMs) ?? 0;
return left - right;
});
const messages: CronSessionFallbackMessage[] = [];
const prompt = params.job?.payload?.message || params.job?.payload?.text || '';
const taskName = params.job?.name?.trim()
|| params.sessionEntry?.label?.replace(/^Cron:\s*/, '').trim()
|| '';
const firstRelevantTimestamp = matchingRuns.length > 0
? (normalizeTimestampMs(matchingRuns[0]?.runAtMs) ?? normalizeTimestampMs(matchingRuns[0]?.ts))
: (normalizeTimestampMs(params.job?.state?.runningAtMs) ?? params.sessionEntry?.updatedAt);
if (taskName || prompt) {
const lines = [taskName ? `Scheduled task: ${taskName}` : 'Scheduled task'];
if (prompt) lines.push(`Prompt: ${prompt}`);
messages.push({
id: `cron-meta-${parsed.jobId}`,
role: 'system',
content: lines.join('\n'),
timestamp: Math.max(0, (firstRelevantTimestamp ?? Date.now()) - 1),
});
}
matchingRuns.forEach((entry, index) => {
const message = buildCronRunMessage(entry, index);
if (message) messages.push(message);
});
if (matchingRuns.length === 0) {
const runningAt = normalizeTimestampMs(params.job?.state?.runningAtMs);
if (runningAt) {
messages.push({
id: `cron-running-${parsed.jobId}`,
role: 'system',
content: 'This scheduled task is still running in OpenClaw, but no chat transcript is available yet.',
timestamp: runningAt,
});
} else if (messages.length === 0) {
messages.push({
id: `cron-empty-${parsed.jobId}`,
role: 'system',
content: 'No chat transcript is available for this scheduled task yet.',
timestamp: params.sessionEntry?.updatedAt ?? Date.now(),
});
}
}
const limit = typeof params.limit === 'number' && Number.isFinite(params.limit)
? Math.max(1, Math.floor(params.limit))
: messages.length;
return messages.slice(-limit);
}
function getUnsupportedCronDeliveryError(_channel: string | undefined): string | null {
return null;
}
function normalizeCronDelivery(
rawDelivery: unknown,
fallbackMode: CronJobDelivery['mode'] = 'none',
): CronJobDelivery {
if (!rawDelivery || typeof rawDelivery !== 'object') return { mode: fallbackMode };
const delivery = rawDelivery as JsonRecord;
const mode = delivery.mode === 'announce' ? 'announce' : fallbackMode;
const channel = typeof delivery.channel === 'string' && delivery.channel.trim()
? toOpenClawChannelType(delivery.channel.trim())
: undefined;
const to = typeof delivery.to === 'string' && delivery.to.trim() ? delivery.to.trim() : undefined;
const accountId = typeof delivery.accountId === 'string' && delivery.accountId.trim()
? delivery.accountId.trim()
: undefined;
if (mode === 'announce' && !channel) return { mode: 'none' };
return {
mode,
...(channel ? { channel } : {}),
...(to ? { to } : {}),
...(accountId ? { accountId } : {}),
};
}
function normalizeCronSchedule(schedule: GatewayCronJob['schedule']): CronJob['schedule'] {
if (schedule.kind === 'at' && typeof schedule.at === 'string') {
return { kind: 'at', at: schedule.at };
}
if (schedule.kind === 'every' && typeof schedule.everyMs === 'number') {
return {
kind: 'every',
everyMs: schedule.everyMs,
...(typeof (schedule as CronSchedule & { anchorMs?: unknown }).anchorMs === 'number'
? { anchorMs: (schedule as CronSchedule & { anchorMs: number }).anchorMs }
: {}),
};
}
if (schedule.kind === 'cron' && typeof schedule.expr === 'string') {
return { kind: 'cron', expr: schedule.expr, ...(schedule.tz ? { tz: schedule.tz } : {}) };
}
return typeof schedule.expr === 'string' ? schedule.expr : '';
}
/**
* Normalize a UI-supplied schedule (plain cron string or structured CronSchedule)
* into the structured form the Gateway expects. Plain strings become a cron
* schedule; structured `at` / `every` / `cron` objects pass through after a
* minimal shape check.
*/
function normalizeScheduleInput(schedule: unknown): CronSchedule {
if (typeof schedule === 'string') {
return { kind: 'cron', expr: schedule };
}
if (schedule && typeof schedule === 'object') {
const record = schedule as Record<string, unknown>;
if (record.kind === 'at' && typeof record.at === 'string' && record.at.trim()) {
return { kind: 'at', at: record.at };
}
if (record.kind === 'every' && typeof record.everyMs === 'number' && Number.isFinite(record.everyMs)) {
return {
kind: 'every',
everyMs: record.everyMs,
...(typeof record.anchorMs === 'number' ? { anchorMs: record.anchorMs } : {}),
};
}
if (record.kind === 'cron' && typeof record.expr === 'string') {
return { kind: 'cron', expr: record.expr, ...(typeof record.tz === 'string' && record.tz ? { tz: record.tz } : {}) };
}
}
throw new Error('Invalid schedule: expected a cron expression string or a CronSchedule object');
}
function normalizeCronDeliveryPatch(rawDelivery: unknown): Record<string, unknown> {
if (!rawDelivery || typeof rawDelivery !== 'object') return {};
const delivery = rawDelivery as JsonRecord;
const patch: Record<string, unknown> = {};
if ('mode' in delivery) {
patch.mode = typeof delivery.mode === 'string' && delivery.mode.trim() ? delivery.mode.trim() : 'none';
}
if ('channel' in delivery) {
patch.channel = typeof delivery.channel === 'string' && delivery.channel.trim()
? toOpenClawChannelType(delivery.channel.trim())
: '';
}
if ('to' in delivery) patch.to = typeof delivery.to === 'string' ? delivery.to : '';
if ('accountId' in delivery) patch.accountId = typeof delivery.accountId === 'string' ? delivery.accountId : '';
return patch;
}
function buildCronUpdatePatch(input: Record<string, unknown>): Record<string, unknown> {
const patch = { ...input };
if ('schedule' in patch && patch.schedule !== undefined) patch.schedule = normalizeScheduleInput(patch.schedule);
if (typeof patch.message === 'string') {
patch.payload = { kind: 'agentTurn', message: patch.message };
delete patch.message;
}
if ('delivery' in patch) patch.delivery = normalizeCronDeliveryPatch(patch.delivery);
if ('agentId' in patch) {
patch.agentId = typeof patch.agentId === 'string' && patch.agentId.trim() ? patch.agentId.trim() : 'main';
}
return patch;
}
function transformCronJob(job: GatewayCronJob): CronJob {
const message = job.payload?.message || job.payload?.text || '';
const gatewayDelivery = normalizeCronDelivery(job.delivery);
const channelType = gatewayDelivery.channel ? toUiChannelType(gatewayDelivery.channel) : undefined;
const delivery = channelType ? { ...gatewayDelivery, channel: channelType } : gatewayDelivery;
const target = channelType
? {
channelType,
channelId: delivery.accountId || gatewayDelivery.channel || channelType,
channelName: channelType,
recipient: delivery.to,
}
: undefined;
const lastRun = job.state?.lastRunAtMs
? {
time: new Date(job.state.lastRunAtMs).toISOString(),
success: job.state.lastStatus === 'ok',
error: job.state.lastError,
duration: job.state.lastDurationMs,
}
: undefined;
const nextRun = job.state?.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : undefined;
const agentId = (job as unknown as { agentId?: string }).agentId || 'main';
return {
id: job.id,
name: job.name,
message,
schedule: normalizeCronSchedule(job.schedule),
delivery,
target,
enabled: job.enabled,
createdAt: new Date(job.createdAtMs).toISOString(),
updatedAt: new Date(job.updatedAtMs).toISOString(),
lastRun,
nextRun,
agentId,
};
}
async function listCronJobs(gatewayManager: GatewayManager): Promise<CronJob[]> {
let jobs: GatewayCronJob[] = [];
let usedFallback = false;
try {
const result = await gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000);
const data = result as { jobs?: GatewayCronJob[] };
jobs = data?.jobs ?? (Array.isArray(result) ? result as GatewayCronJob[] : []);
} catch {
try {
const cronJsonPath = join(getOpenClawConfigDir(), 'cron', 'cron.json');
const raw = await readFile(cronJsonPath, 'utf-8');
const parsed = JSON.parse(raw);
jobs = (Array.isArray(parsed) ? parsed : (parsed?.jobs ?? [])) as GatewayCronJob[];
usedFallback = true;
} catch {
// No fallback data available.
}
}
if (!usedFallback && jobs.length > 0) {
repairCronJobsInBackground(gatewayManager, jobs);
}
return jobs.map((job) => ({ ...transformCronJob(job), ...(usedFallback ? { _fromFallback: true } : {}) }));
}
function repairCronJobsInBackground(gatewayManager: GatewayManager, jobs: GatewayCronJob[]): void {
const jobsToRepairDelivery = jobs.filter((job) => {
const isIsolatedAgent = (job.sessionTarget === 'isolated' || !job.sessionTarget)
&& job.payload?.kind === 'agentTurn';
return isIsolatedAgent && job.delivery?.mode === 'announce' && !job.delivery?.channel;
});
if (jobsToRepairDelivery.length > 0) {
void (async () => {
for (const job of jobsToRepairDelivery) {
try {
await gatewayManager.rpc('cron.update', {
id: job.id,
patch: { delivery: { mode: 'none' } },
});
} catch {
// ignore per-job repair failure
}
}
})();
for (const job of jobsToRepairDelivery) {
job.delivery = { mode: 'none' };
if (job.state?.lastError?.includes('Channel is required')) {
job.state.lastError = undefined;
job.state.lastStatus = 'ok';
}
}
}
const jobsToRepairAgent = jobs.filter((job) => {
const jobAgentId = (job as unknown as { agentId?: string }).agentId;
return (
(job.sessionTarget === 'isolated' || !job.sessionTarget)
&& job.payload?.kind === 'agentTurn'
&& job.delivery?.mode === 'announce'
&& job.delivery?.channel
&& jobAgentId === undefined
);
});
if (jobsToRepairAgent.length > 0) {
void (async () => {
for (const job of jobsToRepairAgent) {
try {
const channel = toOpenClawChannelType(job.delivery!.channel!);
const accountId = job.delivery!.accountId;
const toAddress = job.delivery!.to;
let correctAgentId = await resolveAgentIdFromChannel(channel, accountId);
let resolvedAccountId: string | null = null;
if (!correctAgentId && !accountId && toAddress) {
resolvedAccountId = await resolveAccountIdFromSessionHistory(toAddress, channel);
if (resolvedAccountId) {
correctAgentId = await resolveAgentIdFromChannel(channel, resolvedAccountId);
}
}
if (correctAgentId) {
const patch: Record<string, unknown> = { agentId: correctAgentId };
if (resolvedAccountId && !accountId) patch.delivery = { accountId: resolvedAccountId };
await gatewayManager.rpc('cron.update', { id: job.id, patch });
(job as unknown as { agentId: string }).agentId = correctAgentId;
if (resolvedAccountId && !accountId && job.delivery) job.delivery.accountId = resolvedAccountId;
}
} catch {
// ignore per-job repair failure
}
}
})();
}
}
function getId(payload: unknown): string {
const body = isRecord(payload) ? payload : {};
const id = body.id;
if (typeof id !== 'string' || !id.trim()) throw new Error('id is required');
return id.trim();
}
export function createCronApi({ gatewayManager }: { gatewayManager: GatewayManager }): CompleteHostServiceRegistry['cron'] {
return {
list: async () => listCronJobs(gatewayManager),
create: async (payload) => {
const input = payload;
const agentId = typeof input.agentId === 'string' && input.agentId.trim() ? input.agentId.trim() : 'main';
const delivery = normalizeCronDelivery(input.delivery);
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(delivery.channel);
if (delivery.mode === 'announce' && unsupportedDeliveryError) {
throw new Error(unsupportedDeliveryError);
}
const result = await gatewayManager.rpc('cron.add', {
name: input.name,
schedule: normalizeScheduleInput(input.schedule),
payload: { kind: 'agentTurn', message: input.message },
enabled: typeof input.enabled === 'boolean' ? input.enabled : true,
wakeMode: 'next-heartbeat',
sessionTarget: 'isolated',
agentId,
delivery,
});
if (!result || typeof result !== 'object') {
throw new Error('Cron create returned an invalid job');
}
return transformCronJob(result as GatewayCronJob);
},
update: async (payload) => {
const body = payload;
const id = getId(body);
const input = isRecord(body.input) ? body.input : {};
const patch = buildCronUpdatePatch(input);
delete patch.id;
delete patch.input;
const deliveryPatch = patch.delivery && typeof patch.delivery === 'object'
? patch.delivery as Record<string, unknown>
: undefined;
const deliveryChannel = typeof deliveryPatch?.channel === 'string' && deliveryPatch.channel.trim()
? deliveryPatch.channel.trim()
: undefined;
const deliveryMode = typeof deliveryPatch?.mode === 'string' && deliveryPatch.mode.trim()
? deliveryPatch.mode.trim()
: undefined;
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(deliveryChannel);
if (unsupportedDeliveryError && deliveryMode !== 'none') {
throw new Error(unsupportedDeliveryError);
}
const result = await gatewayManager.rpc('cron.update', { id, patch });
if (!result || typeof result !== 'object') {
throw new Error('Cron update returned an invalid job');
}
return transformCronJob(result as GatewayCronJob);
},
delete: async (payload) => gatewayManager.rpc('cron.remove', { id: getId(payload) }),
toggle: async (payload) => {
const body = payload;
return gatewayManager.rpc('cron.update', {
id: getId(body),
patch: { enabled: body.enabled === true },
});
},
trigger: async (payload) => gatewayManager.rpc('cron.run', { id: getId(payload), mode: 'force' }),
sessionHistory: async (payload) => {
const body = payload;
const sessionKey = typeof body.sessionKey === 'string' ? body.sessionKey.trim() : '';
const parsedSession = parseCronSessionKey(sessionKey);
if (!parsedSession) return { success: false, error: `Invalid cron sessionKey: ${sessionKey}` };
const rawLimit = typeof body.limit === 'number' ? body.limit : Number(body.limit || 200);
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 200) : 200;
const [jobsResult, runs, sessionEntry] = await Promise.all([
gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000)
.catch(() => ({ jobs: [] as GatewayCronJob[] })),
readCronRunLog(parsedSession.jobId),
readSessionStoreEntry(parsedSession.agentId, sessionKey),
]);
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
const job = jobs.find((item) => item.id === parsedSession.jobId);
return {
messages: buildCronSessionFallbackMessages({
sessionKey,
job,
runs,
sessionEntry: sessionEntry ? {
label: typeof sessionEntry.label === 'string' ? sessionEntry.label : undefined,
updatedAt: normalizeTimestampMs(sessionEntry.updatedAt),
} : undefined,
limit,
}),
};
},
deliveryTargets: async () => ({ success: true, targets: [] }),
};
}
+83
View File
@@ -0,0 +1,83 @@
import { open } from 'node:fs/promises';
import { join } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { GatewayManager } from '../gateway/manager';
import { logger } from '../utils/logger';
import { getOpenClawConfigDir } from '../utils/paths';
import { buildGatewayHealthSummary } from '../utils/gateway-health';
import { buildChannelAccountsView, getChannelStatusDiagnostics } from './channels-api';
const DEFAULT_TAIL_LINES = 200;
type DiagnosticsApiContext = {
gatewayManager: GatewayManager;
};
async function readTail(filePath: string, tailLines = DEFAULT_TAIL_LINES): Promise<string> {
const safeTailLines = Math.max(1, Math.floor(tailLines));
try {
const file = await open(filePath, 'r');
try {
const stat = await file.stat();
if (stat.size === 0) return '';
const chunkSize = 64 * 1024;
let position = stat.size;
let content = '';
let lineCount = 0;
while (position > 0 && lineCount <= safeTailLines) {
const bytesToRead = Math.min(chunkSize, position);
position -= bytesToRead;
const buffer = Buffer.allocUnsafe(bytesToRead);
const { bytesRead } = await file.read(buffer, 0, bytesToRead, position);
content = `${buffer.subarray(0, bytesRead).toString('utf-8')}${content}`;
lineCount = content.split('\n').length - 1;
}
const lines = content.split('\n');
return lines.length <= safeTailLines ? content : lines.slice(-safeTailLines).join('\n');
} finally {
await file.close();
}
} catch {
return '';
}
}
export function createDiagnosticsApi(ctx: DiagnosticsApiContext): CompleteHostServiceRegistry['diagnostics'] {
return {
gatewaySnapshot: async () => {
const { channels } = await buildChannelAccountsView(ctx, { probe: false });
const diagnostics = ctx.gatewayManager.getDiagnostics?.() ?? {
consecutiveHeartbeatMisses: 0,
consecutiveRpcFailures: 0,
};
const channelStatusDiagnostics = getChannelStatusDiagnostics();
const gatewayStatus = ctx.gatewayManager.getStatus();
const gatewaySummary = buildGatewayHealthSummary({
status: gatewayStatus,
diagnostics,
lastChannelsStatusOkAt: channelStatusDiagnostics.lastChannelsStatusOkAt,
lastChannelsStatusFailureAt: channelStatusDiagnostics.lastChannelsStatusFailureAt,
});
const gateway = {
...gatewayStatus,
...gatewaySummary,
capabilities: typeof ctx.gatewayManager.getCapabilitySnapshot === 'function'
? ctx.gatewayManager.getCapabilitySnapshot(gatewaySummary)
: undefined,
};
const openClawDir = getOpenClawConfigDir();
return {
capturedAt: Date.now(),
platform: process.platform,
gateway,
channels,
clawxLogTail: await logger.readLogFile(DEFAULT_TAIL_LINES),
gatewayLogTail: await readTail(join(openClawDir, 'logs', 'gateway.log')),
gatewayErrLogTail: await readTail(join(openClawDir, 'logs', 'gateway.err.log')),
};
},
};
}
+9
View File
@@ -0,0 +1,9 @@
import { dialog, type MessageBoxOptions, type OpenDialogOptions } from 'electron';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
export function createDialogApi(): CompleteHostServiceRegistry['dialog'] {
return {
open: (payload) => dialog.showOpenDialog(payload as OpenDialogOptions),
message: (payload) => dialog.showMessageBox(payload as MessageBoxOptions),
};
}
+485
View File
@@ -0,0 +1,485 @@
import { app, nativeImage } from 'electron';
import crypto from 'node:crypto';
import { homedir } from 'node:os';
import { basename, extname, join, relative, resolve, sep } from 'node:path';
import type {
FilePreviewTreeNode,
FilePreviewTreeOptions,
FileReadBinaryOptions,
} from '@shared/host-api/contract';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { expandPath } from '../utils/paths';
import { isRecord } from './payload-utils';
const EXT_MIME_MAP: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
'.mkv': 'video/x-matroska',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
'.pdf': 'application/pdf',
'.zip': 'application/zip',
'.gz': 'application/gzip',
'.tar': 'application/x-tar',
'.7z': 'application/x-7z-compressed',
'.rar': 'application/vnd.rar',
'.json': 'application/json',
'.xml': 'application/xml',
'.csv': 'text/csv',
'.txt': 'text/plain',
'.md': 'text/markdown',
'.html': 'text/html',
'.htm': 'text/html',
'.css': 'text/css',
'.js': 'text/javascript',
'.ts': 'text/typescript',
'.py': 'text/x-python',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
};
const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound');
const DIRECTORY_MIME_TYPE = 'application/x-directory';
const FILE_PREVIEW_MAX_TEXT_BYTES = 2 * 1024 * 1024;
const FILE_PREVIEW_MAX_BINARY_BYTES = 50 * 1024 * 1024;
const FILE_PREVIEW_TREE_MAX_DEPTH = 6;
const FILE_PREVIEW_TREE_MAX_NODES = 5000;
const FILE_PREVIEW_DIR_BLACKLIST = new Set([
'node_modules',
'.venv',
'__pycache__',
'.git',
'dist',
'build',
'.next',
'.turbo',
'.cache',
]);
type StagePathsPayload = {
filePaths?: unknown;
};
type StageBufferPayload = {
base64?: unknown;
fileName?: unknown;
mimeType?: unknown;
};
type PathPayload = {
path?: unknown;
content?: unknown;
opts?: unknown;
};
type ResolvedSandboxedPath = {
realPath: string;
readOnly: boolean;
};
function getMimeType(ext: string): string {
return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream';
}
function mimeToExt(mimeType: string): string {
for (const [ext, mime] of Object.entries(EXT_MIME_MAP)) {
if (mime === mimeType) return ext;
}
return '';
}
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
try {
const img = nativeImage.createFromPath(filePath);
if (img.isEmpty()) return null;
const size = img.getSize();
const maxDim = 512;
if (size.width > maxDim || size.height > maxDim) {
const resized = size.width >= size.height
? img.resize({ width: maxDim })
: img.resize({ height: maxDim });
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
}
const { readFile } = await import('node:fs/promises');
const buf = await readFile(filePath);
return `data:${mimeType};base64,${buf.toString('base64')}`;
} catch {
return null;
}
}
function requirePath(payload: unknown): string {
const path = isRecord(payload) ? payload.path : payload;
if (typeof path !== 'string' || !path.trim()) {
throw new Error('Invalid file path');
}
return path;
}
function isPathInside(child: string, parent: string): boolean {
const c = resolve(child);
const p = resolve(parent);
if (process.platform === 'win32') {
const cl = c.toLowerCase();
const pl = p.toLowerCase();
return cl === pl || cl.startsWith(pl + sep);
}
return c === p || c.startsWith(p + sep);
}
function getFilePreviewWriteRoots(): string[] {
const roots: string[] = [];
roots.push(resolve(join(homedir(), '.openclaw')));
try {
roots.push(resolve(app.getPath('userData')));
} catch {
// ignore
}
roots.push(resolve(OUTBOUND_DIR));
return roots;
}
async function resolveSandboxedPath(
input: string,
mode: 'read' | 'write' = 'read',
): Promise<ResolvedSandboxedPath> {
if (!input.trim()) {
throw new Error('outsideSandbox');
}
const expanded = expandPath(input);
const fsP = await import('node:fs/promises');
let real: string;
try {
real = await fsP.realpath(expanded);
} catch {
real = resolve(expanded);
}
const writeRoots = getFilePreviewWriteRoots();
if (writeRoots.some((root) => isPathInside(real, root))) {
return { realPath: real, readOnly: false };
}
if (mode === 'write') {
throw new Error('readOnlyRoot');
}
return { realPath: real, readOnly: true };
}
function looksLikeBinary(buf: Buffer): boolean {
const limit = Math.min(buf.length, 8192);
for (let i = 0; i < limit; i += 1) {
if (buf[i] === 0) return true;
}
return false;
}
function shouldSkipDirEntry(name: string, includeHidden: boolean): boolean {
if (FILE_PREVIEW_DIR_BLACKLIST.has(name)) return true;
if (!includeHidden && name.startsWith('.')) return true;
return false;
}
function shouldSkipFileEntry(name: string, includeHidden: boolean): boolean {
if (!includeHidden && name.startsWith('.')) return true;
return false;
}
function getTreeOptions(opts: unknown): FilePreviewTreeOptions {
return isRecord(opts) ? opts as FilePreviewTreeOptions : {};
}
function getBinaryOptions(opts: unknown): FileReadBinaryOptions {
return isRecord(opts) ? opts as FileReadBinaryOptions : {};
}
export function createFilesApi(): CompleteHostServiceRegistry['files'] {
return {
stagePaths: async (payload) => {
const body = isRecord(payload) ? payload as StagePathsPayload : {};
const filePaths = Array.isArray(body.filePaths)
? body.filePaths.filter((value): value is string => typeof value === 'string')
: [];
const fsP = await import('node:fs/promises');
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
const results = [];
for (const filePath of filePaths) {
const id = crypto.randomUUID();
const fileName = basename(filePath);
const sourceStat = await fsP.stat(filePath);
if (sourceStat.isDirectory()) {
results.push({
id,
fileName,
mimeType: DIRECTORY_MIME_TYPE,
fileSize: 0,
stagedPath: filePath,
preview: null,
});
continue;
}
const ext = extname(filePath);
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
await fsP.copyFile(filePath, stagedPath);
const s = await fsP.stat(stagedPath);
const mimeType = getMimeType(ext);
const preview = mimeType.startsWith('image/')
? await generateImagePreview(stagedPath, mimeType)
: null;
results.push({ id, fileName, mimeType, fileSize: s.size, stagedPath, preview });
}
return results;
},
stageBuffer: async (payload) => {
const body = isRecord(payload) ? payload as StageBufferPayload : {};
if (typeof body.base64 !== 'string' || typeof body.fileName !== 'string') {
throw new Error('Invalid staged buffer payload');
}
const fsP = await import('node:fs/promises');
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
const id = crypto.randomUUID();
const payloadMimeType = typeof body.mimeType === 'string' ? body.mimeType : '';
const ext = extname(body.fileName) || mimeToExt(payloadMimeType);
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
const buffer = Buffer.from(body.base64, 'base64');
await fsP.writeFile(stagedPath, buffer);
const mimeType = payloadMimeType || getMimeType(ext);
const preview = mimeType.startsWith('image/')
? await generateImagePreview(stagedPath, mimeType)
: null;
return {
id,
fileName: body.fileName,
mimeType,
fileSize: buffer.length,
stagedPath,
preview,
};
},
readText: async (payload) => {
try {
const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read');
const fsP = await import('node:fs/promises');
const stat = await fsP.stat(real);
if (!stat.isFile()) return { ok: false, error: 'notFound' };
if (stat.size > FILE_PREVIEW_MAX_TEXT_BYTES) return { ok: false, error: 'tooLarge', size: stat.size };
const buf = await fsP.readFile(real);
if (looksLikeBinary(buf)) return { ok: false, error: 'binary', size: stat.size };
return {
ok: true,
content: buf.toString('utf8'),
mimeType: getMimeType(extname(real)),
size: stat.size,
readOnly,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
if (message.includes('ENOENT')) return { ok: false, error: 'notFound' };
return { ok: false, error: message };
}
},
readBinary: async (payload) => {
try {
const body = isRecord(payload) ? payload as PathPayload : {};
const opts = getBinaryOptions(body.opts);
const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read');
const fsP = await import('node:fs/promises');
const stat = await fsP.stat(real);
if (!stat.isFile()) return { ok: false, error: 'notFound' };
const maxBytes = typeof opts.maxBytes === 'number' ? opts.maxBytes : undefined;
const cap = Math.max(1, Math.min(maxBytes ?? FILE_PREVIEW_MAX_BINARY_BYTES, FILE_PREVIEW_MAX_BINARY_BYTES));
if (stat.size > cap) return { ok: false, error: 'tooLarge', size: stat.size };
const buf = await fsP.readFile(real);
const view = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
return {
ok: true,
data: view,
mimeType: getMimeType(extname(real)),
size: stat.size,
readOnly,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
if (message.includes('ENOENT')) return { ok: false, error: 'notFound' };
return { ok: false, error: message };
}
},
writeText: async (payload) => {
try {
const body = isRecord(payload) ? payload as PathPayload : {};
if (typeof body.content !== 'string') return { ok: false, error: 'invalidContent' };
if (Buffer.byteLength(body.content, 'utf8') > FILE_PREVIEW_MAX_TEXT_BYTES) {
return { ok: false, error: 'tooLarge' };
}
const { realPath: real } = await resolveSandboxedPath(requirePath(payload), 'write');
const fsP = await import('node:fs/promises');
let stat;
try {
stat = await fsP.stat(real);
} catch {
return { ok: false, error: 'notFound' };
}
if (!stat.isFile()) return { ok: false, error: 'notFound' };
await fsP.writeFile(real, body.content, 'utf8');
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
if (message === 'readOnlyRoot') return { ok: false, error: 'readOnlyRoot' };
return { ok: false, error: message };
}
},
stat: async (payload) => {
try {
const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read');
const fsP = await import('node:fs/promises');
const stat = await fsP.stat(real);
return {
ok: true,
size: stat.size,
mtime: stat.mtimeMs,
isFile: stat.isFile(),
isDir: stat.isDirectory(),
readOnly,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
if (message.includes('ENOENT')) return { ok: false, error: 'notFound' };
return { ok: false, error: message };
}
},
listDir: async (payload) => {
try {
const { realPath: real } = await resolveSandboxedPath(requirePath(payload), 'read');
const fsP = await import('node:fs/promises');
const dirents = await fsP.readdir(real, { withFileTypes: true });
const entries = await Promise.all(dirents.map(async (entry) => {
const abs = join(real, entry.name);
let size = 0;
try {
if (entry.isFile()) size = (await fsP.stat(abs)).size;
} catch {
// non-fatal
}
return {
name: entry.name,
path: abs,
isDir: entry.isDirectory(),
size,
};
}));
return { ok: true, entries };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
if (message.includes('ENOENT')) return { ok: false, error: 'notFound' };
return { ok: false, error: message };
}
},
listTree: async (payload) => {
try {
const body = isRecord(payload) ? payload as PathPayload : {};
const opts = getTreeOptions(body.opts);
const { realPath: real } = await resolveSandboxedPath(requirePath(payload), 'read');
const fsP = await import('node:fs/promises');
const stat = await fsP.stat(real);
if (!stat.isDirectory()) return { ok: false, error: 'notDirectory' };
const maxDepth = Math.max(1, Math.min(opts.maxDepth ?? FILE_PREVIEW_TREE_MAX_DEPTH, 12));
const maxNodes = Math.max(1, Math.min(opts.maxNodes ?? FILE_PREVIEW_TREE_MAX_NODES, 50000));
const includeHidden = !!opts.includeHidden;
let nodeCount = 0;
let truncated = false;
const walk = async (absDir: string, depth: number): Promise<FilePreviewTreeNode[] | undefined> => {
if (depth > maxDepth || truncated) return undefined;
let dirents;
try {
dirents = await fsP.readdir(absDir, { withFileTypes: true });
} catch {
return [];
}
const children: FilePreviewTreeNode[] = [];
for (const entry of dirents) {
if (truncated) break;
const isDir = entry.isDirectory();
const isFile = entry.isFile();
if (!isDir && !isFile) continue;
if (isDir && shouldSkipDirEntry(entry.name, includeHidden)) continue;
if (isFile && shouldSkipFileEntry(entry.name, includeHidden)) continue;
if (nodeCount >= maxNodes) {
truncated = true;
break;
}
nodeCount += 1;
const abs = join(absDir, entry.name);
const node: FilePreviewTreeNode = {
name: entry.name,
relPath: relative(real, abs).split(sep).join('/'),
absPath: abs,
isDir,
};
if (isFile) {
try {
const fstat = await fsP.stat(abs);
node.size = fstat.size;
node.mtime = fstat.mtimeMs;
} catch {
// non-fatal
}
} else {
try {
node.mtime = (await fsP.stat(abs)).mtimeMs;
} catch {
// non-fatal
}
node.children = await walk(abs, depth + 1) ?? [];
}
children.push(node);
}
children.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
return children;
};
const root: FilePreviewTreeNode = {
name: basename(real) || real,
relPath: '',
absPath: real,
isDir: true,
mtime: stat.mtimeMs,
children: (await walk(real, 1)) ?? [],
};
return { ok: true, root, truncated };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
if (message.includes('ENOENT')) return { ok: false, error: 'notFound' };
return { ok: false, error: message };
}
},
};
}
+79
View File
@@ -0,0 +1,79 @@
import type { GatewayManager } from '../gateway/manager';
import type { GatewayRpcBackpressure } from '../gateway/rpc-backpressure';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { PORTS } from '../utils/config';
import { scheduleControlUiDeviceAutoApproval } from '../utils/control-ui-device-pairing';
import { buildOpenClawControlUiUrl } from '../utils/openclaw-control-ui';
import { getSetting } from '../utils/store';
import { isRecord } from './payload-utils';
type HealthPayload = {
probe?: unknown;
};
type ControlUiPayload = {
view?: unknown;
};
type RpcPayload = {
method?: unknown;
params?: unknown;
timeoutMs?: unknown;
};
function parseTimeoutMs(timeoutMs: unknown): number | undefined {
if (timeoutMs === undefined) return undefined;
if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new Error('Invalid gateway RPC timeout');
}
return timeoutMs;
}
export function createGatewayApi(
gatewayManager: GatewayManager,
gatewayRpcBackpressure: GatewayRpcBackpressure,
): CompleteHostServiceRegistry['gateway'] {
return {
status: () => gatewayManager.getStatus(),
start: async () => {
await gatewayManager.start();
return { success: true };
},
stop: async () => {
await gatewayManager.stop();
return { success: true };
},
restart: async () => {
await gatewayManager.restart();
return { success: true };
},
health: async (payload) => {
const body = isRecord(payload) ? payload as HealthPayload : {};
return gatewayManager.checkHealth({ probe: body.probe === true });
},
controlUi: async (payload) => {
const body = isRecord(payload) ? payload as ControlUiPayload : {};
const status = gatewayManager.getStatus();
const token = await getSetting('gatewayToken');
const port = status.port || PORTS.OPENCLAW_GATEWAY;
const view = body.view === 'dreams' ? 'dreams' : undefined;
const url = buildOpenClawControlUiUrl(port, token, { view });
scheduleControlUiDeviceAutoApproval(gatewayManager);
return { success: true, url, token, port };
},
rpc: async (payload) => {
const body = isRecord(payload) ? payload as RpcPayload : {};
const method = typeof body.method === 'string' ? body.method.trim() : '';
if (!method) {
throw new Error('Invalid gateway RPC method');
}
const timeoutMs = parseTimeoutMs(body.timeoutMs);
return gatewayRpcBackpressure.run(
method,
body.params,
timeoutMs,
(rpcMethod, rpcParams, rpcTimeoutMs) => gatewayManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
);
},
};
}
+87
View File
@@ -0,0 +1,87 @@
import { readFile } from 'node:fs/promises';
import { extname, relative, resolve, sep } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { logger } from '../utils/logger';
import { isRecord } from './payload-utils';
type RecentPayload = {
tailLines?: unknown;
};
type ReadFilePayload = RecentPayload & {
path?: unknown;
};
type MemoryPayload = {
count?: unknown;
};
function safePositiveInteger(value: unknown, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
return Math.max(1, Math.floor(value));
}
function isPathInside(parentDir: string, childPath: string): boolean {
const relativePath = relative(parentDir, childPath);
return relativePath.length > 0
&& !relativePath.startsWith('..')
&& !relativePath.includes(`..${sep}`);
}
async function validateLogFilePath(path: unknown): Promise<string> {
if (typeof path !== 'string' || path.length === 0) {
throw new Error('Invalid log file path');
}
const resolvedPath = resolve(path);
const files = await logger.listLogFiles();
if (files.some((file) => resolve(file.path) === resolvedPath)) {
return resolvedPath;
}
const logDir = logger.getLogDir();
if (!logDir) {
throw new Error('Invalid log file path');
}
const resolvedLogDir = resolve(logDir);
if (!isPathInside(resolvedLogDir, resolvedPath) || extname(resolvedPath) !== '.log') {
throw new Error('Invalid log file path');
}
return resolvedPath;
}
async function readLogFileTail(path: string, tailLines: number): Promise<string> {
const content = await readFile(path, 'utf8');
const lines = content.split('\n');
const hasTrailingNewline = lines.at(-1) === '';
if (hasTrailingNewline) {
lines.pop();
}
if (lines.length <= tailLines) return content;
const tail = lines.slice(-tailLines).join('\n');
return hasTrailingNewline ? `${tail}\n` : tail;
}
export function createLogsApi(): CompleteHostServiceRegistry['logs'] {
return {
recent: async (payload) => {
const body = isRecord(payload) ? payload as RecentPayload : {};
return { content: await logger.readLogFile(safePositiveInteger(body.tailLines, 100)) };
},
memory: (payload) => {
const body = isRecord(payload) ? payload as MemoryPayload : {};
return logger.getRecentLogs(
body.count === undefined ? undefined : safePositiveInteger(body.count, 100),
);
},
dir: () => ({ dir: logger.getLogDir() }),
filePath: () => ({ path: logger.getLogFilePath() }),
listFiles: async () => ({ files: await logger.listLogFiles() }),
readFile: async (payload) => {
const body = isRecord(payload) ? payload as ReadFilePayload : {};
const path = await validateLogFilePath(body.path);
return { content: await readLogFileTail(path, safePositiveInteger(body.tailLines, 200)) };
},
};
}
+221
View File
@@ -0,0 +1,221 @@
import { dialog, nativeImage } from 'electron';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import {
CLAWX_OPENAI_IMAGE_DEFAULT_MODEL,
CLAWX_OPENAI_IMAGE_PROVIDER_KEY,
} from '../utils/openclaw-image-relay-constants';
import {
applyOpenAiImageRelaySettings,
getImageGenerationSettingsSnapshot,
listImageGenerationProvidersFromRuntime,
runImageGenerationTest,
setImageGenerationConfig,
type ImageGenerationModelConfig,
} from '../utils/openclaw-image-generation';
import { isRecord } from './payload-utils';
type ThumbnailEntry = {
filePath?: unknown;
gatewayUrl?: unknown;
mimeType?: unknown;
};
type SaveImagePayload = {
base64?: unknown;
mimeType?: unknown;
filePath?: unknown;
defaultFileName?: unknown;
};
type ImageGenerationSettingsPayload = {
timeoutMs?: unknown;
openAiRelayEnabled?: unknown;
openAiRelayBaseUrl?: unknown;
openAiRelayModel?: unknown;
openAiRelayApiKey?: unknown;
};
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
try {
const { readFile } = await import('node:fs/promises');
if (mimeType === 'image/svg+xml') {
const buf = await readFile(filePath);
return `data:${mimeType};base64,${buf.toString('base64')}`;
}
const img = nativeImage.createFromPath(filePath);
if (img.isEmpty()) return null;
const size = img.getSize();
const maxDim = 512;
if (size.width > maxDim || size.height > maxDim) {
const resized = size.width >= size.height
? img.resize({ width: maxDim })
: img.resize({ height: maxDim });
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
}
const buf = await readFile(filePath);
return `data:${mimeType};base64,${buf.toString('base64')}`;
} catch {
return null;
}
}
async function resolveOutgoingMediaUrl(
gatewayUrl: string,
): Promise<{ path: string; mimeType: string } | null> {
try {
const match = gatewayUrl.match(/\/api\/chat\/media\/outgoing\/[^/]+\/([^/]+)\//);
if (!match) return null;
const attachmentId = decodeURIComponent(match[1]);
if (!/^[A-Za-z0-9._-]+$/.test(attachmentId)) return null;
const recordPath = join(homedir(), '.openclaw', 'media', 'outgoing', 'records', `${attachmentId}.json`);
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(recordPath, 'utf8');
const record = JSON.parse(raw) as {
original?: { path?: string; contentType?: string };
};
const original = record?.original;
if (!original?.path) return null;
return {
path: original.path,
mimeType: typeof original.contentType === 'string' && original.contentType
? original.contentType
: 'application/octet-stream',
};
} catch {
return null;
}
}
function normalizeThumbnailEntries(payload: unknown): ThumbnailEntry[] {
const value = isRecord(payload) ? payload.paths : payload;
return Array.isArray(value) ? value as ThumbnailEntry[] : [];
}
export function createMediaApi(): CompleteHostServiceRegistry['media'] {
return {
thumbnails: async (payload) => {
const entries = normalizeThumbnailEntries(payload);
const fsP = await import('node:fs/promises');
const results: Record<string, { preview: string | null; fileSize: number }> = {};
for (const entry of entries) {
const mimeType = typeof entry.mimeType === 'string' ? entry.mimeType : 'application/octet-stream';
if (typeof entry.filePath === 'string' && entry.filePath) {
try {
const stat = await fsP.stat(entry.filePath);
const preview = mimeType.startsWith('image/')
? await generateImagePreview(entry.filePath, mimeType)
: null;
results[entry.filePath] = { preview, fileSize: stat.size };
} catch {
results[entry.filePath] = { preview: null, fileSize: 0 };
}
continue;
}
if (typeof entry.gatewayUrl === 'string' && entry.gatewayUrl) {
const resolved = await resolveOutgoingMediaUrl(entry.gatewayUrl);
if (!resolved) {
results[entry.gatewayUrl] = { preview: null, fileSize: 0 };
continue;
}
try {
const stat = await fsP.stat(resolved.path);
const preview = resolved.mimeType.startsWith('image/')
? await generateImagePreview(resolved.path, resolved.mimeType)
: null;
results[entry.gatewayUrl] = { preview, fileSize: stat.size };
} catch {
results[entry.gatewayUrl] = { preview: null, fileSize: 0 };
}
}
}
return results;
},
saveImage: async (payload) => {
const body = isRecord(payload) ? payload as SaveImagePayload : {};
const defaultFileName = typeof body.defaultFileName === 'string' && body.defaultFileName
? body.defaultFileName
: 'image.png';
const mimeType = typeof body.mimeType === 'string' ? body.mimeType : undefined;
const ext = defaultFileName.includes('.')
? defaultFileName.split('.').pop()!
: (mimeType?.split('/')[1] || 'png');
const result = await dialog.showSaveDialog({
defaultPath: join(homedir(), 'Downloads', defaultFileName),
filters: [
{ name: 'Images', extensions: [ext, 'png', 'jpg', 'jpeg', 'webp', 'gif'] },
{ name: 'All Files', extensions: ['*'] },
],
});
if (result.canceled || !result.filePath) return { success: false };
const fsP = await import('node:fs/promises');
if (typeof body.filePath === 'string' && body.filePath) {
try {
await fsP.access(body.filePath);
await fsP.copyFile(body.filePath, result.filePath);
} catch {
return { success: false, error: 'Source file not found' };
}
} else if (typeof body.base64 === 'string' && body.base64) {
await fsP.writeFile(result.filePath, Buffer.from(body.base64, 'base64'));
} else {
return { success: false, error: 'No image data provided' };
}
return { success: true, savedPath: result.filePath };
},
imageGenerationSettings: async () => ({
success: true,
...(await getImageGenerationSettingsSnapshot()),
}),
saveImageGenerationSettings: async (payload) => {
const body = isRecord(payload) ? payload as ImageGenerationSettingsPayload : {};
const current = await getImageGenerationSettingsSnapshot();
const normalizeRelayModel = (value: unknown): string => {
const raw = typeof value === 'string' && value.trim()
? value.trim()
: (current.openAiRelay.model || CLAWX_OPENAI_IMAGE_DEFAULT_MODEL);
const slash = raw.indexOf('/');
return (slash > 0 ? raw.slice(slash + 1) : raw).trim() || CLAWX_OPENAI_IMAGE_DEFAULT_MODEL;
};
const relayModel = normalizeRelayModel(body.openAiRelayModel);
let nextPrimary = current.config.primary;
if (body.openAiRelayEnabled === true) {
nextPrimary = `${CLAWX_OPENAI_IMAGE_PROVIDER_KEY}/${relayModel}`;
} else if (body.openAiRelayEnabled === false) {
nextPrimary = null;
}
const next: ImageGenerationModelConfig = {
primary: nextPrimary,
fallbacks: [],
timeoutMs: body.timeoutMs !== undefined
? (typeof body.timeoutMs === 'number' && body.timeoutMs > 0 ? Math.floor(body.timeoutMs) : null)
: current.config.timeoutMs,
};
if (typeof body.openAiRelayEnabled === 'boolean') {
await applyOpenAiImageRelaySettings({
enabled: body.openAiRelayEnabled,
baseUrl: typeof body.openAiRelayBaseUrl === 'string' ? body.openAiRelayBaseUrl : null,
apiKey: typeof body.openAiRelayApiKey === 'string' ? body.openAiRelayApiKey : undefined,
model: relayModel,
});
}
const config = await setImageGenerationConfig(next);
return {
success: true,
...(await getImageGenerationSettingsSnapshot()),
config,
};
},
imageGenerationProviders: async () => ({
success: true,
providers: await listImageGenerationProvidersFromRuntime(),
}),
testImageGeneration: async (payload) => runImageGenerationTest(isRecord(payload) ? payload : {}),
};
}
+25
View File
@@ -0,0 +1,25 @@
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { getOpenClawCliCommand } from '../utils/openclaw-cli';
import { ensureDir, getOpenClawSkillsDir, getOpenClawStatus } from '../utils/paths';
import { existsSync } from 'node:fs';
export function createOpenClawApi(): CompleteHostServiceRegistry['openclaw'] {
return {
status: () => getOpenClawStatus(),
getSkillsDir: () => {
const dir = getOpenClawSkillsDir();
ensureDir(dir);
return dir;
},
getCliCommand: () => {
const status = getOpenClawStatus();
if (!status.packageExists) {
return { success: false, error: `OpenClaw package not found at: ${status.dir}` };
}
if (!existsSync(status.entryPath)) {
return { success: false, error: `OpenClaw entry script not found at: ${status.entryPath}` };
}
return { success: true, command: getOpenClawCliCommand() };
},
};
}
+5
View File
@@ -0,0 +1,5 @@
export type UnknownRecord = Record<string, unknown>;
export function isRecord(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
+501
View File
@@ -0,0 +1,501 @@
import type { BrowserWindow } from 'electron';
import type { HostApiContract } from '@shared/host-api/contract';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { GatewayManager } from '../gateway/manager';
import type { ProviderConfig } from '../utils/secure-storage';
import { browserOAuthManager, type BrowserOAuthProviderType } from '../utils/browser-oauth';
import { deviceOAuthManager, type OAuthProviderType } from '../utils/device-oauth';
import { removeProviderFromOpenClaw, saveProviderKeyToOpenClaw } from '../utils/openclaw-auth';
import { getProviderConfig } from '../utils/provider-registry';
import { logger } from '../utils/logger';
import { getProviderService } from './providers/provider-service';
import { providerAccountToConfig } from './providers/provider-store';
import {
getOpenClawProviderKey,
syncDefaultProviderToRuntime,
syncDeletedProviderApiKeyToRuntime,
syncDeletedProviderToRuntime,
syncProviderApiKeyToRuntime,
syncSavedProviderToRuntime,
syncUpdatedProviderToRuntime,
} from './providers/provider-runtime-sync';
import { validateApiKeyWithProvider } from './providers/provider-validation';
import type { ProviderAccount } from '../shared/providers/types';
import { isRecord } from './payload-utils';
type ProvidersApiContext = {
gatewayManager: GatewayManager;
mainWindow: BrowserWindow;
};
type ProviderPayload<Action extends keyof HostApiContract['providers']> =
Parameters<HostApiContract['providers'][Action]>[0];
type ValidationOptions = {
baseUrl?: string;
apiProtocol?: string;
};
function hasObjectChanges<T extends Record<string, unknown>>(
existing: T,
patch: Partial<T> | undefined,
): boolean {
if (!patch) return false;
const keys = Object.keys(patch) as Array<keyof T>;
if (keys.length === 0) return false;
return keys.some((key) => JSON.stringify(existing[key]) !== JSON.stringify(patch[key]));
}
function selectReplacementDefaultAccount(
accounts: ProviderAccount[],
deletedAccountId: string,
): ProviderAccount | undefined {
return accounts
.filter((account) => account.id !== deletedAccountId)
.sort((left, right) => {
if (left.enabled !== right.enabled) {
return left.enabled ? -1 : 1;
}
const updatedAtOrder = right.updatedAt.localeCompare(left.updatedAt);
return updatedAtOrder !== 0 ? updatedAtOrder : left.id.localeCompare(right.id);
})[0];
}
function payloadString(payload: unknown, key: string): string | undefined {
if (typeof payload === 'string') return payload;
if (!isRecord(payload)) return undefined;
const value = payload[key];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function requireString(payload: unknown, key: string, action: string): string {
const value = payloadString(payload, key);
if (!value) {
throw new Error(`Invalid providers.${action} payload`);
}
return value;
}
function getPayloadRecord(payload: unknown, action: string): Record<string, unknown> {
if (!isRecord(payload)) {
throw new Error(`Invalid providers.${action} payload`);
}
return payload;
}
function getProviderId(payload: unknown, action: string): string {
if (Array.isArray(payload)) {
const [providerId] = payload;
if (typeof providerId === 'string' && providerId.trim()) return providerId.trim();
}
return requireString(payload, 'providerId', action);
}
function getAccountId(payload: unknown, action: string): string {
return requireString(payload, 'accountId', action);
}
function getApiKeyPayload(payload: unknown, action: string): { providerId: string; apiKey: string } {
if (Array.isArray(payload)) {
const [providerId, apiKey] = payload;
if (typeof providerId === 'string' && providerId.trim() && typeof apiKey === 'string') {
return { providerId: providerId.trim(), apiKey };
}
}
const record = getPayloadRecord(payload, action);
const providerId = typeof record.providerId === 'string' ? record.providerId.trim() : '';
if (!providerId || typeof record.apiKey !== 'string') {
throw new Error(`Invalid providers.${action} payload`);
}
return { providerId, apiKey: record.apiKey };
}
function getProviderUpdatePayload(payload: unknown): {
providerId: string;
updates: Partial<ProviderConfig>;
apiKey?: string;
} {
if (Array.isArray(payload)) {
const [providerId, updates, apiKey] = payload;
if (typeof providerId === 'string' && providerId.trim() && isRecord(updates)) {
return { providerId: providerId.trim(), updates: updates as Partial<ProviderConfig>, apiKey: typeof apiKey === 'string' ? apiKey : undefined };
}
}
const record = getPayloadRecord(payload, 'updateWithKey');
const providerId = typeof record.providerId === 'string' ? record.providerId.trim() : '';
if (!providerId || !isRecord(record.updates)) {
throw new Error('Invalid providers.updateWithKey payload');
}
return {
providerId,
updates: record.updates as Partial<ProviderConfig>,
apiKey: typeof record.apiKey === 'string' ? record.apiKey : undefined,
};
}
function getSavePayload(payload: unknown): { config: ProviderConfig; apiKey?: string } {
if (Array.isArray(payload)) {
const [config, apiKey] = payload;
if (isRecord(config)) {
return { config: config as unknown as ProviderConfig, apiKey: typeof apiKey === 'string' ? apiKey : undefined };
}
}
const record = getPayloadRecord(payload, 'save');
if (!isRecord(record.config)) {
throw new Error('Invalid providers.save payload');
}
return {
config: record.config as unknown as ProviderConfig,
apiKey: typeof record.apiKey === 'string' ? record.apiKey : undefined,
};
}
async function validateKey(payload: ProviderPayload<'validateKey'>): Promise<{ valid: boolean; error?: string }> {
try {
const body = getPayloadRecord(payload, 'validateKey');
const accountId = typeof body.accountId === 'string' && body.accountId.trim()
? body.accountId.trim()
: undefined;
const vendorId = typeof body.vendorId === 'string' && body.vendorId.trim()
? body.vendorId.trim()
: undefined;
const providerId = typeof body.providerId === 'string' && body.providerId.trim()
? body.providerId.trim()
: undefined;
const apiKey = typeof body.apiKey === 'string' ? body.apiKey : undefined;
if (!apiKey) {
return { valid: false, error: 'Invalid providers.validateKey payload' };
}
const providerService = getProviderService();
const lookupId = accountId || vendorId || providerId || '';
const account = lookupId ? await providerService.getAccount(lookupId) : null;
const legacyProvider = !account && providerId ? await providerService._getProviderInternal(providerId) : null;
const providerType = account?.vendorId || legacyProvider?.type || vendorId || providerId || lookupId;
if (!providerType) {
return { valid: false, error: 'Invalid providers.validateKey payload' };
}
const options = isRecord(body.options) ? body.options as ValidationOptions : undefined;
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
const resolvedBaseUrl = options?.baseUrl || account?.baseUrl || legacyProvider?.baseUrl || registryBaseUrl;
const resolvedProtocol = options?.apiProtocol || account?.apiProtocol || legacyProvider?.apiProtocol;
return await validateApiKeyWithProvider(providerType, apiKey, {
baseUrl: resolvedBaseUrl,
apiProtocol: resolvedProtocol,
});
} catch (error) {
return { valid: false, error: String(error) };
}
}
async function saveProvider(payload: ProviderPayload<'save'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const { config, apiKey } = getSavePayload(payload);
try {
await providerService._saveProviderInternal(config);
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await providerService._setProviderApiKeyInternal(config.id, trimmedKey);
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
}
}
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function deleteProvider(payload: ProviderPayload<'delete'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const providerId = getProviderId(payload, 'delete');
try {
const existing = await providerService._getProviderInternal(providerId);
await providerService._deleteProviderInternal(providerId);
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function setProviderApiKey(payload: ProviderPayload<'setApiKey'>) {
const providerService = getProviderService();
const { providerId, apiKey } = getApiKeyPayload(payload, 'setApiKey');
try {
await providerService._setProviderApiKeyInternal(providerId, apiKey);
const provider = await providerService._getProviderInternal(providerId);
const providerType = provider?.type || providerId;
await syncProviderApiKeyToRuntime(providerType, providerId, apiKey);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function updateProviderWithKey(payload: ProviderPayload<'updateWithKey'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const { providerId, updates, apiKey } = getProviderUpdatePayload(payload);
const existing = await providerService._getProviderInternal(providerId);
if (!existing) {
return { success: false, error: 'Provider not found' };
}
const previousKey = await providerService._getProviderApiKeyInternal(providerId);
const previousOck = getOpenClawProviderKey(existing.type, providerId);
try {
const nextConfig: ProviderConfig = {
...existing,
...updates,
updatedAt: new Date().toISOString(),
};
const ock = getOpenClawProviderKey(nextConfig.type, providerId);
await providerService._saveProviderInternal(nextConfig);
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await providerService._setProviderApiKeyInternal(providerId, trimmedKey);
await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey);
} else {
await providerService._deleteProviderApiKeyInternal(providerId);
await removeProviderFromOpenClaw(ock);
}
}
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
return { success: true };
} catch (error) {
try {
await providerService._saveProviderInternal(existing);
if (previousKey) {
await providerService._setProviderApiKeyInternal(providerId, previousKey);
await saveProviderKeyToOpenClaw(previousOck, previousKey);
} else {
await providerService._deleteProviderApiKeyInternal(providerId);
await removeProviderFromOpenClaw(previousOck);
}
} catch (rollbackError) {
logger.warn('Failed to rollback provider updateWithKey:', rollbackError);
}
return { success: false, error: String(error) };
}
}
async function deleteProviderApiKey(payload: ProviderPayload<'deleteApiKey'>) {
const providerService = getProviderService();
const providerId = getProviderId(payload, 'deleteApiKey');
try {
await providerService._deleteProviderApiKeyInternal(providerId);
const provider = await providerService._getProviderInternal(providerId);
await syncDeletedProviderApiKeyToRuntime(provider, providerId);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function setDefaultProvider(payload: ProviderPayload<'setDefault'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const providerId = getProviderId(payload, 'setDefault');
try {
await providerService._setDefaultProviderInternal(providerId);
await syncDefaultProviderToRuntime(providerId, gatewayManager);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function createAccount(payload: ProviderPayload<'createAccount'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const body = getPayloadRecord(payload, 'createAccount');
if (!isRecord(body.account)) {
throw new Error('Invalid providers.createAccount payload');
}
const apiKey = typeof body.apiKey === 'string' ? body.apiKey : undefined;
try {
const account = await providerService.createAccount(body.account as unknown as ProviderAccount, apiKey);
await syncSavedProviderToRuntime(providerAccountToConfig(account), apiKey, gatewayManager);
return { success: true, account };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function updateAccount(payload: ProviderPayload<'updateAccount'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const body = getPayloadRecord(payload, 'updateAccount');
const accountId = typeof body.accountId === 'string' ? body.accountId.trim() : '';
const updates = isRecord(body.updates) ? body.updates as Partial<ProviderAccount> : undefined;
const apiKey = typeof body.apiKey === 'string' ? body.apiKey : undefined;
if (!accountId || !updates) {
throw new Error('Invalid providers.updateAccount payload');
}
try {
const existing = await providerService.getAccount(accountId);
if (!existing) {
return { success: false, error: 'Provider account not found' };
}
const hasPatchChanges = hasObjectChanges(existing as unknown as Record<string, unknown>, updates as Record<string, unknown>);
if (!hasPatchChanges && apiKey === undefined) {
return { success: true, noChange: true, account: existing };
}
const account = await providerService.updateAccount(accountId, updates, apiKey);
await syncUpdatedProviderToRuntime(providerAccountToConfig(account), apiKey, gatewayManager);
return { success: true, account };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function deleteAccount(
payload: ProviderPayload<'deleteAccount'> & { apiKeyOnly?: boolean },
gatewayManager?: GatewayManager,
) {
const providerService = getProviderService();
const body = getPayloadRecord(payload, 'deleteAccount');
const accountId = typeof body.accountId === 'string' ? body.accountId.trim() : '';
const apiKeyOnly = body.apiKeyOnly === true;
if (!accountId) {
throw new Error('Invalid providers.deleteAccount payload');
}
try {
const existing = await providerService.getAccount(accountId);
const runtimeProviderKey = existing?.authMode === 'oauth_browser' && existing.vendorId === 'openai'
? 'openai'
: undefined;
if (apiKeyOnly) {
await providerService._deleteProviderApiKeyInternal(accountId);
await syncDeletedProviderApiKeyToRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
runtimeProviderKey,
);
return { success: true };
}
const currentDefaultAccountId = await providerService.getDefaultAccountId();
const replacementDefault = currentDefaultAccountId === accountId
? selectReplacementDefaultAccount(await providerService.listAccounts(), accountId)
: undefined;
await providerService.deleteAccount(accountId);
if (replacementDefault) {
await providerService.setDefaultAccount(replacementDefault.id);
await syncDefaultProviderToRuntime(replacementDefault.id);
}
await syncDeletedProviderToRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
gatewayManager,
runtimeProviderKey,
);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function setDefaultAccount(payload: ProviderPayload<'setDefaultAccount'>, gatewayManager?: GatewayManager) {
const providerService = getProviderService();
const accountId = getAccountId(payload, 'setDefaultAccount');
try {
const currentDefault = await providerService.getDefaultAccountId();
if (currentDefault === accountId) {
return { success: true, noChange: true };
}
await providerService.setDefaultAccount(accountId);
await syncDefaultProviderToRuntime(accountId, gatewayManager);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function requestOAuth(payload: ProviderPayload<'requestOAuth'>) {
const body = getPayloadRecord(payload, 'requestOAuth');
const provider = typeof body.provider === 'string' ? body.provider : undefined;
if (!provider) {
return { success: false, error: 'Invalid providers.requestOAuth payload' };
}
const region = body.region === 'global' || body.region === 'cn' ? body.region : undefined;
const options = {
accountId: typeof body.accountId === 'string' ? body.accountId : undefined,
label: typeof body.label === 'string' ? body.label : undefined,
};
try {
if (provider === 'openai') {
await browserOAuthManager.startFlow(provider as BrowserOAuthProviderType, options);
} else {
await deviceOAuthManager.startFlow(provider as OAuthProviderType, region, options);
}
return { success: true };
} catch (error) {
logger.error('providers.requestOAuth failed', error);
return { success: false, error: String(error) };
}
}
async function cancelOAuth() {
try {
await deviceOAuthManager.stopFlow();
await browserOAuthManager.stopFlow();
return { success: true };
} catch (error) {
logger.error('providers.cancelOAuth failed', error);
return { success: false, error: String(error) };
}
}
async function submitOAuth(payload: ProviderPayload<'submitOAuth'>) {
const body = getPayloadRecord(payload, 'submitOAuth');
const code = typeof body.code === 'string' ? body.code : '';
try {
const accepted = browserOAuthManager.submitManualCode(code);
if (!accepted) {
return { success: false, error: 'No active manual OAuth input pending' };
}
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
export function createProvidersApi(ctx: ProvidersApiContext): CompleteHostServiceRegistry['providers'] {
const providerService = getProviderService();
deviceOAuthManager.setWindow(ctx.mainWindow);
browserOAuthManager.setWindow(ctx.mainWindow);
return {
list: async () => providerService._listProvidersWithKeyInfoInternal(),
get: async (payload) => providerService._getProviderInternal(getProviderId(payload, 'get')),
getDefault: async () => providerService._getDefaultProviderInternal(),
hasApiKey: async (payload) => providerService._hasProviderApiKeyInternal(getProviderId(payload, 'hasApiKey')),
getApiKey: async (payload) => providerService._getProviderApiKeyInternal(getProviderId(payload, 'getApiKey')),
validateKey,
save: async (payload) => saveProvider(payload, ctx.gatewayManager),
delete: async (payload) => deleteProvider(payload, ctx.gatewayManager),
setApiKey: setProviderApiKey,
updateWithKey: async (payload) => updateProviderWithKey(payload, ctx.gatewayManager),
deleteApiKey: deleteProviderApiKey,
setDefault: async (payload) => setDefaultProvider(payload, ctx.gatewayManager),
accounts: async () => providerService.listAccounts(),
vendors: async () => providerService.listVendors(),
accountKeyInfo: async () => providerService.listAccountsKeyInfo(),
getDefaultAccount: async () => ({ accountId: await providerService.getDefaultAccountId() ?? null }),
getAccount: async (payload) => providerService.getAccount(getAccountId(payload, 'getAccount')),
getAccountApiKey: async (payload) => providerService.getAccountApiKey(getAccountId(payload, 'getAccountApiKey')),
hasAccountApiKey: async (payload) => providerService.hasAccountApiKey(getAccountId(payload, 'hasAccountApiKey')),
createAccount: async (payload) => createAccount(payload, ctx.gatewayManager),
updateAccount: async (payload) => updateAccount(payload, ctx.gatewayManager),
deleteAccount: async (payload) => deleteAccount(payload, ctx.gatewayManager),
deleteAccountApiKey: async (payload) => deleteAccount({ accountId: getAccountId(payload, 'deleteAccountApiKey'), apiKeyOnly: true }, ctx.gatewayManager),
setDefaultAccount: async (payload) => setDefaultAccount(payload, ctx.gatewayManager),
requestOAuth,
cancelOAuth,
submitOAuth,
};
}
@@ -6,7 +6,7 @@ import {
} from './provider-store';
import { getClawXProviderStore } from './store-instance';
const PROVIDER_STORE_SCHEMA_VERSION = 1;
const PROVIDER_STORE_SCHEMA_VERSION = 2;
export async function ensureProviderStoreMigrated(): Promise<void> {
const store = await getClawXProviderStore();
@@ -16,19 +16,31 @@ export async function ensureProviderStoreMigrated(): Promise<void> {
return;
}
const legacyProviders = (store.get('providers') ?? {}) as Record<string, ProviderConfig>;
const defaultProviderId = (store.get('defaultProvider') ?? null) as string | null;
const existingDefaultAccountId = await getDefaultProviderAccountId();
// v0 → v1: migrate legacy `providers` entries to `providerAccounts`.
if (schemaVersion < 1) {
const legacyProviders = (store.get('providers') ?? {}) as Record<string, ProviderConfig>;
const defaultProviderId = (store.get('defaultProvider') ?? null) as string | null;
const existingDefaultAccountId = await getDefaultProviderAccountId();
for (const provider of Object.values(legacyProviders)) {
const account = providerConfigToAccount(provider, {
isDefault: provider.id === defaultProviderId,
});
await saveProviderAccount(account);
for (const provider of Object.values(legacyProviders)) {
const account = providerConfigToAccount(provider, {
isDefault: provider.id === defaultProviderId,
});
await saveProviderAccount(account);
}
if (!existingDefaultAccountId && defaultProviderId) {
store.set('defaultProviderAccountId', defaultProviderId);
}
}
if (!existingDefaultAccountId && defaultProviderId) {
store.set('defaultProviderAccountId', defaultProviderId);
// v1 → v2: clear the legacy `providers` store.
// The old `saveProvider()` was duplicating entries into this store, causing
// phantom and duplicate accounts when the migration above re-runs.
// Now that createAccount/updateAccount no longer write to `providers`,
// we clear it to prevent stale entries from causing issues.
if (schemaVersion < 2) {
store.set('providers', {});
}
store.set('schemaVersion', PROVIDER_STORE_SCHEMA_VERSION);
@@ -5,20 +5,40 @@ import type { ProviderConfig } from '../../utils/secure-storage';
import { getAllProviders, getApiKey, getDefaultProvider, getProvider } from '../../utils/secure-storage';
import { getProviderConfig, getProviderDefaultModel } from '../../utils/provider-registry';
import {
ensureAnthropicMessagesModelMaxTokens,
ensureOpenClawProviderAgentRuntimePins,
migrateAllAgentAuthProfilesToSqlite,
pruneInvalidApiProviderEntries,
removeProviderFromOpenClaw,
removeProviderKeyFromOpenClaw,
saveOAuthTokenToOpenClaw,
saveProviderKeyToOpenClaw,
OPENAI_CODEX_OAUTH_PROVIDER_CONFIG,
setOpenClawDefaultModel,
setOpenClawDefaultModelWithOverride,
syncProviderConfigToOpenClaw,
updateAgentModelProvider,
updateSingleAgentModelProvider,
getProviderApiKeyFromOpenClaw,
} from '../../utils/openclaw-auth';
import {
piAiModelsJsonModelEntry,
type PiAiModelCostRates,
} from '../../shared/pi-ai-model-cost';
import { logger } from '../../utils/logger';
import { listAgentsSnapshot } from '../../utils/agent-config';
const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli';
const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`;
const OPENAI_OAUTH_RUNTIME_PROVIDER = 'openai-codex';
const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.3-codex`;
/** OpenClaw Codex OAuth hooks only apply to the canonical `openai` provider id. */
const OPENAI_OAUTH_RUNTIME_PROVIDER = 'openai';
const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.5`;
/**
* Provider types that are not in the built-in provider registry (no `providerConfig.api`).
* They require explicit api-protocol defaulting to `openai-completions`.
*/
function isUnregisteredProviderType(type: string): boolean {
return type === 'custom' || type === 'ollama';
}
type RuntimeProviderSyncContext = {
runtimeProviderKey: string;
@@ -41,7 +61,7 @@ function normalizeProviderBaseUrl(
return normalized.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
}
if (config.type === 'custom' || config.type === 'ollama') {
if (isUnregisteredProviderType(config.type)) {
const protocol = apiProtocol || config.apiProtocol || 'openai-completions';
if (protocol === 'openai-responses') {
return normalized.replace(/\/responses?$/i, '');
@@ -62,7 +82,16 @@ function shouldUseExplicitDefaultOverride(config: ProviderConfig, runtimeProvide
}
export function getOpenClawProviderKey(type: string, providerId: string): string {
if (type === 'custom' || type === 'ollama') {
if (isUnregisteredProviderType(type)) {
// If the providerId is already a runtime key (e.g. re-seeded from openclaw.json
// as "custom-XXXXXXXX"), return it directly to avoid double-hashing.
const prefix = `${type}-`;
if (providerId.startsWith(prefix)) {
const tail = providerId.slice(prefix.length);
if (tail.length === 8 && !tail.includes('-')) {
return providerId;
}
}
const suffix = providerId.replace(/-/g, '').slice(0, 8);
return `${type}-${suffix}`;
}
@@ -74,13 +103,8 @@ export function getOpenClawProviderKey(type: string, providerId: string): string
async function resolveRuntimeProviderKey(config: ProviderConfig): Promise<string> {
const account = await getProviderAccount(config.id);
if (account?.authMode === 'oauth_browser') {
if (config.type === 'google') {
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
}
if (config.type === 'openai') {
return OPENAI_OAUTH_RUNTIME_PROVIDER;
}
if (account?.authMode === 'oauth_browser' && config.type === 'openai') {
return OPENAI_OAUTH_RUNTIME_PROVIDER;
}
return getOpenClawProviderKey(config.type, config.id);
}
@@ -96,9 +120,6 @@ async function getBrowserOAuthRuntimeProvider(config: ProviderConfig): Promise<s
return null;
}
if (config.type === 'google') {
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
}
if (config.type === 'openai') {
return OPENAI_OAUTH_RUNTIME_PROVIDER;
}
@@ -160,10 +181,12 @@ export async function getProviderFallbackModelRefs(config: ProviderConfig): Prom
return results;
}
function scheduleGatewayRestart(
type GatewayRefreshMode = 'reload' | 'restart';
function scheduleGatewayRefresh(
gatewayManager: GatewayManager | undefined,
message: string,
options?: { delayMs?: number; onlyIfRunning?: boolean },
options?: { delayMs?: number; onlyIfRunning?: boolean; mode?: GatewayRefreshMode },
): void {
if (!gatewayManager) {
return;
@@ -174,7 +197,11 @@ function scheduleGatewayRestart(
}
logger.info(message);
gatewayManager.debouncedRestart(options?.delayMs);
if (options?.mode === 'restart') {
gatewayManager.debouncedRestart(options?.delayMs);
return;
}
gatewayManager.debouncedReload(options?.delayMs);
}
export async function syncProviderApiKeyToRuntime(
@@ -187,8 +214,8 @@ export async function syncProviderApiKeyToRuntime(
}
export async function syncAllProviderAuthToRuntime(): Promise<void> {
await migrateAllAgentAuthProfilesToSqlite();
const accounts = await listProviderAccounts();
for (const account of accounts) {
const runtimeProviderKey = await resolveRuntimeProviderKey({
id: account.id,
@@ -240,6 +267,12 @@ async function syncProviderSecretToRuntime(
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await saveProviderKeyToOpenClaw(runtimeProviderKey, trimmedKey);
} else {
// An explicit empty string means the caller wants to clear the key.
// Mirror that intent into OpenClaw auth-profiles so the gateway no
// longer authenticates with the stale value (matches the explicit
// delete branch in the legacy /api/providers/:id PUT handler).
await removeProviderKeyFromOpenClaw(runtimeProviderKey);
}
return;
}
@@ -268,7 +301,7 @@ async function syncProviderSecretToRuntime(
async function resolveRuntimeSyncContext(config: ProviderConfig): Promise<RuntimeProviderSyncContext | null> {
const runtimeProviderKey = await resolveRuntimeProviderKey(config);
const meta = getProviderConfig(config.type);
const api = config.apiProtocol || (config.type === 'custom' ? 'openai-completions' : meta?.api);
const api = config.apiProtocol || (isUnregisteredProviderType(config.type) ? 'openai-completions' : meta?.api);
if (!api) {
return null;
}
@@ -288,7 +321,7 @@ async function syncRuntimeProviderConfig(
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl, context.api),
api: context.api,
apiKeyEnv: context.meta?.apiKeyEnv,
headers: context.meta?.headers,
headers: config.headers ?? context.meta?.headers,
});
}
@@ -297,7 +330,7 @@ async function syncCustomProviderAgentModel(
runtimeProviderKey: string,
apiKey: string | undefined,
): Promise<void> {
if (config.type !== 'custom') {
if (!isUnregisteredProviderType(config.type)) {
return;
}
@@ -310,7 +343,7 @@ async function syncCustomProviderAgentModel(
await updateAgentModelProvider(runtimeProviderKey, {
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl, config.apiProtocol || 'openai-completions'),
api: config.apiProtocol || 'openai-completions',
models: modelId ? [{ id: modelId, name: modelId }] : [],
models: modelId ? [piAiModelsJsonModelEntry(modelId)] : [],
apiKey: resolvedKey,
});
}
@@ -330,6 +363,147 @@ async function syncProviderToRuntime(
return context;
}
async function removeDeletedProviderFromOpenClaw(
provider: ProviderConfig,
providerId: string,
runtimeProviderKey?: string,
): Promise<void> {
const keys = new Set<string>();
if (runtimeProviderKey) {
keys.add(runtimeProviderKey);
} else {
keys.add(await resolveRuntimeProviderKey({ ...provider, id: providerId }));
}
keys.add(providerId);
for (const key of keys) {
await removeProviderFromOpenClaw(key);
}
// Legacy Codex OAuth used runtime key openai-codex; cleanup may leave a bare
// models.providers.openai entry behind. Drop that slot when no API key credentials remain.
if (runtimeProviderKey === OPENAI_OAUTH_RUNTIME_PROVIDER || runtimeProviderKey === 'openai-codex') {
const openClawKey = await getProviderApiKeyFromOpenClaw('openai');
if (openClawKey) {
return;
}
const storeAccounts = await listProviderAccounts();
for (const account of storeAccounts) {
if (account.vendorId !== 'openai' || account.authMode === 'oauth_browser') {
continue;
}
const apiKey = await getApiKey(account.id);
if (apiKey) {
return;
}
}
await removeProviderFromOpenClaw('openai');
}
}
function parseModelRef(modelRef: string): { providerKey: string; modelId: string } | null {
const trimmed = modelRef.trim();
const separatorIndex = trimmed.indexOf('/');
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; cost: PiAiModelCostRates }>;
apiKey?: string;
authHeader?: boolean;
} | null> {
const meta = getProviderConfig(config.type);
const api = config.apiProtocol || (isUnregisteredProviderType(config.type) ? 'openai-completions' : meta?.api);
const baseUrl = normalizeProviderBaseUrl(config, config.baseUrl || meta?.baseUrl, api);
if (!api || !baseUrl) {
return null;
}
let apiKey: string | undefined;
let authHeader: boolean | undefined;
if (isUnregisteredProviderType(config.type)) {
apiKey = (await getApiKey(config.id)) || undefined;
} else if (config.type === 'minimax-portal' || config.type === 'minimax-portal-cn') {
const accountApiKey = await getApiKey(config.id);
if (accountApiKey) {
apiKey = accountApiKey;
} else {
authHeader = true;
apiKey = 'minimax-oauth';
}
}
return {
baseUrl,
api,
models: [piAiModelsJsonModelEntry(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,
@@ -340,9 +514,15 @@ export async function syncSavedProviderToRuntime(
return;
}
scheduleGatewayRestart(
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider save:', err);
}
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway restart after saving provider "${context.runtimeProviderKey}" config`,
`Scheduling Gateway reload after saving provider "${context.runtimeProviderKey}" config`,
);
}
@@ -360,15 +540,16 @@ export async function syncUpdatedProviderToRuntime(
const fallbackModels = await getProviderFallbackModelRefs(config);
const defaultProviderId = await getDefaultProvider();
if (defaultProviderId === config.id) {
const isDefaultProvider = defaultProviderId === config.id;
if (isDefaultProvider) {
const modelOverride = config.model ? `${ock}/${config.model}` : undefined;
if (config.type !== 'custom') {
if (!isUnregisteredProviderType(config.type)) {
if (shouldUseExplicitDefaultOverride(config, ock)) {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl, context.api),
api: context.api,
apiKeyEnv: context.meta?.apiKeyEnv,
headers: context.meta?.headers,
headers: config.headers ?? context.meta?.headers,
}, fallbackModels);
} else {
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
@@ -377,13 +558,20 @@ export async function syncUpdatedProviderToRuntime(
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl, config.apiProtocol || 'openai-completions'),
api: config.apiProtocol || 'openai-completions',
headers: config.headers,
}, fallbackModels);
}
}
scheduleGatewayRestart(
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider update:', err);
}
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway restart after updating provider "${ock}" config`,
`Scheduling Gateway reload after updating provider "${ock}" config`,
);
}
@@ -398,11 +586,12 @@ export async function syncDeletedProviderToRuntime(
}
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
await removeProviderFromOpenClaw(ock);
await removeDeletedProviderFromOpenClaw(provider, providerId, ock);
scheduleGatewayRestart(
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway restart after deleting provider "${ock}"`,
{ mode: 'restart' },
);
}
@@ -416,7 +605,7 @@ export async function syncDeletedProviderApiKeyToRuntime(
}
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
await removeProviderFromOpenClaw(ock);
await removeProviderKeyFromOpenClaw(ock);
}
export async function syncDefaultProviderToRuntime(
@@ -428,10 +617,52 @@ export async function syncDefaultProviderToRuntime(
return;
}
// Self-heal: opportunistically remove any pre-existing models.providers
// entries with an invalid `api` field so a switch to a healthy provider
// can rescue the user from a previously broken config (e.g. the historical
// openrouter `api: 'openrouter'` bug). Covers both OAuth and non-OAuth
// branches below.
try {
const removed = await pruneInvalidApiProviderEntries();
if (removed.length > 0) {
logger.warn(
`[provider-runtime] Pruned invalid models.providers entries before switch: ${removed.join(', ')}`,
);
}
} catch (err) {
logger.warn('[provider-runtime] Failed to prune invalid provider entries before switch:', err);
}
// Self-heal: pin the embedded agent runtime for legacy OpenAI provider entries
// (`openai`, `openai-codex`) that would otherwise be auto-routed to the
// unbundled `codex` harness. Running this before every default-provider switch
// repairs on-disk config written by earlier ClawX builds.
try {
const pinned = await ensureOpenClawProviderAgentRuntimePins();
if (pinned.length > 0) {
logger.warn(
`[provider-runtime] Pinned embedded agent runtime for models.providers entries before switch: ${pinned.join(', ')}`,
);
}
} catch (err) {
logger.warn('[provider-runtime] Failed to pin embedded agent runtime for provider entries before switch:', err);
}
try {
const healed = await ensureAnthropicMessagesModelMaxTokens();
if (healed.length > 0) {
logger.warn(
`[provider-runtime] Ensured anthropic-messages maxTokens for models.providers entries before switch: ${healed.join(', ')}`,
);
}
} catch (err) {
logger.warn('[provider-runtime] Failed to ensure anthropic-messages maxTokens before switch:', err);
}
const ock = await resolveRuntimeProviderKey(provider);
const providerKey = await getApiKey(providerId);
const fallbackModels = await getProviderFallbackModelRefs(provider);
const oauthTypes = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
const oauthTypes = ['minimax-portal', 'minimax-portal-cn'];
const browserOAuthRuntimeProvider = await getBrowserOAuthRuntimeProvider(provider);
const isOAuthProvider = (oauthTypes.includes(provider.type) && !providerKey) || Boolean(browserOAuthRuntimeProvider);
@@ -440,10 +671,11 @@ export async function syncDefaultProviderToRuntime(
? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`)
: undefined;
if (provider.type === 'custom') {
if (isUnregisteredProviderType(provider.type)) {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'),
api: provider.apiProtocol || 'openai-completions',
headers: provider.headers,
}, fallbackModels);
} else if (shouldUseExplicitDefaultOverride(provider, ock)) {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
@@ -454,7 +686,7 @@ export async function syncDefaultProviderToRuntime(
),
api: provider.apiProtocol || getProviderConfig(provider.type)?.api,
apiKeyEnv: getProviderConfig(provider.type)?.apiKeyEnv,
headers: getProviderConfig(provider.type)?.headers,
headers: provider.headers ?? getProviderConfig(provider.type)?.headers,
}, fallbackModels);
} else {
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
@@ -473,43 +705,50 @@ export async function syncDefaultProviderToRuntime(
expires: secret.expiresAt,
email: secret.email,
projectId: secret.subject,
accountId: secret.subject,
});
}
const defaultModelRef = browserOAuthRuntimeProvider === GOOGLE_OAUTH_RUNTIME_PROVIDER
? GOOGLE_OAUTH_DEFAULT_MODEL_REF
: OPENAI_OAUTH_DEFAULT_MODEL_REF;
const defaultModelRef = OPENAI_OAUTH_DEFAULT_MODEL_REF;
const modelOverride = provider.model
? (provider.model.startsWith(`${browserOAuthRuntimeProvider}/`)
? provider.model
? provider.model.replace(/^openai-codex\//, `${browserOAuthRuntimeProvider}/`)
: `${browserOAuthRuntimeProvider}/${provider.model}`)
: defaultModelRef;
await setOpenClawDefaultModel(browserOAuthRuntimeProvider, modelOverride, fallbackModels);
await setOpenClawDefaultModelWithOverride(
browserOAuthRuntimeProvider,
modelOverride,
{
baseUrl: OPENAI_CODEX_OAUTH_PROVIDER_CONFIG.baseUrl,
api: OPENAI_CODEX_OAUTH_PROVIDER_CONFIG.api,
},
fallbackModels.map((fallback) => fallback.replace(/^openai-codex\//, `${browserOAuthRuntimeProvider}/`)),
);
logger.info(`Configured openclaw.json for browser OAuth provider "${provider.id}"`);
scheduleGatewayRestart(
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after browser OAuth switch:', err);
}
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway restart after provider switch to "${browserOAuthRuntimeProvider}"`,
`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';
: 'https://api.minimaxi.com/anthropic';
const api = 'anthropic-messages' as const;
let baseUrl = provider.baseUrl || defaultBaseUrl;
if ((provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') && baseUrl) {
if (baseUrl) {
baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
}
const targetProviderKey = (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn')
? 'minimax-portal'
: provider.type;
const targetProviderKey = 'minimax-portal';
await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), {
baseUrl,
@@ -527,7 +766,7 @@ export async function syncDefaultProviderToRuntime(
api,
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
models: defaultModelId ? [{ id: defaultModelId, name: defaultModelId }] : [],
models: defaultModelId ? [piAiModelsJsonModelEntry(defaultModelId)] : [],
});
} catch (err) {
logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err);
@@ -535,7 +774,7 @@ export async function syncDefaultProviderToRuntime(
}
if (
provider.type === 'custom' &&
isUnregisteredProviderType(provider.type) &&
providerKey &&
provider.baseUrl
) {
@@ -543,14 +782,20 @@ export async function syncDefaultProviderToRuntime(
await updateAgentModelProvider(ock, {
baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'),
api: provider.apiProtocol || 'openai-completions',
models: modelId ? [{ id: modelId, name: modelId }] : [],
models: modelId ? [piAiModelsJsonModelEntry(modelId)] : [],
apiKey: providerKey,
});
}
scheduleGatewayRestart(
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after default provider switch:', err);
}
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway restart after provider switch to "${ock}"`,
`Scheduling Gateway reload after provider switch to "${ock}"`,
{ onlyIfRunning: true },
);
}
+424 -40
View File
@@ -6,9 +6,12 @@ import type {
ProviderAccount,
ProviderConfig,
ProviderDefinition,
ProviderType,
} from '../../shared/providers/types';
import { BUILTIN_PROVIDER_TYPES } from '../../shared/providers/types';
import { ensureProviderStoreMigrated } from './provider-migration';
import {
deleteProviderAccount,
getDefaultProviderAccountId,
getProviderAccount,
listProviderAccounts,
@@ -22,10 +25,20 @@ import {
deleteProvider,
getApiKey,
hasApiKey,
saveProvider,
setDefaultProvider,
storeApiKey,
} from '../../utils/secure-storage';
import {
getActiveOpenClawProviders,
getOpenClawProvidersConfig,
getProviderApiKeyFromOpenClaw,
} from '../../utils/openclaw-auth';
import {
filterActiveProviderKeysForUi,
getAliasSourceTypes,
OPENAI_CODEX_RUNTIME_PROVIDER_KEY,
resolveOpenClawProviderKey,
} from '../../utils/provider-keys';
import type { ProviderWithKeyInfo } from '../../shared/providers/types';
import { logger } from '../../utils/logger';
@@ -49,6 +62,40 @@ function logLegacyProviderApiUsage(method: string, replacement: string): void {
);
}
function inferProviderVendorIdFromOpenClawEntry(
key: string,
entry: Record<string, unknown>,
): ProviderType | 'custom' {
if (key === 'minimax-portal') {
const baseUrl = typeof entry.baseUrl === 'string' ? entry.baseUrl.toLowerCase() : '';
if (baseUrl.includes('api.minimaxi.com')) {
return 'minimax-portal-cn';
}
}
return ((BUILTIN_PROVIDER_TYPES as readonly string[]).includes(key) ? key : 'custom') as ProviderType | 'custom';
}
function providerMetadataEquals(
left: ProviderAccount['metadata'] | undefined,
right: ProviderAccount['metadata'] | undefined,
): boolean {
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
}
function mergeSyncedProviderMetadata(
existing: ProviderAccount['metadata'] | undefined,
synced: ProviderAccount['metadata'] | undefined,
): ProviderAccount['metadata'] | undefined {
const next = { ...(existing ?? {}) };
if (synced?.customModels && synced.customModels.length > 0) {
next.customModels = synced.customModels;
} else {
delete next.customModels;
}
return Object.keys(next).length > 0 ? next : undefined;
}
export class ProviderService {
async listVendors(): Promise<ProviderDefinition[]> {
return PROVIDER_DEFINITIONS;
@@ -56,7 +103,234 @@ export class ProviderService {
async listAccounts(): Promise<ProviderAccount[]> {
await ensureProviderStoreMigrated();
return listProviderAccounts();
// ── openclaw.json is the ONLY source of truth ──
// The provider list is derived entirely from openclaw.json.
// The electron-store is only used as a metadata cache (label, authMode, etc.).
const { providers: openClawProviders, defaultModel } = await getOpenClawProvidersConfig();
const activeProviders = await getActiveOpenClawProviders();
if (activeProviders.size === 0) {
return [];
}
// Read store accounts as a lookup cache (NOT as the source of what to display).
const allStoreAccounts = await listProviderAccounts();
// Index store accounts by their openclaw runtime key for fast lookup.
const storeByKey = new Map<string, ProviderAccount[]>();
for (const account of allStoreAccounts) {
const ock = resolveOpenClawProviderKey(account);
const group = storeByKey.get(ock) ?? [];
group.push(account);
storeByKey.set(ock, group);
}
const result: ProviderAccount[] = [];
const processedKeys = new Set<string>();
let hasConfiguredOpenAiApiKey = false;
if (activeProviders.has('openai')) {
const openClawKey = await getProviderApiKeyFromOpenClaw('openai');
if (openClawKey) {
hasConfiguredOpenAiApiKey = true;
} else {
for (const account of storeByKey.get('openai') ?? []) {
if (account.authMode === 'oauth_browser') {
continue;
}
const apiKey = await getApiKey(account.id);
if (apiKey) {
hasConfiguredOpenAiApiKey = true;
break;
}
}
}
}
const activeKeysForUi = filterActiveProviderKeysForUi(activeProviders, {
hasConfiguredOpenAiApiKey,
});
// For each active provider in openclaw.json, produce exactly ONE account.
for (const key of activeKeysForUi) {
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));
// Clean up orphaned duplicates from the store.
let 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);
}
}
const entry = openClawProviders[key];
if (entry) {
const [syncedAccount] = ProviderService.buildAccountsFromOpenClawEntries(
{ [key]: entry },
new Set(),
new Set(),
defaultModel,
);
if (syncedAccount) {
const nextMetadata = mergeSyncedProviderMetadata(kept.metadata, syncedAccount.metadata);
const shouldSyncSelectedModel = defaultModel?.startsWith(`${key}/`) ?? false;
const nextModel = shouldSyncSelectedModel ? syncedAccount.model : kept.model;
const shouldSyncModelState = kept.model !== nextModel
|| !providerMetadataEquals(kept.metadata, nextMetadata);
if (shouldSyncModelState) {
kept = {
...kept,
model: nextModel,
metadata: nextMetadata,
updatedAt: new Date().toISOString(),
};
await saveProviderAccount(kept);
}
}
}
result.push(kept);
} else {
// No store account for this key — create a seed from openclaw.json.
const entry = openClawProviders[key];
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`);
}
}
}
}
if (activeProviders.has(OPENAI_CODEX_RUNTIME_PROVIDER_KEY) || !hasConfiguredOpenAiApiKey) {
const openaiStoreAccounts = storeByKey.get('openai') ?? [];
for (const account of openaiStoreAccounts) {
if (account.authMode !== 'api_key' && account.authMode !== undefined) {
continue;
}
const apiKey = await getApiKey(account.id);
const openClawKey = await getProviderApiKeyFromOpenClaw('openai');
if (!apiKey && !openClawKey) {
logger.info(
`[provider-sync] Removing unconfigured OpenAI API key account "${account.id}"`
+ (activeProviders.has(OPENAI_CODEX_RUNTIME_PROVIDER_KEY)
? ` (OAuth uses ${OPENAI_CODEX_RUNTIME_PROVIDER_KEY})`
: ' (Codex OAuth removed)'),
);
await deleteProviderAccount(account.id);
const resultIndex = result.findIndex((entry) => entry.id === account.id);
if (resultIndex >= 0) {
result.splice(resultIndex, 1);
}
}
}
}
return result;
}
/**
* 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 vendorId = inferProviderVendorIdFromOpenClawEntry(key, entry);
const definition = getProviderDefinition(vendorId === 'custom' ? key : vendorId);
// Skip if an account with this vendorId already exists (e.g. user already
// created "openrouter-uuid" via UI — no need to import bare "openrouter").
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;
const customModels = Array.isArray(entry.models)
? Array.from(new Set(entry.models
.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return '';
const raw = (item as Record<string, unknown>).id;
return typeof raw === 'string' ? raw.trim() : '';
})
.filter(Boolean)))
: undefined;
// Infer model from the default model if it belongs to this provider
let model: string | undefined;
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,
metadata: customModels && customModels.length > 0
? { customModels }
: undefined,
enabled: true,
isDefault: false,
createdAt: now,
updatedAt: now,
};
built.push(account);
}
return built;
}
async getAccount(accountId: string): Promise<ProviderAccount | null> {
@@ -71,7 +345,8 @@ export class ProviderService {
async createAccount(account: ProviderAccount, apiKey?: string): Promise<ProviderAccount> {
await ensureProviderStoreMigrated();
await saveProvider(providerAccountToConfig(account));
// Only save to providerAccounts store — do NOT call saveProvider() which
// writes to the legacy `providers` store and causes phantom/duplicate issues.
await saveProviderAccount(account);
if (apiKey !== undefined && apiKey.trim()) {
await storeApiKey(account.id, apiKey.trim());
@@ -97,7 +372,7 @@ export class ProviderService {
updatedAt: patch.updatedAt ?? new Date().toISOString(),
};
await saveProvider(providerAccountToConfig(nextAccount));
// Only save to providerAccounts store — skip legacy saveProvider().
await saveProviderAccount(nextAccount);
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
@@ -116,22 +391,22 @@ export class ProviderService {
return deleteProvider(accountId);
}
/**
* @deprecated Use listAccounts() and map account data in callers.
*/
async listLegacyProviders(): Promise<ProviderConfig[]> {
logLegacyProviderApiUsage('listLegacyProviders', 'listAccounts');
await ensureProviderStoreMigrated();
const accounts = await listProviderAccounts();
// ── Internal silent variants ─────────────────────────────────────
// These mirror the legacy public API but never emit deprecation
// warnings, so internal callers (HTTP routes, IPC handlers, the new
// /api/provider-accounts surface) can reuse the same logic without
// contributing to the migration noise. Public legacy methods below
// delegate here after logging exactly once per process.
/** Internal: list providers in the legacy ProviderConfig shape. */
async _listProvidersFromAccountsInternal(): Promise<ProviderConfig[]> {
const accounts = await this.listAccounts();
return accounts.map(providerAccountToConfig);
}
/**
* @deprecated Use listAccounts() + secret-store based key summary.
*/
async listLegacyProvidersWithKeyInfo(): Promise<ProviderWithKeyInfo[]> {
logLegacyProviderApiUsage('listLegacyProvidersWithKeyInfo', 'listAccounts');
const providers = await this.listLegacyProviders();
/** Internal: list providers with hasKey/keyMasked metadata. */
async _listProvidersWithKeyInfoInternal(): Promise<ProviderWithKeyInfo[]> {
const providers = await this._listProvidersFromAccountsInternal();
const results: ProviderWithKeyInfo[] = [];
for (const provider of providers) {
const apiKey = await getApiKey(provider.id);
@@ -144,21 +419,15 @@ export class ProviderService {
return results;
}
/**
* @deprecated Use getAccount(accountId).
*/
async getLegacyProvider(providerId: string): Promise<ProviderConfig | null> {
logLegacyProviderApiUsage('getLegacyProvider', 'getAccount');
/** Internal: resolve a single provider in the legacy ProviderConfig shape. */
async _getProviderInternal(providerId: string): Promise<ProviderConfig | null> {
await ensureProviderStoreMigrated();
const account = await getProviderAccount(providerId);
return account ? providerAccountToConfig(account) : null;
}
/**
* @deprecated Use createAccount()/updateAccount().
*/
async saveLegacyProvider(config: ProviderConfig): Promise<void> {
logLegacyProviderApiUsage('saveLegacyProvider', 'createAccount/updateAccount');
/** Internal: upsert a legacy provider config (creates or updates the account). */
async _saveProviderInternal(config: ProviderConfig): Promise<void> {
await ensureProviderStoreMigrated();
const account = providerConfigToAccount(config);
const existing = await getProviderAccount(config.id);
@@ -169,14 +438,129 @@ export class ProviderService {
await this.createAccount(account);
}
/** Internal: delete a provider account by id. */
async _deleteProviderInternal(providerId: string): Promise<boolean> {
await ensureProviderStoreMigrated();
await this.deleteAccount(providerId);
return true;
}
/** Internal: set default account without warning. */
async _setDefaultProviderInternal(providerId: string): Promise<void> {
await this.setDefaultAccount(providerId);
}
/** Internal: read default account id without warning. */
async _getDefaultProviderInternal(): Promise<string | undefined> {
return this.getDefaultAccountId();
}
/** Internal: store an account's api key without warning. */
async _setProviderApiKeyInternal(providerId: string, apiKey: string): Promise<boolean> {
return storeApiKey(providerId, apiKey);
}
/** Internal: read an account's api key without warning. */
async _getProviderApiKeyInternal(providerId: string): Promise<string | null> {
return getApiKey(providerId);
}
/** Internal: delete an account's api key without warning. */
async _deleteProviderApiKeyInternal(providerId: string): Promise<boolean> {
return deleteApiKey(providerId);
}
/** Internal: check if an account has a stored api key. */
async _hasProviderApiKeyInternal(providerId: string): Promise<boolean> {
return hasApiKey(providerId);
}
// ── New clean account-based public API ───────────────────────────
// These never log deprecation warnings — they operate purely in
// the account namespace and are the preferred surface for the
// /api/provider-accounts/* HTTP routes and modern renderer code.
/** Return per-account API key status for the new account API surface. */
async listAccountsKeyInfo(): Promise<Array<{ accountId: string; hasKey: boolean; keyMasked: string | null }>> {
const accounts = await this.listAccounts();
const results: Array<{ accountId: string; hasKey: boolean; keyMasked: string | null }> = [];
for (const account of accounts) {
const runtimeProviderKey = resolveOpenClawProviderKey(account);
const apiKey = (await getProviderApiKeyFromOpenClaw(runtimeProviderKey))
?? (await getApiKey(account.id))
?? (runtimeProviderKey !== account.id ? await getApiKey(runtimeProviderKey) : null);
results.push({
accountId: account.id,
hasKey: !!apiKey,
keyMasked: maskApiKey(apiKey),
});
}
return results;
}
/** Read an account's API key (clean alternative to getLegacyProviderApiKey). */
async getAccountApiKey(accountId: string): Promise<string | null> {
return this._getProviderApiKeyInternal(accountId);
}
/** Check whether an account has an API key stored. */
async hasAccountApiKey(accountId: string): Promise<boolean> {
const account = await this.getAccount(accountId);
const runtimeProviderKey = account
? resolveOpenClawProviderKey(account)
: accountId;
if (await getProviderApiKeyFromOpenClaw(runtimeProviderKey)) {
return true;
}
if (runtimeProviderKey !== accountId && (await hasApiKey(runtimeProviderKey))) {
return true;
}
return this._hasProviderApiKeyInternal(accountId);
}
// ── Legacy public API (logs deprecation warning once per method) ─
// These exist solely for backward compatibility with external clients
// (older Gateway code, third-party tooling, in-flight tests). Internal
// ClawX callers should use the internal/clean methods above.
/**
* @deprecated Use listAccounts() and map account data in callers.
*/
async listLegacyProviders(): Promise<ProviderConfig[]> {
logLegacyProviderApiUsage('listLegacyProviders', 'listAccounts');
return this._listProvidersFromAccountsInternal();
}
/**
* @deprecated Use listAccountsKeyInfo() + the account snapshot API.
*/
async listLegacyProvidersWithKeyInfo(): Promise<ProviderWithKeyInfo[]> {
logLegacyProviderApiUsage('listLegacyProvidersWithKeyInfo', 'listAccountsKeyInfo');
return this._listProvidersWithKeyInfoInternal();
}
/**
* @deprecated Use getAccount(accountId).
*/
async getLegacyProvider(providerId: string): Promise<ProviderConfig | null> {
logLegacyProviderApiUsage('getLegacyProvider', 'getAccount');
return this._getProviderInternal(providerId);
}
/**
* @deprecated Use createAccount()/updateAccount().
*/
async saveLegacyProvider(config: ProviderConfig): Promise<void> {
logLegacyProviderApiUsage('saveLegacyProvider', 'createAccount/updateAccount');
return this._saveProviderInternal(config);
}
/**
* @deprecated Use deleteAccount(accountId).
*/
async deleteLegacyProvider(providerId: string): Promise<boolean> {
logLegacyProviderApiUsage('deleteLegacyProvider', 'deleteAccount');
await ensureProviderStoreMigrated();
await this.deleteAccount(providerId);
return true;
return this._deleteProviderInternal(providerId);
}
/**
@@ -184,7 +568,7 @@ export class ProviderService {
*/
async setDefaultLegacyProvider(providerId: string): Promise<void> {
logLegacyProviderApiUsage('setDefaultLegacyProvider', 'setDefaultAccount');
await this.setDefaultAccount(providerId);
return this._setDefaultProviderInternal(providerId);
}
/**
@@ -192,7 +576,7 @@ export class ProviderService {
*/
async getDefaultLegacyProvider(): Promise<string | undefined> {
logLegacyProviderApiUsage('getDefaultLegacyProvider', 'getDefaultAccountId');
return this.getDefaultAccountId();
return this._getDefaultProviderInternal();
}
/**
@@ -200,15 +584,15 @@ export class ProviderService {
*/
async setLegacyProviderApiKey(providerId: string, apiKey: string): Promise<boolean> {
logLegacyProviderApiUsage('setLegacyProviderApiKey', 'setProviderSecret(accountId, api_key)');
return storeApiKey(providerId, apiKey);
return this._setProviderApiKeyInternal(providerId, apiKey);
}
/**
* @deprecated Use secret-store APIs by accountId.
* @deprecated Use getAccountApiKey(accountId).
*/
async getLegacyProviderApiKey(providerId: string): Promise<string | null> {
logLegacyProviderApiUsage('getLegacyProviderApiKey', 'getProviderSecret(accountId)');
return getApiKey(providerId);
logLegacyProviderApiUsage('getLegacyProviderApiKey', 'getAccountApiKey');
return this._getProviderApiKeyInternal(providerId);
}
/**
@@ -216,15 +600,15 @@ export class ProviderService {
*/
async deleteLegacyProviderApiKey(providerId: string): Promise<boolean> {
logLegacyProviderApiUsage('deleteLegacyProviderApiKey', 'deleteProviderSecret(accountId)');
return deleteApiKey(providerId);
return this._deleteProviderApiKeyInternal(providerId);
}
/**
* @deprecated Use secret-store APIs by accountId.
* @deprecated Use hasAccountApiKey(accountId).
*/
async hasLegacyProviderApiKey(providerId: string): Promise<boolean> {
logLegacyProviderApiUsage('hasLegacyProviderApiKey', 'getProviderSecret(accountId)');
return hasApiKey(providerId);
logLegacyProviderApiUsage('hasLegacyProviderApiKey', 'hasAccountApiKey');
return this._hasProviderApiKeyInternal(providerId);
}
async setDefaultAccount(accountId: string): Promise<void> {
@@ -2,7 +2,6 @@ import type { ProviderAccount, ProviderConfig, ProviderType } from '../../shared
import { getProviderDefinition } from '../../shared/providers/registry';
import { getClawXProviderStore } from './store-instance';
const PROVIDER_STORE_SCHEMA_VERSION = 1;
function inferAuthMode(type: ProviderType): ProviderAccount['authMode'] {
if (type === 'ollama') {
@@ -30,6 +29,7 @@ export function providerConfigToAccount(
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,
@@ -47,6 +47,7 @@ export function providerAccountToConfig(account: ProviderAccount): ProviderConfi
type: account.vendorId,
baseUrl: account.baseUrl,
apiProtocol: account.apiProtocol,
headers: account.headers,
model: account.model,
fallbackModels: account.fallbackModels,
fallbackProviderIds: account.fallbackAccountIds,
@@ -73,7 +74,6 @@ export async function saveProviderAccount(account: ProviderAccount): Promise<voi
const accounts = (store.get('providerAccounts') ?? {}) as Record<string, ProviderAccount>;
accounts[account.id] = account;
store.set('providerAccounts', accounts);
store.set('schemaVersion', PROVIDER_STORE_SCHEMA_VERSION);
}
export async function deleteProviderAccount(accountId: string): Promise<void> {

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