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