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,166 @@
1
+ /**
2
+ * ShadowClaw — Client-Side Backup Controller
3
+ *
4
+ * Orchestrates workspace file backups from the browser client's OPFS / local storage
5
+ * to the server via the `/api/backup/*` REST endpoints.
6
+ */
7
+
8
+ import { ulid } from "../utils/ulid.js";
9
+
10
+ export interface BackupProgress {
11
+ current: number;
12
+ total: number;
13
+ file: string;
14
+ }
15
+
16
+ export interface BackupControllerOptions {
17
+ clientId: string;
18
+ token?: string;
19
+ serverBaseUrl?: string;
20
+ fileEnumerator?: () => Promise<string[]>;
21
+ fileReader?: (path: string) => Promise<string | Uint8Array | null>;
22
+ onProgress?: (progress: BackupProgress) => void;
23
+ }
24
+
25
+ export interface BackupInitiateResult {
26
+ success: boolean;
27
+ backupId: string;
28
+ fileCount: number;
29
+ totalBytes: number;
30
+ }
31
+
32
+ function getTargetAddressSpace(urlStr: string): string | undefined {
33
+ try {
34
+ const u = new URL(urlStr);
35
+ const host = u.hostname.toLowerCase();
36
+ if (
37
+ host === "localhost" ||
38
+ host === "127.0.0.1" ||
39
+ host === "::1" ||
40
+ host === "[::1]"
41
+ ) {
42
+ return "loopback";
43
+ }
44
+ if (/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(host)) {
45
+ return "private";
46
+ }
47
+ } catch {}
48
+ return undefined;
49
+ }
50
+
51
+ export class BackupController {
52
+ private _clientId: string;
53
+ private _token?: string;
54
+ private _serverBaseUrl: string;
55
+ private _fileEnumerator: () => Promise<string[]>;
56
+ private _fileReader: (path: string) => Promise<string | Uint8Array | null>;
57
+ private _onProgress?: (progress: BackupProgress) => void;
58
+
59
+ constructor(options: BackupControllerOptions) {
60
+ this._clientId = options.clientId;
61
+ this._token = options.token;
62
+ this._serverBaseUrl = options.serverBaseUrl || "";
63
+ this._fileEnumerator = options.fileEnumerator || (async () => []);
64
+ this._fileReader = options.fileReader || (async () => null);
65
+ this._onProgress = options.onProgress;
66
+ }
67
+
68
+ public async initiate(): Promise<BackupInitiateResult> {
69
+ const backupId = ulid();
70
+ const files = await this._fileEnumerator();
71
+ let totalBytes = 0;
72
+ let uploadedCount = 0;
73
+
74
+ const headers: Record<string, string> = {
75
+ "Content-Type": "application/json",
76
+ };
77
+ if (this._token) {
78
+ headers["x-control-token"] = this._token;
79
+ }
80
+
81
+ for (let i = 0; i < files.length; i++) {
82
+ const filePath = files[i];
83
+ const data = await this._fileReader(filePath);
84
+
85
+ if (data === null || data === undefined) {
86
+ continue;
87
+ }
88
+
89
+ let contentStr: string;
90
+ let encoding = "utf-8";
91
+
92
+ if (typeof data === "string") {
93
+ contentStr = data;
94
+ totalBytes += Buffer.from(data, "utf-8").length;
95
+ } else {
96
+ contentStr = Buffer.from(data).toString("base64");
97
+ encoding = "base64";
98
+ totalBytes += data.byteLength || data.length || 0;
99
+ }
100
+
101
+ const uploadUrl = `${this._serverBaseUrl}/api/backup/upload`;
102
+ const uploadOpts: any = {
103
+ method: "POST",
104
+ headers,
105
+ body: JSON.stringify({
106
+ clientId: this._clientId,
107
+ backupId,
108
+ path: filePath,
109
+ content: contentStr,
110
+ encoding,
111
+ }),
112
+ };
113
+ const uploadAddressSpace = getTargetAddressSpace(uploadUrl);
114
+ if (uploadAddressSpace) {
115
+ uploadOpts.targetAddressSpace = uploadAddressSpace;
116
+ }
117
+ const res = await fetch(uploadUrl, uploadOpts);
118
+
119
+ if (!res.ok) {
120
+ const errorText = await res.text().catch(() => res.statusText);
121
+ throw new Error(
122
+ `Upload failed for ${filePath} (${res.status}): ${errorText}`,
123
+ );
124
+ }
125
+
126
+ uploadedCount++;
127
+ this._onProgress?.({
128
+ current: uploadedCount,
129
+ total: files.length,
130
+ file: filePath,
131
+ });
132
+ }
133
+
134
+ // Finalize backup
135
+ const completeUrl = `${this._serverBaseUrl}/api/backup/complete`;
136
+ const completeOpts: any = {
137
+ method: "POST",
138
+ headers,
139
+ body: JSON.stringify({
140
+ clientId: this._clientId,
141
+ backupId,
142
+ fileCount: uploadedCount,
143
+ totalBytes,
144
+ }),
145
+ };
146
+ const completeAddressSpace = getTargetAddressSpace(completeUrl);
147
+ if (completeAddressSpace) {
148
+ completeOpts.targetAddressSpace = completeAddressSpace;
149
+ }
150
+ const completeRes = await fetch(completeUrl, completeOpts);
151
+
152
+ if (!completeRes.ok) {
153
+ const errorText = await completeRes
154
+ .text()
155
+ .catch(() => completeRes.statusText);
156
+ throw new Error(`Failed to complete backup: ${errorText}`);
157
+ }
158
+
159
+ return {
160
+ success: true,
161
+ backupId,
162
+ fileCount: uploadedCount,
163
+ totalBytes,
164
+ };
165
+ }
166
+ }
@@ -0,0 +1,415 @@
1
+ import { jest } from "@jest/globals";
2
+ import {
3
+ ControlPlaneClient,
4
+ type CommandHandler,
5
+ } from "./control-plane-client.js";
6
+ import type {
7
+ ControlMessage,
8
+ CommandExecutePayload,
9
+ CommandResultPayload,
10
+ } from "../server/control-plane-types.js";
11
+
12
+ // Mock WebSocket implementation for client testing
13
+ class MockClientWebSocket {
14
+ static readonly CONNECTING = 0;
15
+ static readonly OPEN = 1;
16
+ static readonly CLOSING = 2;
17
+ static readonly CLOSED = 3;
18
+
19
+ static instances: MockClientWebSocket[] = [];
20
+ url: string;
21
+ readyState: number = 0; // CONNECTING
22
+ sentMessages: string[] = [];
23
+ onopen: (() => void) | null = null;
24
+ onmessage: ((event: { data: string }) => void) | null = null;
25
+ onclose: (() => void) | null = null;
26
+ onerror: ((err: any) => void) | null = null;
27
+
28
+ constructor(url: string) {
29
+ this.url = url;
30
+ this.readyState = MockClientWebSocket.CONNECTING;
31
+ MockClientWebSocket.instances.push(this);
32
+ setTimeout(() => {
33
+ if (this.readyState === MockClientWebSocket.CONNECTING) {
34
+ this.readyState = MockClientWebSocket.OPEN;
35
+ this.onopen?.();
36
+ }
37
+ }, 10);
38
+ }
39
+
40
+ send(data: string) {
41
+ this.sentMessages.push(data);
42
+ }
43
+
44
+ close() {
45
+ this.readyState = MockClientWebSocket.CLOSED;
46
+ this.onclose?.();
47
+ }
48
+
49
+ // Helper for test to simulate server messages
50
+ receiveMessageFromServer(msg: ControlMessage) {
51
+ this.onmessage?.({ data: JSON.stringify(msg) });
52
+ }
53
+ }
54
+
55
+ // Mock EventSource implementation for SSE client testing
56
+ class MockEventSource {
57
+ static instances: MockEventSource[] = [];
58
+ url: string;
59
+ onopen: (() => void) | null = null;
60
+ onmessage: ((event: { data: string }) => void) | null = null;
61
+ onerror: ((err: any) => void) | null = null;
62
+ listeners: Map<string, ((event: { data: string }) => void)[]> = new Map();
63
+ closed: boolean = false;
64
+
65
+ constructor(url: string) {
66
+ this.url = url;
67
+ MockEventSource.instances.push(this);
68
+ setTimeout(() => {
69
+ if (!this.closed) {
70
+ this.onopen?.();
71
+ }
72
+ }, 10);
73
+ }
74
+
75
+ addEventListener(event: string, handler: (e: { data: string }) => void) {
76
+ const list = this.listeners.get(event) || [];
77
+ list.push(handler);
78
+ this.listeners.set(event, list);
79
+ }
80
+
81
+ close() {
82
+ this.closed = true;
83
+ }
84
+
85
+ // Helper for test to simulate server event
86
+ receiveEvent(type: string, data: any) {
87
+ const dataStr = typeof data === "string" ? data : JSON.stringify(data);
88
+ const handlers = this.listeners.get(type) || [];
89
+ for (const h of handlers) {
90
+ h({ data: dataStr });
91
+ }
92
+ if (type === "message") {
93
+ this.onmessage?.({ data: dataStr });
94
+ }
95
+ }
96
+ }
97
+
98
+ describe("control-plane-client", () => {
99
+ let mockFetch: jest.Mock<any>;
100
+ let postedRequests: Array<{
101
+ url: string;
102
+ body: any;
103
+ headers: any;
104
+ targetAddressSpace?: string;
105
+ }>;
106
+
107
+ beforeEach(() => {
108
+ MockClientWebSocket.instances = [];
109
+ MockEventSource.instances = [];
110
+ postedRequests = [];
111
+ mockFetch = jest.fn(async (url: string, opts: any) => {
112
+ postedRequests.push({
113
+ url,
114
+ body: JSON.parse(opts.body),
115
+ headers: opts.headers,
116
+ targetAddressSpace: opts.targetAddressSpace,
117
+ });
118
+ return { ok: true, json: async () => ({ status: "received" }) };
119
+ }) as any;
120
+ jest.useFakeTimers();
121
+ });
122
+
123
+ afterEach(() => {
124
+ jest.useRealTimers();
125
+ });
126
+
127
+ describe("SSE Transport (Default)", () => {
128
+ it("connects via EventSource and sends registration over HTTP POST with targetAddressSpace", async () => {
129
+ const client = new ControlPlaneClient({
130
+ clientId: "sse-client-1",
131
+ deviceLabel: "SSE Device",
132
+ capabilities: ["opfs", "webmcp"],
133
+ EventSourceClass: MockEventSource as any,
134
+ fetchFn: mockFetch as any,
135
+ });
136
+
137
+ expect(client.transport).toBe("sse");
138
+ client.connect();
139
+
140
+ expect(MockEventSource.instances).toHaveLength(1);
141
+ const es = MockEventSource.instances[0];
142
+ expect(es.url).toContain("/api/control/events");
143
+ expect(es.url).toContain("clientId=sse-client-1");
144
+
145
+ // Advance timers to trigger onopen
146
+ jest.advanceTimersByTime(15);
147
+ expect(client.getState()).toBe("connected");
148
+
149
+ // Verify registration POST
150
+ expect(postedRequests).toHaveLength(1);
151
+ expect(postedRequests[0].body.type).toBe("client:register");
152
+ expect(postedRequests[0].body.payload.clientId).toBe("sse-client-1");
153
+ expect(postedRequests[0].body.payload.transport).toBe("sse");
154
+ expect(postedRequests[0].targetAddressSpace).toBe("loopback");
155
+
156
+ client.disconnect();
157
+ expect(client.getState()).toBe("disconnected");
158
+ expect(es.closed).toBe(true);
159
+ });
160
+
161
+ it("sends periodic heartbeats over HTTP POST in SSE mode", async () => {
162
+ const client = new ControlPlaneClient({
163
+ clientId: "sse-client-hb",
164
+ heartbeatIntervalMs: 10000,
165
+ EventSourceClass: MockEventSource as any,
166
+ fetchFn: mockFetch as any,
167
+ });
168
+
169
+ client.connect();
170
+ jest.advanceTimersByTime(15); // open -> register
171
+ expect(postedRequests).toHaveLength(1);
172
+
173
+ // Advance 10s
174
+ jest.advanceTimersByTime(10000);
175
+ expect(postedRequests).toHaveLength(2);
176
+ expect(postedRequests[1].body.type).toBe("client:heartbeat");
177
+
178
+ // Advance another 10s
179
+ jest.advanceTimersByTime(10000);
180
+ expect(postedRequests).toHaveLength(3);
181
+
182
+ client.disconnect();
183
+ });
184
+
185
+ it("executes command from SSE stream and posts result back", async () => {
186
+ const mockHandler = jest.fn(async (args: any) => ({
187
+ echo: args.input,
188
+ }));
189
+
190
+ const client = new ControlPlaneClient({
191
+ clientId: "sse-client-cmd",
192
+ EventSourceClass: MockEventSource as any,
193
+ fetchFn: mockFetch as any,
194
+ handlers: {
195
+ "echo-command": mockHandler,
196
+ },
197
+ });
198
+
199
+ client.connect();
200
+ const es = MockEventSource.instances[0];
201
+ jest.advanceTimersByTime(15);
202
+
203
+ const cmdMsg: ControlMessage = {
204
+ id: "cmd-msg-1",
205
+ type: "command:execute",
206
+ payload: {
207
+ commandId: "cmd-id-123",
208
+ action: "echo-command" as any,
209
+ args: { input: "hello sse" },
210
+ } as CommandExecutePayload,
211
+ };
212
+
213
+ es.receiveEvent("command:execute", cmdMsg);
214
+
215
+ await Promise.resolve();
216
+
217
+ expect(mockHandler).toHaveBeenCalledWith({ input: "hello sse" });
218
+ expect(postedRequests).toHaveLength(2); // register + result
219
+ const resultPost = postedRequests[1];
220
+ expect(resultPost.body.type).toBe("command:result");
221
+ expect(resultPost.body.replyTo).toBe("cmd-msg-1");
222
+ expect(resultPost.body.payload.commandId).toBe("cmd-id-123");
223
+ expect(resultPost.body.payload.success).toBe(true);
224
+ expect(resultPost.body.payload.data).toEqual({ echo: "hello sse" });
225
+
226
+ client.disconnect();
227
+ });
228
+ });
229
+
230
+ describe("WebSocket Transport (Opt-in)", () => {
231
+ it("connects and registers client with server", async () => {
232
+ const client = new ControlPlaneClient({
233
+ transport: "websocket",
234
+ url: "ws://127.0.0.1:8888/ws/control",
235
+ token: "secret-token",
236
+ clientId: "client-test-1",
237
+ deviceLabel: "Test Device",
238
+ capabilities: ["webmcp", "opfs"],
239
+ WebSocketClass: MockClientWebSocket as any,
240
+ });
241
+
242
+ expect(client.transport).toBe("websocket");
243
+ client.connect();
244
+ expect(MockClientWebSocket.instances).toHaveLength(1);
245
+ const ws = MockClientWebSocket.instances[0];
246
+
247
+ // Trigger onopen
248
+ jest.advanceTimersByTime(15);
249
+ expect(client.getState()).toBe("connected");
250
+
251
+ // Verify client:register was sent
252
+ expect(ws.sentMessages).toHaveLength(1);
253
+ const regMsg: ControlMessage = JSON.parse(ws.sentMessages[0]);
254
+ expect(regMsg.type).toBe("client:register");
255
+ expect(regMsg.payload).toEqual({
256
+ clientId: "client-test-1",
257
+ deviceLabel: "Test Device",
258
+ capabilities: ["webmcp", "opfs"],
259
+ version: "1.23.4",
260
+ transport: "websocket",
261
+ });
262
+
263
+ client.disconnect();
264
+ expect(client.getState()).toBe("disconnected");
265
+ });
266
+
267
+ it("sends periodic heartbeats while connected in WebSocket mode", async () => {
268
+ const client = new ControlPlaneClient({
269
+ transport: "websocket",
270
+ url: "ws://127.0.0.1:8888/ws/control",
271
+ clientId: "client-test-hb",
272
+ heartbeatIntervalMs: 10000,
273
+ WebSocketClass: MockClientWebSocket as any,
274
+ });
275
+
276
+ client.connect();
277
+ const ws = MockClientWebSocket.instances[0];
278
+ jest.advanceTimersByTime(15); // open
279
+
280
+ expect(ws.sentMessages).toHaveLength(1); // register
281
+
282
+ // Advance 10s -> heartbeat 1
283
+ jest.advanceTimersByTime(10000);
284
+ expect(ws.sentMessages).toHaveLength(2);
285
+ const hb1: ControlMessage = JSON.parse(ws.sentMessages[1]);
286
+ expect(hb1.type).toBe("client:heartbeat");
287
+
288
+ // Advance another 10s -> heartbeat 2
289
+ jest.advanceTimersByTime(10000);
290
+ expect(ws.sentMessages).toHaveLength(3);
291
+
292
+ client.disconnect();
293
+ });
294
+
295
+ it("executes registered command handlers and returns result over WebSocket", async () => {
296
+ const mockHandler: CommandHandler = jest.fn(async (_args: any) => ({
297
+ tasks: [{ id: "t1" }],
298
+ }));
299
+
300
+ const client = new ControlPlaneClient({
301
+ transport: "websocket",
302
+ url: "ws://127.0.0.1:8888/ws/control",
303
+ clientId: "client-test-cmd",
304
+ WebSocketClass: MockClientWebSocket as any,
305
+ handlers: {
306
+ "list-tasks": mockHandler,
307
+ },
308
+ });
309
+
310
+ client.connect();
311
+ const ws = MockClientWebSocket.instances[0];
312
+ jest.advanceTimersByTime(15);
313
+
314
+ // Simulate server sending command:execute
315
+ const cmdMsg: ControlMessage = {
316
+ id: "srv-cmd-1",
317
+ type: "command:execute",
318
+ payload: {
319
+ commandId: "cmd-ulid-1",
320
+ action: "list-tasks",
321
+ args: { filter: "enabled" },
322
+ } as CommandExecutePayload,
323
+ };
324
+
325
+ ws.receiveMessageFromServer(cmdMsg);
326
+
327
+ // Allow handler promise to resolve
328
+ await Promise.resolve();
329
+
330
+ expect(mockHandler).toHaveBeenCalledWith({ filter: "enabled" });
331
+
332
+ // Client sends command:result
333
+ expect(ws.sentMessages).toHaveLength(2); // register + result
334
+ const resultMsg: ControlMessage = JSON.parse(ws.sentMessages[1]);
335
+ expect(resultMsg.type).toBe("command:result");
336
+ expect(resultMsg.replyTo).toBe("srv-cmd-1");
337
+ const payload = resultMsg.payload as CommandResultPayload;
338
+ expect(payload.commandId).toBe("cmd-ulid-1");
339
+ expect(payload.success).toBe(true);
340
+ expect(payload.data).toEqual({ tasks: [{ id: "t1" }] });
341
+
342
+ client.disconnect();
343
+ });
344
+
345
+ it("returns error result when handler throws in WebSocket mode", async () => {
346
+ const client = new ControlPlaneClient({
347
+ transport: "websocket",
348
+ url: "ws://127.0.0.1:8888/ws/control",
349
+ clientId: "client-test-err",
350
+ WebSocketClass: MockClientWebSocket as any,
351
+ handlers: {
352
+ "failing-action": async () => {
353
+ throw new Error("Something went wrong");
354
+ },
355
+ },
356
+ });
357
+
358
+ client.connect();
359
+ const ws = MockClientWebSocket.instances[0];
360
+ jest.advanceTimersByTime(15);
361
+
362
+ ws.receiveMessageFromServer({
363
+ id: "srv-cmd-2",
364
+ type: "command:execute",
365
+ payload: {
366
+ commandId: "cmd-ulid-2",
367
+ action: "failing-action" as any,
368
+ args: {},
369
+ } as unknown as CommandExecutePayload,
370
+ });
371
+
372
+ await Promise.resolve();
373
+
374
+ const resultMsg: ControlMessage = JSON.parse(ws.sentMessages[1]);
375
+ expect(resultMsg.type).toBe("command:result");
376
+ const payload = resultMsg.payload as CommandResultPayload;
377
+ expect(payload.success).toBe(false);
378
+ expect(payload.error).toContain("Something went wrong");
379
+
380
+ client.disconnect();
381
+ });
382
+
383
+ it("auto-reconnects when connection is lost", async () => {
384
+ const client = new ControlPlaneClient({
385
+ transport: "websocket",
386
+ url: "ws://127.0.0.1:8888/ws/control",
387
+ clientId: "client-test-reconnect",
388
+ reconnectDelayMs: 500,
389
+ WebSocketClass: MockClientWebSocket as any,
390
+ });
391
+
392
+ client.connect();
393
+ expect(MockClientWebSocket.instances).toHaveLength(1);
394
+ const ws1 = MockClientWebSocket.instances[0];
395
+ jest.advanceTimersByTime(15); // open
396
+ expect(client.getState()).toBe("connected");
397
+
398
+ // Simulate unexpected disconnect
399
+ ws1.close();
400
+ expect(client.getState()).toBe("disconnected");
401
+
402
+ // Advance reconnect timer
403
+ jest.advanceTimersByTime(500);
404
+
405
+ // New WebSocket instance created
406
+ expect(MockClientWebSocket.instances).toHaveLength(2);
407
+ jest.advanceTimersByTime(15); // open ws2
408
+ expect(client.getState()).toBe("connected");
409
+ const ws2 = MockClientWebSocket.instances[1];
410
+ expect(ws2.sentMessages).toHaveLength(1); // Re-registers on connect
411
+
412
+ client.disconnect();
413
+ });
414
+ });
415
+ });