@mycadet/cli 0.12.6-dev.202610082233.54244ee → 0.12.7-dev.202610102207.24bf931

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 (100) hide show
  1. package/eslint.config.mjs +7 -0
  2. package/node_modules/@mycadet/local-mode/src/__tests__/environment.test.ts +1 -4
  3. package/node_modules/@mycadet/local-mode/src/config.ts +14 -19
  4. package/node_modules/@mycadet/local-mode/src/index.ts +5 -0
  5. package/node_modules/@mycadet/local-mode/src/local-signing-key.ts +73 -0
  6. package/node_modules/@mycadet/local-mode/src/lockfile.ts +18 -3
  7. package/package.json +5 -4
  8. package/src/__tests__/assistant-config.test.ts +30 -28
  9. package/src/__tests__/backup.test.ts +40 -45
  10. package/src/__tests__/connect-import.test.ts +55 -39
  11. package/src/__tests__/debug.test.ts +32 -32
  12. package/src/__tests__/default-main-screen-formatting.test.ts +5 -4
  13. package/src/__tests__/flags.test.ts +2 -2
  14. package/src/__tests__/guardian-token.test.ts +149 -56
  15. package/src/__tests__/hatch-provider-secrets.test.ts +3 -3
  16. package/src/__tests__/helpers/guardian-token.ts +22 -0
  17. package/src/__tests__/helpers/http.ts +10 -0
  18. package/src/__tests__/ingress-config.test.ts +2 -2
  19. package/src/__tests__/login-loopback.test.ts +1 -1
  20. package/src/__tests__/nginx-ingress.test.ts +80 -88
  21. package/src/__tests__/pair.test.ts +128 -121
  22. package/src/__tests__/platform-client.test.ts +2 -2
  23. package/src/__tests__/{recover.test.ts → restore.test.ts} +118 -24
  24. package/src/__tests__/retire.test.ts +5 -3
  25. package/src/__tests__/signing-key-continuity.test.ts +261 -0
  26. package/src/__tests__/sleep.test.ts +202 -19
  27. package/src/__tests__/tailscale-tunnel.test.ts +1 -1
  28. package/src/__tests__/teleport.test.ts +55 -71
  29. package/src/__tests__/tunnel.test.ts +135 -154
  30. package/src/__tests__/uninstall.test.ts +180 -0
  31. package/src/__tests__/upgrade-local.test.ts +16 -3
  32. package/src/__tests__/use.test.ts +2 -2
  33. package/src/__tests__/wake-error.test.ts +76 -0
  34. package/src/__tests__/wake.test.ts +551 -68
  35. package/src/commands/backup.ts +6 -2
  36. package/src/commands/clean.ts +6 -2
  37. package/src/commands/client.ts +91 -41
  38. package/src/commands/confirm.ts +6 -2
  39. package/src/commands/connect.ts +6 -2
  40. package/src/commands/debug.ts +6 -2
  41. package/src/commands/devices.ts +6 -2
  42. package/src/commands/events.ts +6 -2
  43. package/src/commands/exec.ts +50 -12
  44. package/src/commands/flags.ts +6 -2
  45. package/src/commands/gateway.ts +6 -2
  46. package/src/commands/hatch.ts +6 -2
  47. package/src/commands/login.ts +26 -356
  48. package/src/commands/logs.ts +6 -2
  49. package/src/commands/message.ts +6 -2
  50. package/src/commands/pair.ts +6 -2
  51. package/src/commands/ps.ts +7 -2
  52. package/src/commands/restart.ts +30 -0
  53. package/src/commands/restore.ts +130 -8
  54. package/src/commands/retire.ts +6 -2
  55. package/src/commands/rollback.ts +15 -17
  56. package/src/commands/setup.ts +76 -9
  57. package/src/commands/sleep.ts +131 -31
  58. package/src/commands/teleport.ts +6 -2
  59. package/src/commands/terminal.ts +157 -13
  60. package/src/commands/tunnel.ts +6 -2
  61. package/src/commands/uninstall.ts +276 -0
  62. package/src/commands/unpair.ts +6 -2
  63. package/src/commands/upgrade.ts +15 -27
  64. package/src/commands/use.ts +6 -2
  65. package/src/commands/voice.ts +6 -2
  66. package/src/commands/wake.ts +222 -70
  67. package/src/components/DefaultMainScreen.tsx +250 -36
  68. package/src/index.ts +26 -64
  69. package/src/lib/assistant-config.ts +28 -22
  70. package/src/lib/backup-ops.ts +3 -4
  71. package/src/lib/cli-error.ts +5 -0
  72. package/src/lib/client-identity.ts +8 -10
  73. package/src/lib/cloud-login.ts +422 -0
  74. package/src/lib/color.ts +16 -0
  75. package/src/lib/default-main-screen-formatting.ts +32 -8
  76. package/src/lib/docker.ts +107 -6
  77. package/src/lib/environments/__tests__/paths.test.ts +9 -11
  78. package/src/lib/environments/paths.ts +2 -20
  79. package/src/lib/guardian-token.ts +104 -18
  80. package/src/lib/hatch-local.ts +60 -13
  81. package/src/lib/health-check.ts +24 -15
  82. package/src/lib/lifecycle-reporter.ts +9 -1
  83. package/src/lib/local-hatch-lock.ts +19 -27
  84. package/src/lib/local-runtime-client.ts +25 -14
  85. package/src/lib/local.ts +17 -43
  86. package/src/lib/nginx-ingress.ts +9 -3
  87. package/src/lib/ngrok.ts +48 -16
  88. package/src/lib/platform-client.ts +141 -72
  89. package/src/lib/retire-archive.ts +4 -4
  90. package/src/lib/retire-local.ts +2 -25
  91. package/src/lib/teleport-backup.ts +23 -9
  92. package/src/lib/terminal-client.ts +21 -9
  93. package/src/lib/terminal-session.ts +4 -4
  94. package/src/lib/tunnel-edge.ts +11 -13
  95. package/src/lib/upgrade-lifecycle.ts +16 -18
  96. package/src/lib/workos-pkce.ts +52 -21
  97. package/src/commands/recover.ts +0 -126
  98. package/src/commands/roadmap.ts +0 -455
  99. package/src/commands/ssh.ts +0 -140
  100. package/src/commands/workflows.ts +0 -301
package/eslint.config.mjs CHANGED
@@ -10,6 +10,13 @@ const eslintConfig = defineConfig([
10
10
  "error",
11
11
  { argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
12
12
  ],
13
+ // Ban `as` casts (the platform repo's rule) at warning level: the
14
+ // lint script pins --max-warnings to the current count and each PR
15
+ // chips away at it — prefer zod schemas or `in`-narrowing.
16
+ "@typescript-eslint/consistent-type-assertions": [
17
+ "warn",
18
+ { assertionStyle: "never" },
19
+ ],
13
20
  },
14
21
  },
15
22
  ]);
@@ -117,8 +117,7 @@ describe("path resolvers honor the persisted default", () => {
117
117
  CADET_ENVIRONMENT: "production",
118
118
  };
119
119
  expect(resolveLockfilePaths(env)).toEqual([
120
- path.join(os.homedir(), ".cadet.lock.json"),
121
- path.join(os.homedir(), ".cadet.lockfile.json"),
120
+ path.join(configHome, "cadet", "lockfile.json"),
122
121
  ]);
123
122
  });
124
123
  });
@@ -162,8 +161,6 @@ describe("Windows path resolution", () => {
162
161
  const productionEnv = { CADET_ENVIRONMENT: "production" };
163
162
  expect(resolveLockfilePaths(productionEnv, options)).toEqual([
164
163
  "C:\\Users\\Example\\AppData\\Roaming\\cadet\\lockfile.json",
165
- "C:\\Users\\Example\\.cadet.lock.json",
166
- "C:\\Users\\Example\\.cadet.lockfile.json",
167
164
  ]);
168
165
  expect(defaultEnvironmentFilePaths(env, options)).toEqual([
169
166
  "C:\\Users\\Example\\AppData\\Roaming\\cadet\\environment",
@@ -52,31 +52,26 @@ export function resolveLockfilePaths(
52
52
  if ((options.platform ?? process.platform) === "win32") {
53
53
  const canonicalDir = lockfileDir ?? resolveConfigDir(env, options);
54
54
  const canonical = joinLocalPath(options, canonicalDir, "lockfile.json");
55
- const legacyDir = lockfileDir ?? options.homeDir ?? os.homedir();
56
- const legacy =
57
- cadetEnv === PRODUCTION_ENVIRONMENT_NAME
58
- ? [".cadet.lock.json", ".cadet.lockfile.json"].map((name) =>
59
- joinLocalPath(options, legacyDir, name),
60
- )
61
- : [
62
- joinLocalPath(
63
- options,
64
- lockfileDir ?? resolveConfigDirPaths(env, options).at(-1)!,
65
- "lockfile.json",
66
- ),
67
- ];
55
+ if (cadetEnv === PRODUCTION_ENVIRONMENT_NAME) {
56
+ return [canonical];
57
+ }
68
58
  return [
69
59
  canonical,
70
- ...legacy.filter((candidate) => candidate !== canonical),
60
+ joinLocalPath(
61
+ options,
62
+ lockfileDir ?? resolveConfigDirPaths(env, options).at(-1)!,
63
+ "lockfile.json",
64
+ ),
71
65
  ];
72
66
  }
73
67
 
74
68
  if (cadetEnv === PRODUCTION_ENVIRONMENT_NAME) {
75
- const dir = lockfileDir ?? options.homeDir ?? os.homedir();
76
- return [
77
- joinLocalPath(options, dir, ".cadet.lock.json"),
78
- joinLocalPath(options, dir, ".cadet.lockfile.json"),
79
- ];
69
+ // Canonical: the XDG config dir, where every other environment keeps
70
+ // its lockfile. Installs that predate the move are handled by the
71
+ // vellum → cadet migration, which moves the home-level file into
72
+ // place — no legacy candidates here.
73
+ const dir = lockfileDir ?? resolveConfigDir(env, options);
74
+ return [joinLocalPath(options, dir, "lockfile.json")];
80
75
  }
81
76
 
82
77
  const dir = lockfileDir ?? resolveConfigDir(env, options);
@@ -140,3 +140,8 @@ export type {
140
140
  PairedForwardAuthorizationResult,
141
141
  PairedGuardianTokenProvider,
142
142
  } from "./gateway-proxy";
143
+
144
+ export {
145
+ resolveExistingLocalSigningKey,
146
+ SigningKeyContinuityError,
147
+ } from "./local-signing-key";
@@ -0,0 +1,73 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+
6
+ const KeySchema = z.string().regex(/^[0-9a-f]{64}$/i);
7
+ const ResourcesSchema = z.object({
8
+ instanceDir: z.string().min(1),
9
+ signingKey: KeySchema.optional(),
10
+ });
11
+
12
+ export class SigningKeyContinuityError extends Error {
13
+ constructor(
14
+ public readonly code: "missing" | "invalid" | "unreadable" | "conflict",
15
+ ) {
16
+ super(
17
+ `Cannot resume authentication: signing key ${code}. Preserve the existing authentication state and restore its original key; no replacement key was generated.`,
18
+ );
19
+ this.name = "SigningKeyContinuityError";
20
+ }
21
+ }
22
+
23
+ /** Resolve existing identity material. This operation never creates or rotates a key. */
24
+ export function resolveExistingLocalSigningKey(
25
+ resources: unknown,
26
+ stateRoot?: string,
27
+ ): string {
28
+ const parsed = ResourcesSchema.safeParse(resources);
29
+ if (!parsed.success) {
30
+ throw new SigningKeyContinuityError("invalid");
31
+ }
32
+ const { instanceDir, signingKey } = parsed.data;
33
+ // The host registry is authoritative after provisioning or migration.
34
+ // Legacy files can be stale after an explicit identity repair.
35
+ if (signingKey) {
36
+ return signingKey.toLowerCase();
37
+ }
38
+ const candidates: Buffer[] = [];
39
+ const root = stateRoot ?? instanceDir;
40
+ // Read-only migration sources, scoped to this instance rather than the user's home.
41
+ const legacyPaths = [
42
+ join(root, "gateway-security", "actor-token-signing-key"),
43
+ join(root, "workspace", "deprecated", "actor-token-signing-key"),
44
+ ];
45
+ for (const keyPath of legacyPaths) {
46
+ let raw: Buffer;
47
+ try {
48
+ raw = readFileSync(keyPath);
49
+ } catch (error) {
50
+ if (
51
+ error &&
52
+ typeof error === "object" &&
53
+ "code" in error &&
54
+ error.code === "ENOENT"
55
+ ) {
56
+ continue;
57
+ }
58
+ throw new SigningKeyContinuityError("unreadable");
59
+ }
60
+ if (raw.length !== 32) {
61
+ throw new SigningKeyContinuityError("invalid");
62
+ }
63
+ candidates.push(raw);
64
+ }
65
+ const key = candidates[0];
66
+ if (!key) {
67
+ throw new SigningKeyContinuityError("missing");
68
+ }
69
+ if (candidates.some((candidate) => !timingSafeEqual(candidate, key))) {
70
+ throw new SigningKeyContinuityError("conflict");
71
+ }
72
+ return key.toString("hex");
73
+ }
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
4
  import {
5
+ LockfileAssistantSchema,
5
6
  parseLockfile,
6
7
  resolveCloud,
7
8
  type Lockfile,
@@ -207,7 +208,8 @@ export function stampLockfileAssistantOnboardedIfPresent(
207
208
  lockfilePaths,
208
209
  assistantId,
209
210
  { onboardedAt },
210
- (entry) => typeof entry.onboardedAt === "string" && entry.onboardedAt !== "",
211
+ (entry) =>
212
+ typeof entry.onboardedAt === "string" && entry.onboardedAt !== "",
211
213
  );
212
214
  }
213
215
 
@@ -289,6 +291,10 @@ export function upsertLockfileAssistant(
289
291
  return locked.ok ? locked.value : lockFailure(locked.error);
290
292
  }
291
293
 
294
+ const RendererAssistantUpdateSchema = LockfileAssistantSchema.omit({
295
+ resources: true,
296
+ });
297
+
292
298
  const PAIRED_LOCKFILE_WRITE_ERROR =
293
299
  "Paired assistant identity can only be changed through the connect flow";
294
300
 
@@ -296,6 +302,7 @@ const PAIRED_LOCKFILE_WRITE_ERROR =
296
302
  * Apply a renderer-originated lockfile upsert without allowing it to create,
297
303
  * retarget, or reclassify a paired assistant. Existing paired entries may
298
304
  * still update non-security fields and become the active assistant.
305
+ * Resources and secrets are host-owned and never accepted from a renderer.
299
306
  */
300
307
  export function upsertRendererLockfileAssistant(
301
308
  lockfilePaths: string[],
@@ -309,7 +316,11 @@ export function upsertRendererLockfileAssistant(
309
316
  // Lock spans the paired-guard read and the upsert (which reenters) so the
310
317
  // guard cannot be judged against a snapshot another writer replaces.
311
318
  const locked = withLockfileLock(lockfilePaths, (): WriteResult => {
312
- const lockfile = readRawLockfile(lockfilePaths);
319
+ const read = readRawLockfileStrict(lockfilePaths);
320
+ if (!read.ok) {
321
+ return { ok: false, status: 409, error: read.error };
322
+ }
323
+ const lockfile = read.lockfile;
313
324
  const assistants = Array.isArray(lockfile.assistants)
314
325
  ? (lockfile.assistants as Array<Record<string, unknown>>)
315
326
  : [];
@@ -335,7 +346,11 @@ export function upsertRendererLockfileAssistant(
335
346
  return { ok: false, status: 403, error: PAIRED_LOCKFILE_WRITE_ERROR };
336
347
  }
337
348
 
338
- return upsertLockfileAssistant(lockfilePaths, assistant, activeAssistant);
349
+ const update = RendererAssistantUpdateSchema.safeParse(assistant);
350
+ if (!update.success) {
351
+ return { ok: false, status: 400, error: "Invalid assistant metadata" };
352
+ }
353
+ return upsertLockfileAssistant(lockfilePaths, update.data, activeAssistant);
339
354
  });
340
355
  return locked.ok ? locked.value : lockFailure(locked.error);
341
356
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mycadet/cli",
3
- "version": "0.12.6-dev.202610082233.54244ee",
3
+ "version": "0.12.7-dev.202610102207.24bf931",
4
4
  "description": "CLI tools for mycadet",
5
5
  "type": "module",
6
6
  "exports": {
@@ -15,12 +15,12 @@
15
15
  "./src/commands/*": "./src/commands/*.ts"
16
16
  },
17
17
  "bin": {
18
- "cadet": "./src/index.ts"
18
+ "cadet-dev": "./src/index.ts"
19
19
  },
20
20
  "scripts": {
21
21
  "format": "prettier --write .",
22
22
  "format:check": "prettier --check .",
23
- "lint": "eslint",
23
+ "lint": "eslint --max-warnings 326",
24
24
  "lint:unused": "knip --include files,dependencies,unlisted",
25
25
  "compile:check": "bun build --compile src/index.ts --outfile /dev/null",
26
26
  "test": "bun run scripts/run-tests.ts",
@@ -40,7 +40,8 @@
40
40
  "nanoid": "5.1.7",
41
41
  "qrcode-terminal": "0.12.0",
42
42
  "react": "19.2.6",
43
- "react-devtools-core": "7.0.1"
43
+ "react-devtools-core": "7.0.1",
44
+ "zod": "4.3.6"
44
45
  },
45
46
  "bundledDependencies": [
46
47
  "@mycadet/assistant-api",
@@ -46,7 +46,7 @@ afterAll(() => {
46
46
 
47
47
  function writeLockfile(data: unknown): void {
48
48
  writeFileSync(
49
- join(testDir, ".cadet.lock.json"),
49
+ join(testDir, "lockfile.json"),
50
50
  JSON.stringify(data, null, 2),
51
51
  );
52
52
  }
@@ -64,14 +64,9 @@ const makeEntry = (
64
64
 
65
65
  describe("assistant-config", () => {
66
66
  beforeEach(() => {
67
- // Reset lockfile between tests
67
+ // Reset the canonical lockfile between tests
68
68
  try {
69
- rmSync(join(testDir, ".cadet.lock.json"));
70
- } catch {
71
- // file may not exist
72
- }
73
- try {
74
- rmSync(join(testDir, ".cadet.lockfile.json"));
69
+ rmSync(join(testDir, "lockfile.json"));
75
70
  } catch {
76
71
  // file may not exist
77
72
  }
@@ -82,7 +77,17 @@ describe("assistant-config", () => {
82
77
  });
83
78
 
84
79
  test("loadAllAssistants returns empty array for malformed lockfile", () => {
85
- writeFileSync(join(testDir, ".cadet.lock.json"), "not json");
80
+ writeFileSync(join(testDir, "lockfile.json"), "not json");
81
+ expect(loadAllAssistants()).toEqual([]);
82
+ });
83
+
84
+ test("legacy home-level lockfile names are no longer read (the migration moves them into place)", () => {
85
+ // A pre-move home-level file alone must NOT be picked up — the
86
+ // migration tooling is what moves it to the canonical config-dir path.
87
+ writeFileSync(
88
+ join(testDir, ".cadet.lock.json"),
89
+ JSON.stringify({ assistants: [makeEntry("legacy-1")] }, null, 2),
90
+ );
86
91
  expect(loadAllAssistants()).toEqual([]);
87
92
  });
88
93
 
@@ -103,11 +108,11 @@ describe("assistant-config", () => {
103
108
  saveAssistantEntry(makeEntry("locked-1"));
104
109
  expect(loadAllAssistants()).toHaveLength(1);
105
110
  // The shared `${lockfilePath}.lock` key hosts contend on is released.
106
- expect(existsSync(join(testDir, ".cadet.lock.json.lock"))).toBe(false);
111
+ expect(existsSync(join(testDir, "lockfile.json.lock"))).toBe(false);
107
112
  });
108
113
 
109
114
  test("saveAssistantEntry breaks a stale lock and still lands the write", () => {
110
- const lockDir = join(testDir, ".cadet.lock.json.lock");
115
+ const lockDir = join(testDir, "lockfile.json.lock");
111
116
  mkdirSync(lockDir);
112
117
  const past = new Date(Date.now() - 60_000);
113
118
  utimesSync(lockDir, past, past);
@@ -118,7 +123,7 @@ describe("assistant-config", () => {
118
123
  });
119
124
 
120
125
  test("saveAssistantEntry fails fast without writing when another holder is fresh", () => {
121
- const lockDir = join(testDir, ".cadet.lock.json.lock");
126
+ const lockDir = join(testDir, "lockfile.json.lock");
122
127
  mkdirSync(lockDir);
123
128
  try {
124
129
  const start = Date.now();
@@ -615,9 +620,11 @@ describe("legacy migration via loadAllAssistants", () => {
615
620
  );
616
621
  expect(all[0].resources!.gatewayPort).toBe(7830);
617
622
 
618
- // AND the lockfile on disk should reflect the migration
623
+ // AND the lockfile on disk should reflect the migration — persisted to
624
+ // the canonical config-dir path (the legacy seed file is left in place
625
+ // as a read-only fallback).
619
626
  const rawDisk = JSON.parse(
620
- readFileSync(join(testDir, ".cadet.lock.json"), "utf-8"),
627
+ readFileSync(join(testDir, "lockfile.json"), "utf-8"),
621
628
  );
622
629
  const diskEntry = rawDisk.assistants[0];
623
630
  expect(diskEntry.baseDataDir).toBeUndefined();
@@ -715,21 +722,17 @@ describe("env-scoped lockfile and migration", () => {
715
722
  }
716
723
  });
717
724
 
718
- test("production lockfile path is unchanged — uses .cadet.lock.json", () => {
725
+ test("production writes land at the config-dir lockfile.json, never the legacy home-level name", () => {
719
726
  // With CADET_ENVIRONMENT unset, the env package resolves production,
720
- // whose canonical lockfile filename is `.cadet.lock.json`. This test
721
- // uses the existing CADET_LOCKFILE_DIR=testDir override to route the
722
- // lockfile to a scratch directory but verifies the FILENAME matches
723
- // the production convention (not the non-prod "lockfile.json").
727
+ // whose canonical lockfile is the config-dir `lockfile.json` — the same
728
+ // convention every environment uses. The home-level `.cadet.lock.json`
729
+ // name is a read-only fallback and is never (re)created by writes.
724
730
  const prevEnv = process.env.CADET_ENVIRONMENT;
725
731
  delete process.env.CADET_ENVIRONMENT;
726
732
  try {
727
733
  // Clear any existing lockfile from earlier tests
728
- try {
729
- rmSync(join(testDir, ".cadet.lock.json"));
730
- } catch {
731
- // ignore
732
- }
734
+ rmSync(join(testDir, "lockfile.json"), { force: true });
735
+ rmSync(join(testDir, ".cadet.lock.json"), { force: true });
733
736
 
734
737
  saveAssistantEntry({
735
738
  assistantId: "prod-bot",
@@ -737,12 +740,10 @@ describe("env-scoped lockfile and migration", () => {
737
740
  cloud: "local",
738
741
  });
739
742
 
740
- // The file should land at testDir/.cadet.lock.json — the production
741
- // filename — rather than testDir/lockfile.json.
742
- const prodPath = join(testDir, ".cadet.lock.json");
743
- const raw = JSON.parse(readFileSync(prodPath, "utf-8"));
743
+ const raw = JSON.parse(readFileSync(join(testDir, "lockfile.json"), "utf-8"));
744
744
  expect(raw.assistants).toHaveLength(1);
745
745
  expect(raw.assistants[0].assistantId).toBe("prod-bot");
746
+ expect(existsSync(join(testDir, ".cadet.lock.json"))).toBe(false);
746
747
  } finally {
747
748
  if (prevEnv !== undefined) {
748
749
  process.env.CADET_ENVIRONMENT = prevEnv;
@@ -765,6 +766,7 @@ describe("cross-environment hint on resolution failure", () => {
765
766
  // copy is deterministic. The current-env lockfile is emptied so resolution
766
767
  // genuinely finds nothing.
767
768
  process.env.CADET_ENVIRONMENT = "production";
769
+ rmSync(join(testDir, "lockfile.json"), { force: true });
768
770
  rmSync(join(testDir, ".cadet.lock.json"), { force: true });
769
771
  rmSync(join(testDir, ".cadet.lockfile.json"), { force: true });
770
772
  });
@@ -5,6 +5,7 @@ import {
5
5
  describe,
6
6
  expect,
7
7
  mock,
8
+ type Mock,
8
9
  spyOn,
9
10
  test,
10
11
  } from "bun:test";
@@ -79,6 +80,7 @@ const localRuntimePollJobStatusMock = spyOn(
79
80
  result: { manifest_sha256: "abc123def456" },
80
81
  });
81
82
 
83
+ /** A complete-but-fake guardian token; the SUT only reads the fields it sets. */
82
84
  // Mode 1 (runtime-direct local backup) uses guardian tokens. Don't exercise
83
85
  // it here, but the spies need to exist so the module under test can import
84
86
  // them without surprises. Saved to variables so afterAll can restore them —
@@ -87,31 +89,25 @@ const localRuntimePollJobStatusMock = spyOn(
87
89
  const loadGuardianTokenSpy = spyOn(
88
90
  guardianToken,
89
91
  "loadGuardianToken",
90
- ).mockReturnValue({
91
- accessToken: "local-token",
92
- accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
93
- } as unknown as ReturnType<typeof guardianToken.loadGuardianToken>);
92
+ ).mockReturnValue(makeGuardianTokenData({ accessToken: "local-token" }));
94
93
  const leaseGuardianTokenSpy = spyOn(
95
94
  guardianToken,
96
95
  "leaseGuardianToken",
97
- ).mockResolvedValue({
98
- accessToken: "leased-token",
99
- accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
100
- } as unknown as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>);
96
+ ).mockResolvedValue(makeGuardianTokenData({ accessToken: "leased-token" }));
101
97
 
102
98
  const getBackupsDirMock = spyOn(backupOps, "getBackupsDir").mockReturnValue(
103
99
  "/tmp/backups-default",
104
100
  );
105
101
 
106
102
  const mkdirSyncMock = spyOn(fs, "mkdirSync").mockImplementation(
107
- (() => undefined) as never,
103
+ () => undefined,
108
104
  );
109
105
  const writeFileSyncMock = spyOn(fs, "writeFileSync").mockImplementation(
110
106
  () => undefined,
111
107
  );
112
108
 
113
109
  let originalFetch: typeof globalThis.fetch;
114
- let exitMock: ReturnType<typeof mock>;
110
+ let exitMock: Mock<(code?: number | string | undefined) => never>;
115
111
 
116
112
  const CADET_ENTRY = {
117
113
  assistantId: "11111111-2222-3333-4444-555555555555",
@@ -126,10 +122,10 @@ function setArgv(...rest: string[]) {
126
122
 
127
123
  beforeEach(() => {
128
124
  originalFetch = globalThis.fetch;
129
- exitMock = mock((code?: number) => {
125
+ exitMock = mock((code?: number | string | undefined): never => {
130
126
  throw new Error(`process.exit:${code}`);
131
127
  });
132
- process.exit = exitMock as unknown as typeof process.exit;
128
+ process.exit = exitMock;
133
129
 
134
130
  findAssistantByNameMock.mockReset();
135
131
  findAssistantByNameMock.mockReturnValue(null);
@@ -162,17 +158,15 @@ beforeEach(() => {
162
158
  getBackupsDirMock.mockReset();
163
159
  getBackupsDirMock.mockReturnValue("/tmp/backups-default");
164
160
  loadGuardianTokenSpy.mockReset();
165
- loadGuardianTokenSpy.mockReturnValue({
166
- accessToken: "local-token",
167
- accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
168
- } as unknown as ReturnType<typeof guardianToken.loadGuardianToken>);
161
+ loadGuardianTokenSpy.mockReturnValue(
162
+ makeGuardianTokenData({ accessToken: "local-token" }),
163
+ );
169
164
  leaseGuardianTokenSpy.mockReset();
170
- leaseGuardianTokenSpy.mockResolvedValue({
171
- accessToken: "leased-token",
172
- accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
173
- } as unknown as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>);
165
+ leaseGuardianTokenSpy.mockResolvedValue(
166
+ makeGuardianTokenData({ accessToken: "leased-token" }),
167
+ );
174
168
  mkdirSyncMock.mockReset();
175
- mkdirSyncMock.mockImplementation((() => undefined) as never);
169
+ mkdirSyncMock.mockImplementation(() => undefined);
176
170
  writeFileSyncMock.mockReset();
177
171
  writeFileSyncMock.mockImplementation(() => undefined);
178
172
  });
@@ -200,20 +194,26 @@ afterAll(() => {
200
194
  });
201
195
 
202
196
  import { backup } from "../commands/backup.js";
197
+ import { withPreconnect } from "./helpers/http.js";
198
+ import { makeGuardianTokenData } from "./helpers/guardian-token.js";
203
199
 
204
200
  // ---------------------------------------------------------------------------
205
201
  // Helper: simulated GCS download response
206
202
  // ---------------------------------------------------------------------------
207
- function mockGcsDownload(body: Uint8Array, ok = true, status = 200) {
208
- globalThis.fetch = mock(async () => {
209
- const responseBody: BodyInit = ok
210
- ? new Blob([body as unknown as ArrayBuffer])
211
- : "boom";
203
+ function mockGcsDownload(
204
+ body: Uint8Array<ArrayBuffer>,
205
+ ok = true,
206
+ status = 200,
207
+ ) {
208
+ const gcsFetch = mock(async () => {
209
+ const responseBody: BodyInit = ok ? new Blob([body]) : "boom";
212
210
  return new Response(responseBody, {
213
211
  status,
214
212
  statusText: ok ? "OK" : "Error",
215
213
  });
216
- }) as unknown as typeof globalThis.fetch;
214
+ });
215
+ globalThis.fetch = withPreconnect(gcsFetch);
216
+ return gcsFetch;
217
217
  }
218
218
 
219
219
  describe("cadet backup <local>: guardian bootstrap secret", () => {
@@ -228,11 +228,9 @@ describe("cadet backup <local>: guardian bootstrap secret", () => {
228
228
  loadGuardianTokenSpy.mockReturnValue(null);
229
229
  setArgv("my-local", "--output", "/tmp/local-backup.vbundle");
230
230
 
231
- globalThis.fetch = mock(async () => {
232
- return new Response(new Uint8Array([1, 2, 3]), {
233
- status: 200,
234
- });
235
- }) as unknown as typeof globalThis.fetch;
231
+ globalThis.fetch = withPreconnect(
232
+ async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
233
+ );
236
234
 
237
235
  await backup();
238
236
 
@@ -294,9 +292,9 @@ describe("cadet backup: --export-timeout flag", () => {
294
292
  "/tmp/local-backup.vbundle",
295
293
  );
296
294
 
297
- globalThis.fetch = mock(async () => {
298
- return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
299
- }) as unknown as typeof globalThis.fetch;
295
+ globalThis.fetch = withPreconnect(
296
+ async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
297
+ );
300
298
 
301
299
  await backup();
302
300
 
@@ -313,7 +311,7 @@ describe("cadet backup <platform-managed>: GCS happy path", () => {
313
311
  setArgv("my-platform");
314
312
 
315
313
  const bytes = new Uint8Array([1, 2, 3, 4]);
316
- mockGcsDownload(bytes);
314
+ const gcsFetch = mockGcsDownload(bytes);
317
315
 
318
316
  await backup();
319
317
 
@@ -366,13 +364,12 @@ describe("cadet backup <platform-managed>: GCS happy path", () => {
366
364
  "https://cloud.mycadet.ai",
367
365
  );
368
366
  const downloadCall = platformRequestSignedUrlMock.mock.calls.find(
369
- (c) => (c[0] as { operation: string }).operation === "download",
367
+ (c) => c[0].operation === "download",
370
368
  );
371
369
  expect(downloadCall).toBeDefined();
372
370
  expect(downloadCall![0]).not.toHaveProperty("targetRuntimeVersion");
373
371
 
374
372
  // GCS fetch went directly to the signed download URL with no auth.
375
- const gcsFetch = globalThis.fetch as unknown as ReturnType<typeof mock>;
376
373
  expect(gcsFetch).toHaveBeenCalledWith(
377
374
  "https://storage.googleapis.com/bucket/signed-download",
378
375
  );
@@ -382,7 +379,7 @@ describe("cadet backup <platform-managed>: GCS happy path", () => {
382
379
  const [outputPath, written] = writeFileSyncMock.mock.calls[0]!;
383
380
  expect(written).toEqual(bytes);
384
381
  expect(typeof outputPath).toBe("string");
385
- expect(outputPath as string).toMatch(
382
+ expect(outputPath).toMatch(
386
383
  /\/tmp\/backups-default\/my-platform-.*\.vbundle$/,
387
384
  );
388
385
  expect(mkdirSyncMock).toHaveBeenCalled();
@@ -411,10 +408,8 @@ describe("cadet backup <platform-managed>: GCS happy path", () => {
411
408
  await backup();
412
409
 
413
410
  const [outputPath] = writeFileSyncMock.mock.calls[0]!;
414
- expect(outputPath as string).toMatch(
415
- /^\/tmp\/backups-default\/my-platform-/,
416
- );
417
- expect(outputPath as string).toMatch(/\.vbundle$/);
411
+ expect(outputPath).toMatch(/^\/tmp\/backups-default\/my-platform-/);
412
+ expect(outputPath).toMatch(/\.vbundle$/);
418
413
  });
419
414
 
420
415
  test("signed-URL requests target entry.runtimeUrl, not getPlatformUrl() — regression for staging/dev assistants", async () => {
@@ -502,10 +497,10 @@ describe("cadet backup <platform-managed>: GCS happy path", () => {
502
497
  // `platformToken` parameter instead of `exportPlatformToken`, a
503
498
  // future poll-loop refresh would silently break this invariant.
504
499
  const uploadCallToken = platformRequestSignedUrlMock.mock.calls.find(
505
- (c) => (c[0] as { operation: string }).operation === "upload",
500
+ (c) => c[0].operation === "upload",
506
501
  )![1];
507
502
  const downloadCallToken = platformRequestSignedUrlMock.mock.calls.find(
508
- (c) => (c[0] as { operation: string }).operation === "download",
503
+ (c) => c[0].operation === "download",
509
504
  )![1];
510
505
  expect(downloadCallToken).toBe(uploadCallToken);
511
506
  const kickoffToken = localRuntimeExportToGcsMock.mock.calls[0]![1];