shadow-claw 1.23.4 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. package/README.md +42 -5
  2. package/bin/cli.mjs +166 -0
  3. package/bin/cli.test.mjs +60 -0
  4. package/bin/commands/backup.mjs +70 -0
  5. package/bin/commands/clients.mjs +41 -0
  6. package/bin/commands/peer-id.mjs +76 -0
  7. package/bin/commands/peer-id.test.mjs +104 -0
  8. package/bin/commands/send.mjs +51 -0
  9. package/bin/commands/tasks.mjs +59 -0
  10. package/bin/commands/webrtc-listen.mjs +254 -0
  11. package/bin/prerender-dsd-shell/prerender-dsd-shell.test.mjs +46 -0
  12. package/bin/rolldown.config.test.mjs +401 -8
  13. package/bin/utils/control-client.mjs +243 -0
  14. package/bin/utils/control-client.test.mjs +83 -0
  15. package/bin/utils/ulid.mjs +45 -0
  16. package/bin/utils/webrtc-control-client.mjs +699 -0
  17. package/bin/utils/webrtc-control-client.test.mjs +318 -0
  18. package/dist/electron/main.cjs +1 -1
  19. package/dist/public/AGENTS.md +5 -3
  20. package/dist/public/README.md +42 -5
  21. package/dist/public/agent.worker.js +1 -1
  22. package/dist/public/backup-controller-C_s9in2t.js +1 -0
  23. package/dist/public/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.css +1 -0
  24. package/dist/public/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.html +1 -0
  25. package/dist/public/components/settings/shadow-claw-task-server/shadow-claw-task-server.css +1 -1
  26. package/dist/public/components/settings/shadow-claw-task-server/shadow-claw-task-server.html +1 -1
  27. package/dist/public/components/shadow-claw-settings/shadow-claw-settings.html +1 -1
  28. package/dist/public/config-CrHchneq.js +1 -0
  29. package/dist/public/{configurePeerJs-B4Ym9VMj.js → configurePeerJs-CxpZeDJJ.js} +1 -1
  30. package/dist/public/{connections-BEn6JYdu.js → connections-DvbgdkaB.js} +1 -1
  31. package/dist/public/{custom-element-security-DdYlUozq.js → custom-element-security-NCqQ5lmC.js} +1 -1
  32. package/dist/public/docs/README.md +3 -1
  33. package/dist/public/docs/architecture/overview.md +12 -12
  34. package/dist/public/docs/decisions/bundled-typescript-architecture.md +1 -1
  35. package/dist/public/docs/decisions/indexeddb-and-opfs-storage.md +2 -2
  36. package/dist/public/docs/decisions/native-web-components-and-signals.md +1 -1
  37. package/dist/public/docs/example/article/index.html +1 -1
  38. package/dist/public/docs/guides/configuring-messaging-channels.md +41 -6
  39. package/dist/public/docs/guides/server-development-configuration.md +24 -0
  40. package/dist/public/docs/publishing/index.html +1 -1
  41. package/dist/public/docs/skill-creator/index.html +1 -1
  42. package/dist/public/docs/subsystems/backup.md +56 -0
  43. package/dist/public/docs/subsystems/cli.md +124 -0
  44. package/dist/public/docs/subsystems/control-plane.md +158 -0
  45. package/dist/public/docs/subsystems/notifications.md +54 -2
  46. package/dist/public/docs/subsystems/reactive-ui.md +1 -0
  47. package/dist/public/downloadGroupFile-By0GR6eV.js +1 -0
  48. package/dist/public/e2e/README.md +5 -1
  49. package/dist/public/{e2e-bridge-BQ4KzUt5.js → e2e-bridge-IGWsRl4i.js} +1 -1
  50. package/dist/public/file-viewer-D0eflgt6.js +1 -0
  51. package/dist/public/getAllTasks-vpBlgGsc.js +1 -0
  52. package/dist/public/getGroupDir-P1h9wl6S.js +1 -0
  53. package/dist/public/{git-DEUvQTUA.js → git-7Vq_SJSp.js} +2 -2
  54. package/dist/public/{iframe-sanitizer-0NS4XLOd.js → iframe-sanitizer-y2Z1pPwY.js} +1 -1
  55. package/dist/public/index.html +1 -1
  56. package/dist/public/index.js +1 -1
  57. package/dist/public/{initChatSplitResize-llGkSJXg.js → initChatSplitResize-CzV2E7Ul.js} +1 -1
  58. package/dist/public/initControlPlane-C9jpnWev.js +1 -0
  59. package/dist/public/llms.txt +1 -1
  60. package/dist/public/main/index.html +1 -1
  61. package/dist/public/main/memory/index.html +1 -1
  62. package/dist/public/{markdown-CyYR4App.js → markdown-BnpKxSxl.js} +1 -1
  63. package/dist/public/{mcp-reconnect-CSx4aMwy.js → mcp-reconnect-9emsqGRe.js} +1 -1
  64. package/dist/public/{memoryStorage-Dwg8NhAZ.js → memoryStorage-C0KvLNUp.js} +1 -1
  65. package/dist/public/{orchestrator-DRfEudXk.js → orchestrator-3h9xxHUm.js} +676 -676
  66. package/dist/public/orchestrator-nJj59RqD.js +15 -0
  67. package/dist/public/{peerjs-CurBuKvk.js → peerjs-BP4laMXj.js} +1 -1
  68. package/dist/public/{push-client-Dii-vPEh.js → push-client-CrjEXK3U.js} +1 -1
  69. package/dist/public/service-worker/push-handler.js +1 -1
  70. package/dist/public/service-worker.js +1 -1
  71. package/dist/public/{shadow-claw-5IHaDDMJ.js → shadow-claw-CbwETBWf.js} +2 -2
  72. package/dist/public/{shadow-claw-accounts-CZvz4iQz.js → shadow-claw-accounts-B4sBTQEj.js} +1 -1
  73. package/dist/public/{shadow-claw-channels-BKXFyRS3.js → shadow-claw-channels-BDWG5Qa3.js} +1 -1
  74. package/dist/public/{shadow-claw-chat-CCLuhWdI.js → shadow-claw-chat-Crv0DTCY.js} +6 -6
  75. package/dist/public/shadow-claw-control-plane-CX1IGbh9.js +199 -0
  76. package/dist/public/{shadow-claw-conversations-DV-87UUh.js → shadow-claw-conversations-_9qAaRXk.js} +1 -1
  77. package/dist/public/shadow-claw-dialog-BrOwAOdk.js +1 -0
  78. package/dist/public/{shadow-claw-file-viewer-Dk2dbAUd.js → shadow-claw-file-viewer-Blf6yGVD.js} +1 -1
  79. package/dist/public/shadow-claw-files-CYySBDCl.js +840 -0
  80. package/dist/public/{shadow-claw-git-DRA7_qck.js → shadow-claw-git-v8S0JjuR.js} +2 -2
  81. package/dist/public/{shadow-claw-integrations-1Xu5snuV.js → shadow-claw-integrations-D7ufpwhX.js} +1 -1
  82. package/dist/public/{shadow-claw-llm-6Aci1BDP.js → shadow-claw-llm-GWgoVfhI.js} +2 -2
  83. package/dist/public/{shadow-claw-mcp-remote-BUjAeVXC.js → shadow-claw-mcp-remote-kY4LMFNN.js} +1 -1
  84. package/dist/public/{shadow-claw-networking-FQe4nCp3.js → shadow-claw-networking-qHbP95z9.js} +1 -1
  85. package/dist/public/{shadow-claw-notifications-DV2wibL3.js → shadow-claw-notifications-YyHOK2Fr.js} +1 -1
  86. package/dist/public/{shadow-claw-page-header-B63S4HTU.js → shadow-claw-page-header-DMKbkv3s.js} +1 -1
  87. package/dist/public/{shadow-claw-pages-DE3jWj5K.js → shadow-claw-pages-DFZHhzFZ.js} +1 -1
  88. package/dist/public/{shadow-claw-settings-S6DAKfYx.js → shadow-claw-settings-DhxrKIfA.js} +10 -3
  89. package/dist/public/{shadow-claw-storage-DaacA3_K.js → shadow-claw-storage-BFynbP0j.js} +2 -2
  90. package/dist/public/{shadow-claw-task-server-IjKX042p.js → shadow-claw-task-server-3BIeb5oZ.js} +25 -4
  91. package/dist/public/{shadow-claw-tasks-Cy0JYmGU.js → shadow-claw-tasks-Caxc9atL.js} +1 -1
  92. package/dist/public/{shadow-claw-terminal-znAt7HyP.js → shadow-claw-terminal-Beb5qF-U.js} +1 -1
  93. package/dist/public/{shadow-claw-tools-Cutv9Si5.js → shadow-claw-tools-BfJx3A9W.js} +1 -1
  94. package/dist/public/{shadow-claw-webvm-BH8mPDEu.js → shadow-claw-webvm-BCS8ykeP.js} +1 -1
  95. package/dist/public/{syncWebMcpRegistration-BqJxaMGR.js → syncWebMcpRegistration-B_M2fCjH.js} +1 -1
  96. package/dist/server.js +286 -189
  97. package/package.json +2 -1
  98. package/src/__mocks__/jszip.cjs +15 -5
  99. package/src/components/settings/shadow-claw-accounts/shadow-claw-accounts.test.ts +35 -0
  100. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.css +117 -0
  101. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.html +81 -0
  102. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.test.ts +127 -0
  103. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.ts +292 -0
  104. package/src/components/settings/shadow-claw-git/shadow-claw-git.test.ts +72 -0
  105. package/src/components/settings/shadow-claw-integrations/connection-test-auth.test.ts +20 -30
  106. package/src/components/settings/shadow-claw-integrations/shadow-claw-integrations.test.ts +116 -0
  107. package/src/components/settings/shadow-claw-llm/model-ranking.test.ts +143 -51
  108. package/src/components/settings/shadow-claw-llm/shadow-claw-llm.test.ts +307 -4
  109. package/src/components/settings/shadow-claw-llm/shadow-claw-llm.ts +8 -8
  110. package/src/components/settings/shadow-claw-mcp-remote/shadow-claw-mcp-remote.test.ts +31 -0
  111. package/src/components/settings/shadow-claw-peerjs/shadow-claw-peerjs.test.ts +186 -0
  112. package/src/components/settings/shadow-claw-storage/shadow-claw-storage.test.ts +76 -0
  113. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.css +23 -2
  114. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.html +1 -1
  115. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.test.ts +19 -0
  116. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.ts +2 -1
  117. package/src/components/shadow-claw/utils/initChatSplitResize.test.ts +180 -13
  118. package/src/components/shadow-claw/utils/setupEffects.test.ts +39 -0
  119. package/src/components/shadow-claw-a2ui/catalog/basic/shared.test.ts +117 -0
  120. package/src/components/shadow-claw-a2ui/shadow-claw-a2ui.test.ts +1159 -1
  121. package/src/components/shadow-claw-chat/shadow-claw-chat.test.ts +1523 -19
  122. package/src/components/shadow-claw-chat/utils/escapeHtml.test.ts +13 -0
  123. package/src/components/shadow-claw-chat/utils/getPeerChatDisplayStatus.test.ts +20 -0
  124. package/src/components/shadow-claw-conversations/shadow-claw-conversations.test.ts +494 -0
  125. package/src/components/shadow-claw-dialog/shadow-claw-dialog.ts +13 -2
  126. package/src/components/shadow-claw-file-viewer/shadow-claw-file-viewer.test.ts +273 -0
  127. package/src/components/shadow-claw-files/shadow-claw-files.test.ts +408 -1
  128. package/src/components/shadow-claw-pdf-viewer/utils/installGetOrInsertComputedPolyfill.test.ts +65 -0
  129. package/src/components/shadow-claw-settings/shadow-claw-settings.html +7 -0
  130. package/src/components/shadow-claw-settings/shadow-claw-settings.test.ts +221 -0
  131. package/src/components/shadow-claw-settings/shadow-claw-settings.ts +2 -0
  132. package/src/components/shadow-claw-tasks/shadow-claw-tasks.test.ts +407 -0
  133. package/src/components/shadow-claw-terminal/utils/consumeEscapeSequence.test.ts +58 -0
  134. package/src/components/shadow-claw-tools/shadow-claw-tools.test.ts +80 -0
  135. package/src/config/config.ts +10 -0
  136. package/src/core/backup-controller.test.ts +101 -0
  137. package/src/core/backup-controller.ts +137 -0
  138. package/src/core/control-plane-client.test.ts +408 -0
  139. package/src/core/control-plane-client.ts +425 -0
  140. package/src/core/orchestrator/orchestrator.ts +4 -0
  141. package/src/core/orchestrator/utils/compactContext.test.ts +78 -0
  142. package/src/core/orchestrator/utils/operations/channel.test.ts +254 -9
  143. package/src/core/orchestrator/utils/operations/provider.test.ts +326 -34
  144. package/src/core/orchestrator/utils/operations/vm.test.ts +114 -2
  145. package/src/core/orchestrator/utils/syncProxyConfigToServiceWorker.test.ts +60 -0
  146. package/src/core/utils/initControlPlane.test.ts +276 -0
  147. package/src/core/utils/initControlPlane.ts +437 -0
  148. package/src/core/utils/initializeApp.ts +18 -0
  149. package/src/db/getSession.test.ts +13 -1
  150. package/src/server/app.test.ts +12 -0
  151. package/src/server/app.ts +18 -8
  152. package/src/server/client-registry.test.ts +250 -0
  153. package/src/server/client-registry.ts +477 -0
  154. package/src/server/config.ts +15 -0
  155. package/src/server/control-plane-types.ts +120 -0
  156. package/src/server/control-plane.test.ts +472 -0
  157. package/src/server/control-plane.ts +581 -0
  158. package/src/server/routes/backup.test.ts +263 -0
  159. package/src/server/routes/backup.ts +262 -0
  160. package/src/server/routes/integrations-email.test.ts +532 -0
  161. package/src/server/routes/llamafile.test.ts +172 -48
  162. package/src/server/routes/mesh-llm.test.ts +192 -0
  163. package/src/server/routes/ollama.test.ts +49 -0
  164. package/src/server/server-peer.test.ts +86 -0
  165. package/src/server/server-peer.ts +330 -0
  166. package/src/server/server.ts +35 -0
  167. package/src/server/services/llamafile-manager.test.ts +230 -6
  168. package/src/server/services/transformers-runtime.test.ts +186 -18
  169. package/src/server/webrtc.test.ts +21 -0
  170. package/src/server/webrtc.ts +72 -0
  171. package/src/service-worker/fetch-proxy.test.ts +64 -1
  172. package/src/service-worker/push-handler.ts +39 -0
  173. package/src/shell/vm.test.ts +744 -12
  174. package/src/storage/ensureMainGroupIndex.test.ts +153 -0
  175. package/src/storage/getGroupFile.test.ts +66 -0
  176. package/src/storage/memoryStorage.test.ts +82 -2
  177. package/src/storage/writeGroupFileBytes.test.ts +143 -0
  178. package/src/stores/chat-ui.test.ts +20 -0
  179. package/src/stores/orchestrator.test.ts +134 -0
  180. package/src/stores/toast.test.ts +50 -0
  181. package/src/stores/tools.test.ts +147 -10
  182. package/src/subsystems/channels/imessage.test.ts +59 -0
  183. package/src/subsystems/channels/peerjs.test.ts +500 -62
  184. package/src/subsystems/channels/peerjs.ts +71 -4
  185. package/src/subsystems/channels/telegram.test.ts +177 -0
  186. package/src/subsystems/email/catalog.test.ts +1 -0
  187. package/src/subsystems/email/connections.test.ts +39 -1
  188. package/src/subsystems/git/credentials.test.ts +160 -825
  189. package/src/subsystems/git/git.test.ts +463 -0
  190. package/src/subsystems/notifications/__mocks__/node-sqlite.cjs +12 -1
  191. package/src/subsystems/notifications/push-routes.test.ts +33 -0
  192. package/src/subsystems/notifications/push-routes.ts +23 -0
  193. package/src/subsystems/notifications/push-store.ts +1 -1
  194. package/src/subsystems/providers/builtin-ai-tasks.test.ts +74 -0
  195. package/src/subsystems/providers/litert-lm-provider.test.ts +212 -51
  196. package/src/subsystems/providers/model-registry.test.ts +115 -0
  197. package/src/subsystems/providers/providers.test.ts +94 -1
  198. package/src/subsystems/providers/transformers-js-provider.test.ts +279 -0
  199. package/src/subsystems/providers/utils/createModelCacheFetch.test.ts +2 -2
  200. package/src/subsystems/providers/utils/downloadModelToCache.test.ts +2 -2
  201. package/src/subsystems/providers/utils/loadModelStream.test.ts +2 -2
  202. package/src/subsystems/skills/activateSkill.test.ts +101 -0
  203. package/src/subsystems/skills/discoverSkills.test.ts +251 -0
  204. package/src/subsystems/tools/cron.test.ts +64 -0
  205. package/src/subsystems/tools/declarative.test.ts +255 -89
  206. package/src/testing/assets/v86.9pfs/libv86.mjs +63 -0
  207. package/src/testing/assets/v86.ext2/libv86.mjs +3 -0
  208. package/src/testing/assets_missing_v86/assets/v86.9pfs/libv86.mjs +1 -0
  209. package/src/testing/jest-setup.ts +328 -40
  210. package/src/testing/mock-v86.mjs +49 -0
  211. package/src/ui/a2ui/types.ts +4 -3
  212. package/src/ui/a2ui/utils/normaliseComponentsToMap.test.ts +102 -0
  213. package/src/ui/a2ui/utils/resolveChildIds.test.ts +63 -0
  214. package/src/ui/a2ui/utils/resolveDynamicNumber.test.ts +13 -1
  215. package/src/worker/tools/bash/utils/waitForVMReady.test.ts +40 -0
  216. package/src/worker/tools/email/email.test.ts +193 -8
  217. package/src/worker/tools/email/utils/asEmailAttachmentInputs.test.ts +47 -0
  218. package/src/worker/tools/git/git.test.ts +214 -3
  219. package/src/worker/tools/rooms/invite-to-room.test.ts +54 -0
  220. package/src/worker/tools/rooms/leave-room.test.ts +40 -0
  221. package/src/worker/tools/rooms/list-room-members.test.ts +75 -0
  222. package/src/worker/tools/tasks/tasks-utils.test.ts +22 -0
  223. package/src/worker/tools/ui/ask-user.test.ts +50 -0
  224. package/src/worker/tools/ui/clear-chat.test.ts +19 -0
  225. package/src/worker/tools/ui/get-current-time.test.ts +19 -0
  226. package/src/worker/tools/ui/javascript.test.ts +116 -0
  227. package/src/worker/tools/ui/list-components.test.ts +10 -0
  228. package/src/worker/tools/ui/list-tool-profiles.test.ts +42 -0
  229. package/src/worker/tools/ui/manage-tools.test.ts +34 -0
  230. package/src/worker/tools/ui/render-component.test.ts +346 -0
  231. package/src/worker/tools/ui/send-notification.test.ts +40 -0
  232. package/src/worker/tools/ui/utils/getAllowFullInternetAccess.test.ts +31 -0
  233. package/src/worker/tools/ui/utils/parseBooleanConfig.test.ts +25 -0
  234. package/src/worker/tools/workspace/attach-file.test.ts +84 -0
  235. package/src/worker/tools/workspace/copy-file.test.ts +69 -0
  236. package/src/worker/tools/workspace/create-directory.test.ts +40 -0
  237. package/src/worker/tools/workspace/delete-file.test.ts +73 -0
  238. package/src/worker/tools/workspace/diff-files.test.ts +94 -0
  239. package/src/worker/tools/workspace/list-files.test.ts +36 -0
  240. package/src/worker/tools/workspace/move-file.test.ts +66 -0
  241. package/src/worker/tools/workspace/open-file.test.ts +57 -0
  242. package/src/worker/tools/workspace/patch-file.test.ts +81 -0
  243. package/src/worker/tools/workspace/read-file.test.ts +116 -0
  244. package/src/worker/tools/workspace/send-file.test.ts +82 -0
  245. package/src/worker/tools/workspace/update-memory.test.ts +29 -0
  246. package/src/worker/tools/workspace/write-file.test.ts +28 -0
  247. package/src/worker/utils/rate-limit.test.ts +199 -0
  248. package/src/worker/utils/tool-state.test.ts +29 -0
  249. package/src/worker/utils/transformers-js-utils.test.ts +59 -0
  250. package/dist/public/config-BYAuxx3j.js +0 -1
  251. package/dist/public/downloadGroupFile-Drdvg1Ev.js +0 -1
  252. package/dist/public/file-viewer-CcZYw-PS.js +0 -1
  253. package/dist/public/orchestrator-o9Fj67BN.js +0 -15
  254. package/dist/public/shadow-claw-dialog-ByyHgxM9.js +0 -1
  255. package/dist/public/shadow-claw-files-BpfpJIuP.js +0 -840
@@ -0,0 +1,581 @@
1
+ /**
2
+ * ShadowClaw — WebSocket & HTTP Control Plane Server
3
+ *
4
+ * Provides bidirectional communication between the server/CLI and connected
5
+ * browser/Electron clients. Supports WebSocket connections at `/ws/control`
6
+ * and REST/SSE endpoints under `/api/control/*`.
7
+ */
8
+
9
+ import http from "node:http";
10
+ import { URL } from "node:url";
11
+ import { WebSocketServer, WebSocket } from "ws";
12
+ import type { Express, Request, Response, NextFunction } from "express";
13
+ import { ulid } from "../utils/ulid.js";
14
+ import {
15
+ registerClient,
16
+ updateClientHeartbeat,
17
+ getAllClients,
18
+ pruneStaleClients,
19
+ getOrCreateControlToken,
20
+ } from "./client-registry.js";
21
+ import type {
22
+ ControlMessage,
23
+ ClientRegisterPayload,
24
+ ClientHeartbeatPayload,
25
+ CommandResultPayload,
26
+ CommandAction,
27
+ CommandExecutePayload,
28
+ ClientInfo,
29
+ } from "./control-plane-types.js";
30
+
31
+ export interface ControlPlaneOptions {
32
+ httpServer: http.Server;
33
+ app?: Express;
34
+ token?: string;
35
+ heartbeatTimeoutMs?: number;
36
+ verbose?: boolean;
37
+ }
38
+
39
+ export interface PendingCommand {
40
+ resolve: (value: CommandResultPayload) => void;
41
+ reject: (reason: Error) => void;
42
+ timeout: NodeJS.Timeout;
43
+ }
44
+
45
+ export interface ControlPlane {
46
+ sendCommand: (
47
+ clientId: string,
48
+ action: CommandAction,
49
+ args?: Record<string, unknown>,
50
+ timeoutMs?: number,
51
+ ) => Promise<CommandResultPayload>;
52
+ broadcastCommand: (
53
+ action: CommandAction,
54
+ args?: Record<string, unknown>,
55
+ timeoutMs?: number,
56
+ ) => Promise<CommandResultPayload[]>;
57
+ isClientConnected: (clientId: string) => boolean;
58
+ getConnectedClients: () => ClientInfo[];
59
+ getToken: () => string;
60
+ close: () => void;
61
+ }
62
+
63
+ /**
64
+ * Creates and mounts the Control Plane on the provided HTTP server and Express application.
65
+ */
66
+ export function createControlPlane(options: ControlPlaneOptions): ControlPlane {
67
+ const {
68
+ httpServer,
69
+ app,
70
+ heartbeatTimeoutMs = 90_000,
71
+ verbose = false,
72
+ } = options;
73
+
74
+ const token = options.token || getOrCreateControlToken();
75
+
76
+ const wss = new WebSocketServer({ noServer: true });
77
+ const activeSockets = new Map<string, WebSocket>();
78
+ const socketToClientId = new Map<WebSocket, string>();
79
+ const sseClientsByClientId = new Map<string, Response>();
80
+ const pendingCommands = new Map<string, PendingCommand>();
81
+ const sseClients = new Set<Response>();
82
+
83
+ // Heartbeat stale client pruner interval
84
+ const prunerInterval = setInterval(
85
+ () => {
86
+ pruneStaleClients(heartbeatTimeoutMs);
87
+ },
88
+ Math.max(10_000, Math.floor(heartbeatTimeoutMs / 2)),
89
+ );
90
+
91
+ function log(msg: string) {
92
+ if (verbose) {
93
+ console.log(`[control-plane] ${msg}`);
94
+ }
95
+ }
96
+
97
+ function validateToken(providedToken?: string | null): boolean {
98
+ if (!providedToken) {
99
+ return false;
100
+ }
101
+ if (providedToken === token) {
102
+ return true;
103
+ }
104
+ try {
105
+ const currentToken = getOrCreateControlToken();
106
+ if (currentToken && providedToken === currentToken) {
107
+ return true;
108
+ }
109
+ } catch (_) {}
110
+ return false;
111
+ }
112
+
113
+ function isSameOriginBrowser(req: http.IncomingMessage): boolean {
114
+ const host = req.headers.host || "127.0.0.1";
115
+ const origin = req.headers.origin;
116
+ const referer = req.headers.referer;
117
+
118
+ if (origin) {
119
+ try {
120
+ const originUrl = new URL(origin);
121
+ if (originUrl.host === host) {
122
+ return true;
123
+ }
124
+ if (
125
+ originUrl.hostname === "127.0.0.1" ||
126
+ originUrl.hostname === "localhost"
127
+ ) {
128
+ return true;
129
+ }
130
+ } catch (_) {}
131
+ }
132
+
133
+ if (referer) {
134
+ try {
135
+ const refererUrl = new URL(referer);
136
+ if (refererUrl.host === host) {
137
+ return true;
138
+ }
139
+ if (
140
+ refererUrl.hostname === "127.0.0.1" ||
141
+ refererUrl.hostname === "localhost"
142
+ ) {
143
+ return true;
144
+ }
145
+ } catch (_) {}
146
+ }
147
+
148
+ const secFetchSite = req.headers["sec-fetch-site"];
149
+ if (secFetchSite === "same-origin" || secFetchSite === "same-site") {
150
+ return true;
151
+ }
152
+
153
+ return false;
154
+ }
155
+
156
+ function extractTokenFromReq(
157
+ req: http.IncomingMessage,
158
+ parsedUrl: URL,
159
+ ): string | null {
160
+ const queryToken = parsedUrl.searchParams.get("token");
161
+ if (queryToken) {
162
+ return queryToken;
163
+ }
164
+
165
+ const headerToken = req.headers["x-control-token"];
166
+ if (typeof headerToken === "string") {
167
+ return headerToken;
168
+ }
169
+
170
+ const authHeader = req.headers["authorization"];
171
+ if (authHeader && authHeader.startsWith("Bearer ")) {
172
+ return authHeader.slice(7).trim();
173
+ }
174
+
175
+ return null;
176
+ }
177
+
178
+ // Handle WebSocket upgrade
179
+ const upgradeHandler = (
180
+ req: http.IncomingMessage,
181
+ socket: any,
182
+ head: Buffer,
183
+ ) => {
184
+ const host = req.headers.host || "127.0.0.1";
185
+ const parsedUrl = new URL(req.url || "/", `http://${host}`);
186
+
187
+ if (parsedUrl.pathname === "/ws/control") {
188
+ const providedToken = extractTokenFromReq(req, parsedUrl);
189
+ const isAuthValid = providedToken
190
+ ? validateToken(providedToken)
191
+ : isSameOriginBrowser(req);
192
+
193
+ if (!isAuthValid) {
194
+ log(
195
+ `Rejected unauthorized WebSocket connection from ${req.socket.remoteAddress}`,
196
+ );
197
+ // Handle upgrade to reject cleanly via WebSocket close code 4001
198
+ wss.handleUpgrade(req, socket, head, (ws) => {
199
+ ws.close(4001, "Unauthorized");
200
+ });
201
+ return;
202
+ }
203
+
204
+ wss.handleUpgrade(req, socket, head, (ws) => {
205
+ wss.emit("connection", ws, req);
206
+ });
207
+ }
208
+ };
209
+
210
+ httpServer.on("upgrade", upgradeHandler);
211
+
212
+ // Handle incoming message from client
213
+ function handleIncomingMessage(
214
+ ws: WebSocket | null,
215
+ message: ControlMessage,
216
+ ): ControlMessage | void {
217
+ const { id, type, payload } = message;
218
+
219
+ if (type === "client:register") {
220
+ const reg = payload as ClientRegisterPayload;
221
+ if (
222
+ reg?.clientId &&
223
+ typeof reg.clientId === "string" &&
224
+ reg.clientId.trim()
225
+ ) {
226
+ const cleanClientId = reg.clientId.trim();
227
+ registerClient({
228
+ clientId: cleanClientId,
229
+ deviceLabel: reg.deviceLabel || "Unknown device",
230
+ capabilities: reg.capabilities || [],
231
+ version: reg.version || "1.0.0",
232
+ peerId: reg.peerId,
233
+ });
234
+
235
+ if (ws) {
236
+ activeSockets.set(cleanClientId, ws);
237
+ socketToClientId.set(ws, cleanClientId);
238
+ }
239
+
240
+ log(`Client registered: ${cleanClientId} (${reg.deviceLabel})`);
241
+
242
+ const ack: ControlMessage = {
243
+ id: ulid(),
244
+ type: "server:registered",
245
+ replyTo: id,
246
+ payload: {
247
+ clientId: cleanClientId,
248
+ status: "ok",
249
+ serverTime: Date.now(),
250
+ },
251
+ };
252
+
253
+ if (ws && ws.readyState === WebSocket.OPEN) {
254
+ ws.send(JSON.stringify(ack));
255
+ }
256
+
257
+ return ack;
258
+ } else {
259
+ log("Warning: received client:register without valid clientId");
260
+ }
261
+ } else if (type === "client:heartbeat") {
262
+ const hb = payload as ClientHeartbeatPayload;
263
+ if (hb?.clientId) {
264
+ updateClientHeartbeat(hb.clientId);
265
+ log(`Heartbeat from client: ${hb.clientId}`);
266
+ }
267
+ } else if (type === "command:result") {
268
+ const res = payload as CommandResultPayload;
269
+ if (res?.commandId && pendingCommands.has(res.commandId)) {
270
+ const pending = pendingCommands.get(res.commandId)!;
271
+ clearTimeout(pending.timeout);
272
+ pendingCommands.delete(res.commandId);
273
+ pending.resolve(res);
274
+ log(`Command ${res.commandId} resolved: success=${res.success}`);
275
+ }
276
+ }
277
+ }
278
+
279
+ // WebSocket connection handler
280
+ wss.on("connection", (ws: WebSocket) => {
281
+ log("WebSocket client connected");
282
+
283
+ ws.on("message", (data) => {
284
+ try {
285
+ const message = JSON.parse(data.toString()) as ControlMessage;
286
+ handleIncomingMessage(ws, message);
287
+ } catch (err: any) {
288
+ log(`Error parsing WebSocket message: ${err.message}`);
289
+ }
290
+ });
291
+
292
+ ws.on("close", () => {
293
+ const clientId = socketToClientId.get(ws);
294
+ if (clientId) {
295
+ activeSockets.delete(clientId);
296
+ socketToClientId.delete(ws);
297
+ log(`Client disconnected: ${clientId}`);
298
+ }
299
+ });
300
+
301
+ ws.on("error", (err) => {
302
+ log(`WebSocket error: ${err.message}`);
303
+ });
304
+ });
305
+
306
+ // Wire REST & SSE endpoints if Express app is provided
307
+ if (app) {
308
+ const adminAuthMiddleware = (
309
+ req: Request,
310
+ res: Response,
311
+ next: NextFunction,
312
+ ) => {
313
+ const host = req.headers.host || "127.0.0.1";
314
+ const parsedUrl = new URL(req.url, `http://${host}`);
315
+ const providedToken = extractTokenFromReq(req, parsedUrl);
316
+
317
+ if (!validateToken(providedToken)) {
318
+ res.status(401).json({ error: "Unauthorized: Invalid control token" });
319
+ return;
320
+ }
321
+ next();
322
+ };
323
+
324
+ const clientAuthMiddleware = (
325
+ req: Request,
326
+ res: Response,
327
+ next: NextFunction,
328
+ ) => {
329
+ const host = req.headers.host || "127.0.0.1";
330
+ const parsedUrl = new URL(req.url, `http://${host}`);
331
+ const providedToken = extractTokenFromReq(req, parsedUrl);
332
+
333
+ const isAuthValid = providedToken
334
+ ? validateToken(providedToken)
335
+ : isSameOriginBrowser(req);
336
+
337
+ if (!isAuthValid) {
338
+ res.status(401).json({ error: "Unauthorized: Invalid control token" });
339
+ return;
340
+ }
341
+ next();
342
+ };
343
+
344
+ // List all registered clients
345
+ app.get(
346
+ "/api/control/clients",
347
+ adminAuthMiddleware,
348
+ (_req: Request, res: Response) => {
349
+ const clients = getAllClients();
350
+ res.json({ clients });
351
+ },
352
+ );
353
+
354
+ // Send a command to a client via REST
355
+ app.post(
356
+ "/api/control/command",
357
+ adminAuthMiddleware,
358
+ async (req: Request, res: Response) => {
359
+ const { clientId, action, args, timeoutMs } = req.body || {};
360
+ if (!clientId || !action) {
361
+ res.status(400).json({ error: "clientId and action are required" });
362
+ return;
363
+ }
364
+
365
+ try {
366
+ const result = await sendCommand(clientId, action, args, timeoutMs);
367
+ res.json(result);
368
+ } catch (err: any) {
369
+ res.status(err.message?.includes("not connected") ? 404 : 500).json({
370
+ error: err.message,
371
+ });
372
+ }
373
+ },
374
+ );
375
+
376
+ // Inbound messages over HTTP
377
+ app.post(
378
+ "/api/control/messages",
379
+ clientAuthMiddleware,
380
+ (req: Request, res: Response) => {
381
+ const message = req.body as ControlMessage;
382
+ if (!message || !message.type) {
383
+ res.status(400).json({ error: "Invalid control message payload" });
384
+ return;
385
+ }
386
+
387
+ const reply = handleIncomingMessage(null, message);
388
+ res.json({ status: "received", reply });
389
+ },
390
+ );
391
+
392
+ // SSE Events Stream
393
+ app.get(
394
+ "/api/control/events",
395
+ clientAuthMiddleware,
396
+ (req: Request, res: Response) => {
397
+ const host = req.headers.host || "127.0.0.1";
398
+ const parsedUrl = new URL(req.url, `http://${host}`);
399
+ const clientId =
400
+ parsedUrl.searchParams.get("clientId") ||
401
+ (req.headers["x-client-id"] as string) ||
402
+ undefined;
403
+
404
+ res.setHeader("Content-Type", "text/event-stream");
405
+ res.setHeader("Cache-Control", "no-cache");
406
+ res.setHeader("Connection", "keep-alive");
407
+ res.flushHeaders?.();
408
+
409
+ sseClients.add(res);
410
+ if (clientId) {
411
+ sseClientsByClientId.set(clientId, res);
412
+ log(`SSE client connected: ${clientId}`);
413
+ }
414
+
415
+ res.write(
416
+ `event: ready\ndata: ${JSON.stringify({ status: "ok", serverTime: Date.now() })}\n\n`,
417
+ );
418
+
419
+ const keepAlive = setInterval(() => {
420
+ if (!res.writableEnded) {
421
+ res.write(": ping\n\n");
422
+ }
423
+ }, 15_000);
424
+
425
+ req.on("close", () => {
426
+ clearInterval(keepAlive);
427
+ sseClients.delete(res);
428
+ if (clientId && sseClientsByClientId.get(clientId) === res) {
429
+ sseClientsByClientId.delete(clientId);
430
+ log(`SSE client disconnected: ${clientId}`);
431
+ }
432
+ });
433
+ },
434
+ );
435
+ }
436
+
437
+ /**
438
+ * Send a command to a connected client and wait for the result.
439
+ */
440
+ function sendCommand(
441
+ clientId: string,
442
+ action: CommandAction,
443
+ args: Record<string, unknown> = {},
444
+ timeoutMs: number = 30_000,
445
+ ): Promise<CommandResultPayload> {
446
+ const ws = activeSockets.get(clientId);
447
+ const sseRes = sseClientsByClientId.get(clientId);
448
+
449
+ const isWsOpen = ws && ws.readyState === WebSocket.OPEN;
450
+ const isSseOpen = sseRes && !sseRes.writableEnded;
451
+
452
+ if (!isWsOpen && !isSseOpen) {
453
+ return Promise.reject(new Error(`Client not connected: ${clientId}`));
454
+ }
455
+
456
+ const commandId = ulid();
457
+ const commandPayload: CommandExecutePayload = {
458
+ commandId,
459
+ action,
460
+ args,
461
+ };
462
+
463
+ const message: ControlMessage = {
464
+ id: ulid(),
465
+ type: "command:execute",
466
+ payload: commandPayload,
467
+ };
468
+
469
+ return new Promise<CommandResultPayload>((resolve, reject) => {
470
+ const timeout = setTimeout(() => {
471
+ pendingCommands.delete(commandId);
472
+ reject(
473
+ new Error(
474
+ `Command ${action} to client ${clientId} timed out after ${timeoutMs}ms`,
475
+ ),
476
+ );
477
+ }, timeoutMs);
478
+
479
+ pendingCommands.set(commandId, { resolve, reject, timeout });
480
+
481
+ try {
482
+ if (isWsOpen) {
483
+ ws!.send(JSON.stringify(message));
484
+ } else if (isSseOpen) {
485
+ sseRes!.write(
486
+ `event: command:execute\ndata: ${JSON.stringify(message)}\n\n`,
487
+ );
488
+ }
489
+ } catch (err: any) {
490
+ clearTimeout(timeout);
491
+ pendingCommands.delete(commandId);
492
+ reject(err);
493
+ }
494
+ });
495
+ }
496
+
497
+ /**
498
+ * Broadcast a command to all connected clients.
499
+ */
500
+ async function broadcastCommand(
501
+ action: CommandAction,
502
+ args: Record<string, unknown> = {},
503
+ timeoutMs: number = 30_000,
504
+ ): Promise<CommandResultPayload[]> {
505
+ const allConnectedClientIds = new Set<string>([
506
+ ...activeSockets.keys(),
507
+ ...sseClientsByClientId.keys(),
508
+ ]);
509
+
510
+ const promises: Promise<CommandResultPayload>[] = [];
511
+ for (const clientId of allConnectedClientIds) {
512
+ if (isClientConnected(clientId)) {
513
+ promises.push(
514
+ sendCommand(clientId, action, args, timeoutMs).catch((err) => ({
515
+ commandId: "",
516
+ success: false,
517
+ error: err.message,
518
+ })),
519
+ );
520
+ }
521
+ }
522
+ return Promise.all(promises);
523
+ }
524
+
525
+ function isClientConnected(clientId: string): boolean {
526
+ const ws = activeSockets.get(clientId);
527
+ if (ws && ws.readyState === WebSocket.OPEN) {
528
+ return true;
529
+ }
530
+ const sseRes = sseClientsByClientId.get(clientId);
531
+ if (sseRes && !sseRes.writableEnded) {
532
+ return true;
533
+ }
534
+ return false;
535
+ }
536
+
537
+ function getConnectedClients(): ClientInfo[] {
538
+ const all = getAllClients();
539
+ return all.filter((c) => isClientConnected(c.clientId));
540
+ }
541
+
542
+ function close(): void {
543
+ clearInterval(prunerInterval);
544
+ httpServer.removeListener("upgrade", upgradeHandler);
545
+
546
+ for (const [, pending] of pendingCommands) {
547
+ clearTimeout(pending.timeout);
548
+ pending.reject(new Error("Control plane closed"));
549
+ }
550
+ pendingCommands.clear();
551
+
552
+ for (const ws of activeSockets.values()) {
553
+ try {
554
+ ws.close();
555
+ } catch (_) {}
556
+ }
557
+ activeSockets.clear();
558
+ socketToClientId.clear();
559
+
560
+ for (const sseRes of sseClients) {
561
+ try {
562
+ sseRes.end();
563
+ } catch (_) {}
564
+ }
565
+ sseClients.clear();
566
+ sseClientsByClientId.clear();
567
+
568
+ try {
569
+ wss.close();
570
+ } catch (_) {}
571
+ }
572
+
573
+ return {
574
+ sendCommand,
575
+ broadcastCommand,
576
+ isClientConnected,
577
+ getConnectedClients,
578
+ getToken: () => token,
579
+ close,
580
+ };
581
+ }