@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,3009 +1,81 @@
1
- import {
2
- afterAll,
3
- afterEach,
4
- beforeEach,
5
- describe,
6
- expect,
7
- mock,
8
- spyOn,
9
- test,
10
- } from "bun:test";
11
- import type { Mock } from "bun:test";
12
- import { mkdtempSync, rmSync } from "node:fs";
13
- import { tmpdir } from "node:os";
14
- import { join } from "node:path";
15
-
16
- // ---------------------------------------------------------------------------
17
- // Temp directory for lockfile isolation
18
- // ---------------------------------------------------------------------------
19
-
20
- const testDir = mkdtempSync(join(tmpdir(), "cli-teleport-test-"));
21
- process.env.CADET_LOCKFILE_DIR = testDir;
22
-
23
- // ---------------------------------------------------------------------------
24
- // Mocks — must be set up before importing the module under test.
25
- //
26
- // We use Bun's `mock.module()` API (rather than `spyOn` on the imported
27
- // module namespace). `spyOn` mutates the shared module namespace, and
28
- // `mockRestore()` in `afterAll` was not reliably restoring on CI — the
29
- // mutations leaked into sibling test files (notably `guardian-token.test.ts`)
30
- // and caused flaky failures when the runtime function for that file returned
31
- // teleport's mock value instead of the real implementation.
32
- //
33
- // `mock.module()` registers a per-module factory in Bun's loader. To prevent
34
- // the mock from leaking across files in the same `bun test` run, we capture
35
- // the real exports up front and re-register them in `afterAll` to restore
36
- // the original module bindings.
37
- // ---------------------------------------------------------------------------
38
-
39
- import * as assistantConfig from "../lib/assistant-config.js";
40
- import * as guardianToken from "../lib/guardian-token.js";
41
- import * as cloudClient from "../lib/cloud-client.js";
42
- import * as localRuntimeClient from "../lib/local-runtime-client.js";
43
- import * as backupOps from "../lib/backup-ops.js";
44
- import * as teleportBackup from "../lib/teleport-backup.js";
45
-
46
- // Snapshot the real exports before any `mock.module()` call so we can
47
- // reliably restore them after this file's tests complete, regardless of
48
- // how Bun's loader treats namespace bindings post-mock.
49
- const realAssistantConfig = { ...assistantConfig };
50
- const realGuardianToken = { ...guardianToken };
51
-
52
- const realCloudClient = { ...cloudClient };
53
- const realLocalRuntimeClient = { ...localRuntimeClient };
54
- const realBackupOps = { ...backupOps };
55
- const realTeleportBackup = { ...teleportBackup };
56
-
57
- const findAssistantByNameMock = mock<
58
- typeof assistantConfig.findAssistantByName
59
- >(() => null);
60
- const saveAssistantEntryMock = mock<typeof assistantConfig.saveAssistantEntry>(
61
- () => {},
62
- );
63
- const loadAllAssistantsMock = mock<typeof assistantConfig.loadAllAssistants>(
64
- () => [],
65
- );
66
- const removeAssistantEntryMock = mock<
67
- typeof assistantConfig.removeAssistantEntry
68
- >(() => {});
69
-
70
- mock.module("../lib/assistant-config.js", () => ({
71
- ...realAssistantConfig,
72
- findAssistantByName: findAssistantByNameMock,
73
- saveAssistantEntry: saveAssistantEntryMock,
74
- loadAllAssistants: loadAllAssistantsMock,
75
- removeAssistantEntry: removeAssistantEntryMock,
76
- }));
77
-
78
- const loadGuardianTokenMock = mock<typeof guardianToken.loadGuardianToken>(() =>
79
- makeGuardianTokenData({
80
- accessToken: "local-token",
81
- accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
82
- }),
83
- );
84
-
85
- const leaseGuardianTokenMock = mock<typeof guardianToken.leaseGuardianToken>(
86
- async () =>
87
- makeGuardianTokenData({
88
- accessToken: "leased-token",
89
- accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
90
- }),
91
- );
92
-
93
- const computeDeviceIdMock = mock<typeof guardianToken.computeDeviceId>(
94
- () => "device-id-123",
95
- );
96
-
97
- mock.module("../lib/guardian-token.js", () => ({
98
- ...realGuardianToken,
99
- loadGuardianToken: loadGuardianTokenMock,
100
- leaseGuardianToken: leaseGuardianTokenMock,
101
- computeDeviceId: computeDeviceIdMock,
102
- }));
103
-
104
- const readCloudTokenMock = mock<typeof cloudClient.readCloudToken>(
105
- () => "platform-token",
106
- );
107
-
108
- const getCloudUrlMock = mock<typeof cloudClient.getCloudUrl>(
109
- () => "https://cloud.mycadet.ai",
110
- );
111
-
112
- const hatchAssistantMock = mock<typeof cloudClient.hatchAssistant>(
113
- async () => ({
114
- assistant: {
115
- id: "platform-new-id",
116
- name: "platform-new",
117
- status: "active",
118
- },
119
- reusedExisting: false,
120
- }),
121
- );
122
-
123
- const platformPollJobStatusMock = mock<
124
- typeof cloudClient.platformPollJobStatus
125
- >(async () => ({
126
- jobId: "platform-job-1",
127
- type: "export",
128
- status: "complete",
129
- bundleKey: "platform-bundle-key-abc",
130
- }));
131
-
132
- const cloudRequestSignedUrlMock = mock<
133
- typeof cloudClient.cloudRequestSignedUrl
134
- >(async (params) => ({
135
- url:
136
- params.operation === "upload"
137
- ? "https://storage.googleapis.com/bucket/signed-upload"
138
- : "https://storage.googleapis.com/bucket/signed-download",
139
- bundleKey: params.bundleKey ?? "bundle-key-123",
140
- expiresAt: new Date(Date.now() + 3600_000).toISOString(),
141
- }));
142
-
143
- const platformImportBundleFromGcsMock = mock<
144
- typeof cloudClient.platformImportBundleFromGcs
145
- >(async () => ({
146
- statusCode: 200,
147
- body: {
148
- success: true,
149
- summary: {
150
- total_files: 3,
151
- files_created: 2,
152
- files_overwritten: 1,
153
- files_skipped: 0,
154
- backups_created: 1,
155
- },
156
- },
157
- }));
158
-
159
- const platformImportPreflightFromGcsMock = mock<
160
- typeof cloudClient.platformImportPreflightFromGcs
161
- >(async () => ({
162
- statusCode: 200,
163
- body: {
164
- can_import: true,
165
- summary: {
166
- files_to_create: 2,
167
- files_to_overwrite: 1,
168
- files_unchanged: 0,
169
- total_files: 3,
170
- },
171
- },
172
- }));
173
-
174
- const checkExistingCloudAssistantMock = mock<
175
- typeof cloudClient.checkExistingCloudAssistant
176
- >(async () => null);
177
-
178
- const ensureSelfHostedLocalRegistrationMock = mock<
179
- typeof cloudClient.ensureSelfHostedLocalRegistration
180
- >(async () => ({
181
- assistant: { id: "platform-assistant-1", name: "my-assistant" },
182
- registration: {
183
- client_installation_id: "device-id-123",
184
- runtime_assistant_id: "target-local",
185
- client_platform: "cli",
186
- },
187
- assistant_api_key: "api-key-123",
188
- webhook_secret: "webhook-secret-123",
189
- }));
190
-
191
- const injectCredentialsIntoAssistantMock = mock<
192
- typeof cloudClient.injectCredentialsIntoAssistant
193
- >(async () => true);
194
-
195
- const fetchCurrentUserMock = mock<typeof cloudClient.fetchCurrentUser>(
196
- async () => ({
197
- id: "user-1",
198
- email: "test@example.com",
199
- display: "Test",
200
- }),
201
- );
202
-
203
- const fetchOrganizationIdMock = mock<typeof cloudClient.fetchOrganizationId>(
204
- async () => "org-1",
205
- );
206
-
207
- mock.module("../lib/cloud-client.js", () => ({
208
- ...realCloudClient,
209
- readCloudToken: readCloudTokenMock,
210
- getCloudUrl: getCloudUrlMock,
211
- hatchAssistant: hatchAssistantMock,
212
- platformPollJobStatus: platformPollJobStatusMock,
213
- cloudRequestSignedUrl: cloudRequestSignedUrlMock,
214
- platformImportBundleFromGcs: platformImportBundleFromGcsMock,
215
- platformImportPreflightFromGcs: platformImportPreflightFromGcsMock,
216
- checkExistingCloudAssistant: checkExistingCloudAssistantMock,
217
- ensureSelfHostedLocalRegistration: ensureSelfHostedLocalRegistrationMock,
218
- injectCredentialsIntoAssistant: injectCredentialsIntoAssistantMock,
219
- fetchCurrentUser: fetchCurrentUserMock,
220
- fetchOrganizationId: fetchOrganizationIdMock,
221
- }));
222
-
223
- const localRuntimeExportToGcsMock = mock<
224
- typeof localRuntimeClient.localRuntimeExportToGcs
225
- >(async () => ({ jobId: "local-export-job-1" }));
226
-
227
- const localRuntimeImportFromGcsMock = mock<
228
- typeof localRuntimeClient.localRuntimeImportFromGcs
229
- >(async () => ({ jobId: "local-import-job-1" }));
230
-
231
- // Default to a fixed version string. Tests that exercise the version-gate
232
- // surface override this mock per-case to assert the value flows from the
233
- // target runtime's `/v1/identity` (NOT from `cliPkg.version`) into the
234
- // download signed-URL request.
235
- const localRuntimeIdentityMock = mock<
236
- typeof localRuntimeClient.localRuntimeIdentity
237
- >(async () => ({ version: "0.6.5" }));
238
-
239
- const localRuntimePollJobStatusMock = mock<
240
- typeof localRuntimeClient.localRuntimePollJobStatus
241
- >(async (_runtimeUrl, _token, jobId) => ({
242
- jobId,
243
- type: jobId.includes("import") ? "import" : "export",
244
- status: "complete",
245
- result: {
246
- success: true,
247
- summary: {
248
- total_files: 3,
249
- files_created: 2,
250
- files_overwritten: 1,
251
- files_skipped: 0,
252
- backups_created: 1,
253
- },
254
- },
255
- }));
256
-
257
- const localRuntimePreflightFromGcsMock = mock<
258
- typeof localRuntimeClient.localRuntimePreflightFromGcs
259
- >(async () => ({
260
- can_import: true,
261
- summary: {
262
- files_to_create: 2,
263
- files_to_overwrite: 1,
264
- files_unchanged: 0,
265
- total_files: 3,
266
- },
267
- files: [],
268
- conflicts: [],
269
- }));
270
-
271
- mock.module("../lib/local-runtime-client.js", () => ({
272
- ...realLocalRuntimeClient,
273
- localRuntimeExportToGcs: localRuntimeExportToGcsMock,
274
- localRuntimeImportFromGcs: localRuntimeImportFromGcsMock,
275
- localRuntimeIdentity: localRuntimeIdentityMock,
276
- localRuntimePollJobStatus: localRuntimePollJobStatusMock,
277
- localRuntimePreflightFromGcs: localRuntimePreflightFromGcsMock,
278
- }));
279
-
280
- // Pre-export source backup. Default to "no backups yet" so the create path
281
- // runs; individual tests override to exercise reuse and failure.
282
- const listAssistantBackupTimesMock = mock<
283
- typeof backupOps.listAssistantBackupTimes
284
- >(() => []);
285
- const createBackupMock = mock<typeof backupOps.createBackup>(
286
- async () => "/tmp/backups/my-local-pre-teleport-2026.vbundle",
287
- );
288
- const pruneOldBackupsMock = mock<typeof backupOps.pruneOldBackups>(() => {});
289
-
290
- mock.module("../lib/backup-ops.js", () => ({
291
- ...realBackupOps,
292
- listAssistantBackupTimes: listAssistantBackupTimesMock,
293
- createBackup: createBackupMock,
294
- pruneOldBackups: pruneOldBackupsMock,
295
- }));
296
-
297
- const listPlatformBackupsMock = mock<typeof teleportBackup.listPlatformBackups>(
298
- async () => [],
299
- );
300
- const createPlatformBackupMock = mock<
301
- typeof teleportBackup.createPlatformBackup
302
- >(async () => {});
303
-
304
- mock.module("../lib/teleport-backup.js", () => ({
305
- ...realTeleportBackup,
306
- listPlatformBackups: listPlatformBackupsMock,
307
- createPlatformBackup: createPlatformBackupMock,
308
- }));
309
-
310
- // Snapshot the remaining real modules before mocking so `afterAll` can
311
- // restore them too — otherwise these mocks leak into sibling test files that
312
- // import the same modules in the same `bun test` run.
313
- const realHatchLocal = { ...(await import("../lib/hatch-local.js")) };
314
- const realDocker = { ...(await import("../lib/docker.js")) };
315
- const realProcess = { ...(await import("../lib/process.js")) };
316
- const realRetireLocal = { ...(await import("../lib/retire-local.js")) };
317
- const realUpgradeLifecycle = {
318
- ...(await import("../lib/upgrade-lifecycle.js")),
1
+ import { afterAll, afterEach, beforeEach, expect, spyOn, test } from "bun:test";
2
+ import * as assistants from "../lib/assistant-config.js";
3
+ import * as host from "../lib/host-teleport.js";
4
+ import { parseArgs, teleport } from "../commands/teleport.js";
5
+
6
+ const entry: assistants.AssistantEntry = {
7
+ assistantId: "source-1",
8
+ name: "source",
9
+ cloud: "local",
10
+ runtimeUrl: "http://127.0.0.1:7821",
11
+ hatchedAt: "2026-01-01T00:00:00Z",
319
12
  };
320
-
321
- const hatchLocalMock = mock(async () => {});
322
-
323
- mock.module("../lib/hatch-local.js", () => ({
324
- hatchLocal: hatchLocalMock,
325
- }));
326
-
327
- const hatchDockerMock = mock(async () => {});
328
- const retireDockerMock = mock(async () => {});
329
-
330
- const sleepContainersMock = mock(async () => {});
331
- const dockerResourceNamesMock = mock((name: string) => ({
332
- assistantContainer: `${name}-assistant`,
333
- cesContainer: `${name}-credential-executor`,
334
- cesSecurityVolume: `${name}-ces-sec`,
335
- dockerdDataVolume: `${name}-dockerd-data`,
336
- gatewayContainer: `${name}-gateway`,
337
- gatewaySecurityVolume: `${name}-gateway-sec`,
338
- network: `${name}-net`,
339
- socketVolume: `${name}-socket`,
340
- workspaceVolume: `${name}-workspace`,
341
- }));
342
-
343
- mock.module("../lib/docker.js", () => ({
344
- hatchDocker: hatchDockerMock,
345
- retireDocker: retireDockerMock,
346
- sleepContainers: sleepContainersMock,
347
- dockerResourceNames: dockerResourceNamesMock,
348
- }));
349
-
350
- const stopProcessByPidFileMock = mock(async () => true);
351
-
352
- mock.module("../lib/process.js", () => ({
353
- ...realProcess,
354
- stopProcessByPidFile: stopProcessByPidFileMock,
355
- }));
356
-
357
- const retireLocalMock = mock(async () => {});
358
-
359
- mock.module("../lib/retire-local.js", () => ({
360
- retireLocal: retireLocalMock,
361
- }));
362
-
363
- const fetchCurrentVersionMock = mock(
364
- async (_runtimeUrl: string): Promise<string | undefined> => undefined,
13
+ const find = spyOn(assistants, "findAssistantByName");
14
+ const move = spyOn(host, "teleportHostInstallation").mockResolvedValue(
15
+ undefined,
365
16
  );
366
-
367
- mock.module("../lib/upgrade-lifecycle.js", () => ({
368
- fetchCurrentVersion: fetchCurrentVersionMock,
369
- }));
370
-
371
- import {
372
- teleport,
373
- parseArgs,
374
- resolveOrHatchTarget,
375
- } from "../commands/teleport.js";
376
- import type { AssistantEntry } from "../lib/assistant-config.js";
377
- import { withPreconnect } from "./helpers/http.js";
378
- import { makeGuardianTokenData } from "./helpers/guardian-token.js";
379
-
380
- // ---------------------------------------------------------------------------
381
- // Helpers
382
- // ---------------------------------------------------------------------------
383
-
384
- afterAll(() => {
385
- // Restore the real module exports so the mocks do not leak into sibling
386
- // test files (e.g. guardian-token.test.ts, cloud-client tests) that
387
- // run in the same `bun test` invocation.
388
- mock.module("../lib/assistant-config.js", () => realAssistantConfig);
389
- mock.module("../lib/guardian-token.js", () => realGuardianToken);
390
- mock.module("../lib/cloud-client.js", () => realCloudClient);
391
- mock.module("../lib/local-runtime-client.js", () => realLocalRuntimeClient);
392
- mock.module("../lib/backup-ops.js", () => realBackupOps);
393
- mock.module("../lib/teleport-backup.js", () => realTeleportBackup);
394
- mock.module("../lib/hatch-local.js", () => realHatchLocal);
395
- mock.module("../lib/docker.js", () => realDocker);
396
- mock.module("../lib/process.js", () => realProcess);
397
- mock.module("../lib/retire-local.js", () => realRetireLocal);
398
- mock.module("../lib/upgrade-lifecycle.js", () => realUpgradeLifecycle);
399
- rmSync(testDir, { recursive: true, force: true });
400
- delete process.env.CADET_LOCKFILE_DIR;
17
+ const exit = spyOn(process, "exit").mockImplementation((code): never => {
18
+ throw new Error(`exit:${code}`);
401
19
  });
402
-
403
- let originalArgv: string[];
404
- let exitMock: Mock<(code?: number | string | undefined) => never>;
405
- let originalExit: typeof process.exit;
406
- let consoleLogSpy: ReturnType<typeof spyOn>;
407
- let consoleErrorSpy: ReturnType<typeof spyOn>;
408
- let fetchCalls: Array<{ url: string; body: unknown }>;
409
-
410
- function defaultLocalRuntimePollImpl(
411
- _entry: unknown,
412
- _token: string,
413
- jobId: string,
414
- ): Promise<{
415
- jobId: string;
416
- type: "export" | "import";
417
- status: "complete";
418
- result: Record<string, unknown>;
419
- }> {
420
- return Promise.resolve({
421
- jobId,
422
- type: jobId.includes("import") ? "import" : "export",
423
- status: "complete",
424
- result: {
425
- success: true,
426
- summary: {
427
- total_files: 3,
428
- files_created: 2,
429
- files_overwritten: 1,
430
- files_skipped: 0,
431
- backups_created: 1,
432
- },
433
- },
434
- });
435
- }
436
-
20
+ const errors = spyOn(console, "error").mockImplementation(() => {});
21
+ const argv = process.argv;
437
22
  beforeEach(() => {
438
- originalArgv = [...process.argv];
439
- fetchCalls = [];
440
-
441
- findAssistantByNameMock.mockReset();
442
- findAssistantByNameMock.mockReturnValue(null);
443
- saveAssistantEntryMock.mockReset();
444
- saveAssistantEntryMock.mockImplementation(() => {});
445
- loadAllAssistantsMock.mockReset();
446
- loadAllAssistantsMock.mockReturnValue([]);
447
- removeAssistantEntryMock.mockReset();
448
- removeAssistantEntryMock.mockImplementation(() => {});
449
-
450
- loadGuardianTokenMock.mockReset();
451
- loadGuardianTokenMock.mockReturnValue(
452
- makeGuardianTokenData({
453
- accessToken: "local-token",
454
- accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
455
- }),
456
- );
457
- leaseGuardianTokenMock.mockReset();
458
-
459
- readCloudTokenMock.mockReset();
460
- readCloudTokenMock.mockReturnValue("platform-token");
461
- getCloudUrlMock.mockReset();
462
- getCloudUrlMock.mockReturnValue("https://cloud.mycadet.ai");
463
- hatchAssistantMock.mockReset();
464
- hatchAssistantMock.mockResolvedValue({
465
- assistant: {
466
- id: "platform-new-id",
467
- name: "platform-new",
468
- status: "active",
469
- },
470
- reusedExisting: false,
471
- });
472
- platformPollJobStatusMock.mockReset();
473
- platformPollJobStatusMock.mockResolvedValue({
474
- jobId: "platform-job-1",
475
- type: "export",
476
- status: "complete",
477
- bundleKey: "platform-bundle-key-abc",
478
- });
479
- cloudRequestSignedUrlMock.mockReset();
480
- cloudRequestSignedUrlMock.mockImplementation(async (params) => ({
481
- url:
482
- params.operation === "upload"
483
- ? "https://storage.googleapis.com/bucket/signed-upload"
484
- : "https://storage.googleapis.com/bucket/signed-download",
485
- bundleKey: params.bundleKey ?? "bundle-key-123",
486
- expiresAt: new Date(Date.now() + 3600_000).toISOString(),
487
- }));
488
- platformImportBundleFromGcsMock.mockReset();
489
- platformImportBundleFromGcsMock.mockResolvedValue({
490
- statusCode: 200,
491
- body: {
492
- success: true,
493
- summary: {
494
- total_files: 3,
495
- files_created: 2,
496
- files_overwritten: 1,
497
- files_skipped: 0,
498
- backups_created: 1,
499
- },
500
- },
501
- });
502
- platformImportPreflightFromGcsMock.mockReset();
503
- platformImportPreflightFromGcsMock.mockResolvedValue({
504
- statusCode: 200,
505
- body: {
506
- can_import: true,
507
- summary: {
508
- files_to_create: 2,
509
- files_to_overwrite: 1,
510
- files_unchanged: 0,
511
- total_files: 3,
512
- },
513
- },
514
- });
515
- checkExistingCloudAssistantMock.mockReset();
516
- checkExistingCloudAssistantMock.mockResolvedValue(null);
517
- ensureSelfHostedLocalRegistrationMock.mockReset();
518
- ensureSelfHostedLocalRegistrationMock.mockResolvedValue({
519
- assistant: { id: "platform-assistant-1", name: "my-assistant" },
520
- registration: {
521
- client_installation_id: "device-id-123",
522
- runtime_assistant_id: "target-local",
523
- client_platform: "cli",
524
- },
525
- assistant_api_key: "api-key-123",
526
- webhook_secret: "webhook-secret-123",
527
- });
528
- injectCredentialsIntoAssistantMock.mockReset();
529
- injectCredentialsIntoAssistantMock.mockResolvedValue(true);
530
- fetchCurrentUserMock.mockReset();
531
- fetchCurrentUserMock.mockResolvedValue({
532
- id: "user-1",
533
- email: "test@example.com",
534
- display: "Test",
535
- });
536
- fetchOrganizationIdMock.mockReset();
537
- fetchOrganizationIdMock.mockResolvedValue("org-1");
538
- computeDeviceIdMock.mockReset();
539
- computeDeviceIdMock.mockReturnValue("device-id-123");
540
-
541
- localRuntimeExportToGcsMock.mockReset();
542
- localRuntimeExportToGcsMock.mockResolvedValue({
543
- jobId: "local-export-job-1",
544
- });
545
- localRuntimeImportFromGcsMock.mockReset();
546
- localRuntimeImportFromGcsMock.mockResolvedValue({
547
- jobId: "local-import-job-1",
548
- });
549
- localRuntimePreflightFromGcsMock.mockReset();
550
- localRuntimePreflightFromGcsMock.mockResolvedValue({
551
- can_import: true,
552
- summary: {
553
- files_to_create: 2,
554
- files_to_overwrite: 1,
555
- files_unchanged: 0,
556
- total_files: 3,
557
- },
558
- files: [],
559
- conflicts: [],
560
- });
561
- localRuntimeIdentityMock.mockReset();
562
- localRuntimeIdentityMock.mockResolvedValue({ version: "0.6.5" });
563
- localRuntimePollJobStatusMock.mockReset();
564
- localRuntimePollJobStatusMock.mockImplementation(defaultLocalRuntimePollImpl);
565
-
566
- listAssistantBackupTimesMock.mockReset();
567
- listAssistantBackupTimesMock.mockReturnValue([]);
568
- createBackupMock.mockReset();
569
- createBackupMock.mockResolvedValue(
570
- "/tmp/backups/my-local-pre-teleport-2026.vbundle",
571
- );
572
- pruneOldBackupsMock.mockReset();
573
- pruneOldBackupsMock.mockImplementation(() => {});
574
- listPlatformBackupsMock.mockReset();
575
- listPlatformBackupsMock.mockResolvedValue([]);
576
- createPlatformBackupMock.mockReset();
577
- createPlatformBackupMock.mockResolvedValue(undefined);
578
-
579
- hatchLocalMock.mockReset();
580
- hatchLocalMock.mockResolvedValue(undefined);
581
- hatchDockerMock.mockReset();
582
- hatchDockerMock.mockResolvedValue(undefined);
583
- retireDockerMock.mockReset();
584
- retireDockerMock.mockResolvedValue(undefined);
585
- retireLocalMock.mockReset();
586
- retireLocalMock.mockResolvedValue(undefined);
587
- fetchCurrentVersionMock.mockReset();
588
- fetchCurrentVersionMock.mockResolvedValue(undefined);
589
- sleepContainersMock.mockReset();
590
- sleepContainersMock.mockResolvedValue(undefined);
591
- stopProcessByPidFileMock.mockReset();
592
- stopProcessByPidFileMock.mockResolvedValue(true);
593
-
594
- // Mock process.exit to throw so we can catch it
595
- exitMock = mock((code?: number | string | undefined): never => {
596
- throw new Error(`process.exit:${code}`);
597
- });
598
- originalExit = process.exit;
599
- process.exit = exitMock;
600
-
601
- consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
602
- consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
23
+ find.mockReturnValue(entry);
24
+ move.mockClear();
25
+ errors.mockClear();
26
+ process.argv = [
27
+ "bun",
28
+ "cadet",
29
+ "teleport",
30
+ "--from",
31
+ "source",
32
+ "--docker",
33
+ "target",
34
+ "--keep-source",
35
+ ];
603
36
  });
604
-
605
37
  afterEach(() => {
606
- process.argv = originalArgv;
607
- process.exit = originalExit;
608
- consoleLogSpy.mockRestore();
609
- consoleErrorSpy.mockRestore();
38
+ process.argv = argv;
610
39
  });
611
-
612
- function setArgv(...args: string[]): void {
613
- process.argv = ["bun", "cadet", "teleport", ...args];
614
- }
615
-
616
- function makeEntry(
617
- id: string,
618
- overrides?: Partial<AssistantEntry>,
619
- ): AssistantEntry {
620
- return {
621
- assistantId: id,
622
- runtimeUrl: "http://localhost:7821",
623
- cloud: "local",
624
- ...overrides,
625
- };
626
- }
627
-
628
- /**
629
- * Tracking fetch mock — records every call so tests can verify that the CLI
630
- * never sends a bundle-sized request body. With the new GCS-unified flow
631
- * all bundle bytes travel between the runtime and GCS directly, so the CLI
632
- * should make zero fetch calls carrying binary payloads.
633
- */
634
- function installTrackingFetch(): () => void {
635
- const originalFetch = globalThis.fetch;
636
- globalThis.fetch = withPreconnect(
637
- async (input: RequestInfo | URL, init?: RequestInit) => {
638
- const urlStr = String(input);
639
- fetchCalls.push({ url: urlStr, body: init?.body });
640
- return new Response("not found", { status: 404 });
641
- },
40
+ afterAll(() => {
41
+ find.mockRestore();
42
+ move.mockRestore();
43
+ exit.mockRestore();
44
+ errors.mockRestore();
45
+ });
46
+ test("routes local and Docker installation moves through the host tar implementation", async () => {
47
+ await teleport();
48
+ expect(move).toHaveBeenCalledWith(entry, "docker", "target", {
49
+ keepSource: true,
50
+ dryRun: false,
51
+ });
52
+ });
53
+ test("cloud destination refuses before starting any host operation", async () => {
54
+ process.argv = ["bun", "cadet", "teleport", "--from", "source", "--cloud"];
55
+ await expect(teleport()).rejects.toThrow("exit:1");
56
+ expect(move).not.toHaveBeenCalled();
57
+ expect(errors.mock.calls.flat().join(" ")).toContain(
58
+ "Cloud teleport is unavailable",
642
59
  );
643
- return () => {
644
- globalThis.fetch = originalFetch;
645
- };
646
- }
647
-
648
- // ---------------------------------------------------------------------------
649
- // Arg parsing tests
650
- // ---------------------------------------------------------------------------
651
-
652
- describe("teleport arg parsing", () => {
653
- test("--help prints usage and exits 0", async () => {
654
- setArgv("--help");
655
- await expect(teleport()).rejects.toThrow("process.exit:0");
656
- expect(consoleLogSpy).toHaveBeenCalledWith(
657
- expect.stringContaining("Usage:"),
658
- );
659
- });
660
-
661
- test("-h prints usage and exits 0", async () => {
662
- setArgv("-h");
663
- await expect(teleport()).rejects.toThrow("process.exit:0");
664
- expect(consoleLogSpy).toHaveBeenCalledWith(
665
- expect.stringContaining("Usage:"),
666
- );
667
- });
668
-
669
- test("missing --from and env flag prints help and exits 1", async () => {
670
- setArgv();
671
- await expect(teleport()).rejects.toThrow("process.exit:1");
672
- expect(consoleLogSpy).toHaveBeenCalledWith(
673
- expect.stringContaining("Usage:"),
674
- );
675
- });
676
-
677
- test("missing env flag prints help and exits 1", async () => {
678
- setArgv("--from", "source");
679
- await expect(teleport()).rejects.toThrow("process.exit:1");
680
- expect(consoleLogSpy).toHaveBeenCalledWith(
681
- expect.stringContaining("Usage:"),
682
- );
683
- });
684
-
685
- test("--local sets targetEnv to 'local' with no name", () => {
686
- const result = parseArgs(["--from", "source", "--local"]);
687
- expect(result.from).toBe("source");
688
- expect(result.targetEnv).toBe("local");
689
- expect(result.targetName).toBeUndefined();
690
- });
691
-
692
- test("--docker my-name sets targetEnv to 'docker' and targetName to 'my-name'", () => {
693
- const result = parseArgs(["--from", "source", "--docker", "my-name"]);
694
- expect(result.from).toBe("source");
695
- expect(result.targetEnv).toBe("docker");
696
- expect(result.targetName).toBe("my-name");
697
- });
698
-
699
- test("--platform sets targetEnv to 'platform'", () => {
700
- const result = parseArgs(["--from", "source", "--platform"]);
701
- expect(result.from).toBe("source");
702
- expect(result.targetEnv).toBe("platform");
703
- expect(result.targetName).toBeUndefined();
704
- });
705
-
706
- test("multiple env flags error", () => {
707
- expect(() => parseArgs(["--from", "src", "--local", "--docker"])).toThrow(
708
- "process.exit:1",
709
- );
710
- expect(consoleErrorSpy).toHaveBeenCalledWith(
711
- expect.stringContaining("Only one environment flag"),
712
- );
713
- });
714
-
715
- test("--keep-source is parsed", () => {
716
- const result = parseArgs(["--from", "source", "--docker", "--keep-source"]);
717
- expect(result.keepSource).toBe(true);
718
- });
719
-
720
- test("--dry-run is parsed", () => {
721
- const result = parseArgs(["--from", "source", "--local", "--dry-run"]);
722
- expect(result.dryRun).toBe(true);
723
- });
724
-
725
- test("target name after env flag is consumed but --flags are not", () => {
726
- const result = parseArgs(["--from", "source", "--docker", "--keep-source"]);
727
- expect(result.targetEnv).toBe("docker");
728
- expect(result.targetName).toBeUndefined();
729
- expect(result.keepSource).toBe(true);
730
- });
731
- });
732
-
733
- // ---------------------------------------------------------------------------
734
- // Same-environment rejection tests
735
- // ---------------------------------------------------------------------------
736
-
737
- describe("same-environment rejection", () => {
738
- test("source local, target local -> error (after resolving target)", async () => {
739
- setArgv("--from", "src", "--local", "dst");
740
-
741
- const srcEntry = makeEntry("src", { cloud: "local" });
742
- const dstEntry = makeEntry("dst", { cloud: "local" });
743
-
744
- findAssistantByNameMock.mockImplementation((name: string) => {
745
- if (name === "src") return srcEntry;
746
- if (name === "dst") return dstEntry;
747
- return null;
748
- });
749
-
750
- await expect(teleport()).rejects.toThrow("process.exit:1");
751
- expect(consoleErrorSpy).toHaveBeenCalledWith(
752
- expect.stringContaining("Cannot teleport between two local assistants"),
753
- );
754
- });
755
-
756
- test("source docker, target docker -> error", async () => {
757
- setArgv("--from", "src", "--docker", "dst");
758
-
759
- const srcEntry = makeEntry("src", { cloud: "docker" });
760
- const dstEntry = makeEntry("dst", { cloud: "docker" });
761
-
762
- findAssistantByNameMock.mockImplementation((name: string) => {
763
- if (name === "src") return srcEntry;
764
- if (name === "dst") return dstEntry;
765
- return null;
766
- });
767
-
768
- await expect(teleport()).rejects.toThrow("process.exit:1");
769
- expect(consoleErrorSpy).toHaveBeenCalledWith(
770
- expect.stringContaining("Cannot teleport between two docker assistants"),
771
- );
772
- });
773
-
774
- test("source cadet, target platform -> error", async () => {
775
- setArgv("--from", "src", "--platform", "dst");
776
-
777
- const srcEntry = makeEntry("src", {
778
- cloud: "cadet",
779
- runtimeUrl: "https://cloud.mycadet.ai",
780
- });
781
- const dstEntry = makeEntry("dst", {
782
- cloud: "cadet",
783
- runtimeUrl: "https://cloud.mycadet.ai",
784
- });
785
-
786
- findAssistantByNameMock.mockImplementation((name: string) => {
787
- if (name === "src") return srcEntry;
788
- if (name === "dst") return dstEntry;
789
- return null;
790
- });
791
-
792
- await expect(teleport()).rejects.toThrow("process.exit:1");
793
- expect(consoleErrorSpy).toHaveBeenCalledWith(
794
- expect.stringContaining(
795
- "Cannot teleport between two platform assistants",
796
- ),
797
- );
798
- });
799
-
800
- test("same-env rejection happens before hatching (no orphaned assistants)", async () => {
801
- setArgv("--from", "my-local", "--local");
802
-
803
- const localEntry = makeEntry("my-local", { cloud: "local" });
804
- findAssistantByNameMock.mockImplementation((name: string) => {
805
- if (name === "my-local") return localEntry;
806
- return null;
807
- });
808
-
809
- await expect(teleport()).rejects.toThrow("process.exit:1");
810
- expect(consoleErrorSpy).toHaveBeenCalledWith(
811
- expect.stringContaining("Cannot teleport between two local assistants"),
812
- );
813
- expect(hatchLocalMock).not.toHaveBeenCalled();
814
- expect(hatchDockerMock).not.toHaveBeenCalled();
815
- expect(hatchAssistantMock).not.toHaveBeenCalled();
816
- });
817
-
818
- test("flag says docker but resolved target is local -> rejects cloud mismatch", async () => {
819
- setArgv("--from", "src", "--docker", "misidentified");
820
-
821
- const srcEntry = makeEntry("src", { cloud: "cadet" });
822
- const dstEntry = makeEntry("misidentified", { cloud: "local" });
823
-
824
- findAssistantByNameMock.mockImplementation((name: string) => {
825
- if (name === "src") return srcEntry;
826
- if (name === "misidentified") return dstEntry;
827
- return null;
828
- });
829
-
830
- await expect(teleport()).rejects.toThrow("process.exit:1");
831
- expect(consoleErrorSpy).toHaveBeenCalledWith(
832
- expect.stringContaining("is a local assistant, not docker"),
833
- );
834
- });
835
- });
836
-
837
- // ---------------------------------------------------------------------------
838
- // resolveOrHatchTarget tests
839
- // ---------------------------------------------------------------------------
840
-
841
- describe("resolveOrHatchTarget", () => {
842
- test("existing assistant is returned without hatching", async () => {
843
- const dockerEntry = makeEntry("my-docker", { cloud: "docker" });
844
- findAssistantByNameMock.mockImplementation((name: string) => {
845
- if (name === "my-docker") return dockerEntry;
846
- return null;
847
- });
848
-
849
- const result = await resolveOrHatchTarget("docker", "my-docker");
850
- expect(result).toBe(dockerEntry);
851
- expect(hatchDockerMock).not.toHaveBeenCalled();
852
- });
853
-
854
- test("name not found -> hatch docker", async () => {
855
- const newEntry = makeEntry("new-one", { cloud: "docker" });
856
- findAssistantByNameMock.mockImplementation((name: string) => {
857
- if (name === "new-one" && hatchDockerMock.mock.calls.length > 0) {
858
- return newEntry;
859
- }
860
- return null;
861
- });
862
-
863
- const result = await resolveOrHatchTarget("docker", "new-one");
864
- expect(hatchDockerMock).toHaveBeenCalledWith({
865
- name: "new-one",
866
- setupProviderCredentials: false,
867
- });
868
- expect(result).toBe(newEntry);
869
- });
870
-
871
- test("no name -> hatch local, discovers via diff", async () => {
872
- const existingEntry = makeEntry("existing-local", { cloud: "local" });
873
- const newEntry = makeEntry("auto-generated", { cloud: "local" });
874
-
875
- loadAllAssistantsMock.mockImplementation(() => {
876
- if (hatchLocalMock.mock.calls.length > 0) {
877
- return [existingEntry, newEntry];
878
- }
879
- return [existingEntry];
880
- });
881
-
882
- const result = await resolveOrHatchTarget("local");
883
- expect(hatchLocalMock).toHaveBeenCalled();
884
- expect(result).toBe(newEntry);
885
- });
886
-
887
- test("platform with existing ID -> returns existing without hatching", async () => {
888
- const cloudEntry = makeEntry("uuid-123", {
889
- cloud: "cadet",
890
- runtimeUrl: "https://cloud.mycadet.ai",
891
- });
892
- findAssistantByNameMock.mockImplementation((name: string) => {
893
- if (name === "uuid-123") return cloudEntry;
894
- return null;
895
- });
896
-
897
- const result = await resolveOrHatchTarget("platform", "uuid-123");
898
- expect(result).toBe(cloudEntry);
899
- expect(hatchAssistantMock).not.toHaveBeenCalled();
900
- });
901
-
902
- test("platform with unknown name -> hatches via hatchAssistant", async () => {
903
- findAssistantByNameMock.mockReturnValue(null);
904
-
905
- const result = await resolveOrHatchTarget("platform", "nonexistent");
906
- expect(hatchAssistantMock).toHaveBeenCalledWith("platform-token");
907
- expect(result.assistantId).toBe("platform-new-id");
908
- });
909
-
910
- test("platform with no name -> blocks when hatch returns reusedExisting", async () => {
911
- findAssistantByNameMock.mockReturnValue(null);
912
- hatchAssistantMock.mockResolvedValue({
913
- assistant: {
914
- id: "existing-platform-id",
915
- name: "existing-platform",
916
- status: "active",
917
- },
918
- reusedExisting: true,
919
- });
920
-
921
- await expect(resolveOrHatchTarget("platform", undefined)).rejects.toThrow(
922
- "process.exit:1",
923
- );
924
- expect(consoleErrorSpy).toHaveBeenCalledWith(
925
- expect.stringContaining("already have a cloud assistant"),
926
- );
927
- });
928
-
929
- test("existing assistant with wrong cloud -> rejects", async () => {
930
- const localEntry = makeEntry("my-local", { cloud: "local" });
931
- findAssistantByNameMock.mockImplementation((name: string) => {
932
- if (name === "my-local") return localEntry;
933
- return null;
934
- });
935
-
936
- await expect(resolveOrHatchTarget("docker", "my-local")).rejects.toThrow(
937
- "process.exit:1",
938
- );
939
- expect(consoleErrorSpy).toHaveBeenCalledWith(
940
- expect.stringContaining("is a local assistant, not docker"),
941
- );
942
- });
943
-
944
- test("name with path traversal -> rejects before hatching", async () => {
945
- findAssistantByNameMock.mockReturnValue(null);
946
-
947
- await expect(
948
- resolveOrHatchTarget("docker", "../../../etc/passwd"),
949
- ).rejects.toThrow("process.exit:1");
950
- expect(consoleErrorSpy).toHaveBeenCalledWith(
951
- expect.stringContaining("invalid characters"),
952
- );
953
- expect(hatchDockerMock).not.toHaveBeenCalled();
954
- });
955
60
  });
956
-
957
- // ---------------------------------------------------------------------------
958
- // Unified GCS teleport flow — the four directions
959
- // ---------------------------------------------------------------------------
960
-
961
- describe("unified GCS flow — four directions", () => {
962
- test("local → platform: requests upload URL, drives local runtime export, imports from GCS", async () => {
963
- setArgv("--from", "my-local", "--platform");
964
-
965
- const localEntry = makeEntry("my-local", { cloud: "local" });
966
- findAssistantByNameMock.mockImplementation((name: string) =>
967
- name === "my-local" ? localEntry : null,
968
- );
969
-
970
- const restoreFetch = installTrackingFetch();
971
- try {
972
- await teleport();
973
-
974
- // Signed-URL request for upload — pinned to the cloud target's URL
975
- // so upload and download land on the same platform.
976
- expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
977
- expect.objectContaining({
978
- operation: "upload",
979
- minRuntimeVersion: "0.6.5",
980
- maxRuntimeVersion: null,
981
- }),
982
- "platform-token",
983
- "https://cloud.mycadet.ai",
984
- );
985
-
986
- // Runtime export-to-gcs kicked off with the signed upload URL.
987
- // Helper takes an entry, not a bare URL — the entry's cloud drives
988
- // URL construction (local → gateway loopback path).
989
- expect(localRuntimeExportToGcsMock).toHaveBeenCalledWith(
990
- expect.objectContaining({
991
- cloud: "local",
992
- runtimeUrl: "http://localhost:7821",
993
- }),
994
- "local-token",
995
- expect.objectContaining({
996
- uploadUrl: "https://storage.googleapis.com/bucket/signed-upload",
997
- }),
998
- );
999
-
1000
- // Poll continued until complete
1001
- expect(localRuntimePollJobStatusMock).toHaveBeenCalled();
1002
-
1003
- // Import via GCS with the bundleKey returned from signed-URL request
1004
- expect(platformImportBundleFromGcsMock).toHaveBeenCalledWith(
1005
- "bundle-key-123",
1006
- "platform-token",
1007
- expect.any(String),
1008
- );
1009
-
1010
- // No download-URL request on the import side (platform target pulls
1011
- // directly from GCS).
1012
- const downloadOps = cloudRequestSignedUrlMock.mock.calls.filter(
1013
- (call) => call[0].operation === "download",
1014
- );
1015
- expect(downloadOps.length).toBe(0);
1016
- } finally {
1017
- restoreFetch();
1018
- }
1019
- });
1020
-
1021
- test("platform → local: drives platform export, reads bundle_key, requests download URL for runtime import", async () => {
1022
- setArgv("--from", "my-platform", "--local", "my-local");
1023
-
1024
- const cloudEntry = makeEntry("my-platform", {
1025
- cloud: "cadet",
1026
- runtimeUrl: "https://cloud.mycadet.ai",
1027
- });
1028
- const localEntry = makeEntry("my-local", {
1029
- cloud: "local",
1030
- bearerToken: "local-bearer",
1031
- });
1032
-
1033
- findAssistantByNameMock.mockImplementation((name: string) => {
1034
- if (name === "my-platform") return cloudEntry;
1035
- if (name === "my-local") return localEntry;
1036
- return null;
1037
- });
1038
-
1039
- // Platform poll returns export-complete with a bundle_key.
1040
- platformPollJobStatusMock.mockResolvedValue({
1041
- jobId: "platform-export-job-1",
1042
- type: "export",
1043
- status: "complete",
1044
- bundleKey: "platform-exports/org-1/bundle-abc.vbundle",
1045
- });
1046
-
1047
- // The bundle key now flows from the upload signed-URL request rather than
1048
- // the job-status payload — pin it so the download-URL assertion below
1049
- // still uses the same expected key.
1050
- cloudRequestSignedUrlMock.mockImplementation(async (params) => ({
1051
- url:
1052
- params.operation === "upload"
1053
- ? "https://storage.googleapis.com/bucket/signed-upload"
1054
- : "https://storage.googleapis.com/bucket/signed-download",
1055
- bundleKey:
1056
- params.bundleKey ?? "platform-exports/org-1/bundle-abc.vbundle",
1057
- expiresAt: new Date(Date.now() + 3600_000).toISOString(),
1058
- }));
1059
-
1060
- const restoreFetch = installTrackingFetch();
1061
- try {
1062
- await teleport();
1063
-
1064
- // Platform side: requested an upload URL, kicked off a runtime export to
1065
- // GCS, and polled the unified job status.
1066
- expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
1067
- expect.objectContaining({
1068
- operation: "upload",
1069
- minRuntimeVersion: "0.6.5",
1070
- maxRuntimeVersion: null,
1071
- }),
1072
- "platform-token",
1073
- "https://cloud.mycadet.ai",
1074
- );
1075
- // For platform sources, export-to-gcs is reached via the cloud's
1076
- // wildcard runtime proxy. The helper builds the assistant-scoped URL
1077
- // from the entry (`/v1/assistants/<id>/migrations/export-to-gcs`) and
1078
- // sends platform-token auth — no guardian-token bootstrap.
1079
- expect(localRuntimeExportToGcsMock).toHaveBeenCalledWith(
1080
- expect.objectContaining({
1081
- cloud: "cadet",
1082
- runtimeUrl: "https://cloud.mycadet.ai",
1083
- assistantId: "my-platform",
1084
- }),
1085
- "platform-token",
1086
- expect.objectContaining({
1087
- uploadUrl: "https://storage.googleapis.com/bucket/signed-upload",
1088
- description: "teleport export",
1089
- }),
1090
- );
1091
- // Polling for platform sources also goes through the wildcard via
1092
- // localRuntimePollJobStatus(entry, ...) — the dedicated
1093
- // `/v1/migrations/jobs/{id}/` endpoint queries platform-side
1094
- // ImportJob records and would 404 on runtime-created job IDs.
1095
- expect(localRuntimePollJobStatusMock).toHaveBeenCalledWith(
1096
- expect.objectContaining({
1097
- cloud: "cadet",
1098
- runtimeUrl: "https://cloud.mycadet.ai",
1099
- }),
1100
- "platform-token",
1101
- "local-export-job-1",
1102
- );
1103
-
1104
- // For the local target we request a download URL keyed by the
1105
- // platform's bundle_key. The URL must target the SOURCE platform
1106
- // (where the bundle was written) — pinned so a lockfile change
1107
- // can't split upload and download across instances.
1108
- //
1109
- // `targetRuntimeVersion` MUST come from the target runtime's
1110
- // `/v1/identity` (mocked to "0.6.5"), NOT from the CLI's package
1111
- // version. The local target's daemon can be on a different version
1112
- // than the CLI orchestrating the teleport.
1113
- expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
1114
- expect.objectContaining({
1115
- operation: "download",
1116
- bundleKey: "platform-exports/org-1/bundle-abc.vbundle",
1117
- targetRuntimeVersion: "0.6.5",
1118
- }),
1119
- "platform-token",
1120
- "https://cloud.mycadet.ai",
1121
- );
1122
- expect(localRuntimeIdentityMock).toHaveBeenCalledWith(
1123
- expect.objectContaining({ cloud: "local" }),
1124
- expect.any(String),
1125
- );
1126
-
1127
- // Runtime import-from-gcs was kicked off with that URL.
1128
- expect(localRuntimeImportFromGcsMock).toHaveBeenCalledWith(
1129
- expect.objectContaining({
1130
- cloud: "local",
1131
- runtimeUrl: "http://localhost:7821",
1132
- }),
1133
- "local-token",
1134
- expect.objectContaining({
1135
- bundleUrl: "https://storage.googleapis.com/bucket/signed-download",
1136
- }),
1137
- );
1138
- expect(localRuntimePollJobStatusMock).toHaveBeenCalled();
1139
-
1140
- // No legacy inline-import helpers were touched.
1141
- // (Verified by the absence of fetch calls carrying bundle bodies —
1142
- // see "never buffers bundle bytes" assertion below.)
1143
- } finally {
1144
- restoreFetch();
1145
- }
1146
- });
1147
-
1148
- test("local → docker: export via upload URL, import via download URL", async () => {
1149
- setArgv("--from", "my-local", "--docker");
1150
-
1151
- const localEntry = makeEntry("my-local", { cloud: "local" });
1152
- const dockerEntry = makeEntry("new-docker", {
1153
- cloud: "docker",
1154
- runtimeUrl: "http://localhost:7822",
1155
- });
1156
-
1157
- findAssistantByNameMock.mockImplementation((name: string) =>
1158
- name === "my-local" ? localEntry : null,
1159
- );
1160
-
1161
- loadAllAssistantsMock.mockImplementation(() => {
1162
- if (hatchDockerMock.mock.calls.length > 0) {
1163
- return [localEntry, dockerEntry];
1164
- }
1165
- return [localEntry];
1166
- });
1167
-
1168
- const restoreFetch = installTrackingFetch();
1169
- try {
1170
- await teleport();
1171
-
1172
- // Export and import must pin the same platform URL so the bundle
1173
- // lives in one place end-to-end. For local→docker neither side is
1174
- // platform, so we default to getCloudUrl() (resolved once).
1175
- expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
1176
- expect.objectContaining({
1177
- operation: "upload",
1178
- minRuntimeVersion: "0.6.5",
1179
- maxRuntimeVersion: null,
1180
- }),
1181
- "platform-token",
1182
- "https://cloud.mycadet.ai",
1183
- );
1184
- expect(localRuntimeExportToGcsMock).toHaveBeenCalled();
1185
-
1186
- // Import: download-URL for the docker target, then runtime import.
1187
- // targetRuntimeVersion comes from the docker target's runtime
1188
- // identity (mocked to "0.6.5"), not from cliPkg.version.
1189
- expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
1190
- expect.objectContaining({
1191
- operation: "download",
1192
- bundleKey: "bundle-key-123",
1193
- targetRuntimeVersion: "0.6.5",
1194
- }),
1195
- "platform-token",
1196
- "https://cloud.mycadet.ai",
1197
- );
1198
- expect(localRuntimeIdentityMock).toHaveBeenCalledWith(
1199
- expect.objectContaining({ cloud: "docker" }),
1200
- expect.any(String),
1201
- );
1202
- expect(localRuntimeImportFromGcsMock).toHaveBeenCalledWith(
1203
- expect.objectContaining({
1204
- cloud: "docker",
1205
- runtimeUrl: "http://localhost:7822",
1206
- }),
1207
- "local-token",
1208
- expect.objectContaining({
1209
- bundleUrl: "https://storage.googleapis.com/bucket/signed-download",
1210
- }),
1211
- );
1212
-
1213
- // Source retirement still happens on success for local↔docker.
1214
- expect(retireLocalMock).toHaveBeenCalledWith("my-local", localEntry);
1215
- expect(removeAssistantEntryMock).toHaveBeenCalledWith("my-local");
1216
- } finally {
1217
- restoreFetch();
1218
- }
1219
- });
1220
-
1221
- test("docker → local: export via upload URL, import via download URL", async () => {
1222
- setArgv("--from", "my-docker", "--local");
1223
-
1224
- const dockerEntry = makeEntry("my-docker", {
1225
- cloud: "docker",
1226
- runtimeUrl: "http://localhost:7822",
1227
- });
1228
- const localEntry = makeEntry("new-local", {
1229
- cloud: "local",
1230
- runtimeUrl: "http://localhost:7823",
1231
- });
1232
-
1233
- findAssistantByNameMock.mockImplementation((name: string) =>
1234
- name === "my-docker" ? dockerEntry : null,
1235
- );
1236
-
1237
- loadAllAssistantsMock.mockImplementation(() => {
1238
- if (hatchLocalMock.mock.calls.length > 0) {
1239
- return [dockerEntry, localEntry];
1240
- }
1241
- return [dockerEntry];
1242
- });
1243
-
1244
- const restoreFetch = installTrackingFetch();
1245
- try {
1246
- await teleport();
1247
-
1248
- // Docker source should be put to sleep first.
1249
- expect(sleepContainersMock).toHaveBeenCalled();
1250
-
1251
- // Export leg: upload-URL (pinned to the same platform as import),
1252
- // then runtime export.
1253
- expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
1254
- expect.objectContaining({
1255
- operation: "upload",
1256
- minRuntimeVersion: "0.6.5",
1257
- maxRuntimeVersion: null,
1258
- }),
1259
- "platform-token",
1260
- "https://cloud.mycadet.ai",
1261
- );
1262
- expect(localRuntimeExportToGcsMock).toHaveBeenCalledWith(
1263
- expect.objectContaining({
1264
- cloud: "docker",
1265
- runtimeUrl: "http://localhost:7822",
1266
- }),
1267
- "local-token",
1268
- expect.objectContaining({
1269
- uploadUrl: "https://storage.googleapis.com/bucket/signed-upload",
1270
- }),
1271
- );
1272
-
1273
- // Import leg: download-URL targets the new local runtime
1274
- expect(localRuntimeImportFromGcsMock).toHaveBeenCalledWith(
1275
- expect.objectContaining({
1276
- cloud: "local",
1277
- runtimeUrl: "http://localhost:7823",
1278
- }),
1279
- "local-token",
1280
- expect.anything(),
1281
- );
1282
-
1283
- // Source retirement
1284
- expect(retireDockerMock).toHaveBeenCalledWith("my-docker");
1285
- expect(removeAssistantEntryMock).toHaveBeenCalledWith("my-docker");
1286
- } finally {
1287
- restoreFetch();
1288
- }
1289
- });
61
+ test("cloud source refuses before starting any host operation", async () => {
62
+ find.mockReturnValue({ ...entry, cloud: "cadet" });
63
+ await expect(teleport()).rejects.toThrow("exit:1");
64
+ expect(move).not.toHaveBeenCalled();
1290
65
  });
1291
-
1292
- // ---------------------------------------------------------------------------
1293
- // Target-platform URL threading: signed URL must be requested from the same
1294
- // platform instance the import will run against. Codex P2 regression guard.
1295
- // ---------------------------------------------------------------------------
1296
-
1297
- describe("signed-URL request targets the bundle-owning platform", () => {
1298
- test("local → existing platform target with non-default runtimeUrl: upload URL pinned to target's runtimeUrl", async () => {
1299
- setArgv("--from", "my-local", "--platform", "existing-platform");
1300
-
1301
- const localEntry = makeEntry("my-local", { cloud: "local" });
1302
- // Crucially, the target's runtimeUrl is NOT the default getCloudUrl()
1303
- // return value — this is the regression case Codex flagged.
1304
- const cloudEntry = makeEntry("existing-platform", {
1305
- cloud: "cadet",
1306
- runtimeUrl: "https://staging-cloud.mycadet.ai",
1307
- });
1308
-
1309
- findAssistantByNameMock.mockImplementation((name: string) => {
1310
- if (name === "my-local") return localEntry;
1311
- if (name === "existing-platform") return cloudEntry;
1312
- return null;
1313
- });
1314
-
1315
- const restoreFetch = installTrackingFetch();
1316
- try {
1317
- await teleport();
1318
-
1319
- // The signed-URL request for upload MUST target the existing
1320
- // platform assistant's runtimeUrl, not the default platform URL.
1321
- expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
1322
- expect.objectContaining({
1323
- operation: "upload",
1324
- minRuntimeVersion: "0.6.5",
1325
- maxRuntimeVersion: null,
1326
- }),
1327
- "platform-token",
1328
- "https://staging-cloud.mycadet.ai",
1329
- );
1330
-
1331
- // And the import must run against the same platform.
1332
- expect(platformImportBundleFromGcsMock).toHaveBeenCalledWith(
1333
- "bundle-key-123",
1334
- "platform-token",
1335
- "https://staging-cloud.mycadet.ai",
1336
- );
1337
-
1338
- // Assert none of the signed-URL calls used the default URL — if any
1339
- // did, upload and download would hit different platforms.
1340
- for (const call of cloudRequestSignedUrlMock.mock.calls) {
1341
- expect(call[2]).toBe("https://staging-cloud.mycadet.ai");
1342
- }
1343
- } finally {
1344
- restoreFetch();
1345
- }
1346
- });
1347
-
1348
- test("platform → local with non-default source runtimeUrl: download URL pinned to source's runtimeUrl", async () => {
1349
- setArgv("--from", "my-platform", "--local", "my-local");
1350
-
1351
- const cloudEntry = makeEntry("my-platform", {
1352
- cloud: "cadet",
1353
- runtimeUrl: "https://dev-cloud.mycadet.ai",
1354
- });
1355
- const localEntry = makeEntry("my-local", { cloud: "local" });
1356
-
1357
- findAssistantByNameMock.mockImplementation((name: string) => {
1358
- if (name === "my-platform") return cloudEntry;
1359
- if (name === "my-local") return localEntry;
1360
- return null;
1361
- });
1362
-
1363
- platformPollJobStatusMock.mockResolvedValue({
1364
- jobId: "platform-export-job-1",
1365
- type: "export",
1366
- status: "complete",
1367
- bundleKey: "dev-bundle-key",
1368
- });
1369
-
1370
- // Bundle key flows from the upload signed-URL request now; pin it so the
1371
- // download-URL assertion below uses the same key.
1372
- cloudRequestSignedUrlMock.mockImplementation(async (params) => ({
1373
- url:
1374
- params.operation === "upload"
1375
- ? "https://storage.googleapis.com/bucket/signed-upload"
1376
- : "https://storage.googleapis.com/bucket/signed-download",
1377
- bundleKey: params.bundleKey ?? "dev-bundle-key",
1378
- expiresAt: new Date(Date.now() + 3600_000).toISOString(),
1379
- }));
1380
-
1381
- const restoreFetch = installTrackingFetch();
1382
- try {
1383
- await teleport();
1384
-
1385
- // The download URL must be requested from the SOURCE platform (where
1386
- // the bundle was written by the runtime export), not the default.
1387
- // targetRuntimeVersion comes from the local target's `/v1/identity`
1388
- // (mocked to "0.6.5"), not from cliPkg.version.
1389
- expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
1390
- expect.objectContaining({
1391
- operation: "download",
1392
- bundleKey: "dev-bundle-key",
1393
- targetRuntimeVersion: "0.6.5",
1394
- }),
1395
- "platform-token",
1396
- "https://dev-cloud.mycadet.ai",
1397
- );
1398
- } finally {
1399
- restoreFetch();
1400
- }
1401
- });
66
+ test("same environment refuses before starting any host operation", async () => {
67
+ find.mockReturnValue({ ...entry, cloud: "docker" });
68
+ await expect(teleport()).rejects.toThrow("exit:1");
69
+ expect(move).not.toHaveBeenCalled();
1402
70
  });
1403
-
1404
- // ---------------------------------------------------------------------------
1405
- // Invariants: CLI never buffers bundle bytes
1406
- // ---------------------------------------------------------------------------
1407
-
1408
- describe("CLI never buffers bundle bytes", () => {
1409
- // A teleport bundle is always > 1 KiB in practice; anything near that size
1410
- // would mean the CLI is shuttling bytes when it shouldn't.
1411
- const BUNDLE_BODY_THRESHOLD_BYTES = 1024;
1412
-
1413
- function bodySize(body: unknown): number {
1414
- if (typeof body === "string") return body.length;
1415
- if (body instanceof Uint8Array) return body.byteLength;
1416
- if (body instanceof ArrayBuffer) return body.byteLength;
1417
- if (body instanceof Blob) return body.size;
1418
- return 0;
1419
- }
1420
-
1421
- test("local → platform: no fetch call carries a bundle-sized body", async () => {
1422
- setArgv("--from", "my-local", "--platform");
1423
-
1424
- const localEntry = makeEntry("my-local", { cloud: "local" });
1425
- findAssistantByNameMock.mockImplementation((name: string) =>
1426
- name === "my-local" ? localEntry : null,
1427
- );
1428
-
1429
- const restoreFetch = installTrackingFetch();
1430
- try {
1431
- await teleport();
1432
-
1433
- for (const call of fetchCalls) {
1434
- expect(bodySize(call.body)).toBeLessThan(BUNDLE_BODY_THRESHOLD_BYTES);
1435
- }
1436
- } finally {
1437
- restoreFetch();
1438
- }
1439
- });
1440
-
1441
- test("platform → local: no fetch call carries a bundle-sized body", async () => {
1442
- setArgv("--from", "my-platform", "--local", "my-local");
1443
-
1444
- const cloudEntry = makeEntry("my-platform", {
1445
- cloud: "cadet",
1446
- runtimeUrl: "https://cloud.mycadet.ai",
1447
- });
1448
- const localEntry = makeEntry("my-local", { cloud: "local" });
1449
-
1450
- findAssistantByNameMock.mockImplementation((name: string) => {
1451
- if (name === "my-platform") return cloudEntry;
1452
- if (name === "my-local") return localEntry;
1453
- return null;
1454
- });
1455
-
1456
- platformPollJobStatusMock.mockResolvedValue({
1457
- jobId: "platform-export-job-1",
1458
- type: "export",
1459
- status: "complete",
1460
- bundleKey: "platform-exports/org-1/bundle.vbundle",
1461
- });
1462
-
1463
- const restoreFetch = installTrackingFetch();
1464
- try {
1465
- await teleport();
1466
-
1467
- for (const call of fetchCalls) {
1468
- expect(bodySize(call.body)).toBeLessThan(BUNDLE_BODY_THRESHOLD_BYTES);
1469
- }
1470
- } finally {
1471
- restoreFetch();
1472
- }
71
+ test("parses dry run and rejects multiple destinations", () => {
72
+ expect(
73
+ parseArgs(["--from", "source", "--local", "target", "--dry-run"]),
74
+ ).toMatchObject({
75
+ from: "source",
76
+ targetEnv: "local",
77
+ targetName: "target",
78
+ dryRun: true,
1473
79
  });
80
+ expect(() => parseArgs(["--local", "--docker"])).toThrow("exit:1");
1474
81
  });
1475
-
1476
- // ---------------------------------------------------------------------------
1477
- // Polling behavior
1478
- // ---------------------------------------------------------------------------
1479
-
1480
- describe("polling", () => {
1481
- test("local-runtime export poll continues until complete", async () => {
1482
- setArgv("--from", "my-local", "--platform");
1483
-
1484
- const localEntry = makeEntry("my-local", { cloud: "local" });
1485
- findAssistantByNameMock.mockImplementation((name: string) =>
1486
- name === "my-local" ? localEntry : null,
1487
- );
1488
-
1489
- let callIdx = 0;
1490
- localRuntimePollJobStatusMock.mockImplementation(
1491
- async (_runtimeUrl, _token, jobId) => {
1492
- callIdx++;
1493
- if (callIdx < 3) {
1494
- return {
1495
- jobId,
1496
- type: "export" as const,
1497
- status: "processing" as const,
1498
- };
1499
- }
1500
- return {
1501
- jobId,
1502
- type: "export" as const,
1503
- status: "complete" as const,
1504
- result: undefined,
1505
- };
1506
- },
1507
- );
1508
-
1509
- const restoreFetch = installTrackingFetch();
1510
- try {
1511
- await teleport();
1512
- expect(callIdx).toBeGreaterThanOrEqual(3);
1513
- } finally {
1514
- restoreFetch();
1515
- }
1516
- });
1517
-
1518
- test("local-runtime export failure → teleport exits 1", async () => {
1519
- setArgv("--from", "my-local", "--platform");
1520
-
1521
- const localEntry = makeEntry("my-local", { cloud: "local" });
1522
- findAssistantByNameMock.mockImplementation((name: string) =>
1523
- name === "my-local" ? localEntry : null,
1524
- );
1525
-
1526
- localRuntimePollJobStatusMock.mockResolvedValue({
1527
- jobId: "local-export-job-1",
1528
- type: "export",
1529
- status: "failed",
1530
- error: "simulated failure",
1531
- });
1532
-
1533
- const restoreFetch = installTrackingFetch();
1534
- try {
1535
- await expect(teleport()).rejects.toThrow("process.exit:1");
1536
- expect(consoleErrorSpy).toHaveBeenCalledWith(
1537
- expect.stringContaining("simulated failure"),
1538
- );
1539
- } finally {
1540
- restoreFetch();
1541
- }
1542
- });
1543
-
1544
- test("local-runtime import failure → teleport exits 1", async () => {
1545
- setArgv("--from", "my-platform", "--local", "my-local");
1546
-
1547
- const cloudEntry = makeEntry("my-platform", {
1548
- cloud: "cadet",
1549
- runtimeUrl: "https://cloud.mycadet.ai",
1550
- });
1551
- const localEntry = makeEntry("my-local", { cloud: "local" });
1552
-
1553
- findAssistantByNameMock.mockImplementation((name: string) => {
1554
- if (name === "my-platform") return cloudEntry;
1555
- if (name === "my-local") return localEntry;
1556
- return null;
1557
- });
1558
-
1559
- platformPollJobStatusMock.mockResolvedValue({
1560
- jobId: "platform-export-job-1",
1561
- type: "export",
1562
- status: "complete",
1563
- bundleKey: "key-1",
1564
- });
1565
-
1566
- localRuntimePollJobStatusMock.mockImplementation(
1567
- async (_runtimeUrl, _token, jobId) => {
1568
- if (jobId.includes("import")) {
1569
- return {
1570
- jobId,
1571
- type: "import" as const,
1572
- status: "failed" as const,
1573
- error: "import blew up",
1574
- };
1575
- }
1576
- return {
1577
- jobId,
1578
- type: "export" as const,
1579
- status: "complete" as const,
1580
- result: undefined,
1581
- };
1582
- },
1583
- );
1584
-
1585
- const restoreFetch = installTrackingFetch();
1586
- try {
1587
- await expect(teleport()).rejects.toThrow("process.exit:1");
1588
- expect(consoleErrorSpy).toHaveBeenCalledWith(
1589
- expect.stringContaining("import blew up"),
1590
- );
1591
- } finally {
1592
- restoreFetch();
1593
- }
1594
- });
1595
- });
1596
-
1597
- // ---------------------------------------------------------------------------
1598
- // MigrationInProgressError handling
1599
- // ---------------------------------------------------------------------------
1600
-
1601
- describe("MigrationInProgressError handling", () => {
1602
- test("local-runtime export already in flight → fail fast with existing job id", async () => {
1603
- setArgv("--from", "my-local", "--platform");
1604
-
1605
- const localEntry = makeEntry("my-local", { cloud: "local" });
1606
- findAssistantByNameMock.mockImplementation((name: string) =>
1607
- name === "my-local" ? localEntry : null,
1608
- );
1609
-
1610
- localRuntimeExportToGcsMock.mockRejectedValue(
1611
- new localRuntimeClient.MigrationInProgressError(
1612
- "export_in_progress",
1613
- "existing-job-42",
1614
- ),
1615
- );
1616
-
1617
- const restoreFetch = installTrackingFetch();
1618
- try {
1619
- await expect(teleport()).rejects.toThrow("process.exit:1");
1620
-
1621
- // Must not have polled the existing job — the existing job's bundle
1622
- // lives at a different GCS key (its caller's signed URL), so polling
1623
- // it would leave the teleport pointing at an empty/unrelated bundle.
1624
- const polledIds = localRuntimePollJobStatusMock.mock.calls.map(
1625
- (call: unknown[]) => call[2],
1626
- );
1627
- expect(polledIds).not.toContain("existing-job-42");
1628
-
1629
- // Error must mention the existing job id so the user can act on it.
1630
- expect(consoleErrorSpy).toHaveBeenCalledWith(
1631
- expect.stringContaining("existing-job-42"),
1632
- );
1633
- expect(consoleErrorSpy).toHaveBeenCalledWith(
1634
- expect.stringContaining("already in progress"),
1635
- );
1636
- } finally {
1637
- restoreFetch();
1638
- }
1639
- });
1640
-
1641
- test("local-runtime import already in flight → fail fast with existing job id", async () => {
1642
- setArgv("--from", "my-platform", "--local", "my-local");
1643
-
1644
- const cloudEntry = makeEntry("my-platform", {
1645
- cloud: "cadet",
1646
- runtimeUrl: "https://cloud.mycadet.ai",
1647
- });
1648
- const localEntry = makeEntry("my-local", { cloud: "local" });
1649
-
1650
- findAssistantByNameMock.mockImplementation((name: string) => {
1651
- if (name === "my-platform") return cloudEntry;
1652
- if (name === "my-local") return localEntry;
1653
- return null;
1654
- });
1655
-
1656
- platformPollJobStatusMock.mockResolvedValue({
1657
- jobId: "platform-export-job-1",
1658
- type: "export",
1659
- status: "complete",
1660
- bundleKey: "bundle-key-from-platform",
1661
- });
1662
-
1663
- localRuntimeImportFromGcsMock.mockRejectedValue(
1664
- new localRuntimeClient.MigrationInProgressError(
1665
- "import_in_progress",
1666
- "existing-import-99",
1667
- ),
1668
- );
1669
-
1670
- const restoreFetch = installTrackingFetch();
1671
- try {
1672
- await expect(teleport()).rejects.toThrow("process.exit:1");
1673
-
1674
- // Must not poll the existing import — it's importing somebody else's
1675
- // bundle, not ours, so reporting on it would be misleading.
1676
- const polledIds = localRuntimePollJobStatusMock.mock.calls.map(
1677
- (call: unknown[]) => call[2],
1678
- );
1679
- expect(polledIds).not.toContain("existing-import-99");
1680
-
1681
- expect(consoleErrorSpy).toHaveBeenCalledWith(
1682
- expect.stringContaining("existing-import-99"),
1683
- );
1684
- expect(consoleErrorSpy).toHaveBeenCalledWith(
1685
- expect.stringContaining("already in progress"),
1686
- );
1687
- } finally {
1688
- restoreFetch();
1689
- }
1690
- });
1691
- });
1692
-
1693
- // ---------------------------------------------------------------------------
1694
- // VersionMismatchError handling — 422 from cloudRequestSignedUrl on the
1695
- // download leg is terminal: surface a friendly message + exit 1, no retry.
1696
- // ---------------------------------------------------------------------------
1697
-
1698
- describe("VersionMismatchError handling", () => {
1699
- test("local target: 422 on download signed-URL exits 1 with friendly message", async () => {
1700
- setArgv("--from", "my-platform", "--local", "my-local");
1701
-
1702
- const cloudEntry = makeEntry("my-platform", {
1703
- cloud: "cadet",
1704
- runtimeUrl: "https://cloud.mycadet.ai",
1705
- });
1706
- const localEntry = makeEntry("my-local", { cloud: "local" });
1707
-
1708
- findAssistantByNameMock.mockImplementation((name: string) => {
1709
- if (name === "my-platform") return cloudEntry;
1710
- if (name === "my-local") return localEntry;
1711
- return null;
1712
- });
1713
-
1714
- platformPollJobStatusMock.mockResolvedValue({
1715
- jobId: "platform-export-job-1",
1716
- type: "export",
1717
- status: "complete",
1718
- bundleKey: "platform-bundle-key-abc",
1719
- });
1720
-
1721
- // Upload signed-URL succeeds; download signed-URL throws version-mismatch.
1722
- cloudRequestSignedUrlMock.mockImplementation(async (params) => {
1723
- if (params.operation === "download") {
1724
- throw new cloudClient.VersionMismatchError(
1725
- { min_runtime_version: "99.0.0", max_runtime_version: null },
1726
- "0.7.1",
1727
- );
1728
- }
1729
- return {
1730
- url: "https://storage.googleapis.com/bucket/signed-upload",
1731
- bundleKey: params.bundleKey ?? "bundle-key-123",
1732
- expiresAt: new Date(Date.now() + 3600_000).toISOString(),
1733
- };
1734
- });
1735
-
1736
- const restoreFetch = installTrackingFetch();
1737
- try {
1738
- await expect(teleport()).rejects.toThrow("process.exit:1");
1739
-
1740
- // Friendly message uses the prefix from VersionMismatchError.formatMessage.
1741
- expect(consoleErrorSpy).toHaveBeenCalledWith(
1742
- expect.stringContaining("Cannot import: bundle requires runtime"),
1743
- );
1744
-
1745
- // Terminal: no retry of the download signed-URL request.
1746
- const downloadCalls = cloudRequestSignedUrlMock.mock.calls.filter(
1747
- (c) => c[0].operation === "download",
1748
- );
1749
- expect(downloadCalls).toHaveLength(1);
1750
-
1751
- // Runtime import-from-gcs must NOT be kicked off after the 422.
1752
- expect(localRuntimeImportFromGcsMock).not.toHaveBeenCalled();
1753
- } finally {
1754
- restoreFetch();
1755
- }
1756
- });
1757
-
1758
- test("docker target: 422 on download signed-URL exits 1 with friendly message", async () => {
1759
- setArgv("--from", "my-local", "--docker");
1760
-
1761
- const localEntry = makeEntry("my-local", { cloud: "local" });
1762
- const dockerEntry = makeEntry("new-docker", {
1763
- cloud: "docker",
1764
- runtimeUrl: "http://localhost:7822",
1765
- });
1766
-
1767
- findAssistantByNameMock.mockImplementation((name: string) =>
1768
- name === "my-local" ? localEntry : null,
1769
- );
1770
-
1771
- loadAllAssistantsMock.mockImplementation(() => {
1772
- if (hatchDockerMock.mock.calls.length > 0) {
1773
- return [localEntry, dockerEntry];
1774
- }
1775
- return [localEntry];
1776
- });
1777
-
1778
- cloudRequestSignedUrlMock.mockImplementation(async (params) => {
1779
- if (params.operation === "download") {
1780
- throw new cloudClient.VersionMismatchError(
1781
- { min_runtime_version: "99.0.0", max_runtime_version: null },
1782
- "0.7.1",
1783
- );
1784
- }
1785
- return {
1786
- url: "https://storage.googleapis.com/bucket/signed-upload",
1787
- bundleKey: params.bundleKey ?? "bundle-key-123",
1788
- expiresAt: new Date(Date.now() + 3600_000).toISOString(),
1789
- };
1790
- });
1791
-
1792
- const restoreFetch = installTrackingFetch();
1793
- try {
1794
- await expect(teleport()).rejects.toThrow("process.exit:1");
1795
-
1796
- expect(consoleErrorSpy).toHaveBeenCalledWith(
1797
- expect.stringContaining("Cannot import: bundle requires runtime"),
1798
- );
1799
-
1800
- expect(localRuntimeImportFromGcsMock).not.toHaveBeenCalled();
1801
- } finally {
1802
- restoreFetch();
1803
- }
1804
- });
1805
-
1806
- test("non-VersionMismatchError on download signed-URL re-raises", async () => {
1807
- setArgv("--from", "my-platform", "--local", "my-local");
1808
-
1809
- const cloudEntry = makeEntry("my-platform", {
1810
- cloud: "cadet",
1811
- runtimeUrl: "https://cloud.mycadet.ai",
1812
- });
1813
- const localEntry = makeEntry("my-local", { cloud: "local" });
1814
-
1815
- findAssistantByNameMock.mockImplementation((name: string) => {
1816
- if (name === "my-platform") return cloudEntry;
1817
- if (name === "my-local") return localEntry;
1818
- return null;
1819
- });
1820
-
1821
- platformPollJobStatusMock.mockResolvedValue({
1822
- jobId: "platform-export-job-1",
1823
- type: "export",
1824
- status: "complete",
1825
- bundleKey: "platform-bundle-key-abc",
1826
- });
1827
-
1828
- cloudRequestSignedUrlMock.mockImplementation(async (params) => {
1829
- if (params.operation === "download") {
1830
- throw new Error("network blew up");
1831
- }
1832
- return {
1833
- url: "https://storage.googleapis.com/bucket/signed-upload",
1834
- bundleKey: params.bundleKey ?? "bundle-key-123",
1835
- expiresAt: new Date(Date.now() + 3600_000).toISOString(),
1836
- };
1837
- });
1838
-
1839
- const restoreFetch = installTrackingFetch();
1840
- try {
1841
- await expect(teleport()).rejects.toThrow("network blew up");
1842
- } finally {
1843
- restoreFetch();
1844
- }
1845
- });
1846
- });
1847
-
1848
- // ---------------------------------------------------------------------------
1849
- // Target-runtime version fetch — the download signed-URL request must use
1850
- // the TARGET runtime's reported version, not the orchestrating CLI's
1851
- // version (which can diverge when the target was upgraded independently).
1852
- // ---------------------------------------------------------------------------
1853
-
1854
- describe("target runtime version fetch", () => {
1855
- test("local target: targetRuntimeVersion comes from /v1/identity, not cliPkg", async () => {
1856
- setArgv("--from", "my-platform", "--local", "my-local");
1857
-
1858
- const cloudEntry = makeEntry("my-platform", {
1859
- cloud: "cadet",
1860
- runtimeUrl: "https://cloud.mycadet.ai",
1861
- });
1862
- const localEntry = makeEntry("my-local", { cloud: "local" });
1863
-
1864
- findAssistantByNameMock.mockImplementation((name: string) => {
1865
- if (name === "my-platform") return cloudEntry;
1866
- if (name === "my-local") return localEntry;
1867
- return null;
1868
- });
1869
-
1870
- platformPollJobStatusMock.mockResolvedValue({
1871
- jobId: "platform-export-job-1",
1872
- type: "export",
1873
- status: "complete",
1874
- bundleKey: "platform-bundle-key-abc",
1875
- });
1876
-
1877
- // Choose a version unlikely to match cliPkg.version so a regression
1878
- // would be obvious.
1879
- localRuntimeIdentityMock.mockResolvedValue({ version: "1.2.3-runtime" });
1880
-
1881
- const restoreFetch = installTrackingFetch();
1882
- try {
1883
- await teleport();
1884
-
1885
- const downloadCall = cloudRequestSignedUrlMock.mock.calls.find(
1886
- (c) => c[0].operation === "download",
1887
- );
1888
- expect(downloadCall).toBeDefined();
1889
- expect(downloadCall![0]).toMatchObject({
1890
- targetRuntimeVersion: "1.2.3-runtime",
1891
- });
1892
-
1893
- // Identity was fetched against the TARGET (local) entry, not the
1894
- // platform source.
1895
- expect(localRuntimeIdentityMock).toHaveBeenCalledWith(
1896
- expect.objectContaining({
1897
- cloud: "local",
1898
- assistantId: "my-local",
1899
- }),
1900
- expect.any(String),
1901
- );
1902
- } finally {
1903
- restoreFetch();
1904
- }
1905
- });
1906
-
1907
- test("identity fetch failure aborts before requesting download URL", async () => {
1908
- setArgv("--from", "my-platform", "--local", "my-local");
1909
-
1910
- const cloudEntry = makeEntry("my-platform", {
1911
- cloud: "cadet",
1912
- runtimeUrl: "https://cloud.mycadet.ai",
1913
- });
1914
- const localEntry = makeEntry("my-local", { cloud: "local" });
1915
-
1916
- findAssistantByNameMock.mockImplementation((name: string) => {
1917
- if (name === "my-platform") return cloudEntry;
1918
- if (name === "my-local") return localEntry;
1919
- return null;
1920
- });
1921
-
1922
- platformPollJobStatusMock.mockResolvedValue({
1923
- jobId: "platform-export-job-1",
1924
- type: "export",
1925
- status: "complete",
1926
- bundleKey: "platform-bundle-key-abc",
1927
- });
1928
-
1929
- // Identity is fetched twice in this flow: once on the source (platform)
1930
- // for `min_runtime_version` on the upload signed-URL, then once on the
1931
- // target (local) for `targetRuntimeVersion` on the download signed-URL.
1932
- // Succeed on the source call so the flow gets as far as the target
1933
- // identity fetch — that's the failure this test is exercising.
1934
- localRuntimeIdentityMock.mockImplementation(async (entry) => {
1935
- if (entry.cloud === "local") {
1936
- throw new Error("Local runtime identity failed (502): bad gateway");
1937
- }
1938
- return { version: "0.6.5" };
1939
- });
1940
-
1941
- const restoreFetch = installTrackingFetch();
1942
- try {
1943
- await expect(teleport()).rejects.toThrow("process.exit:1");
1944
-
1945
- expect(consoleErrorSpy).toHaveBeenCalledWith(
1946
- expect.stringContaining("Could not read target runtime version"),
1947
- );
1948
-
1949
- // No download signed-URL request was made — we aborted before that.
1950
- const downloadCalls = cloudRequestSignedUrlMock.mock.calls.filter(
1951
- (c) => c[0].operation === "download",
1952
- );
1953
- expect(downloadCalls).toHaveLength(0);
1954
- expect(localRuntimeImportFromGcsMock).not.toHaveBeenCalled();
1955
- } finally {
1956
- restoreFetch();
1957
- }
1958
- });
1959
- });
1960
-
1961
- // ---------------------------------------------------------------------------
1962
- // Dry-run behavior
1963
- // ---------------------------------------------------------------------------
1964
-
1965
- describe("dry-run", () => {
1966
- test("dry-run without existing target does not hatch or export", async () => {
1967
- setArgv("--from", "my-local", "--docker", "--dry-run");
1968
-
1969
- const localEntry = makeEntry("my-local", { cloud: "local" });
1970
- findAssistantByNameMock.mockImplementation((name: string) =>
1971
- name === "my-local" ? localEntry : null,
1972
- );
1973
-
1974
- await teleport();
1975
-
1976
- expect(hatchDockerMock).not.toHaveBeenCalled();
1977
- expect(hatchLocalMock).not.toHaveBeenCalled();
1978
- expect(hatchAssistantMock).not.toHaveBeenCalled();
1979
- expect(retireLocalMock).not.toHaveBeenCalled();
1980
- expect(retireDockerMock).not.toHaveBeenCalled();
1981
- expect(localRuntimeExportToGcsMock).not.toHaveBeenCalled();
1982
- expect(localRuntimeImportFromGcsMock).not.toHaveBeenCalled();
1983
- });
1984
-
1985
- test("dry-run with existing platform target runs preflight-from-gcs", async () => {
1986
- setArgv(
1987
- "--from",
1988
- "my-local",
1989
- "--platform",
1990
- "existing-platform",
1991
- "--dry-run",
1992
- );
1993
-
1994
- const localEntry = makeEntry("my-local", { cloud: "local" });
1995
- const cloudEntry = makeEntry("existing-platform", {
1996
- cloud: "cadet",
1997
- runtimeUrl: "https://cloud.mycadet.ai",
1998
- });
1999
-
2000
- findAssistantByNameMock.mockImplementation((name: string) => {
2001
- if (name === "my-local") return localEntry;
2002
- if (name === "existing-platform") return cloudEntry;
2003
- return null;
2004
- });
2005
-
2006
- const restoreFetch = installTrackingFetch();
2007
- try {
2008
- await teleport();
2009
- expect(platformImportPreflightFromGcsMock).toHaveBeenCalledWith(
2010
- "bundle-key-123",
2011
- "platform-token",
2012
- "https://cloud.mycadet.ai",
2013
- );
2014
- expect(hatchAssistantMock).not.toHaveBeenCalled();
2015
- } finally {
2016
- restoreFetch();
2017
- }
2018
- });
2019
-
2020
- test("dry-run with existing local target calls preflight-from-gcs on runtime", async () => {
2021
- setArgv("--from", "my-platform", "--local", "my-local", "--dry-run");
2022
-
2023
- const cloudEntry = makeEntry("my-platform", {
2024
- cloud: "cadet",
2025
- runtimeUrl: "https://cloud.mycadet.ai",
2026
- });
2027
- const localEntry = makeEntry("my-local", { cloud: "local" });
2028
-
2029
- findAssistantByNameMock.mockImplementation((name: string) => {
2030
- if (name === "my-platform") return cloudEntry;
2031
- if (name === "my-local") return localEntry;
2032
- return null;
2033
- });
2034
-
2035
- const restoreFetch = installTrackingFetch();
2036
- try {
2037
- await teleport();
2038
-
2039
- // Preflight was run against the local runtime
2040
- expect(localRuntimePreflightFromGcsMock).toHaveBeenCalledTimes(1);
2041
- expect(localRuntimePreflightFromGcsMock).toHaveBeenCalledWith(
2042
- expect.objectContaining({ assistantId: "my-local" }),
2043
- expect.any(String),
2044
- { bundleUrl: "https://storage.googleapis.com/bucket/signed-download" },
2045
- );
2046
-
2047
- // No actual import happened — dry-run only
2048
- expect(localRuntimeImportFromGcsMock).not.toHaveBeenCalled();
2049
-
2050
- // Source was not retired
2051
- expect(retireLocalMock).not.toHaveBeenCalled();
2052
- expect(retireDockerMock).not.toHaveBeenCalled();
2053
- } finally {
2054
- restoreFetch();
2055
- }
2056
- });
2057
- });
2058
-
2059
- // ---------------------------------------------------------------------------
2060
- // Pre-check: block teleport to platform when existing assistant detected
2061
- // ---------------------------------------------------------------------------
2062
-
2063
- describe("pre-export source backup", () => {
2064
- test("local source with no recent backup: exports a host-side backup before exporting", async () => {
2065
- setArgv("--from", "my-local", "--platform");
2066
- const localEntry = makeEntry("my-local", { cloud: "local" });
2067
- findAssistantByNameMock.mockImplementation((name: string) =>
2068
- name === "my-local" ? localEntry : null,
2069
- );
2070
-
2071
- const order: string[] = [];
2072
- createBackupMock.mockImplementation(async () => {
2073
- order.push("backup");
2074
- return "/tmp/backups/my-local-pre-teleport-2026.vbundle";
2075
- });
2076
- localRuntimeExportToGcsMock.mockImplementation(async () => {
2077
- order.push("export");
2078
- return { jobId: "local-export-job-1" };
2079
- });
2080
-
2081
- const restoreFetch = installTrackingFetch();
2082
- try {
2083
- await teleport();
2084
- } finally {
2085
- restoreFetch();
2086
- }
2087
-
2088
- expect(listAssistantBackupTimesMock).toHaveBeenCalledWith("my-local");
2089
- expect(createBackupMock).toHaveBeenCalledWith(
2090
- "http://localhost:7821",
2091
- "my-local",
2092
- expect.objectContaining({ prefix: "my-local-pre-teleport" }),
2093
- );
2094
- expect(pruneOldBackupsMock).toHaveBeenCalledWith(
2095
- "my-local",
2096
- 3,
2097
- "pre-teleport",
2098
- );
2099
- expect(createPlatformBackupMock).not.toHaveBeenCalled();
2100
- expect(order).toEqual(["backup", "export"]);
2101
- });
2102
-
2103
- test("local source with a recent backup: reuses it and skips the export", async () => {
2104
- setArgv("--from", "my-local", "--platform");
2105
- const localEntry = makeEntry("my-local", { cloud: "local" });
2106
- findAssistantByNameMock.mockImplementation((name: string) =>
2107
- name === "my-local" ? localEntry : null,
2108
- );
2109
- listAssistantBackupTimesMock.mockReturnValue([
2110
- new Date(Date.now() - 5 * 60_000).toISOString(),
2111
- ]);
2112
-
2113
- const restoreFetch = installTrackingFetch();
2114
- try {
2115
- await teleport();
2116
- } finally {
2117
- restoreFetch();
2118
- }
2119
-
2120
- expect(createBackupMock).not.toHaveBeenCalled();
2121
- expect(localRuntimeExportToGcsMock).toHaveBeenCalled();
2122
- });
2123
-
2124
- test("local source with only stale backups: takes a fresh one", async () => {
2125
- setArgv("--from", "my-local", "--platform");
2126
- const localEntry = makeEntry("my-local", { cloud: "local" });
2127
- findAssistantByNameMock.mockImplementation((name: string) =>
2128
- name === "my-local" ? localEntry : null,
2129
- );
2130
- listAssistantBackupTimesMock.mockReturnValue([
2131
- new Date(Date.now() - 3 * 60 * 60_000).toISOString(),
2132
- ]);
2133
-
2134
- const restoreFetch = installTrackingFetch();
2135
- try {
2136
- await teleport();
2137
- } finally {
2138
- restoreFetch();
2139
- }
2140
-
2141
- expect(createBackupMock).toHaveBeenCalledTimes(1);
2142
- });
2143
-
2144
- test("docker source: backs up host-side through its runtime URL", async () => {
2145
- setArgv("--from", "my-docker", "--local");
2146
- const dockerEntry = makeEntry("my-docker", {
2147
- cloud: "docker",
2148
- runtimeUrl: "http://localhost:9821",
2149
- });
2150
- findAssistantByNameMock.mockImplementation((name: string) =>
2151
- name === "my-docker" ? dockerEntry : null,
2152
- );
2153
- const freshLocal = makeEntry("fresh-local", { cloud: "local" });
2154
- loadAllAssistantsMock.mockImplementation(() =>
2155
- hatchLocalMock.mock.calls.length > 0
2156
- ? [dockerEntry, freshLocal]
2157
- : [dockerEntry],
2158
- );
2159
-
2160
- const restoreFetch = installTrackingFetch();
2161
- try {
2162
- await teleport();
2163
- } finally {
2164
- restoreFetch();
2165
- }
2166
-
2167
- expect(createBackupMock).toHaveBeenCalledWith(
2168
- "http://localhost:9821",
2169
- "my-docker",
2170
- expect.objectContaining({ prefix: "my-docker-pre-teleport" }),
2171
- );
2172
- });
2173
-
2174
- test("platform source: takes a PVC snapshot through the source platform before exporting", async () => {
2175
- setArgv("--from", "my-platform", "--local", "my-local");
2176
- const cloudEntry = makeEntry("my-platform", {
2177
- cloud: "cadet",
2178
- runtimeUrl: "https://cloud.mycadet.ai",
2179
- });
2180
- const localEntry = makeEntry("my-local", { cloud: "local" });
2181
- findAssistantByNameMock.mockImplementation((name: string) => {
2182
- if (name === "my-platform") return cloudEntry;
2183
- if (name === "my-local") return localEntry;
2184
- return null;
2185
- });
2186
-
2187
- const order: string[] = [];
2188
- createPlatformBackupMock.mockImplementation(async () => {
2189
- order.push("backup");
2190
- });
2191
- localRuntimeExportToGcsMock.mockImplementation(async () => {
2192
- order.push("export");
2193
- return { jobId: "local-export-job-1" };
2194
- });
2195
-
2196
- const restoreFetch = installTrackingFetch();
2197
- try {
2198
- await teleport();
2199
- } finally {
2200
- restoreFetch();
2201
- }
2202
-
2203
- expect(listPlatformBackupsMock).toHaveBeenCalledWith(
2204
- expect.objectContaining({
2205
- assistantId: "my-platform",
2206
- runtimeUrl: "https://cloud.mycadet.ai",
2207
- }),
2208
- "platform-token",
2209
- );
2210
- expect(createPlatformBackupMock).toHaveBeenCalledWith(
2211
- expect.objectContaining({ assistantId: "my-platform" }),
2212
- "platform-token",
2213
- );
2214
- expect(createBackupMock).not.toHaveBeenCalled();
2215
- expect(order).toEqual(["backup", "export"]);
2216
- });
2217
-
2218
- test("platform source with a recent ready snapshot: reuses it", async () => {
2219
- setArgv("--from", "my-platform", "--local", "my-local");
2220
- const cloudEntry = makeEntry("my-platform", {
2221
- cloud: "cadet",
2222
- runtimeUrl: "https://cloud.mycadet.ai",
2223
- });
2224
- const localEntry = makeEntry("my-local", { cloud: "local" });
2225
- findAssistantByNameMock.mockImplementation((name: string) => {
2226
- if (name === "my-platform") return cloudEntry;
2227
- if (name === "my-local") return localEntry;
2228
- return null;
2229
- });
2230
- listPlatformBackupsMock.mockResolvedValue([
2231
- new Date(Date.now() - 20 * 60_000).toISOString(),
2232
- ]);
2233
-
2234
- const restoreFetch = installTrackingFetch();
2235
- try {
2236
- await teleport();
2237
- } finally {
2238
- restoreFetch();
2239
- }
2240
-
2241
- expect(createPlatformBackupMock).not.toHaveBeenCalled();
2242
- expect(localRuntimeExportToGcsMock).toHaveBeenCalled();
2243
- });
2244
-
2245
- test("host-side backup failure aborts before export and before hatching", async () => {
2246
- setArgv("--from", "my-local", "--platform");
2247
- const localEntry = makeEntry("my-local", { cloud: "local" });
2248
- findAssistantByNameMock.mockImplementation((name: string) =>
2249
- name === "my-local" ? localEntry : null,
2250
- );
2251
- createBackupMock.mockResolvedValue(null);
2252
-
2253
- const restoreFetch = installTrackingFetch();
2254
- try {
2255
- await expect(teleport()).rejects.toThrow("process.exit:1");
2256
- } finally {
2257
- restoreFetch();
2258
- }
2259
-
2260
- expect(localRuntimeExportToGcsMock).not.toHaveBeenCalled();
2261
- expect(hatchAssistantMock).not.toHaveBeenCalled();
2262
- expect(platformImportBundleFromGcsMock).not.toHaveBeenCalled();
2263
- expect(consoleErrorSpy).toHaveBeenCalledWith(
2264
- expect.stringContaining("Could not back up 'my-local'"),
2265
- );
2266
- });
2267
-
2268
- test("platform snapshot failure (never ready) aborts too", async () => {
2269
- setArgv("--from", "my-platform", "--local", "my-local");
2270
- const cloudEntry = makeEntry("my-platform", {
2271
- cloud: "cadet",
2272
- runtimeUrl: "https://cloud.mycadet.ai",
2273
- });
2274
- const localEntry = makeEntry("my-local", { cloud: "local" });
2275
- findAssistantByNameMock.mockImplementation((name: string) => {
2276
- if (name === "my-platform") return cloudEntry;
2277
- if (name === "my-local") return localEntry;
2278
- return null;
2279
- });
2280
- createPlatformBackupMock.mockRejectedValue(
2281
- new Error("Cadet Cloud backup snap-1 was not ready after 600s"),
2282
- );
2283
-
2284
- const restoreFetch = installTrackingFetch();
2285
- try {
2286
- await expect(teleport()).rejects.toThrow("process.exit:1");
2287
- } finally {
2288
- restoreFetch();
2289
- }
2290
-
2291
- expect(localRuntimeExportToGcsMock).not.toHaveBeenCalled();
2292
- expect(consoleErrorSpy).toHaveBeenCalledWith(
2293
- expect.stringContaining("was not ready"),
2294
- );
2295
- });
2296
-
2297
- test("dry-run never backs up the source", async () => {
2298
- setArgv("--from", "my-local", "--platform", "--dry-run");
2299
- const localEntry = makeEntry("my-local", { cloud: "local" });
2300
- findAssistantByNameMock.mockImplementation((name: string) =>
2301
- name === "my-local" ? localEntry : null,
2302
- );
2303
-
2304
- await teleport();
2305
-
2306
- expect(listAssistantBackupTimesMock).not.toHaveBeenCalled();
2307
- expect(createBackupMock).not.toHaveBeenCalled();
2308
- });
2309
- });
2310
-
2311
- describe("pre-check: existing platform assistant", () => {
2312
- test("blocks before any work when pre-check finds existing assistant", async () => {
2313
- setArgv("--from", "my-local", "--platform");
2314
-
2315
- const localEntry = makeEntry("my-local", { cloud: "local" });
2316
- findAssistantByNameMock.mockImplementation((name: string) =>
2317
- name === "my-local" ? localEntry : null,
2318
- );
2319
-
2320
- checkExistingCloudAssistantMock.mockResolvedValue({
2321
- id: "existing-platform-id",
2322
- name: "existing-platform",
2323
- status: "active",
2324
- });
2325
-
2326
- await expect(teleport()).rejects.toThrow("process.exit:1");
2327
-
2328
- expect(checkExistingCloudAssistantMock).toHaveBeenCalledWith(
2329
- "platform-token",
2330
- undefined,
2331
- );
2332
- // No signed-URL or runtime calls
2333
- expect(cloudRequestSignedUrlMock).not.toHaveBeenCalled();
2334
- expect(localRuntimeExportToGcsMock).not.toHaveBeenCalled();
2335
- expect(hatchAssistantMock).not.toHaveBeenCalled();
2336
-
2337
- expect(consoleErrorSpy).toHaveBeenCalledWith(
2338
- expect.stringContaining("already have a cloud assistant"),
2339
- );
2340
- });
2341
- });
2342
-
2343
- // ---------------------------------------------------------------------------
2344
- // Version guard
2345
- // ---------------------------------------------------------------------------
2346
-
2347
- describe("version guard", () => {
2348
- test("blocks platform→local when local version is behind", async () => {
2349
- setArgv("--from", "my-platform", "--local", "my-local");
2350
-
2351
- const cloudEntry = makeEntry("my-platform", {
2352
- cloud: "cadet",
2353
- runtimeUrl: "https://cloud.mycadet.ai",
2354
- });
2355
- const localEntry = makeEntry("my-local", { cloud: "local" });
2356
-
2357
- findAssistantByNameMock.mockImplementation((name: string) => {
2358
- if (name === "my-platform") return cloudEntry;
2359
- if (name === "my-local") return localEntry;
2360
- return null;
2361
- });
2362
-
2363
- fetchCurrentVersionMock.mockImplementation((url: string) => {
2364
- if (url === "https://cloud.mycadet.ai") return Promise.resolve("0.7.0");
2365
- return Promise.resolve("0.6.0");
2366
- });
2367
-
2368
- const restoreFetch = installTrackingFetch();
2369
- try {
2370
- await expect(teleport()).rejects.toThrow("process.exit:1");
2371
- expect(consoleErrorSpy).toHaveBeenCalledWith(
2372
- expect.stringContaining("is running 0.6.0"),
2373
- );
2374
- } finally {
2375
- restoreFetch();
2376
- }
2377
- });
2378
-
2379
- test("allows equal versions", async () => {
2380
- setArgv("--from", "my-platform", "--local", "my-local");
2381
-
2382
- const cloudEntry = makeEntry("my-platform", {
2383
- cloud: "cadet",
2384
- runtimeUrl: "https://cloud.mycadet.ai",
2385
- });
2386
- const localEntry = makeEntry("my-local", { cloud: "local" });
2387
-
2388
- findAssistantByNameMock.mockImplementation((name: string) => {
2389
- if (name === "my-platform") return cloudEntry;
2390
- if (name === "my-local") return localEntry;
2391
- return null;
2392
- });
2393
-
2394
- fetchCurrentVersionMock.mockResolvedValue("0.7.0");
2395
- platformPollJobStatusMock.mockResolvedValue({
2396
- jobId: "platform-export-job-1",
2397
- type: "export",
2398
- status: "complete",
2399
- bundleKey: "b",
2400
- });
2401
-
2402
- const restoreFetch = installTrackingFetch();
2403
- try {
2404
- await teleport();
2405
- expect(consoleLogSpy).toHaveBeenCalledWith(
2406
- expect.stringContaining("Teleport complete"),
2407
- );
2408
- } finally {
2409
- restoreFetch();
2410
- }
2411
- });
2412
-
2413
- test("newly hatched target is cleaned up when version check fails", async () => {
2414
- setArgv("--from", "my-platform", "--local");
2415
-
2416
- const cloudEntry = makeEntry("my-platform", {
2417
- cloud: "cadet",
2418
- runtimeUrl: "https://cloud.mycadet.ai",
2419
- });
2420
- const newLocalEntry = makeEntry("new-local", { cloud: "local" });
2421
-
2422
- findAssistantByNameMock.mockImplementation((name: string) =>
2423
- name === "my-platform" ? cloudEntry : null,
2424
- );
2425
-
2426
- loadAllAssistantsMock.mockImplementation(() => {
2427
- if (hatchLocalMock.mock.calls.length > 0) {
2428
- return [cloudEntry, newLocalEntry];
2429
- }
2430
- return [cloudEntry];
2431
- });
2432
-
2433
- platformPollJobStatusMock.mockResolvedValue({
2434
- jobId: "platform-export-job-1",
2435
- type: "export",
2436
- status: "complete",
2437
- bundleKey: "b",
2438
- });
2439
-
2440
- fetchCurrentVersionMock.mockImplementation((url: string) => {
2441
- if (url === "https://cloud.mycadet.ai") return Promise.resolve("0.7.0");
2442
- return Promise.resolve("0.6.0");
2443
- });
2444
-
2445
- const restoreFetch = installTrackingFetch();
2446
- try {
2447
- await expect(teleport()).rejects.toThrow("process.exit:1");
2448
- expect(hatchLocalMock).toHaveBeenCalled();
2449
- expect(retireLocalMock).toHaveBeenCalledWith("new-local", newLocalEntry);
2450
- expect(removeAssistantEntryMock).toHaveBeenCalledWith("new-local");
2451
- } finally {
2452
- restoreFetch();
2453
- }
2454
- });
2455
- });
2456
-
2457
- // ---------------------------------------------------------------------------
2458
- // Credential import display
2459
- // ---------------------------------------------------------------------------
2460
-
2461
- describe("credential import display", () => {
2462
- test("prints credential counts when credentialsImported is present (platform target)", async () => {
2463
- setArgv("--from", "my-local", "--platform");
2464
-
2465
- const localEntry = makeEntry("my-local", { cloud: "local" });
2466
- findAssistantByNameMock.mockImplementation((name: string) =>
2467
- name === "my-local" ? localEntry : null,
2468
- );
2469
-
2470
- platformImportBundleFromGcsMock.mockResolvedValue({
2471
- statusCode: 200,
2472
- body: {
2473
- success: true,
2474
- summary: {
2475
- total_files: 3,
2476
- files_created: 2,
2477
- files_overwritten: 1,
2478
- files_skipped: 0,
2479
- backups_created: 1,
2480
- },
2481
- credentialsImported: {
2482
- total: 5,
2483
- succeeded: 5,
2484
- failed: 0,
2485
- failedAccounts: [],
2486
- },
2487
- },
2488
- });
2489
-
2490
- const restoreFetch = installTrackingFetch();
2491
- try {
2492
- await teleport();
2493
- expect(consoleLogSpy).toHaveBeenCalledWith(" Credentials imported: 5/5");
2494
- } finally {
2495
- restoreFetch();
2496
- }
2497
- });
2498
-
2499
- test("does not print credential line when absent", async () => {
2500
- setArgv("--from", "my-local", "--platform");
2501
-
2502
- const localEntry = makeEntry("my-local", { cloud: "local" });
2503
- findAssistantByNameMock.mockImplementation((name: string) =>
2504
- name === "my-local" ? localEntry : null,
2505
- );
2506
-
2507
- const restoreFetch = installTrackingFetch();
2508
- try {
2509
- await teleport();
2510
- const allLogCalls = consoleLogSpy.mock.calls.map((c: unknown[]) => c[0]);
2511
- const credentialLines = allLogCalls.filter(
2512
- (msg: string) =>
2513
- typeof msg === "string" && msg.includes("Credentials imported"),
2514
- );
2515
- expect(credentialLines).toHaveLength(0);
2516
- } finally {
2517
- restoreFetch();
2518
- }
2519
- });
2520
- });
2521
-
2522
- // ---------------------------------------------------------------------------
2523
- // Platform credential injection after teleport
2524
- // ---------------------------------------------------------------------------
2525
-
2526
- describe("platform credential injection", () => {
2527
- test("platform→local teleport calls ensureSelfHostedLocalRegistration and injectCredentialsIntoAssistant", async () => {
2528
- setArgv("--from", "my-platform", "--local", "my-local");
2529
-
2530
- const cloudEntry = makeEntry("my-platform", {
2531
- cloud: "cadet",
2532
- runtimeUrl: "https://cloud.mycadet.ai",
2533
- });
2534
- const localEntry = makeEntry("my-local", {
2535
- cloud: "local",
2536
- bearerToken: "local-bearer",
2537
- });
2538
-
2539
- findAssistantByNameMock.mockImplementation((name: string) => {
2540
- if (name === "my-platform") return cloudEntry;
2541
- if (name === "my-local") return localEntry;
2542
- return null;
2543
- });
2544
-
2545
- platformPollJobStatusMock.mockResolvedValue({
2546
- jobId: "platform-export-job-1",
2547
- type: "export",
2548
- status: "complete",
2549
- bundleKey: "bundle-xyz",
2550
- });
2551
-
2552
- const restoreFetch = installTrackingFetch();
2553
- try {
2554
- await teleport();
2555
- expect(ensureSelfHostedLocalRegistrationMock).toHaveBeenCalledWith(
2556
- "platform-token",
2557
- "org-1",
2558
- "device-id-123",
2559
- "my-local",
2560
- "cli",
2561
- undefined, // assistantVersion (gateway unreachable in test)
2562
- expect.any(String), // cloudUrl from getCloudUrl()
2563
- undefined, // ingressUrl (gateway unreachable in test)
2564
- );
2565
- expect(injectCredentialsIntoAssistantMock).toHaveBeenCalledWith({
2566
- gatewayUrl: "http://localhost:7821",
2567
- bearerToken: "local-bearer",
2568
- assistantApiKey: "api-key-123",
2569
- cloudAssistantId: "platform-assistant-1",
2570
- organizationId: "org-1",
2571
- userId: "user-1",
2572
- webhookSecret: "webhook-secret-123",
2573
- });
2574
- expect(saveAssistantEntryMock).toHaveBeenCalledWith(
2575
- expect.objectContaining({
2576
- assistantId: "my-local",
2577
- cloudAssistantId: "platform-assistant-1",
2578
- cloudBaseUrl: "https://cloud.mycadet.ai",
2579
- cloudOrganizationId: "org-1",
2580
- }),
2581
- );
2582
- } finally {
2583
- restoreFetch();
2584
- }
2585
- });
2586
-
2587
- test("local→docker teleport does NOT call credential injection", async () => {
2588
- setArgv("--from", "my-local", "--docker", "my-docker");
2589
-
2590
- const localEntry = makeEntry("my-local", { cloud: "local" });
2591
- const dockerEntry = makeEntry("my-docker", {
2592
- cloud: "docker",
2593
- runtimeUrl: "http://localhost:8821",
2594
- });
2595
-
2596
- findAssistantByNameMock.mockImplementation((name: string) => {
2597
- if (name === "my-local") return localEntry;
2598
- if (name === "my-docker") return dockerEntry;
2599
- return null;
2600
- });
2601
-
2602
- const restoreFetch = installTrackingFetch();
2603
- try {
2604
- await teleport();
2605
- expect(ensureSelfHostedLocalRegistrationMock).not.toHaveBeenCalled();
2606
- expect(injectCredentialsIntoAssistantMock).not.toHaveBeenCalled();
2607
- } finally {
2608
- restoreFetch();
2609
- }
2610
- });
2611
- });
2612
-
2613
- // ---------------------------------------------------------------------------
2614
- // Auth / transient-error resilience (Codex P1/P2 regression guards)
2615
- // ---------------------------------------------------------------------------
2616
-
2617
- describe("auth + transient-error resilience", () => {
2618
- test("runtime 401 on export kickoff triggers token refresh and retry", async () => {
2619
- setArgv("--from", "my-local", "--platform");
2620
-
2621
- const localEntry = makeEntry("my-local", { cloud: "local" });
2622
- findAssistantByNameMock.mockImplementation((name: string) =>
2623
- name === "my-local" ? localEntry : null,
2624
- );
2625
-
2626
- // First kickoff call fails with 401, second succeeds.
2627
- localRuntimeExportToGcsMock.mockImplementationOnce(async () => {
2628
- throw new Error("Local runtime export-to-gcs failed (401): stale token");
2629
- });
2630
- localRuntimeExportToGcsMock.mockImplementationOnce(async () => ({
2631
- jobId: "local-export-job-after-refresh",
2632
- }));
2633
-
2634
- // Ensure the refresh path returns a distinguishable token.
2635
- leaseGuardianTokenMock.mockResolvedValueOnce(
2636
- makeGuardianTokenData({ accessToken: "refreshed-token" }),
2637
- );
2638
-
2639
- const restoreFetch = installTrackingFetch();
2640
- try {
2641
- await teleport();
2642
- } finally {
2643
- restoreFetch();
2644
- }
2645
-
2646
- // Kickoff was attempted twice: once with the cached token, once after
2647
- // a forced refresh lease.
2648
- expect(localRuntimeExportToGcsMock).toHaveBeenCalledTimes(2);
2649
-
2650
- const firstTokenArg = localRuntimeExportToGcsMock.mock.calls[0][1];
2651
- const secondTokenArg = localRuntimeExportToGcsMock.mock.calls[1][1];
2652
- expect(firstTokenArg).toBe("local-token");
2653
- expect(secondTokenArg).toBe("refreshed-token");
2654
-
2655
- // A fresh lease was requested exactly once (the forceRefresh path).
2656
- expect(leaseGuardianTokenMock).toHaveBeenCalledTimes(1);
2657
- });
2658
-
2659
- test("runtime 401 on import kickoff triggers token refresh and retry", async () => {
2660
- setArgv("--from", "my-platform", "--local", "my-local");
2661
-
2662
- const cloudEntry = makeEntry("my-platform", {
2663
- cloud: "cadet",
2664
- runtimeUrl: "https://cloud.mycadet.ai",
2665
- });
2666
- const localEntry = makeEntry("my-local", { cloud: "local" });
2667
-
2668
- findAssistantByNameMock.mockImplementation((name: string) => {
2669
- if (name === "my-platform") return cloudEntry;
2670
- if (name === "my-local") return localEntry;
2671
- return null;
2672
- });
2673
-
2674
- platformPollJobStatusMock.mockResolvedValue({
2675
- jobId: "platform-export-job-1",
2676
- type: "export",
2677
- status: "complete",
2678
- bundleKey: "b-key",
2679
- });
2680
-
2681
- localRuntimeImportFromGcsMock.mockImplementationOnce(async () => {
2682
- throw new Error(
2683
- "Local runtime import-from-gcs failed (401): stale token",
2684
- );
2685
- });
2686
- localRuntimeImportFromGcsMock.mockImplementationOnce(async () => ({
2687
- jobId: "local-import-after-refresh",
2688
- }));
2689
-
2690
- leaseGuardianTokenMock.mockResolvedValueOnce(
2691
- makeGuardianTokenData({ accessToken: "refreshed-import-token" }),
2692
- );
2693
-
2694
- const restoreFetch = installTrackingFetch();
2695
- try {
2696
- await teleport();
2697
- } finally {
2698
- restoreFetch();
2699
- }
2700
-
2701
- expect(localRuntimeImportFromGcsMock).toHaveBeenCalledTimes(2);
2702
- expect(localRuntimeImportFromGcsMock.mock.calls[0][1]).toBe("local-token");
2703
- expect(localRuntimeImportFromGcsMock.mock.calls[1][1]).toBe(
2704
- "refreshed-import-token",
2705
- );
2706
- });
2707
-
2708
- test("runtime non-401 errors do NOT trigger token refresh", async () => {
2709
- setArgv("--from", "my-local", "--platform");
2710
-
2711
- const localEntry = makeEntry("my-local", { cloud: "local" });
2712
- findAssistantByNameMock.mockImplementation((name: string) =>
2713
- name === "my-local" ? localEntry : null,
2714
- );
2715
-
2716
- localRuntimeExportToGcsMock.mockRejectedValue(
2717
- new Error("Local runtime export-to-gcs failed (500): boom"),
2718
- );
2719
-
2720
- const restoreFetch = installTrackingFetch();
2721
- try {
2722
- await expect(teleport()).rejects.toThrow(/500.*boom/);
2723
- } finally {
2724
- restoreFetch();
2725
- }
2726
-
2727
- // One attempt, no forced-refresh lease.
2728
- expect(localRuntimeExportToGcsMock).toHaveBeenCalledTimes(1);
2729
- expect(leaseGuardianTokenMock).not.toHaveBeenCalled();
2730
- });
2731
-
2732
- test("runtime poll 401 mid-migration triggers forceRefresh lease and completes", async () => {
2733
- setArgv("--from", "my-local", "--platform");
2734
-
2735
- const localEntry = makeEntry("my-local", { cloud: "local" });
2736
- findAssistantByNameMock.mockImplementation((name: string) =>
2737
- name === "my-local" ? localEntry : null,
2738
- );
2739
-
2740
- // Export kickoff succeeds with the cached "local-token".
2741
- // During polling, the first status check fails with 401 (token expired
2742
- // mid-migration), the poll loop calls refreshOn401 → leaseGuardianToken,
2743
- // then the next poll succeeds with the new token.
2744
- const tokensSeenByPoll: string[] = [];
2745
- localRuntimePollJobStatusMock.mockImplementation(
2746
- async (_runtimeUrl, token, jobId) => {
2747
- tokensSeenByPoll.push(token);
2748
- if (tokensSeenByPoll.length === 1) {
2749
- throw new Error("Local job status check failed: 401 Unauthorized");
2750
- }
2751
- return {
2752
- jobId,
2753
- type: "export" as const,
2754
- status: "complete" as const,
2755
- result: undefined,
2756
- };
2757
- },
2758
- );
2759
-
2760
- leaseGuardianTokenMock.mockResolvedValueOnce(
2761
- makeGuardianTokenData({ accessToken: "poll-refreshed-token" }),
2762
- );
2763
-
2764
- const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
2765
- const restoreFetch = installTrackingFetch();
2766
- try {
2767
- await teleport();
2768
-
2769
- // The first poll used the cached token; the second (post-refresh) poll
2770
- // used the freshly leased one.
2771
- expect(tokensSeenByPoll.length).toBeGreaterThanOrEqual(2);
2772
- expect(tokensSeenByPoll[0]).toBe("local-token");
2773
- expect(tokensSeenByPoll[1]).toBe("poll-refreshed-token");
2774
-
2775
- // leaseGuardianToken was invoked for the forceRefresh path.
2776
- expect(leaseGuardianTokenMock).toHaveBeenCalledTimes(1);
2777
-
2778
- // The 401 branch emits its own warning — distinct from the generic
2779
- // transient-error warning — so this asserts the refresh path fired.
2780
- expect(warnSpy).toHaveBeenCalledWith(
2781
- expect.stringContaining("refreshing auth"),
2782
- );
2783
- } finally {
2784
- restoreFetch();
2785
- warnSpy.mockRestore();
2786
- }
2787
- });
2788
-
2789
- test("transient poll error does not abort teleport (job completes after retry)", async () => {
2790
- setArgv("--from", "my-local", "--platform");
2791
-
2792
- const localEntry = makeEntry("my-local", { cloud: "local" });
2793
- findAssistantByNameMock.mockImplementation((name: string) =>
2794
- name === "my-local" ? localEntry : null,
2795
- );
2796
-
2797
- // Throw once with a 503 (transient), then succeed with terminal complete.
2798
- let pollCalls = 0;
2799
- localRuntimePollJobStatusMock.mockImplementation(
2800
- async (_runtimeUrl, _token, jobId) => {
2801
- pollCalls += 1;
2802
- if (pollCalls === 1) {
2803
- throw new Error(
2804
- "Local job status check failed: 503 Service Unavailable",
2805
- );
2806
- }
2807
- return {
2808
- jobId,
2809
- type: "export" as const,
2810
- status: "complete" as const,
2811
- result: undefined,
2812
- };
2813
- },
2814
- );
2815
-
2816
- const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
2817
- const restoreFetch = installTrackingFetch();
2818
- try {
2819
- // Should NOT reject — a single transient 503 is retried, not fatal.
2820
- await teleport();
2821
- expect(pollCalls).toBeGreaterThanOrEqual(2);
2822
- expect(warnSpy).toHaveBeenCalledWith(
2823
- expect.stringContaining("polling failed:"),
2824
- );
2825
- } finally {
2826
- restoreFetch();
2827
- warnSpy.mockRestore();
2828
- }
2829
- });
2830
- });
2831
-
2832
- // ---------------------------------------------------------------------------
2833
- // Source-runtime version is sourced from the daemon, not the CLI
2834
- // (Codex P1 regression guard for PR #29436)
2835
- // ---------------------------------------------------------------------------
2836
-
2837
- describe("upload signed-URL records source runtime version (not CLI version)", () => {
2838
- test("local source: identity is fetched BEFORE the upload signed-URL request", async () => {
2839
- setArgv("--from", "my-local", "--platform");
2840
-
2841
- const localEntry = makeEntry("my-local", { cloud: "local" });
2842
- findAssistantByNameMock.mockImplementation((name: string) =>
2843
- name === "my-local" ? localEntry : null,
2844
- );
2845
-
2846
- // Distinguish the daemon's version from anything else hardcoded.
2847
- localRuntimeIdentityMock.mockResolvedValue({ version: "0.5.9" });
2848
-
2849
- // Order tracker: capture which mock was called first.
2850
- const callOrder: string[] = [];
2851
- localRuntimeIdentityMock.mockImplementationOnce(async () => {
2852
- callOrder.push("identity");
2853
- return { version: "0.5.9" };
2854
- });
2855
- cloudRequestSignedUrlMock.mockImplementationOnce(async (params) => {
2856
- callOrder.push("signed-url");
2857
- return {
2858
- url: "https://storage.googleapis.com/bucket/signed-upload",
2859
- bundleKey: params.bundleKey ?? "bundle-key-123",
2860
- expiresAt: new Date(Date.now() + 3600_000).toISOString(),
2861
- };
2862
- });
2863
-
2864
- const restoreFetch = installTrackingFetch();
2865
- try {
2866
- await teleport();
2867
-
2868
- expect(callOrder[0]).toBe("identity");
2869
- expect(callOrder[1]).toBe("signed-url");
2870
-
2871
- // Upload request stamps minRuntimeVersion with the daemon's version,
2872
- // NOT cliPkg.version.
2873
- expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
2874
- expect.objectContaining({
2875
- operation: "upload",
2876
- minRuntimeVersion: "0.5.9",
2877
- maxRuntimeVersion: null,
2878
- }),
2879
- "platform-token",
2880
- "https://cloud.mycadet.ai",
2881
- );
2882
- } finally {
2883
- restoreFetch();
2884
- }
2885
- });
2886
-
2887
- test("local source: identity fetch failure aborts before signed-URL request", async () => {
2888
- setArgv("--from", "my-local", "--platform");
2889
-
2890
- const localEntry = makeEntry("my-local", { cloud: "local" });
2891
- findAssistantByNameMock.mockImplementation((name: string) =>
2892
- name === "my-local" ? localEntry : null,
2893
- );
2894
-
2895
- localRuntimeIdentityMock.mockRejectedValue(
2896
- new Error("Failed to fetch runtime identity: 503 Service Unavailable"),
2897
- );
2898
-
2899
- const restoreFetch = installTrackingFetch();
2900
- try {
2901
- await expect(teleport()).rejects.toThrow("process.exit:1");
2902
-
2903
- // Must NOT have proceeded to signed URL or export.
2904
- expect(cloudRequestSignedUrlMock).not.toHaveBeenCalled();
2905
- expect(localRuntimeExportToGcsMock).not.toHaveBeenCalled();
2906
-
2907
- expect(consoleErrorSpy).toHaveBeenCalledWith(
2908
- expect.stringContaining("Could not fetch runtime identity"),
2909
- );
2910
- } finally {
2911
- restoreFetch();
2912
- }
2913
- });
2914
-
2915
- test("platform source: managed runtime's identity is fetched and recorded", async () => {
2916
- setArgv("--from", "my-platform", "--local", "my-local");
2917
-
2918
- const cloudEntry = makeEntry("my-platform", {
2919
- cloud: "cadet",
2920
- runtimeUrl: "https://cloud.mycadet.ai",
2921
- });
2922
- const localEntry = makeEntry("my-local", { cloud: "local" });
2923
-
2924
- findAssistantByNameMock.mockImplementation((name: string) => {
2925
- if (name === "my-platform") return cloudEntry;
2926
- if (name === "my-local") return localEntry;
2927
- return null;
2928
- });
2929
-
2930
- platformPollJobStatusMock.mockResolvedValue({
2931
- jobId: "platform-export-job-1",
2932
- type: "export",
2933
- status: "complete",
2934
- bundleKey: "platform-bundle",
2935
- });
2936
-
2937
- localRuntimeIdentityMock.mockResolvedValue({ version: "0.7.2" });
2938
-
2939
- const restoreFetch = installTrackingFetch();
2940
- try {
2941
- await teleport();
2942
-
2943
- // Identity was fetched against the cloud-managed runtime entry
2944
- // (cloud=cadet) with the cloud token — not via guardian token.
2945
- expect(localRuntimeIdentityMock).toHaveBeenCalledWith(
2946
- expect.objectContaining({
2947
- cloud: "cadet",
2948
- runtimeUrl: "https://cloud.mycadet.ai",
2949
- assistantId: "my-platform",
2950
- }),
2951
- "platform-token",
2952
- );
2953
-
2954
- // The recorded version came from the cloud runtime, not the CLI.
2955
- expect(cloudRequestSignedUrlMock).toHaveBeenCalledWith(
2956
- expect.objectContaining({
2957
- operation: "upload",
2958
- minRuntimeVersion: "0.7.2",
2959
- maxRuntimeVersion: null,
2960
- }),
2961
- "platform-token",
2962
- "https://cloud.mycadet.ai",
2963
- );
2964
- } finally {
2965
- restoreFetch();
2966
- }
2967
- });
2968
-
2969
- test("platform source: identity fetch failure aborts before signed-URL request", async () => {
2970
- setArgv("--from", "my-platform", "--local", "my-local");
2971
-
2972
- const cloudEntry = makeEntry("my-platform", {
2973
- cloud: "cadet",
2974
- runtimeUrl: "https://cloud.mycadet.ai",
2975
- });
2976
- const localEntry = makeEntry("my-local", { cloud: "local" });
2977
-
2978
- findAssistantByNameMock.mockImplementation((name: string) => {
2979
- if (name === "my-platform") return cloudEntry;
2980
- if (name === "my-local") return localEntry;
2981
- return null;
2982
- });
2983
-
2984
- localRuntimeIdentityMock.mockRejectedValue(
2985
- new Error("Failed to fetch runtime identity: 502 Bad Gateway"),
2986
- );
2987
-
2988
- const restoreFetch = installTrackingFetch();
2989
- try {
2990
- await expect(teleport()).rejects.toThrow("process.exit:1");
2991
-
2992
- // Signed-URL upload request must not have happened.
2993
- const uploadCalls = cloudRequestSignedUrlMock.mock.calls.filter(
2994
- (call) => call[0].operation === "upload",
2995
- );
2996
- expect(uploadCalls.length).toBe(0);
2997
-
2998
- // Runtime export was never kicked off.
2999
- expect(localRuntimeExportToGcsMock).not.toHaveBeenCalled();
3000
-
3001
- expect(consoleErrorSpy).toHaveBeenCalledWith(
3002
- expect.stringContaining("Could not fetch runtime identity"),
3003
- );
3004
- } finally {
3005
- restoreFetch();
3006
- }
3007
- });
3008
- });
3009
-