@mycadet/cli 0.12.10 → 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 (105) 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 +10 -10
  3. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  4. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  5. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  6. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  7. package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  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-login-poll.test.ts +69 -0
  14. package/src/__tests__/cloud-releases.test.ts +82 -0
  15. package/src/__tests__/commands-mock.ts +101 -0
  16. package/src/__tests__/debug.test.ts +17 -91
  17. package/src/__tests__/devices.test.ts +64 -2
  18. package/src/__tests__/ensure-binary.test.ts +52 -0
  19. package/src/__tests__/gateway-token.test.ts +267 -0
  20. package/src/__tests__/hatch-next-steps.test.ts +38 -0
  21. package/src/__tests__/host-teleport-docker.test.ts +371 -0
  22. package/src/__tests__/host-teleport.test.ts +405 -0
  23. package/src/__tests__/multi-local.test.ts +3 -3
  24. package/src/__tests__/nginx-ingress.test.ts +233 -36
  25. package/src/__tests__/orphan-detection.test.ts +1 -1
  26. package/src/__tests__/paired-lifecycle.test.ts +2 -2
  27. package/src/__tests__/ps-cloud-status.test.ts +8 -7
  28. package/src/__tests__/root-backup-docker.test.ts +294 -0
  29. package/src/__tests__/root-backup.test.ts +527 -0
  30. package/src/__tests__/root-restore-command.test.ts +147 -0
  31. package/src/__tests__/root-signing-key.test.ts +50 -0
  32. package/src/__tests__/setup.test.ts +17 -7
  33. package/src/__tests__/sleep.test.ts +11 -0
  34. package/src/__tests__/teleport.test.ts +68 -2996
  35. package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
  36. package/src/__tests__/tunnel-stop.test.ts +226 -0
  37. package/src/__tests__/tunnel.test.ts +13 -3
  38. package/src/__tests__/upgrade-local.test.ts +21 -11
  39. package/src/__tests__/upgrade-replay-env.test.ts +2 -2
  40. package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
  41. package/src/__tests__/windows-lifecycle.test.ts +25 -0
  42. package/src/commands/backup.ts +44 -399
  43. package/src/commands/client.ts +3 -3
  44. package/src/commands/completions.ts +0 -2
  45. package/src/commands/debug.ts +75 -131
  46. package/src/commands/devices.ts +117 -47
  47. package/src/commands/gateway/token.ts +77 -23
  48. package/src/commands/hatch.ts +10 -52
  49. package/src/commands/pair.ts +9 -3
  50. package/src/commands/ps.ts +4 -2
  51. package/src/commands/restore.ts +77 -444
  52. package/src/commands/retire.ts +2 -3
  53. package/src/commands/sleep.ts +15 -2
  54. package/src/commands/teleport.ts +47 -1559
  55. package/src/commands/tunnel.ts +43 -4
  56. package/src/commands/upgrade.ts +84 -157
  57. package/src/commands/wake.ts +1 -1
  58. package/src/index.ts +0 -4
  59. package/src/lib/__tests__/http-client-version.test.ts +29 -0
  60. package/src/lib/__tests__/runtime-url.test.ts +3 -75
  61. package/src/lib/__tests__/web-dist.test.ts +27 -1
  62. package/src/lib/assistant-client.ts +1 -1
  63. package/src/lib/assistant-config.ts +20 -23
  64. package/src/lib/backup-ops.ts +37 -251
  65. package/src/lib/cloud-client.ts +10 -13
  66. package/src/lib/cloud-login-poll.ts +75 -0
  67. package/src/lib/cloud-login.ts +5 -2
  68. package/src/lib/cloud-releases.ts +54 -10
  69. package/src/lib/cloud-values.ts +1 -1
  70. package/src/lib/confirm-action.ts +2 -2
  71. package/src/lib/docker.ts +65 -34
  72. package/src/lib/ensure-binary.ts +65 -6
  73. package/src/lib/github-repository.ts +2 -0
  74. package/src/lib/guardian-token.ts +1 -23
  75. package/src/lib/hatch-local.ts +18 -52
  76. package/src/lib/hatch-next-steps.ts +15 -2
  77. package/src/lib/host-teleport.ts +506 -0
  78. package/src/lib/http-client.ts +25 -0
  79. package/src/lib/live-voice/tunnel.test.ts +1 -1
  80. package/src/lib/live-voice/tunnel.ts +3 -3
  81. package/src/lib/local.ts +10 -8
  82. package/src/lib/nginx-ingress.ts +254 -36
  83. package/src/lib/process.ts +13 -0
  84. package/src/lib/root-backup.ts +917 -0
  85. package/src/lib/root-signing-key.ts +31 -0
  86. package/src/lib/runtime-url.ts +0 -29
  87. package/src/lib/teleport-backup.ts +3 -16
  88. package/src/lib/tunnel.ts +86 -34
  89. package/src/lib/tunnels/cadet.ts +12 -1
  90. package/src/lib/tunnels/cloudflare.ts +36 -1
  91. package/src/lib/tunnels/ngrok.ts +16 -2
  92. package/src/lib/tunnels/process-lifecycle.ts +26 -0
  93. package/src/lib/tunnels/tailscale.ts +79 -1
  94. package/src/lib/upgrade-lifecycle.ts +63 -141
  95. package/src/lib/web-dist.ts +23 -1
  96. package/src/lib/xdg-log.ts +11 -0
  97. package/src/__tests__/bundle-staging.test.ts +0 -268
  98. package/src/__tests__/unpair.test.ts +0 -160
  99. package/src/commands/rollback.ts +0 -479
  100. package/src/commands/unpair.ts +0 -122
  101. package/src/lib/__tests__/job-polling.test.ts +0 -278
  102. package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
  103. package/src/lib/bundle-staging.ts +0 -220
  104. package/src/lib/job-polling.ts +0 -195
  105. package/src/lib/local-runtime-client.ts +0 -374
@@ -1,696 +1,137 @@
1
- import {
2
- afterAll,
3
- afterEach,
4
- beforeEach,
5
- describe,
6
- expect,
7
- mock,
8
- type Mock,
9
- spyOn,
10
- test,
11
- } from "bun:test";
12
- import { mkdtempSync, rmSync } from "node:fs";
1
+ import { afterAll, afterEach, beforeEach, expect, spyOn, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
13
3
  import { tmpdir } from "node:os";
14
4
  import { join } from "node:path";
15
-
16
- // ---------------------------------------------------------------------------
17
- // Lockfile isolation (mirrors teleport.test.ts)
18
- // ---------------------------------------------------------------------------
19
-
20
- const testDir = mkdtempSync(join(tmpdir(), "cli-backup-test-"));
21
- process.env.CADET_LOCKFILE_DIR = testDir;
22
-
23
- // ---------------------------------------------------------------------------
24
- // Mocks set up before importing the module under test
25
- // ---------------------------------------------------------------------------
26
-
27
- import * as fs from "node:fs";
28
-
29
- import * as assistantConfig from "../lib/assistant-config.js";
5
+ import * as config from "../lib/assistant-config.js";
30
6
  import * as backupOps from "../lib/backup-ops.js";
31
- import * as guardianToken from "../lib/guardian-token.js";
32
- import * as localRuntimeClient from "../lib/local-runtime-client.js";
33
- import { MigrationInProgressError } from "../lib/local-runtime-client.js";
34
- import * as cloudClient from "../lib/cloud-client.js";
35
-
36
- const findAssistantByNameMock = spyOn(
37
- assistantConfig,
38
- "findAssistantByName",
39
- ).mockReturnValue(null);
40
-
41
- const readCloudTokenMock = spyOn(
42
- cloudClient,
43
- "readCloudToken",
44
- ).mockReturnValue("platform-token");
45
-
46
- const getCloudUrlMock = spyOn(
47
- cloudClient,
48
- "getCloudUrl",
49
- ).mockReturnValue("https://cloud.mycadet.ai");
50
-
51
- const cloudRequestSignedUrlMock = spyOn(
52
- cloudClient,
53
- "cloudRequestSignedUrl",
54
- ).mockImplementation(async (params) => ({
55
- url:
56
- params.operation === "upload"
57
- ? "https://storage.googleapis.com/bucket/signed-upload"
58
- : "https://storage.googleapis.com/bucket/signed-download",
59
- bundleKey: params.bundleKey ?? "uploads/org-1/bundle-abc.vbundle",
60
- expiresAt: new Date(Date.now() + 3600_000).toISOString(),
61
- }));
62
-
63
- const localRuntimeExportToGcsMock = spyOn(
64
- localRuntimeClient,
65
- "localRuntimeExportToGcs",
66
- ).mockResolvedValue({ jobId: "platform-export-job-1" });
67
-
68
- const localRuntimeIdentityMock = spyOn(
69
- localRuntimeClient,
70
- "localRuntimeIdentity",
71
- ).mockResolvedValue({ version: "0.6.5" });
72
-
73
- const localRuntimePollJobStatusMock = spyOn(
74
- localRuntimeClient,
75
- "localRuntimePollJobStatus",
76
- ).mockResolvedValue({
77
- jobId: "platform-export-job-1",
78
- type: "export",
79
- status: "complete",
80
- result: { manifest_sha256: "abc123def456" },
81
- });
82
-
83
- /** A complete-but-fake guardian token; the SUT only reads the fields it sets. */
84
- // Mode 1 (runtime-direct local backup) uses guardian tokens. Don't exercise
85
- // it here, but the spies need to exist so the module under test can import
86
- // them without surprises. Saved to variables so afterAll can restore them —
87
- // otherwise the spied loadGuardianToken leaks into guardian-token.test.ts and
88
- // setup.test.ts when they run later in the same `bun test` invocation.
89
- const loadGuardianTokenSpy = spyOn(
90
- guardianToken,
91
- "loadGuardianToken",
92
- ).mockReturnValue(makeGuardianTokenData({ accessToken: "local-token" }));
93
- const leaseGuardianTokenSpy = spyOn(
94
- guardianToken,
95
- "leaseGuardianToken",
96
- ).mockResolvedValue(makeGuardianTokenData({ accessToken: "leased-token" }));
97
-
98
- const getBackupsDirMock = spyOn(backupOps, "getBackupsDir").mockReturnValue(
99
- "/tmp/backups-default",
100
- );
101
-
102
- const mkdirSyncMock = spyOn(fs, "mkdirSync").mockImplementation(
103
- () => undefined,
104
- );
105
- const writeFileSyncMock = spyOn(fs, "writeFileSync").mockImplementation(
106
- () => undefined,
107
- );
108
-
109
- let originalFetch: typeof globalThis.fetch;
110
- let exitMock: Mock<(code?: number | string | undefined) => never>;
111
-
112
- const CADET_ENTRY = {
113
- assistantId: "11111111-2222-3333-4444-555555555555",
114
- runtimeUrl: "https://cloud.mycadet.ai",
115
- cloud: "cadet",
116
- hatchedAt: new Date().toISOString(),
117
- } satisfies assistantConfig.AssistantEntry;
7
+ import * as roots from "../lib/root-backup.js";
8
+ import * as cloud from "../lib/cloud-client.js";
9
+ import * as platform from "../lib/teleport-backup.js";
10
+ import * as guardian from "../lib/guardian-token.js";
11
+ import { backup } from "../commands/backup.js";
118
12
 
119
- function setArgv(...rest: string[]) {
120
- process.argv = ["bun", "cadet", "backup", ...rest];
121
- }
13
+ const directory = mkdtempSync(join(tmpdir(), "backup-command-test-"));
14
+ const argv = [...process.argv];
15
+ const lookup = spyOn(config, "findAssistantByName");
16
+ const backupDir = spyOn(backupOps, "getBackupsDir").mockReturnValue(directory);
17
+ const capture = spyOn(roots, "createRootBackup");
18
+ const readToken = spyOn(cloud, "readCloudToken");
19
+ const cloudBackup = spyOn(platform, "createPlatformBackup");
20
+ const guardianRead = spyOn(guardian, "loadGuardianToken");
21
+ const guardianLease = spyOn(guardian, "leaseGuardianToken");
122
22
 
123
23
  beforeEach(() => {
124
- originalFetch = globalThis.fetch;
125
- exitMock = mock((code?: number | string | undefined): never => {
126
- throw new Error(`process.exit:${code}`);
127
- });
128
- process.exit = exitMock;
129
-
130
- findAssistantByNameMock.mockReset();
131
- findAssistantByNameMock.mockReturnValue(null);
132
- readCloudTokenMock.mockReset();
133
- readCloudTokenMock.mockReturnValue("platform-token");
134
- getCloudUrlMock.mockReset();
135
- getCloudUrlMock.mockReturnValue("https://cloud.mycadet.ai");
136
- cloudRequestSignedUrlMock.mockReset();
137
- cloudRequestSignedUrlMock.mockImplementation(async (params) => ({
138
- url:
139
- params.operation === "upload"
140
- ? "https://storage.googleapis.com/bucket/signed-upload"
141
- : "https://storage.googleapis.com/bucket/signed-download",
142
- bundleKey: params.bundleKey ?? "uploads/org-1/bundle-abc.vbundle",
143
- expiresAt: new Date(Date.now() + 3600_000).toISOString(),
144
- }));
145
- localRuntimeExportToGcsMock.mockReset();
146
- localRuntimeExportToGcsMock.mockResolvedValue({
147
- jobId: "platform-export-job-1",
148
- });
149
- localRuntimeIdentityMock.mockReset();
150
- localRuntimeIdentityMock.mockResolvedValue({ version: "0.6.5" });
151
- localRuntimePollJobStatusMock.mockReset();
152
- localRuntimePollJobStatusMock.mockResolvedValue({
153
- jobId: "platform-export-job-1",
154
- type: "export",
155
- status: "complete",
156
- result: { manifest_sha256: "abc123def456" },
24
+ lookup.mockReset();
25
+ capture.mockReset();
26
+ readToken.mockReset();
27
+ cloudBackup.mockReset();
28
+ guardianRead.mockReset();
29
+ guardianLease.mockReset();
30
+ capture.mockImplementation(async (_entry, path) => {
31
+ writeFileSync(path, "tar archive");
32
+ });
33
+ readToken.mockReturnValue("session-token");
34
+ cloudBackup.mockResolvedValue(undefined);
35
+ guardianRead.mockImplementation(() => {
36
+ throw new Error("must not read guardian tokens");
37
+ });
38
+ guardianLease.mockImplementation(async () => {
39
+ throw new Error("must not lease guardian tokens");
157
40
  });
158
- getBackupsDirMock.mockReset();
159
- getBackupsDirMock.mockReturnValue("/tmp/backups-default");
160
- loadGuardianTokenSpy.mockReset();
161
- loadGuardianTokenSpy.mockReturnValue(
162
- makeGuardianTokenData({ accessToken: "local-token" }),
163
- );
164
- leaseGuardianTokenSpy.mockReset();
165
- leaseGuardianTokenSpy.mockResolvedValue(
166
- makeGuardianTokenData({ accessToken: "leased-token" }),
167
- );
168
- mkdirSyncMock.mockReset();
169
- mkdirSyncMock.mockImplementation(() => undefined);
170
- writeFileSyncMock.mockReset();
171
- writeFileSyncMock.mockImplementation(() => undefined);
172
41
  });
173
-
174
42
  afterEach(() => {
175
- globalThis.fetch = originalFetch;
43
+ process.argv = [...argv];
176
44
  });
177
-
178
45
  afterAll(() => {
179
- // Restore module-level spies so they don't bleed into other test files
180
- // when bun test runs the whole suite.
181
- findAssistantByNameMock.mockRestore();
182
- readCloudTokenMock.mockRestore();
183
- getCloudUrlMock.mockRestore();
184
- cloudRequestSignedUrlMock.mockRestore();
185
- localRuntimeExportToGcsMock.mockRestore();
186
- localRuntimeIdentityMock.mockRestore();
187
- localRuntimePollJobStatusMock.mockRestore();
188
- getBackupsDirMock.mockRestore();
189
- mkdirSyncMock.mockRestore();
190
- writeFileSyncMock.mockRestore();
191
- loadGuardianTokenSpy.mockRestore();
192
- leaseGuardianTokenSpy.mockRestore();
193
- rmSync(testDir, { recursive: true, force: true });
46
+ for (const spy of [
47
+ lookup,
48
+ capture,
49
+ backupDir,
50
+ readToken,
51
+ cloudBackup,
52
+ guardianRead,
53
+ guardianLease,
54
+ ])
55
+ spy.mockRestore();
56
+ rmSync(directory, { recursive: true, force: true });
194
57
  });
195
58
 
196
- import { backup } from "../commands/backup.js";
197
- import { withPreconnect } from "./helpers/http.js";
198
- import { makeGuardianTokenData } from "./helpers/guardian-token.js";
199
-
200
- // ---------------------------------------------------------------------------
201
- // Helper: simulated GCS download response
202
- // ---------------------------------------------------------------------------
203
- function mockGcsDownload(
204
- body: Uint8Array<ArrayBuffer>,
205
- ok = true,
206
- status = 200,
207
- ) {
208
- const gcsFetch = mock(async () => {
209
- const responseBody: BodyInit = ok ? new Blob([body]) : "boom";
210
- return new Response(responseBody, {
211
- status,
212
- statusText: ok ? "OK" : "Error",
213
- });
214
- });
215
- globalThis.fetch = withPreconnect(gcsFetch);
216
- return gcsFetch;
217
- }
218
-
219
- describe("cadet backup <local>: guardian bootstrap secret", () => {
220
- test("passes the lockfile bootstrap secret when leasing a fresh guardian token", async () => {
221
- const localEntry = {
222
- assistantId: "local-assistant",
223
- runtimeUrl: "http://127.0.0.1:7830",
224
- cloud: "local",
225
- guardianBootstrapSecret: "bootstrap-secret-value",
226
- } satisfies assistantConfig.AssistantEntry;
227
- findAssistantByNameMock.mockReturnValue(localEntry);
228
- loadGuardianTokenSpy.mockReturnValue(null);
229
- setArgv("my-local", "--output", "/tmp/local-backup.vbundle");
230
-
231
- globalThis.fetch = withPreconnect(
232
- async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
233
- );
234
-
235
- await backup();
236
-
237
- expect(leaseGuardianTokenSpy).toHaveBeenCalledWith(
238
- "http://127.0.0.1:7830",
239
- "local-assistant",
240
- "bootstrap-secret-value",
241
- );
242
- });
243
- });
244
-
245
- describe("cadet backup: --export-timeout flag", () => {
246
- test("rejects a non-numeric value before any lookup", async () => {
247
- setArgv("my-local", "--export-timeout", "soon");
248
-
249
- const consoleErrorSpy = spyOn(console, "error").mockImplementation(
250
- () => undefined,
251
- );
252
- try {
253
- await expect(backup()).rejects.toThrow("process.exit:1");
254
- expect(consoleErrorSpy).toHaveBeenCalledWith(
255
- expect.stringContaining("--export-timeout must be a positive number"),
256
- );
257
- expect(findAssistantByNameMock).not.toHaveBeenCalled();
258
- } finally {
259
- consoleErrorSpy.mockRestore();
260
- }
261
- });
262
-
263
- test("rejects a non-positive value", async () => {
264
- setArgv("my-local", "--export-timeout", "0");
265
-
266
- const consoleErrorSpy = spyOn(console, "error").mockImplementation(
267
- () => undefined,
268
- );
269
- try {
270
- await expect(backup()).rejects.toThrow("process.exit:1");
271
- expect(consoleErrorSpy).toHaveBeenCalledWith(
272
- expect.stringContaining("--export-timeout must be a positive number"),
273
- );
274
- } finally {
275
- consoleErrorSpy.mockRestore();
276
- }
277
- });
278
-
279
- test("accepts a valid override and completes the local export", async () => {
280
- const localEntry = {
281
- assistantId: "local-assistant",
282
- runtimeUrl: "http://127.0.0.1:7830",
283
- cloud: "local",
284
- guardianBootstrapSecret: "bootstrap-secret-value",
285
- } satisfies assistantConfig.AssistantEntry;
286
- findAssistantByNameMock.mockReturnValue(localEntry);
287
- setArgv(
288
- "my-local",
289
- "--export-timeout",
290
- "600",
291
- "--output",
292
- "/tmp/local-backup.vbundle",
293
- );
294
-
295
- globalThis.fetch = withPreconnect(
296
- async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
297
- );
298
-
299
- await backup();
300
-
301
- expect(writeFileSyncMock).toHaveBeenCalledTimes(1);
302
- expect(writeFileSyncMock.mock.calls[0]![0]).toBe(
303
- "/tmp/local-backup.vbundle",
304
- );
305
- });
306
- });
307
-
308
- describe("cadet backup <cloud-managed>: GCS happy path", () => {
309
- test("requests upload URL → kicks off runtime export → polls → downloads from GCS → writes file", async () => {
310
- findAssistantByNameMock.mockReturnValue(CADET_ENTRY);
311
- setArgv("my-platform");
312
-
313
- const bytes = new Uint8Array([1, 2, 3, 4]);
314
- const gcsFetch = mockGcsDownload(bytes);
315
-
316
- await backup();
317
-
318
- // Upload-URL request to the cloud.
319
- expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
320
- expect.objectContaining({
321
- operation: "upload",
322
- minRuntimeVersion: "0.6.5",
323
- maxRuntimeVersion: null,
324
- }),
325
- "platform-token",
326
- "https://cloud.mycadet.ai",
327
- );
328
-
329
- // Runtime export-to-gcs kicked off via the entry-aware helper. URL
330
- // construction is exercised in `local-runtime-client.test.ts`; here we
331
- // assert the helper got the right entry + token + params.
332
- expect(localRuntimeExportToGcsMock).toHaveBeenCalledWith(
333
- expect.objectContaining({
334
- cloud: "cadet",
335
- runtimeUrl: "https://cloud.mycadet.ai",
336
- assistantId: "11111111-2222-3333-4444-555555555555",
337
- }),
338
- "platform-token",
339
- expect.objectContaining({
340
- uploadUrl: "https://storage.googleapis.com/bucket/signed-upload",
341
- description: "CLI backup",
342
- }),
343
- );
344
-
345
- // Poll uses the entry-aware helper (wildcard URL, NOT the dedicated
346
- // platform jobs/{id}/ endpoint).
347
- expect(localRuntimePollJobStatusMock).toHaveBeenCalledWith(
348
- expect.objectContaining({ cloud: "cadet" }),
349
- "platform-token",
350
- "platform-export-job-1",
351
- );
352
-
353
- // Download URL keyed off the upload's bundleKey. We deliberately do
354
- // NOT send `targetRuntimeVersion` here — this backup downloads the
355
- // bundle to disk for offline storage; there is no target runtime to
356
- // gate against, and an older CLI must be able to download newer
357
- // assistant backups.
358
- expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
359
- expect.objectContaining({
360
- operation: "download",
361
- bundleKey: "uploads/org-1/bundle-abc.vbundle",
362
- }),
363
- "platform-token",
364
- "https://cloud.mycadet.ai",
365
- );
366
- const downloadCall = cloudRequestSignedUrlMock.mock.calls.find(
367
- (c) => c[0].operation === "download",
368
- );
369
- expect(downloadCall).toBeDefined();
370
- expect(downloadCall![0]).not.toHaveProperty("targetRuntimeVersion");
371
-
372
- // GCS fetch went directly to the signed download URL with no auth.
373
- expect(gcsFetch).toHaveBeenCalledWith(
374
- "https://storage.googleapis.com/bucket/signed-download",
375
- );
376
-
377
- // File written to disk with the bytes from GCS.
378
- expect(writeFileSyncMock).toHaveBeenCalledTimes(1);
379
- const [outputPath, written] = writeFileSyncMock.mock.calls[0]!;
380
- expect(written).toEqual(bytes);
381
- expect(typeof outputPath).toBe("string");
382
- expect(outputPath).toMatch(
383
- /\/tmp\/backups-default\/my-platform-.*\.vbundle$/,
384
- );
385
- expect(mkdirSyncMock).toHaveBeenCalled();
386
- });
387
-
388
- test("--output override is respected", async () => {
389
- findAssistantByNameMock.mockReturnValue(CADET_ENTRY);
390
- setArgv("my-platform", "--output", "/custom/path/backup.vbundle");
391
-
392
- mockGcsDownload(new Uint8Array([7, 7, 7]));
393
-
394
- await backup();
395
-
396
- expect(writeFileSyncMock).toHaveBeenCalledTimes(1);
397
- expect(writeFileSyncMock.mock.calls[0]![0]).toBe(
398
- "/custom/path/backup.vbundle",
399
- );
400
- });
401
-
402
- test("default output path is getBackupsDir() + name-timestamp.vbundle", async () => {
403
- findAssistantByNameMock.mockReturnValue(CADET_ENTRY);
404
- setArgv("my-platform");
405
-
406
- mockGcsDownload(new Uint8Array([1]));
407
-
408
- await backup();
409
-
410
- const [outputPath] = writeFileSyncMock.mock.calls[0]!;
411
- expect(outputPath).toMatch(/^\/tmp\/backups-default\/my-platform-/);
412
- expect(outputPath).toMatch(/\.vbundle$/);
413
- });
414
-
415
- test("signed-URL requests target entry.runtimeUrl, not getCloudUrl() — regression for staging/dev assistants", async () => {
416
- // Assistant lives on a non-default platform instance (e.g. staging).
417
- // `getCloudUrl()` still returns the default — picking it up for
418
- // signed URLs would target the wrong GCS bucket.
419
- const stagingEntry = {
420
- ...CADET_ENTRY,
421
- runtimeUrl: "https://staging-cloud.mycadet.ai",
59
+ for (const topology of ["local", "docker"] as const) {
60
+ test(`${topology} backup uses root storage without runtime auth`, async () => {
61
+ const entry: config.AssistantEntry = {
62
+ assistantId: "test",
63
+ cloud: topology,
64
+ runtimeUrl: "http://127.0.0.1:1",
422
65
  };
423
- findAssistantByNameMock.mockReturnValue(stagingEntry);
424
- getCloudUrlMock.mockReturnValue("https://cloud.mycadet.ai");
425
- setArgv("my-platform");
426
-
427
- mockGcsDownload(new Uint8Array([9]));
428
-
66
+ lookup.mockReturnValue(entry);
67
+ process.argv = ["bun", "cadet", "backup", "test"];
429
68
  await backup();
430
-
431
- // Both upload and download URL requests are pinned to the entry's
432
- // runtimeUrl. The signed URLs returned by the cloud target the
433
- // GCS bucket the runtime can reach, not the default platform's.
434
- expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
435
- expect.objectContaining({
436
- operation: "upload",
437
- minRuntimeVersion: "0.6.5",
438
- maxRuntimeVersion: null,
439
- }),
440
- "platform-token",
441
- "https://staging-cloud.mycadet.ai",
442
- );
443
- expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
444
- expect.objectContaining({ operation: "download" }),
445
- "platform-token",
446
- "https://staging-cloud.mycadet.ai",
69
+ expect(capture).toHaveBeenCalledWith(
70
+ entry,
71
+ expect.stringMatching(new RegExp(`^${directory}/test-.*\\.tar\\.gz$`)),
447
72
  );
448
- // No call should have used the default platform URL.
449
- const calls = cloudRequestSignedUrlMock.mock.calls;
450
- for (const call of calls) {
451
- expect(call[2]).toBe("https://staging-cloud.mycadet.ai");
452
- }
73
+ expect(guardianRead).not.toHaveBeenCalled();
74
+ expect(guardianLease).not.toHaveBeenCalled();
75
+ expect(cloudBackup).not.toHaveBeenCalled();
453
76
  });
77
+ }
454
78
 
455
- test("download-URL request uses the refreshed platform token if polling re-authed mid-export", async () => {
456
- findAssistantByNameMock.mockReturnValue(CADET_ENTRY);
457
- setArgv("my-platform");
458
-
459
- // Simulate a poll-loop refresh: the helper fires `refreshOn401`
460
- // before resolving terminal. We trigger that hook to mutate the
461
- // token captured by backupPlatform's closure.
462
- localRuntimePollJobStatusMock.mockReset();
463
- localRuntimePollJobStatusMock.mockImplementation(async () => ({
464
- jobId: "platform-export-job-1",
465
- type: "export",
466
- status: "complete",
467
- result: {},
468
- }));
469
- // Make readCloudToken return a fresh value on the second call,
470
- // mimicking the "user re-ran `cadet login` in another terminal"
471
- // scenario. The helper's pollJobUntilDone calls refreshOn401 only
472
- // when its own request 401s — for the test we drive the refresh
473
- // directly by overriding the mock to surface a fresh token at the
474
- // download-step boundary.
475
- readCloudTokenMock.mockReset();
476
- readCloudTokenMock.mockReturnValueOnce("platform-token-old");
477
- readCloudTokenMock.mockReturnValue("platform-token-new");
478
-
479
- // Hook into pollJobUntilDone via overriding poll to intercept the
480
- // refresh call. Easier: just verify the second-arg token to the
481
- // download signed-URL request equals the one we'll inject by
482
- // letting backup re-read the cloud token mid-flight. The current
483
- // implementation only re-reads inside pollJobUntilDone's
484
- // `refreshOn401`, so we simulate a refresh by overriding poll to
485
- // throw-and-recover. Instead we directly assert the regression
486
- // behavior: backup uses `exportPlatformToken` (the closure variable)
487
- // for the download URL — verified by the structural assertion that
488
- // the same variable is used for upload, kickoff, poll, AND download.
489
-
490
- mockGcsDownload(new Uint8Array([1]));
491
-
492
- await backup();
493
-
494
- // All four token-bearing platform calls (upload signed-URL, runtime
495
- // export-to-gcs kickoff, poll, download signed-URL) must use the
496
- // same token string. If the download step fell back to the captured
497
- // `cloudToken` parameter instead of `exportPlatformToken`, a
498
- // future poll-loop refresh would silently break this invariant.
499
- const uploadCallToken = cloudRequestSignedUrlMock.mock.calls.find(
500
- (c) => c[0].operation === "upload",
501
- )![1];
502
- const downloadCallToken = cloudRequestSignedUrlMock.mock.calls.find(
503
- (c) => c[0].operation === "download",
504
- )![1];
505
- expect(downloadCallToken).toBe(uploadCallToken);
506
- const kickoffToken = localRuntimeExportToGcsMock.mock.calls[0]![1];
507
- expect(downloadCallToken).toBe(kickoffToken);
508
- const pollToken = localRuntimePollJobStatusMock.mock.calls[0]![1];
509
- expect(downloadCallToken).toBe(pollToken);
510
- });
79
+ test("cloud backup calls the cloud backup API with session authentication", async () => {
80
+ const entry: config.AssistantEntry = {
81
+ assistantId: "cloud-test",
82
+ cloud: "cloud",
83
+ runtimeUrl: "https://cloud.mycadet.ai",
84
+ };
85
+ lookup.mockReturnValue(entry);
86
+ process.argv = ["bun", "cadet", "backup", "cloud-test"];
87
+ await backup();
88
+ expect(cloudBackup).toHaveBeenCalledWith(entry, "session-token");
89
+ expect(capture).not.toHaveBeenCalled();
90
+ expect(guardianRead).not.toHaveBeenCalled();
511
91
  });
512
92
 
513
- describe("cadet backup <cloud-managed>: failure cases", () => {
514
- test("not logged in (no platform token) exits with 'Run cadet login'", async () => {
515
- findAssistantByNameMock.mockReturnValue(CADET_ENTRY);
516
- readCloudTokenMock.mockReturnValue(null);
517
- setArgv("my-platform");
518
-
519
- const consoleErrorSpy = spyOn(console, "error").mockImplementation(
520
- () => undefined,
521
- );
522
- try {
523
- await expect(backup()).rejects.toThrow("process.exit:1");
524
- expect(consoleErrorSpy).toHaveBeenCalledWith(
525
- expect.stringContaining("Not logged in"),
526
- );
527
- } finally {
528
- consoleErrorSpy.mockRestore();
529
- }
530
- });
531
-
532
- test("MigrationInProgressError on kickoff exits with 'Another backup or teleport export'", async () => {
533
- findAssistantByNameMock.mockReturnValue(CADET_ENTRY);
534
- localRuntimeExportToGcsMock.mockRejectedValue(
535
- new MigrationInProgressError("export_in_progress", "existing-job-99"),
536
- );
537
- setArgv("my-platform");
538
-
539
- const consoleErrorSpy = spyOn(console, "error").mockImplementation(
540
- () => undefined,
541
- );
542
- try {
543
- await expect(backup()).rejects.toThrow("process.exit:1");
544
- const calls = consoleErrorSpy.mock.calls.map((c) => c[0]);
545
- expect(
546
- calls.some(
547
- (m) =>
548
- typeof m === "string" &&
549
- m.includes("Another backup or teleport export") &&
550
- m.includes("existing-job-99"),
551
- ),
552
- ).toBe(true);
553
- } finally {
554
- consoleErrorSpy.mockRestore();
555
- }
556
- });
557
-
558
- test("terminal=failed exits with 'Export failed: <reason>'", async () => {
559
- findAssistantByNameMock.mockReturnValue(CADET_ENTRY);
560
- localRuntimePollJobStatusMock.mockResolvedValue({
561
- jobId: "platform-export-job-1",
562
- type: "export",
563
- status: "failed",
564
- error: "vbundle build crashed",
565
- });
566
- setArgv("my-platform");
567
-
568
- const consoleErrorSpy = spyOn(console, "error").mockImplementation(
569
- () => undefined,
570
- );
571
- try {
572
- await expect(backup()).rejects.toThrow("process.exit:1");
573
- expect(consoleErrorSpy).toHaveBeenCalledWith(
574
- expect.stringContaining("Export failed: vbundle build crashed"),
575
- );
576
- } finally {
577
- consoleErrorSpy.mockRestore();
578
- }
579
- });
580
-
581
- test("GCS fetch !ok exits with 'Failed to fetch bundle from GCS (<status>)'", async () => {
582
- findAssistantByNameMock.mockReturnValue(CADET_ENTRY);
583
- setArgv("my-platform");
584
-
585
- mockGcsDownload(new Uint8Array(), false, 403);
586
-
587
- const consoleErrorSpy = spyOn(console, "error").mockImplementation(
588
- () => undefined,
589
- );
590
- try {
591
- await expect(backup()).rejects.toThrow("process.exit:1");
592
- const calls = consoleErrorSpy.mock.calls.map((c) => c[0]);
593
- expect(
594
- calls.some(
595
- (m) =>
596
- typeof m === "string" &&
597
- m.includes("Failed to fetch bundle from GCS") &&
598
- m.includes("403"),
599
- ),
600
- ).toBe(true);
601
- } finally {
602
- consoleErrorSpy.mockRestore();
603
- }
604
- });
93
+ test("rejects legacy output names and unsupported remote topology before capture", async () => {
94
+ lookup.mockReturnValue({
95
+ assistantId: "test",
96
+ cloud: "local",
97
+ runtimeUrl: "http://127.0.0.1:1",
98
+ });
99
+ process.argv = [
100
+ "bun",
101
+ "cadet",
102
+ "backup",
103
+ "test",
104
+ "--output",
105
+ "legacy.vbundle",
106
+ ];
107
+ await expect(backup()).rejects.toThrow("Unknown backup option: --output");
108
+ lookup.mockReturnValue({
109
+ assistantId: "test",
110
+ cloud: "paired",
111
+ runtimeUrl: "http://127.0.0.1:1",
112
+ });
113
+ process.argv = ["bun", "cadet", "backup", "test"];
114
+ await expect(backup()).rejects.toThrow("must run on its host");
115
+ expect(capture).not.toHaveBeenCalled();
605
116
  });
606
117
 
607
- // NOTE: The `VersionMismatchError handling` describe block was removed when
608
- // backup stopped sending `targetRuntimeVersion` on the download signed-URL
609
- // request — without that field the cloud doesn't run the version gate,
610
- // so 422 `version_mismatch` is no longer reachable from this code path.
611
-
612
- // ---------------------------------------------------------------------------
613
- // Source-runtime version is sourced from the daemon, not the CLI
614
- // (Codex P1 regression guard for PR #29436)
615
- // ---------------------------------------------------------------------------
616
- describe("upload signed-URL records source runtime version (not CLI version)", () => {
617
- test("identity is fetched BEFORE the upload signed-URL request", async () => {
618
- findAssistantByNameMock.mockReturnValue(CADET_ENTRY);
619
- setArgv("my-platform");
620
-
621
- const callOrder: string[] = [];
622
- localRuntimeIdentityMock.mockImplementationOnce(async () => {
623
- callOrder.push("identity");
624
- return { version: "0.5.9" };
625
- });
626
- cloudRequestSignedUrlMock.mockImplementationOnce(async (params) => {
627
- callOrder.push("signed-url");
628
- return {
629
- url: "https://storage.googleapis.com/bucket/signed-upload",
630
- bundleKey: params.bundleKey ?? "uploads/org-1/bundle-abc.vbundle",
631
- expiresAt: new Date(Date.now() + 3600_000).toISOString(),
632
- };
633
- });
634
-
635
- mockGcsDownload(new Uint8Array([1]));
636
-
637
- await backup();
638
-
639
- expect(callOrder[0]).toBe("identity");
640
- expect(callOrder[1]).toBe("signed-url");
641
-
642
- expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
643
- expect.objectContaining({
644
- operation: "upload",
645
- minRuntimeVersion: "0.5.9",
646
- maxRuntimeVersion: null,
647
- }),
648
- "platform-token",
649
- "https://cloud.mycadet.ai",
650
- );
651
- });
652
-
653
- test("identity is fetched against the cloud-managed runtime entry with the cloud token", async () => {
654
- findAssistantByNameMock.mockReturnValue(CADET_ENTRY);
655
- setArgv("my-platform");
656
-
657
- mockGcsDownload(new Uint8Array([1]));
658
-
659
- await backup();
660
-
661
- expect(localRuntimeIdentityMock).toHaveBeenCalledWith(
662
- expect.objectContaining({
663
- cloud: "cadet",
664
- runtimeUrl: "https://cloud.mycadet.ai",
665
- assistantId: "11111111-2222-3333-4444-555555555555",
666
- }),
667
- "platform-token",
668
- );
669
- });
670
-
671
- test("identity fetch failure aborts before signed-URL request", async () => {
672
- findAssistantByNameMock.mockReturnValue(CADET_ENTRY);
673
- setArgv("my-platform");
674
-
675
- localRuntimeIdentityMock.mockRejectedValue(
676
- new Error("Failed to fetch runtime identity: 503 Service Unavailable"),
677
- );
678
-
679
- const consoleErrorSpy = spyOn(console, "error").mockImplementation(
680
- () => undefined,
681
- );
682
- try {
683
- await expect(backup()).rejects.toThrow("process.exit:1");
684
-
685
- // Signed-URL must NOT have been requested.
686
- expect(cloudRequestSignedUrlMock).not.toHaveBeenCalled();
687
- expect(localRuntimeExportToGcsMock).not.toHaveBeenCalled();
688
-
689
- expect(consoleErrorSpy).toHaveBeenCalledWith(
690
- expect.stringContaining("Could not fetch runtime identity"),
691
- );
692
- } finally {
693
- consoleErrorSpy.mockRestore();
694
- }
695
- });
118
+ test("cloud backup requires cloud login and rejects host output paths", async () => {
119
+ lookup.mockReturnValue({
120
+ assistantId: "test",
121
+ cloud: "cloud",
122
+ runtimeUrl: "https://cloud.mycadet.ai",
123
+ });
124
+ process.argv = [
125
+ "bun",
126
+ "cadet",
127
+ "backup",
128
+ "test",
129
+ "--output",
130
+ "backup.tar.gz",
131
+ ];
132
+ await expect(backup()).rejects.toThrow("Unknown backup option: --output");
133
+ process.argv = ["bun", "cadet", "backup", "test"];
134
+ readToken.mockReturnValue(null);
135
+ await expect(backup()).rejects.toThrow("cadet login");
136
+ expect(cloudBackup).not.toHaveBeenCalled();
696
137
  });