shadow-claw 1.23.4 → 1.24.1

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