@mycadet/cli 0.12.11 → 0.13.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 (86) hide show
  1. package/README.md +13 -0
  2. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  3. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  4. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  5. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  6. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  7. package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  8. package/package.json +3 -2
  9. package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
  10. package/src/__tests__/backup-ops-matching.test.ts +15 -13
  11. package/src/__tests__/backup-ops.test.ts +105 -0
  12. package/src/__tests__/backup.test.ts +114 -673
  13. package/src/__tests__/cloud-releases.test.ts +82 -0
  14. package/src/__tests__/commands-mock.ts +101 -0
  15. package/src/__tests__/debug.test.ts +17 -91
  16. package/src/__tests__/devices.test.ts +2 -2
  17. package/src/__tests__/gateway-token.test.ts +267 -0
  18. package/src/__tests__/host-teleport-docker.test.ts +371 -0
  19. package/src/__tests__/host-teleport.test.ts +405 -0
  20. package/src/__tests__/nginx-ingress.test.ts +182 -23
  21. package/src/__tests__/orphan-detection.test.ts +1 -1
  22. package/src/__tests__/paired-lifecycle.test.ts +2 -2
  23. package/src/__tests__/ps-cloud-status.test.ts +6 -6
  24. package/src/__tests__/root-backup-docker.test.ts +294 -0
  25. package/src/__tests__/root-backup.test.ts +527 -0
  26. package/src/__tests__/root-restore-command.test.ts +147 -0
  27. package/src/__tests__/root-signing-key.test.ts +50 -0
  28. package/src/__tests__/setup.test.ts +3 -3
  29. package/src/__tests__/sleep.test.ts +11 -0
  30. package/src/__tests__/teleport.test.ts +68 -2996
  31. package/src/__tests__/tunnel.test.ts +1 -1
  32. package/src/__tests__/upgrade-local.test.ts +21 -11
  33. package/src/__tests__/upgrade-replay-env.test.ts +2 -2
  34. package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
  35. package/src/__tests__/windows-lifecycle.test.ts +25 -0
  36. package/src/commands/backup.ts +44 -399
  37. package/src/commands/client.ts +3 -3
  38. package/src/commands/completions.ts +0 -2
  39. package/src/commands/debug.ts +75 -131
  40. package/src/commands/devices.ts +51 -17
  41. package/src/commands/gateway/token.ts +77 -23
  42. package/src/commands/pair.ts +2 -2
  43. package/src/commands/restore.ts +77 -444
  44. package/src/commands/retire.ts +2 -3
  45. package/src/commands/sleep.ts +15 -2
  46. package/src/commands/teleport.ts +47 -1559
  47. package/src/commands/upgrade.ts +78 -156
  48. package/src/commands/wake.ts +1 -1
  49. package/src/index.ts +0 -4
  50. package/src/lib/__tests__/http-client-version.test.ts +29 -0
  51. package/src/lib/__tests__/runtime-url.test.ts +3 -75
  52. package/src/lib/__tests__/web-dist.test.ts +27 -1
  53. package/src/lib/assistant-client.ts +1 -1
  54. package/src/lib/assistant-config.ts +0 -18
  55. package/src/lib/backup-ops.ts +37 -251
  56. package/src/lib/cloud-client.ts +10 -13
  57. package/src/lib/cloud-login.ts +2 -1
  58. package/src/lib/cloud-releases.ts +54 -10
  59. package/src/lib/cloud-values.ts +1 -1
  60. package/src/lib/confirm-action.ts +2 -2
  61. package/src/lib/docker.ts +59 -22
  62. package/src/lib/github-repository.ts +2 -0
  63. package/src/lib/guardian-token.ts +1 -23
  64. package/src/lib/hatch-local.ts +17 -1
  65. package/src/lib/host-teleport.ts +506 -0
  66. package/src/lib/http-client.ts +25 -0
  67. package/src/lib/live-voice/tunnel.test.ts +1 -1
  68. package/src/lib/live-voice/tunnel.ts +1 -1
  69. package/src/lib/local.ts +4 -2
  70. package/src/lib/nginx-ingress.ts +134 -18
  71. package/src/lib/process.ts +13 -0
  72. package/src/lib/root-backup.ts +917 -0
  73. package/src/lib/root-signing-key.ts +31 -0
  74. package/src/lib/runtime-url.ts +0 -29
  75. package/src/lib/teleport-backup.ts +3 -16
  76. package/src/lib/upgrade-lifecycle.ts +63 -141
  77. package/src/lib/web-dist.ts +23 -1
  78. package/src/__tests__/bundle-staging.test.ts +0 -268
  79. package/src/__tests__/unpair.test.ts +0 -160
  80. package/src/commands/rollback.ts +0 -479
  81. package/src/commands/unpair.ts +0 -122
  82. package/src/lib/__tests__/job-polling.test.ts +0 -278
  83. package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
  84. package/src/lib/bundle-staging.ts +0 -220
  85. package/src/lib/job-polling.ts +0 -195
  86. package/src/lib/local-runtime-client.ts +0 -374
@@ -0,0 +1,405 @@
1
+ import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ findAssistantByName,
7
+ saveAssistantEntry,
8
+ type AssistantEntry,
9
+ } from "../lib/assistant-config.js";
10
+ import {
11
+ teleportHostInstallation,
12
+ TeleportRecoveryError,
13
+ type HostTeleportOps,
14
+ } from "../lib/host-teleport.js";
15
+ import {
16
+ createRootBackup,
17
+ LocalRootStorage,
18
+ restoreRootBackup,
19
+ STATE_ROOTS,
20
+ } from "../lib/root-backup.js";
21
+
22
+ let directory: string,
23
+ source: AssistantEntry,
24
+ target: AssistantEntry,
25
+ ops: HostTeleportOps;
26
+ let order: string[],
27
+ running: Map<string, boolean>,
28
+ stores: Map<string, LocalRootStorage>;
29
+ let log: ReturnType<typeof spyOn>, previousLockDir: string | undefined;
30
+ const options = { dryRun: false, keepSource: false };
31
+ async function instance(
32
+ name: string,
33
+ cloud: "local" | "docker",
34
+ byte: number,
35
+ ): Promise<AssistantEntry> {
36
+ const root = join(directory, name);
37
+ for (const state of STATE_ROOTS) {
38
+ await mkdir(join(root, state), { recursive: true });
39
+ await writeFile(join(root, state, "state"), `${name}:${state}`, {
40
+ mode: 0o600,
41
+ });
42
+ }
43
+ await writeFile(
44
+ join(root, "gateway-security", "actor-token-signing-key"),
45
+ Buffer.alloc(32, byte),
46
+ );
47
+ await writeFile(
48
+ join(root, "ces-security", "keys.enc"),
49
+ Buffer.from([byte, 0, 255]),
50
+ );
51
+ await writeFile(
52
+ join(root, "ces-security", "store.key"),
53
+ Buffer.alloc(32, byte + 1),
54
+ );
55
+ return {
56
+ assistantId: name,
57
+ cloud,
58
+ runtimeUrl: "http://127.0.0.1:7830",
59
+ resources: {
60
+ instanceDir: root,
61
+ daemonPort: 1,
62
+ gatewayPort: 2,
63
+ cesPort: 3,
64
+ qdrantPort: 4,
65
+ signingKey: byte.toString(16).padStart(2, "0").repeat(32),
66
+ },
67
+ };
68
+ }
69
+ const previousDataHome = process.env.XDG_DATA_HOME;
70
+ beforeEach(async () => {
71
+ directory = await mkdtemp(join(tmpdir(), "host-teleport-"));
72
+ process.env.XDG_DATA_HOME = directory;
73
+ previousLockDir = process.env.CADET_LOCKFILE_DIR;
74
+ process.env.CADET_LOCKFILE_DIR = join(directory, "registry");
75
+ source = await instance("source", "docker", 7);
76
+ target = await instance("target", "local", 3);
77
+ saveAssistantEntry(source);
78
+ saveAssistantEntry(target);
79
+ order = [];
80
+ running = new Map([
81
+ ["source", true],
82
+ ["target", true],
83
+ ]);
84
+ stores = new Map();
85
+ log = spyOn(console, "log").mockImplementation(() => {});
86
+ ops = {
87
+ storage: (entry) => {
88
+ let storage = stores.get(entry.assistantId);
89
+ if (!storage) {
90
+ storage = new LocalRootStorage(entry);
91
+ storage.running = async () => {
92
+ storage!.initialRunning ??= new Set(
93
+ running.get(entry.assistantId)
94
+ ? ["assistant", "gateway", "ces"]
95
+ : [],
96
+ );
97
+ return running.get(entry.assistantId) ?? false;
98
+ };
99
+ storage.resume = async () => {
100
+ order.push(`wake:${entry.assistantId}`);
101
+ running.set(entry.assistantId, true);
102
+ };
103
+ stores.set(entry.assistantId, storage);
104
+ }
105
+ return storage;
106
+ },
107
+ sleep: mock(async (entry) => {
108
+ order.push(`sleep:${entry.assistantId}`);
109
+ running.set(entry.assistantId, false);
110
+ }),
111
+ backup: mock(async (entry) => {
112
+ const file = join(directory, `${entry.assistantId}.tar.gz`),
113
+ storage = ops.storage(entry);
114
+ await createRootBackup({ ...entry, cloud: "local" }, file, {
115
+ storage,
116
+ sleep: () => ops.sleep(entry),
117
+ wake: () => storage.resume(),
118
+ save: saveAssistantEntry,
119
+ });
120
+ order.push(`backup:${entry.assistantId}`);
121
+ return file;
122
+ }),
123
+ version: mock(async () => "0.12.9"),
124
+ hatch: mock(async () => target),
125
+ restore: mock(async (entry, archive) => {
126
+ const storage = ops.storage(entry);
127
+ await restoreRootBackup(
128
+ entry,
129
+ archive,
130
+ { resume: false, recoveryPath: join(directory, "pre-restore.tar.gz") },
131
+ {
132
+ storage,
133
+ sleep: () => ops.sleep(entry),
134
+ wake: () => storage.resume(),
135
+ save: saveAssistantEntry,
136
+ },
137
+ );
138
+ await storage.resume();
139
+ order.push(`restore:${entry.assistantId}`);
140
+ }),
141
+ retire: mock(async (entry) => {
142
+ order.push(`retire:${entry.assistantId}`);
143
+ }),
144
+ };
145
+ });
146
+ afterEach(async () => {
147
+ if (previousDataHome === undefined) {
148
+ delete process.env.XDG_DATA_HOME;
149
+ } else {
150
+ process.env.XDG_DATA_HOME = previousDataHome;
151
+ }
152
+ log.mockRestore();
153
+ if (previousLockDir === undefined) {
154
+ delete process.env.CADET_LOCKFILE_DIR;
155
+ } else {
156
+ process.env.CADET_LOCKFILE_DIR = previousLockDir;
157
+ }
158
+ await rm(directory, { recursive: true, force: true });
159
+ });
160
+
161
+ test("moves all four roots and encrypted key pairs using the exact stopped-source safety tar", async () => {
162
+ await teleportHostInstallation(source, "local", "target", options, ops);
163
+ for (const state of STATE_ROOTS) {
164
+ expect(
165
+ await readFile(
166
+ join(target.resources!.instanceDir, state, "state"),
167
+ "utf8",
168
+ ),
169
+ ).toBe(`source:${state}`);
170
+ }
171
+ expect(
172
+ await readFile(
173
+ join(target.resources!.instanceDir, "ces-security", "keys.enc"),
174
+ ),
175
+ ).toEqual(Buffer.from([7, 0, 255]));
176
+ expect(
177
+ await readFile(
178
+ join(target.resources!.instanceDir, "ces-security", "store.key"),
179
+ ),
180
+ ).toEqual(Buffer.alloc(32, 8));
181
+ expect(findAssistantByName("target")!.resources!.signingKey).toBe(
182
+ "07".repeat(32),
183
+ );
184
+ expect(order).toEqual([
185
+ "sleep:source",
186
+ "backup:source",
187
+ "sleep:target",
188
+ "backup:target",
189
+ "wake:target",
190
+ "restore:target",
191
+ "retire:source",
192
+ ]);
193
+ expect(ops.restore).toHaveBeenCalledWith(
194
+ expect.objectContaining({ assistantId: "target" }),
195
+ join(directory, "source.tar.gz"),
196
+ join(directory, "target.tar.gz"),
197
+ );
198
+ });
199
+ test("dry run creates no archives and does not stop or hatch services", async () => {
200
+ await teleportHostInstallation(
201
+ source,
202
+ "local",
203
+ "target",
204
+ { ...options, dryRun: true },
205
+ ops,
206
+ );
207
+ expect(order).toEqual([]);
208
+ expect(ops.version).not.toHaveBeenCalled();
209
+ expect(ops.backup).not.toHaveBeenCalled();
210
+ expect(ops.hatch).not.toHaveBeenCalled();
211
+ });
212
+ test("keep-source retains the stopped original", async () => {
213
+ await teleportHostInstallation(
214
+ source,
215
+ "local",
216
+ "target",
217
+ { ...options, keepSource: true },
218
+ ops,
219
+ );
220
+ expect(running.get("source")).toBe(false);
221
+ expect(ops.retire).not.toHaveBeenCalled();
222
+ });
223
+ test("source backup failure wakes source without touching target", async () => {
224
+ ops.backup = mock(async () => {
225
+ throw new Error("unreadable credential store");
226
+ });
227
+ await expect(
228
+ teleportHostInstallation(source, "local", "target", options, ops),
229
+ ).rejects.toThrow("unreadable credential store");
230
+ expect(order).toEqual(["sleep:source", "wake:source"]);
231
+ expect(ops.restore).not.toHaveBeenCalled();
232
+ expect(ops.retire).not.toHaveBeenCalled();
233
+ });
234
+ test("graceful shutdown failure never captures a live installation", async () => {
235
+ ops.sleep = mock(async () => {
236
+ throw new Error("graceful timeout");
237
+ });
238
+ await expect(
239
+ teleportHostInstallation(source, "local", "target", options, ops),
240
+ ).rejects.toThrow("graceful timeout");
241
+ expect(ops.backup).not.toHaveBeenCalled();
242
+ expect(ops.retire).not.toHaveBeenCalled();
243
+ });
244
+ test("source that fails to stop is rejected even if sleep returned", async () => {
245
+ ops.sleep = mock(async () => {});
246
+ await expect(
247
+ teleportHostInstallation(source, "local", "target", options, ops),
248
+ ).rejects.toThrow("still running");
249
+ expect(ops.backup).not.toHaveBeenCalled();
250
+ });
251
+ test("target capture failure resumes both original installations", async () => {
252
+ const backup = ops.backup;
253
+ ops.backup = mock(async (entry) => {
254
+ if (entry.assistantId === target.assistantId) {
255
+ throw new Error("target capture failed");
256
+ }
257
+ return backup(entry);
258
+ });
259
+ await expect(
260
+ teleportHostInstallation(source, "local", "target", options, ops),
261
+ ).rejects.toThrow("target capture failed");
262
+ expect(order.slice(-2)).toEqual(["wake:target", "wake:source"]);
263
+ expect(ops.restore).not.toHaveBeenCalled();
264
+ });
265
+ test("failed import retains source and resumes recovered target", async () => {
266
+ ops.restore = mock(async () => {
267
+ throw new Error("import failed; original data recovered");
268
+ });
269
+ await expect(
270
+ teleportHostInstallation(source, "local", "target", options, ops),
271
+ ).rejects.toThrow("import failed");
272
+ expect(ops.retire).not.toHaveBeenCalled();
273
+ expect(order.slice(-2)).toEqual(["wake:target", "wake:source"]);
274
+ });
275
+ test("failed recovery leaves damaged target stopped", async () => {
276
+ ops.restore = mock(async () => {
277
+ throw new TeleportRecoveryError([], "recovery failed");
278
+ });
279
+ await expect(
280
+ teleportHostInstallation(source, "local", "target", options, ops),
281
+ ).rejects.toThrow("recovery failed");
282
+ expect(running.get("target")).toBe(false);
283
+ expect(order).not.toContain("wake:target");
284
+ expect(ops.retire).not.toHaveBeenCalled();
285
+ });
286
+ test("originally sleeping source stays asleep on capture failure", async () => {
287
+ running.set("source", false);
288
+ ops.backup = mock(async () => {
289
+ throw new Error("capture failed");
290
+ });
291
+ await expect(
292
+ teleportHostInstallation(source, "local", "target", options, ops),
293
+ ).rejects.toThrow("capture failed");
294
+ expect(order).not.toContain("wake:source");
295
+ });
296
+ test("new target receives safety tar at hatch and becomes ready before source retirement", async () => {
297
+ await teleportHostInstallation(source, "local", "fresh-target", options, ops);
298
+ expect(ops.hatch).toHaveBeenCalledWith(
299
+ "local",
300
+ "fresh-target",
301
+ join(directory, "source.tar.gz"),
302
+ );
303
+ expect(order.indexOf("wake:target")).toBeLessThan(
304
+ order.indexOf("retire:source"),
305
+ );
306
+ expect(ops.restore).not.toHaveBeenCalled();
307
+ });
308
+ test("failed new target readiness cleans it up before waking source", async () => {
309
+ ops.storage(target).resume = async () => {
310
+ throw new Error("migration failed");
311
+ };
312
+ await expect(
313
+ teleportHostInstallation(source, "local", "fresh-target", options, ops),
314
+ ).rejects.toThrow("migration failed");
315
+ expect(order.slice(-3)).toEqual([
316
+ "sleep:target",
317
+ "retire:target",
318
+ "wake:source",
319
+ ]);
320
+ expect(order).not.toContain("retire:source");
321
+ });
322
+ test("hatch failure retains tar and wakes source", async () => {
323
+ ops.hatch = mock(async () => {
324
+ throw new Error("image pull failed");
325
+ });
326
+ await expect(
327
+ teleportHostInstallation(source, "local", "fresh-target", options, ops),
328
+ ).rejects.toThrow("image pull failed");
329
+ expect(order.at(-1)).toBe("wake:source");
330
+ expect(await readFile(join(directory, "source.tar.gz"))).not.toHaveLength(0);
331
+ expect(ops.retire).not.toHaveBeenCalled();
332
+ });
333
+ test("multi-patch downgrade fails before shutdown", async () => {
334
+ ops.version = mock(async (entry) => (entry === source ? "0.12.9" : "0.12.5"));
335
+ await expect(
336
+ teleportHostInstallation(source, "local", "target", options, ops),
337
+ ).rejects.toThrow("older than source");
338
+ expect(order).toEqual([]);
339
+ });
340
+ test("target environment mismatch fails before shutdown", async () => {
341
+ await expect(
342
+ teleportHostInstallation(source, "docker", "target", options, ops),
343
+ ).rejects.toThrow("not docker");
344
+ expect(order).toEqual([]);
345
+ });
346
+ test("source restarted during transfer is retained to preserve newer writes", async () => {
347
+ const restore = ops.restore;
348
+ ops.restore = mock(async (entry, archive, recovery) => {
349
+ await restore(entry, archive, recovery);
350
+ running.set("source", true);
351
+ });
352
+ await expect(
353
+ teleportHostInstallation(source, "local", "target", options, ops),
354
+ ).rejects.toThrow("restarted during teleport");
355
+ expect(ops.retire).not.toHaveBeenCalled();
356
+ expect(order).not.toContain("wake:source");
357
+ });
358
+
359
+ test("holds source and target operation locks through import and releases them afterward", async () => {
360
+ let release!: () => void;
361
+ const blocked = new Promise<void>((resolve) => {
362
+ release = resolve;
363
+ });
364
+ let entered!: () => void;
365
+ const importing = new Promise<void>((resolve) => {
366
+ entered = resolve;
367
+ });
368
+ const restore = ops.restore;
369
+ ops.restore = mock(async (entry, archive, recovery) => {
370
+ entered();
371
+ await blocked;
372
+ await restore(entry, archive, recovery);
373
+ });
374
+ const transfer = teleportHostInstallation(
375
+ source,
376
+ "local",
377
+ "target",
378
+ options,
379
+ ops,
380
+ );
381
+ await importing;
382
+ try {
383
+ for (const entry of [source, target]) {
384
+ const storage = ops.storage(entry);
385
+ await expect(
386
+ createRootBackup(entry, join(directory, "competing.tar.gz"), {
387
+ storage,
388
+ sleep: () => ops.sleep(entry),
389
+ wake: () => storage.resume(),
390
+ save: saveAssistantEntry,
391
+ }),
392
+ ).rejects.toThrow("Another backup/restore owns");
393
+ }
394
+ } finally {
395
+ release();
396
+ }
397
+ await transfer;
398
+ const storage = ops.storage(source);
399
+ await createRootBackup(source, join(directory, "after.tar.gz"), {
400
+ storage,
401
+ sleep: () => ops.sleep(source),
402
+ wake: () => storage.resume(),
403
+ save: saveAssistantEntry,
404
+ });
405
+ });
@@ -51,6 +51,10 @@ mock.module("node:fs", () => ({
51
51
  readFileSync: readFileSyncMock,
52
52
  }));
53
53
 
54
+ const fetchCurrentVersionMock = mock<typeof httpClient.fetchCurrentVersion>(
55
+ async () => "1.2.3",
56
+ );
57
+
54
58
  const waitForDaemonReadyMock = mock<typeof httpClient.waitForDaemonReady>(
55
59
  async () => true,
56
60
  );
@@ -58,6 +62,7 @@ const waitForDaemonReadyMock = mock<typeof httpClient.waitForDaemonReady>(
58
62
  mock.module("../lib/http-client.js", () => ({
59
63
  ...httpClient,
60
64
  waitForDaemonReady: waitForDaemonReadyMock,
65
+ fetchCurrentVersion: fetchCurrentVersionMock,
61
66
  }));
62
67
 
63
68
  // Restore the real modules once this file finishes so the mocks do not leak
@@ -72,7 +77,6 @@ import {
72
77
  buildIngressNginxConfig,
73
78
  hasIpv6Loopback,
74
79
  buildRemoteWebIndexHtml,
75
- cloudWebHubUrl,
76
80
  MAX_PRESERVED_MODULE_PRELOADS,
77
81
  ensureTunnelEdge,
78
82
  EDGE_TEMPLATE_VERSION,
@@ -124,6 +128,8 @@ afterEach(() => {
124
128
  existsSyncMock.mockImplementation(realFs.existsSync);
125
129
  readFileSyncMock.mockReset();
126
130
  readFileSyncMock.mockImplementation(realFs.readFileSync);
131
+ fetchCurrentVersionMock.mockReset();
132
+ fetchCurrentVersionMock.mockImplementation(async () => "1.2.3");
127
133
  waitForDaemonReadyMock.mockReset();
128
134
  waitForDaemonReadyMock.mockImplementation(async () => true);
129
135
  for (const dir of workspaces.splice(0)) {
@@ -307,7 +313,7 @@ describe("buildIngressNginxConfig", () => {
307
313
  ).toEqual([]);
308
314
  });
309
315
 
310
- test("revalidates the shell and forbids storing only the inline config", () => {
316
+ test("revalidates the shell and forbids storing inline config", () => {
311
317
  const blocks = locationBlocks(remoteConf);
312
318
  const cacheControlOf = (matcher: string) =>
313
319
  blocks
@@ -318,11 +324,8 @@ describe("buildIngressNginxConfig", () => {
318
324
  expect(cacheControlOf("^~ /assistant/assets/")).toBe(
319
325
  "public, max-age=31536000, immutable",
320
326
  );
321
- // Only the inline __config return, which nginx gives no validator, still
322
- // refuses storage. A second no-store means a revalidating location
323
- // regressed to re-sending its whole body on every load.
327
+ // Inline config has no validator and must not be stored.
324
328
  expect(cacheControlOf("= /assistant/__config")).toBe("no-store");
325
- expect(remoteConf.match(/no-store/g)).toHaveLength(1);
326
329
  });
327
330
 
328
331
  test("serves remote web config for the SPA", () => {
@@ -527,18 +530,6 @@ describe("buildRemoteWebIndexHtml", () => {
527
530
  });
528
531
  });
529
532
 
530
- describe("cloudWebHubUrl", () => {
531
- test("maps build environments to their cloud SPA base", () => {
532
- expect(cloudWebHubUrl("production")).toBe("https://mycadet.ai/assistant");
533
- expect(cloudWebHubUrl("staging")).toBe(
534
- "https://staging.mycadet.ai/assistant",
535
- );
536
- expect(cloudWebHubUrl("dev")).toBe("https://dev.mycadet.ai/assistant");
537
- expect(cloudWebHubUrl("local")).toBe("https://dev.mycadet.ai/assistant");
538
- expect(cloudWebHubUrl(undefined)).toBe("https://dev.mycadet.ai/assistant");
539
- });
540
- });
541
-
542
533
  describe("nginx ingress process state", () => {
543
534
  function writeIngressState(workspaceDir: string, listenPort: number): void {
544
535
  writeFileSync(
@@ -746,17 +737,19 @@ function mockWebDistMissing(): void {
746
737
  }
747
738
 
748
739
  /** Force findWebDistDir() to resolve a dist dir, regardless of checkout state. */
749
- function mockWebDistPresent(): void {
740
+ function mockWebDistPresent(version: string | null = "1.2.3"): void {
750
741
  existsSyncMock.mockImplementation((path) =>
751
742
  String(path).endsWith(WEB_INDEX_SUFFIX) ? true : realFs.existsSync(path),
752
743
  );
753
744
  readFileSyncMock.mockImplementation(((path, options) =>
754
745
  String(path).endsWith(WEB_INDEX_SUFFIX)
755
746
  ? FAKE_INDEX_HTML
756
- : realFs.readFileSync(
757
- path as never,
758
- options as never,
759
- )) as typeof readFileSync);
747
+ : String(path).endsWith("release.json")
748
+ ? JSON.stringify(version === null ? {} : { version })
749
+ : realFs.readFileSync(
750
+ path as never,
751
+ options as never,
752
+ )) as typeof readFileSync);
760
753
  }
761
754
 
762
755
  /** Record a running edge: ingress state, live pidfile, matching ps output. */
@@ -863,6 +856,7 @@ function spaConfigHash(
863
856
  hubUrl: PRODUCTION_HUB_URL,
864
857
  },
865
858
  webDistDir: opts.webDistDir ?? findWebDistDir(),
859
+ version: "1.2.3",
866
860
  indexHtml: opts.indexHtml ?? FAKE_INDEX_HTML,
867
861
  }),
868
862
  )
@@ -888,6 +882,70 @@ function pinProductionEnvironment(): void {
888
882
  describe("startRemoteWebIngress", () => {
889
883
  pinProductionEnvironment();
890
884
 
885
+ for (const [webVersion, runtimeVersion, ready] of [
886
+ ["1.2.3", "1.2.3", true],
887
+ ["1.2.3", "v1.2.3", true],
888
+ ["1.2.2", "1.2.3", false],
889
+ ["1.2.3", undefined, false],
890
+ [null, "1.2.3", false],
891
+ ] as const) {
892
+ test(`release resolution: UI ${webVersion}, assistant ${runtimeVersion}`, async () => {
893
+ const ws = makeWorkspace();
894
+ mockNginxInstalled();
895
+ mockNginxSpawn();
896
+ mockWebDistPresent(webVersion);
897
+ fetchCurrentVersionMock.mockResolvedValue(runtimeVersion);
898
+ const result = await startRemoteWebIngress({
899
+ workspaceDir: ws,
900
+ gatewayPort: 7830,
901
+ listenPort: 7845,
902
+ assistantId: "assistant-1",
903
+ });
904
+ expect(result.status).toBe("started");
905
+ expect(fetchCurrentVersionMock).toHaveBeenCalledWith(
906
+ "http://127.0.0.1:7830",
907
+ );
908
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf8");
909
+ const detail = conf
910
+ .split('location = "/v1/assistants/assistant-1/" {')[1]
911
+ ?.split("\n }")[0];
912
+ expect(detail).toContain(`return ${ready ? 200 : 409} `);
913
+ if (ready) {
914
+ expect(detail).toContain('\\"version\\":\\"1.2.3\\"');
915
+ } else {
916
+ expect(detail).not.toContain('\\"url\\"');
917
+ }
918
+ });
919
+ }
920
+
921
+ test("restarts an edge when its verified assistant/UI release changes", async () => {
922
+ const ws = makeWorkspace();
923
+ mockNginxInstalled();
924
+ mockNginxSpawn();
925
+ mockWebDistPresent("1.2.3");
926
+ fetchCurrentVersionMock.mockResolvedValue("1.2.4");
927
+ const pid = mockRunningEdge(ws, {
928
+ listenPort: 7845,
929
+ includeWebApp: true,
930
+ gatewayPort: 7830,
931
+ remoteWebConfigHash: spaConfigHash({ assistantId: "assistant-1" }),
932
+ });
933
+ const edge = mockKillableNginx(pid);
934
+ const result = await startRemoteWebIngress({
935
+ workspaceDir: ws,
936
+ gatewayPort: 7830,
937
+ listenPort: 7845,
938
+ assistantId: "assistant-1",
939
+ });
940
+ expect(result.status).toBe("started");
941
+ expect(edge.killed()).toBe(true);
942
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf8");
943
+ const detail = conf
944
+ .split('location = "/v1/assistants/assistant-1/" {')[1]
945
+ ?.split("\n }")[0];
946
+ expect(detail).toContain("return 409 ");
947
+ });
948
+
891
949
  test("webhooks-only mode starts the denylist+proxy edge without a web dist", async () => {
892
950
  const ws = makeWorkspace();
893
951
  mockNginxInstalled();
@@ -2261,3 +2319,104 @@ describe("stopContainerTunnelEdge", () => {
2261
2319
  expect(await stopContainerTunnelEdge(containerEntry(7930))).toBe(false);
2262
2320
  });
2263
2321
  });
2322
+
2323
+ describe("assistant metadata API", () => {
2324
+ function config(id = "assistant-1") {
2325
+ return buildIngressNginxConfig({
2326
+ gatewayPort: 7830,
2327
+ listenPort: 7840,
2328
+ remoteWebIngress: {
2329
+ webDistDir: "/tmp/web",
2330
+ version: "1.2.3",
2331
+ assistantId: id,
2332
+ assistantName: "My assistant",
2333
+ config: { appAllowedOrigins: ["https://parent.example"] },
2334
+ },
2335
+ });
2336
+ }
2337
+ function body(conf: string, path: string, status = 200): unknown {
2338
+ const location = conf
2339
+ .split(`location = "${path}" {`)[1]
2340
+ ?.split("\n }")[0];
2341
+ const value = location?.match(/return (200|409) ("(?:\\.|[^"\\])*");/);
2342
+ expect(Number(value?.[1])).toBe(status);
2343
+ if (!value) {
2344
+ throw new Error(`Missing metadata for ${path}`);
2345
+ }
2346
+ return JSON.parse(JSON.parse(value[2]));
2347
+ }
2348
+ test("lists only this host's assistant and returns its actual UI version and URL", () => {
2349
+ const conf = config();
2350
+ expect(body(conf, "/v1/assistants/")).toEqual({
2351
+ results: [{ id: "assistant-1", name: "My assistant" }],
2352
+ next: null,
2353
+ });
2354
+ expect(body(conf, "/v1/assistants/assistant-1/")).toEqual({
2355
+ id: "assistant-1",
2356
+ name: "My assistant",
2357
+ status: "active",
2358
+ version: "1.2.3",
2359
+ url: "assistant/",
2360
+ });
2361
+ expect(conf).not.toContain('location = "/v1/assistants/other/"');
2362
+ });
2363
+ test("metadata supports both slash forms and only read methods", () => {
2364
+ const conf = config();
2365
+ for (const path of [
2366
+ "/v1/assistants",
2367
+ "/v1/assistants/",
2368
+ "/v1/assistants/assistant-1",
2369
+ "/v1/assistants/assistant-1/",
2370
+ ]) {
2371
+ expect(body(conf, path)).toBeDefined();
2372
+ }
2373
+ expect(conf).toContain(
2374
+ "if ($request_method !~ ^(GET|HEAD)$) { return 405; }",
2375
+ );
2376
+ expect(conf).toContain(
2377
+ '"https://parent.example" "https://parent.example";',
2378
+ );
2379
+ expect(conf).toContain(
2380
+ "add_header Access-Control-Allow-Origin $cadet_app_origin always;",
2381
+ );
2382
+ });
2383
+ test("unknown identity yields an empty list without inventing an assistant ID", () => {
2384
+ const conf = buildIngressNginxConfig({
2385
+ gatewayPort: 7830,
2386
+ listenPort: 7840,
2387
+ remoteWebIngress: { webDistDir: "/tmp/web" },
2388
+ });
2389
+ expect(body(conf, "/v1/assistants/")).toEqual({ results: [], next: null });
2390
+ });
2391
+ test("a known assistant without a UI bundle returns 409 on both detail paths", () => {
2392
+ const conf = buildIngressNginxConfig({
2393
+ gatewayPort: 7830,
2394
+ listenPort: 7840,
2395
+ remoteWebIngress: { webDistDir: "/tmp/web", assistantId: "assistant-1" },
2396
+ });
2397
+ expect(body(conf, "/v1/assistants/")).toEqual({
2398
+ results: [{ id: "assistant-1" }],
2399
+ next: null,
2400
+ });
2401
+ for (const path of [
2402
+ "/v1/assistants/assistant-1",
2403
+ "/v1/assistants/assistant-1/",
2404
+ ]) {
2405
+ expect(body(conf, path, 409)).toEqual({
2406
+ detail: "assistant is not ready",
2407
+ });
2408
+ }
2409
+ });
2410
+ test("invalid browser origins fail before nginx starts", () => {
2411
+ expect(() =>
2412
+ buildIngressNginxConfig({
2413
+ gatewayPort: 7830,
2414
+ listenPort: 7840,
2415
+ remoteWebIngress: {
2416
+ webDistDir: "/tmp/web",
2417
+ config: { appAllowedOrigins: ["https://parent.example/path"] },
2418
+ },
2419
+ }),
2420
+ ).toThrow("App origins");
2421
+ });
2422
+ });
@@ -107,7 +107,7 @@ describe("getKnownPidsFromAssistants", () => {
107
107
  const entry: AssistantEntry = {
108
108
  assistantId: "managed-1",
109
109
  runtimeUrl: "https://cloud.mycadet.ai/foo",
110
- cloud: "cadet",
110
+ cloud: "cloud",
111
111
  };
112
112
  const pids = getKnownPidsFromAssistants([entry]);
113
113
  expect(pids.size).toBe(0);