@mycadet/cli 0.12.10 → 0.13.0-dev.202610171617.9ea363e

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 (111) 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/channels.ts +0 -1
  4. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  5. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  6. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
  7. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  8. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  9. package/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
  10. package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  11. package/package.json +8 -3
  12. package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
  13. package/src/__tests__/backup-ops-matching.test.ts +15 -13
  14. package/src/__tests__/backup-ops.test.ts +105 -0
  15. package/src/__tests__/backup.test.ts +114 -673
  16. package/src/__tests__/cloud-login-poll.test.ts +69 -0
  17. package/src/__tests__/cloud-releases.test.ts +82 -0
  18. package/src/__tests__/commands-mock.ts +101 -0
  19. package/src/__tests__/debug.test.ts +17 -91
  20. package/src/__tests__/devices.test.ts +64 -2
  21. package/src/__tests__/ensure-binary.test.ts +52 -0
  22. package/src/__tests__/gateway-token.test.ts +267 -0
  23. package/src/__tests__/hatch-next-steps.test.ts +38 -0
  24. package/src/__tests__/host-teleport-docker.test.ts +371 -0
  25. package/src/__tests__/host-teleport.test.ts +405 -0
  26. package/src/__tests__/multi-local.test.ts +3 -3
  27. package/src/__tests__/nginx-ingress.test.ts +587 -36
  28. package/src/__tests__/orphan-detection.test.ts +1 -1
  29. package/src/__tests__/paired-lifecycle.test.ts +2 -2
  30. package/src/__tests__/ps-cloud-status.test.ts +8 -7
  31. package/src/__tests__/root-backup-docker.test.ts +294 -0
  32. package/src/__tests__/root-backup.test.ts +527 -0
  33. package/src/__tests__/root-restore-command.test.ts +147 -0
  34. package/src/__tests__/root-signing-key.test.ts +50 -0
  35. package/src/__tests__/setup.test.ts +17 -7
  36. package/src/__tests__/shell-mode.test.ts +83 -0
  37. package/src/__tests__/sleep.test.ts +11 -0
  38. package/src/__tests__/teleport.test.ts +68 -2996
  39. package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
  40. package/src/__tests__/tunnel-stop.test.ts +226 -0
  41. package/src/__tests__/tunnel.test.ts +22 -4
  42. package/src/__tests__/upgrade-local.test.ts +21 -11
  43. package/src/__tests__/upgrade-replay-env.test.ts +2 -2
  44. package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
  45. package/src/__tests__/wake.test.ts +4 -1
  46. package/src/__tests__/windows-lifecycle.test.ts +25 -0
  47. package/src/commands/backup.ts +44 -399
  48. package/src/commands/client.ts +3 -3
  49. package/src/commands/completions.ts +0 -2
  50. package/src/commands/debug.ts +75 -131
  51. package/src/commands/devices.ts +117 -47
  52. package/src/commands/gateway/token.ts +77 -23
  53. package/src/commands/hatch.ts +10 -52
  54. package/src/commands/pair.ts +9 -3
  55. package/src/commands/ps.ts +4 -2
  56. package/src/commands/restore.ts +77 -444
  57. package/src/commands/retire.ts +2 -3
  58. package/src/commands/sleep.ts +15 -2
  59. package/src/commands/teleport.ts +47 -1559
  60. package/src/commands/tunnel.ts +91 -11
  61. package/src/commands/upgrade.ts +84 -157
  62. package/src/commands/wake.ts +1 -1
  63. package/src/index.ts +0 -4
  64. package/src/lib/__tests__/http-client-version.test.ts +29 -0
  65. package/src/lib/__tests__/runtime-url.test.ts +3 -75
  66. package/src/lib/__tests__/web-dist.test.ts +27 -1
  67. package/src/lib/assistant-client.ts +1 -1
  68. package/src/lib/assistant-config.ts +43 -23
  69. package/src/lib/backup-ops.ts +37 -251
  70. package/src/lib/cloud-client.ts +10 -13
  71. package/src/lib/cloud-login-poll.ts +75 -0
  72. package/src/lib/cloud-login.ts +5 -2
  73. package/src/lib/cloud-releases.ts +54 -10
  74. package/src/lib/cloud-values.ts +1 -1
  75. package/src/lib/confirm-action.ts +2 -2
  76. package/src/lib/docker.ts +65 -34
  77. package/src/lib/ensure-binary.ts +65 -6
  78. package/src/lib/github-repository.ts +2 -0
  79. package/src/lib/guardian-token.ts +1 -23
  80. package/src/lib/hatch-local.ts +18 -52
  81. package/src/lib/hatch-next-steps.ts +15 -2
  82. package/src/lib/host-teleport.ts +506 -0
  83. package/src/lib/http-client.ts +25 -0
  84. package/src/lib/ingress-config.ts +31 -0
  85. package/src/lib/live-voice/tunnel.test.ts +1 -1
  86. package/src/lib/live-voice/tunnel.ts +3 -3
  87. package/src/lib/local.ts +10 -8
  88. package/src/lib/nginx-ingress.ts +572 -95
  89. package/src/lib/process.ts +13 -0
  90. package/src/lib/root-backup.ts +917 -0
  91. package/src/lib/root-signing-key.ts +31 -0
  92. package/src/lib/runtime-url.ts +0 -29
  93. package/src/lib/teleport-backup.ts +3 -16
  94. package/src/lib/tunnel.ts +87 -34
  95. package/src/lib/tunnels/cadet.ts +12 -1
  96. package/src/lib/tunnels/cloudflare.ts +36 -1
  97. package/src/lib/tunnels/ngrok.ts +16 -2
  98. package/src/lib/tunnels/process-lifecycle.ts +26 -0
  99. package/src/lib/tunnels/tailscale.ts +79 -1
  100. package/src/lib/upgrade-lifecycle.ts +63 -141
  101. package/src/lib/web-dist.ts +79 -1
  102. package/src/lib/xdg-log.ts +11 -0
  103. package/src/__tests__/bundle-staging.test.ts +0 -268
  104. package/src/__tests__/unpair.test.ts +0 -160
  105. package/src/commands/rollback.ts +0 -479
  106. package/src/commands/unpair.ts +0 -122
  107. package/src/lib/__tests__/job-polling.test.ts +0 -278
  108. package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
  109. package/src/lib/bundle-staging.ts +0 -220
  110. package/src/lib/job-polling.ts +0 -195
  111. package/src/lib/local-runtime-client.ts +0 -374
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Command-level tests for `cadet gateway token get/refresh`.
3
+ *
4
+ * Resolution: the assistant id is OPTIONAL — resolveTargetAssistant falls
5
+ * back to the active (or sole) assistant, so `cadet gateway token refresh`
6
+ * works with no arguments.
7
+ *
8
+ * Self-heal: a spent credential (401 — expired/revoked refresh token) or a
9
+ * missing token file (404) triggers an in-place re-provision via the
10
+ * gateway's bootstrap reset — the same machinery `cadet wake
11
+ * --repair-guardian` runs. It must NEVER suggest `cadet hatch`: hatching
12
+ * creates a different assistant.
13
+ */
14
+ import {
15
+ afterAll,
16
+ afterEach,
17
+ beforeEach,
18
+ describe,
19
+ expect,
20
+ spyOn,
21
+ test,
22
+ } from "bun:test";
23
+ import { mkdtempSync, rmSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { join } from "node:path";
26
+
27
+ import { gatewayToken } from "../commands/gateway/token.js";
28
+ import {
29
+ saveAssistantEntry,
30
+ setActiveAssistant,
31
+ } from "../lib/assistant-config.js";
32
+ import {
33
+ loadGuardianToken,
34
+ saveGuardianToken,
35
+ } from "../lib/guardian-token.js";
36
+ import { makeGuardianTokenData } from "./helpers/guardian-token.js";
37
+ import { withPreconnect } from "./helpers/http.js";
38
+
39
+ const ORIGINAL_LOCKFILE_DIR = process.env.CADET_LOCKFILE_DIR;
40
+ const ORIGINAL_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
41
+ const ORIGINAL_ENVIRONMENT = process.env.CADET_ENVIRONMENT;
42
+ const ORIGINAL_ARGV = [...process.argv];
43
+ const REAL_FETCH = globalThis.fetch;
44
+
45
+ let tempHome: string;
46
+
47
+ function seedLocalEntry(overrides: Record<string, unknown> = {}): void {
48
+ saveAssistantEntry({
49
+ assistantId: "px",
50
+ name: "Prime Quail",
51
+ runtimeUrl: "http://127.0.0.1:7830",
52
+ localUrl: "http://127.0.0.1:7830",
53
+ cloud: "local",
54
+ paired: false,
55
+ ...overrides,
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Run `gatewayToken()` with console.log + console.error + process.exit
61
+ * spied; return the captured streams and whether the run exited 1.
62
+ */
63
+ async function runGatewayToken(): Promise<{
64
+ exited: boolean;
65
+ logs: string;
66
+ errors: string;
67
+ }> {
68
+ const logs: string[] = [];
69
+ const errors: string[] = [];
70
+ const logSpy = spyOn(console, "log").mockImplementation(
71
+ (...a: unknown[]) => {
72
+ logs.push(a.join(" "));
73
+ },
74
+ );
75
+ const errSpy = spyOn(console, "error").mockImplementation(
76
+ (...a: unknown[]) => {
77
+ errors.push(a.join(" "));
78
+ },
79
+ );
80
+ const exitSpy = spyOn(process, "exit").mockImplementation(
81
+ (code?: number | string | undefined): never => {
82
+ throw new Error(`exit:${code ?? 0}`);
83
+ },
84
+ );
85
+ let exited = false;
86
+ try {
87
+ await gatewayToken();
88
+ } catch (e) {
89
+ exited = e instanceof Error && e.message === "exit:1";
90
+ } finally {
91
+ logSpy.mockRestore();
92
+ errSpy.mockRestore();
93
+ exitSpy.mockRestore();
94
+ }
95
+ return { exited, logs: logs.join("\n"), errors: errors.join("\n") };
96
+ }
97
+
98
+ /** Fetch double covering the repair choreography (readyz/reset/init). */
99
+ function mockRepairFetch(initBody: object): void {
100
+ globalThis.fetch = withPreconnect(async (input: RequestInfo | URL) => {
101
+ const url = String(input);
102
+ if (url.endsWith("/readyz")) {
103
+ return new Response("", { status: 404 }); // no starting gate — proceed
104
+ }
105
+ if (url.endsWith("/v1/guardian/reset-bootstrap")) {
106
+ return new Response("{}", { status: 200 });
107
+ }
108
+ if (url.endsWith("/v1/guardian/init")) {
109
+ return new Response(JSON.stringify(initBody), {
110
+ status: 200,
111
+ headers: { "content-type": "application/json" },
112
+ });
113
+ }
114
+ throw new Error(`unexpected fetch: ${url}`);
115
+ });
116
+ }
117
+
118
+ const FRESH_LEASE = {
119
+ guardianPrincipalId: "principal-new",
120
+ accessToken: "fresh-acc",
121
+ accessTokenExpiresAt: Date.now() + 60 * 60 * 1000,
122
+ refreshToken: "fresh-ref",
123
+ refreshTokenExpiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000,
124
+ refreshAfter: Date.now() + 48 * 60 * 60 * 1000,
125
+ isNew: false,
126
+ };
127
+
128
+ describe("gateway token command", () => {
129
+ beforeEach(() => {
130
+ tempHome = mkdtempSync(join(tmpdir(), "gateway-token-test-"));
131
+ process.env.CADET_LOCKFILE_DIR = tempHome;
132
+ process.env.XDG_CONFIG_HOME = tempHome;
133
+ delete process.env.CADET_ENVIRONMENT;
134
+ });
135
+
136
+ afterEach(() => {
137
+ process.argv = [...ORIGINAL_ARGV];
138
+ globalThis.fetch = REAL_FETCH;
139
+ if (ORIGINAL_LOCKFILE_DIR === undefined)
140
+ delete process.env.CADET_LOCKFILE_DIR;
141
+ else process.env.CADET_LOCKFILE_DIR = ORIGINAL_LOCKFILE_DIR;
142
+ if (ORIGINAL_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME;
143
+ else process.env.XDG_CONFIG_HOME = ORIGINAL_CONFIG_HOME;
144
+ if (ORIGINAL_ENVIRONMENT === undefined) delete process.env.CADET_ENVIRONMENT;
145
+ else process.env.CADET_ENVIRONMENT = ORIGINAL_ENVIRONMENT;
146
+ rmSync(tempHome, { recursive: true, force: true });
147
+ });
148
+
149
+ afterAll(() => {
150
+ globalThis.fetch = REAL_FETCH;
151
+ });
152
+
153
+ test("refresh without a name targets the sole assistant and prints the refreshed token", async () => {
154
+ seedLocalEntry();
155
+ saveGuardianToken("px", makeGuardianTokenData());
156
+ let sawRefresh = false;
157
+ globalThis.fetch = withPreconnect(async (input: RequestInfo | URL) => {
158
+ const url = String(input);
159
+ if (url.endsWith("/v1/guardian/refresh")) {
160
+ sawRefresh = true;
161
+ return new Response(JSON.stringify({ accessToken: "new-acc" }), {
162
+ status: 200,
163
+ headers: { "content-type": "application/json" },
164
+ });
165
+ }
166
+ throw new Error(`unexpected fetch: ${url}`);
167
+ });
168
+
169
+ process.argv = ["bun", "cadet", "gateway", "token", "refresh"];
170
+ const { exited, logs, errors } = await runGatewayToken();
171
+
172
+ expect(sawRefresh).toBe(true);
173
+ expect(exited).toBe(false);
174
+ expect(logs).toContain("new-acc");
175
+ expect(loadGuardianToken("px")?.accessToken).toBe("new-acc");
176
+ expect(`${logs}\n${errors}`).not.toContain("hatch");
177
+ });
178
+
179
+ test("get without a name targets the active assistant", async () => {
180
+ seedLocalEntry();
181
+ saveAssistantEntry({
182
+ assistantId: "py",
183
+ name: "Other Box",
184
+ runtimeUrl: "http://127.0.0.1:7831",
185
+ localUrl: "http://127.0.0.1:7831",
186
+ cloud: "local",
187
+ paired: false,
188
+ });
189
+ setActiveAssistant("py");
190
+ saveGuardianToken(
191
+ "py",
192
+ makeGuardianTokenData({ accessToken: "active-token" }),
193
+ );
194
+
195
+ process.argv = ["bun", "cadet", "gateway", "token", "get"];
196
+ const { exited, logs } = await runGatewayToken();
197
+
198
+ expect(exited).toBe(false);
199
+ expect(logs).toContain("active-token");
200
+ });
201
+
202
+ test("an unknown name is a strict-match error", async () => {
203
+ seedLocalEntry();
204
+ saveGuardianToken("px", makeGuardianTokenData());
205
+
206
+ process.argv = ["bun", "cadet", "gateway", "token", "get", "nope"];
207
+ const { exited, errors } = await runGatewayToken();
208
+
209
+ expect(exited).toBe(true);
210
+ expect(errors).toContain("No assistant found with name or ID 'nope'");
211
+ });
212
+
213
+ test("refresh self-heals an expired credential via the bootstrap reset", async () => {
214
+ seedLocalEntry({ guardianBootstrapSecret: "boot-secret-1" });
215
+ saveGuardianToken(
216
+ "px",
217
+ makeGuardianTokenData({
218
+ refreshTokenExpiresAt: new Date(Date.now() - 1000).toISOString(),
219
+ }),
220
+ );
221
+ mockRepairFetch(FRESH_LEASE);
222
+
223
+ process.argv = ["bun", "cadet", "gateway", "token", "refresh"];
224
+ const { exited, logs, errors } = await runGatewayToken();
225
+
226
+ expect(exited).toBe(false);
227
+ expect(errors).toContain(
228
+ "Re-provisioned guardian token (this revoked other devices' paired tokens).",
229
+ );
230
+ expect(logs).toContain("fresh-acc");
231
+ expect(loadGuardianToken("px")?.accessToken).toBe("fresh-acc");
232
+ expect(`${logs}\n${errors}`).not.toContain("hatch");
233
+ });
234
+
235
+ test("refresh reports repair failure and never suggests hatching", async () => {
236
+ seedLocalEntry({ guardianBootstrapSecret: "boot-secret-1" });
237
+ saveGuardianToken(
238
+ "px",
239
+ makeGuardianTokenData({
240
+ refreshTokenExpiresAt: new Date(Date.now() - 1000).toISOString(),
241
+ }),
242
+ );
243
+ // Malformed 200 lease → GuardianLeasePermanentError → no retries.
244
+ mockRepairFetch({});
245
+
246
+ process.argv = ["bun", "cadet", "gateway", "token", "refresh"];
247
+ const { exited, logs, errors } = await runGatewayToken();
248
+
249
+ expect(exited).toBe(true);
250
+ expect(errors).toContain("Guardian token could not be re-provisioned.");
251
+ expect(errors).toContain("--repair-guardian");
252
+ expect(errors).toContain("CADET_REFRESH_ERROR=");
253
+ expect(`${logs}\n${errors}`).not.toContain("hatch");
254
+ });
255
+
256
+ test("refresh re-provisions when the token file is missing entirely", async () => {
257
+ seedLocalEntry({ guardianBootstrapSecret: "boot-secret-1" });
258
+ mockRepairFetch(FRESH_LEASE);
259
+
260
+ process.argv = ["bun", "cadet", "gateway", "token", "refresh"];
261
+ const { exited, logs } = await runGatewayToken();
262
+
263
+ expect(exited).toBe(false);
264
+ expect(logs).toContain("fresh-acc");
265
+ expect(loadGuardianToken("px")?.accessToken).toBe("fresh-acc");
266
+ });
267
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { logHatchNextSteps } from "../lib/hatch-next-steps";
4
+
5
+ function captureNextSteps(): string[] {
6
+ const lines: string[] = [];
7
+ logHatchNextSteps((message) => lines.push(message));
8
+ return lines;
9
+ }
10
+
11
+ describe("logHatchNextSteps", () => {
12
+ test("names the production binary in a production environment", () => {
13
+ const prev = process.env.CADET_ENVIRONMENT;
14
+ try {
15
+ process.env.CADET_ENVIRONMENT = "production";
16
+ const lines = captureNextSteps();
17
+ expect(lines.join("\n")).toContain("`cadet client`");
18
+ expect(lines.join("\n")).toContain("`cadet tunnel`");
19
+ expect(lines.join("\n")).not.toContain("cadet-dev");
20
+ } finally {
21
+ if (prev === undefined) delete process.env.CADET_ENVIRONMENT;
22
+ else process.env.CADET_ENVIRONMENT = prev;
23
+ }
24
+ });
25
+
26
+ test("names the environment-suffixed binary in dev", () => {
27
+ const prev = process.env.CADET_ENVIRONMENT;
28
+ try {
29
+ process.env.CADET_ENVIRONMENT = "dev";
30
+ const lines = captureNextSteps();
31
+ expect(lines.join("\n")).toContain("`cadet-dev client`");
32
+ expect(lines.join("\n")).toContain("`cadet-dev tunnel`");
33
+ } finally {
34
+ if (prev === undefined) delete process.env.CADET_ENVIRONMENT;
35
+ else process.env.CADET_ENVIRONMENT = prev;
36
+ }
37
+ });
38
+ });
@@ -0,0 +1,371 @@
1
+ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test";
2
+ import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { Readable } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
7
+ import * as tar from "tar";
8
+ import {
9
+ restoreHostTeleportTarget,
10
+ TeleportRecoveryError,
11
+ } from "../lib/host-teleport.js";
12
+ import { dockerResourceNames } from "../lib/docker.js";
13
+ import { DOCKER_STATEFUL_SET_SPEC } from "../lib/statefulset.js";
14
+ import { packRoot, STATE_ROOTS } from "../lib/root-backup.js";
15
+ import type { AssistantEntry } from "../lib/assistant-config.js";
16
+ import { mockCommands } from "./commands-mock.js";
17
+
18
+ const entry: AssistantEntry = {
19
+ assistantId: "destination",
20
+ cloud: "docker",
21
+ runtimeUrl: "http://localhost:7830",
22
+ containerInfo: {
23
+ assistantImage: "image-assistant",
24
+ gatewayImage: "image-gateway",
25
+ cesImage: "image-ces",
26
+ assistantPort: 7821,
27
+ },
28
+ };
29
+ const names = dockerResourceNames(entry.assistantId);
30
+ const containers = {
31
+ assistant: names.assistantContainer,
32
+ gateway: names.gatewayContainer,
33
+ "credential-executor": names.cesContainer,
34
+ };
35
+ const services: Array<keyof typeof containers> = ["assistant", "gateway", "credential-executor"];
36
+ const rootContainer = {
37
+ workspace: names.assistantContainer,
38
+ "gateway-security": names.gatewayContainer,
39
+ "ces-security": names.cesContainer,
40
+ "ces-data": names.cesContainer,
41
+ };
42
+ let directory: string, archive: string, recovery: string;
43
+ let commands: ReturnType<typeof mockCommands>;
44
+ let log: ReturnType<typeof spyOn>;
45
+ let failReadiness: boolean, failEveryWrite: boolean, writes: number;
46
+ let states: Map<string, ReturnType<typeof inspection>>;
47
+ function inspection(service: keyof typeof containers) {
48
+ const spec = DOCKER_STATEFUL_SET_SPEC.containers.find(
49
+ (item) => item.internalName === service,
50
+ )!;
51
+ return {
52
+ Id: `${service}-id`,
53
+ Image: `image-${service}`,
54
+ State: { Status: "exited", Running: false },
55
+ Config: {
56
+ User: "1001:1001",
57
+ Env: [
58
+ "ACTOR_TOKEN_SIGNING_KEY=" + "07".repeat(32),
59
+ "CES_SERVICE_TOKEN=internal-test-token",
60
+ "RUNTIME_HTTP_PORT=7821",
61
+ "GATEWAY_PORT=7830",
62
+ "GUARDIAN_BOOTSTRAP_SECRET=bootstrap-test-secret",
63
+ ],
64
+ },
65
+ HostConfig: {
66
+ NetworkMode:
67
+ service === "assistant" ? names.network : "container:assistant-id",
68
+ },
69
+ Mounts: spec.volumeMounts.map((mount) => ({
70
+ Type: "volume",
71
+ Name: DOCKER_STATEFUL_SET_SPEC.volumeClaimTemplates
72
+ .find((volume) => volume.name === mount.volumeName)!
73
+ .dockerVolume(entry.assistantId),
74
+ Source: "/remote/volume",
75
+ Destination: mount.mountPath,
76
+ RW: !mount.readOnly,
77
+ })),
78
+ };
79
+ }
80
+ const previousDataHome = process.env.XDG_DATA_HOME;
81
+ beforeEach(async () => {
82
+ directory = await mkdtemp(join(tmpdir(), "teleport-docker-"));
83
+ process.env.XDG_DATA_HOME = directory;
84
+ archive = join(directory, "source.tar.gz");
85
+ recovery = join(directory, "recovery.tar.gz");
86
+ for (const flavor of ["source", "original", "volumes"]) {
87
+ for (const root of STATE_ROOTS) {
88
+ await mkdir(join(directory, flavor, root), { recursive: true });
89
+ await writeFile(
90
+ join(directory, flavor, root, "value"),
91
+ flavor === "volumes" ? "original" : flavor,
92
+ { mode: 0o600 },
93
+ );
94
+ }
95
+ await writeFile(
96
+ join(directory, flavor, "gateway-security", "actor-token-signing-key"),
97
+ Buffer.alloc(32, flavor === "source" ? 9 : 7),
98
+ );
99
+ await writeFile(
100
+ join(directory, flavor, "ces-security", "keys.enc"),
101
+ flavor === "source" ? "source ciphertext" : "original ciphertext",
102
+ { mode: 0o600 },
103
+ );
104
+ }
105
+ await packRoot(join(directory, "source"), archive);
106
+ await packRoot(join(directory, "original"), recovery);
107
+ states = new Map(
108
+ services.map((service) => [
109
+ containers[service],
110
+ inspection(service),
111
+ ]),
112
+ );
113
+ failReadiness = false;
114
+ failEveryWrite = false;
115
+ writes = 0;
116
+ log = spyOn(console, "log").mockImplementation(() => {});
117
+ commands = mockCommands();
118
+ commands.on(
119
+ "docker",
120
+ (args) => args[0] === "inspect",
121
+ ({ args }) => {
122
+ const state = states.get(args.at(-1)!);
123
+ if (!state) {
124
+ return { code: 1, stderr: "No such container" };
125
+ }
126
+ if (args.includes("--format")) {
127
+ return {
128
+ stdout:
129
+ args[2] === "{{.State.Status}}"
130
+ ? state.State.Status
131
+ : String(state.State.Running),
132
+ };
133
+ }
134
+ return { stdout: JSON.stringify([state]) };
135
+ },
136
+ );
137
+ commands.on(
138
+ "docker",
139
+ (args) => args[0] === "rm",
140
+ ({ args }) => {
141
+ const state = states.get(args[1]!);
142
+ if (!state) {
143
+ return { code: 1, stderr: "No such container" };
144
+ }
145
+ expect(state.State.Running).toBe(false);
146
+ states.delete(args[1]!);
147
+ return {};
148
+ },
149
+ );
150
+ commands.on(
151
+ "docker",
152
+ (args) => args[0] === "create",
153
+ ({ args }) => {
154
+ const container = args[args.indexOf("--name") + 1]!;
155
+ const service = services.find((service) => containers[service] === container)!;
156
+ const state = inspection(service);
157
+ state.Config.Env = args.flatMap((value, index) =>
158
+ value === "-e" ? [args[index + 1]!] : [],
159
+ );
160
+ states.set(container, state);
161
+ return {};
162
+ },
163
+ );
164
+ commands.on(
165
+ "docker",
166
+ (args) => args[0] === "start",
167
+ ({ args }) => {
168
+ const state = states.get(args[1]!)!;
169
+ state.State.Running = true;
170
+ state.State.Status = "running";
171
+ return {};
172
+ },
173
+ );
174
+ commands.on(
175
+ "docker",
176
+ (args) => args[0] === "kill",
177
+ ({ args }) => {
178
+ expect(args[1]).toBe("--signal=SIGTERM");
179
+ const state = states.get(args.at(-1)!)!;
180
+ state.State.Running = false;
181
+ state.State.Status = "exited";
182
+ return {};
183
+ },
184
+ );
185
+ commands.on(
186
+ "docker",
187
+ (args) => args[0] === "cp",
188
+ async ({ args }) => {
189
+ const root = STATE_ROOTS.find((root) => args[1]!.includes(`:/${root}/`))!;
190
+ expect(
191
+ states.get(rootContainer[root])!.State
192
+ .Running,
193
+ ).toBe(false);
194
+ await cp(join(directory, "volumes", root), args[2]!, { recursive: true });
195
+ return {};
196
+ },
197
+ );
198
+ commands.on(
199
+ "docker",
200
+ (args) => args[0] === "run" && args.includes("-i"),
201
+ async ({ args, input }) => {
202
+ const mount = args[args.indexOf("--mount") + 1]!;
203
+ const root = STATE_ROOTS.find(
204
+ (root) =>
205
+ states
206
+ .get(rootContainer[root])!
207
+ .Mounts.find((item) => item.Destination === `/${root}`)?.Name ===
208
+ mount.split("src=")[1]!.split(",")[0]!,
209
+ )!;
210
+ expect([...states.values()].every((state) => !state.State.Running)).toBe(
211
+ true,
212
+ );
213
+ expect(args).toContain("--network=none");
214
+ expect(args).toContain("--user=0");
215
+ expect(args).toContain("1001:1001");
216
+ expect(args[args.indexOf("-ec") + 1]).toContain('chown -R "$1" /restore');
217
+ writes++;
218
+ if (failEveryWrite) {
219
+ return { code: 1, stderr: "injected volume write failure" };
220
+ }
221
+ const rootDir = join(directory, "volumes", root);
222
+ await rm(rootDir, { recursive: true });
223
+ await mkdir(rootDir);
224
+ await pipeline(
225
+ Readable.from([input]),
226
+ tar.x({ cwd: rootDir, preserveOwner: false }),
227
+ );
228
+ return {};
229
+ },
230
+ );
231
+ commands.on(
232
+ "docker",
233
+ (args) => args[0] === "exec",
234
+ () =>
235
+ failReadiness
236
+ ? { code: 2, stdout: "migration_failed", stderr: "migration_failed" }
237
+ : { stdout: "ready" },
238
+ );
239
+ });
240
+ afterEach(async () => {
241
+ if (previousDataHome === undefined) {
242
+ delete process.env.XDG_DATA_HOME;
243
+ } else {
244
+ process.env.XDG_DATA_HOME = previousDataHome;
245
+ }
246
+ commands.restore();
247
+ log.mockRestore();
248
+ await rm(directory, { recursive: true, force: true });
249
+ });
250
+
251
+ test("recreates immutable identity overrides and imports all roots before first start", async () => {
252
+ await restoreHostTeleportTarget(entry, archive, recovery);
253
+ for (const root of STATE_ROOTS) {
254
+ expect(
255
+ await readFile(join(directory, "volumes", root, "value"), "utf8"),
256
+ ).toBe("source");
257
+ }
258
+ expect(
259
+ await readFile(
260
+ join(directory, "volumes", "ces-security", "keys.enc"),
261
+ "utf8",
262
+ ),
263
+ ).toBe("source ciphertext");
264
+ expect(
265
+ await readFile(
266
+ join(directory, "volumes", "gateway-security", "actor-token-signing-key"),
267
+ ),
268
+ ).toEqual(Buffer.alloc(32, 9));
269
+ expect(
270
+ [...states.values()].every(
271
+ (state) =>
272
+ !state.Config.Env.some((value) =>
273
+ value.startsWith("ACTOR_TOKEN_SIGNING_KEY="),
274
+ ),
275
+ ),
276
+ ).toBe(true);
277
+ const firstStart = commands.calls.findIndex(
278
+ (request) => request.args[0] === "start",
279
+ );
280
+ const installs = commands.calls
281
+ .map((request, index) => ({ request, index }))
282
+ .filter(
283
+ ({ request }) => request.args[0] === "run" && request.args.includes("-i"),
284
+ );
285
+ expect(installs).toHaveLength(4);
286
+ expect(installs.every(({ index }) => index < firstStart)).toBe(true);
287
+ });
288
+ test("readiness failure restores original ciphertext, gateway identity, and stopped containers", async () => {
289
+ failReadiness = true;
290
+ await expect(
291
+ restoreHostTeleportTarget(entry, archive, recovery),
292
+ ).rejects.toThrow("did not become ready");
293
+ for (const root of STATE_ROOTS) {
294
+ expect(
295
+ await readFile(join(directory, "volumes", root, "value"), "utf8"),
296
+ ).toBe("original");
297
+ }
298
+ expect(
299
+ await readFile(
300
+ join(directory, "volumes", "ces-security", "keys.enc"),
301
+ "utf8",
302
+ ),
303
+ ).toBe("original ciphertext");
304
+ expect([...states.values()].every((state) => !state.State.Running)).toBe(
305
+ true,
306
+ );
307
+ expect(states.get(names.gatewayContainer)!.Config.Env).toContain(
308
+ "ACTOR_TOKEN_SIGNING_KEY=" + "07".repeat(32),
309
+ );
310
+ });
311
+ test("unrecoverable writes leave services stopped and report the retained recovery tar", async () => {
312
+ failEveryWrite = true;
313
+ await expect(
314
+ restoreHostTeleportTarget(entry, archive, recovery),
315
+ ).rejects.toBeInstanceOf(TeleportRecoveryError);
316
+ expect([...states.values()].every((state) => !state.State.Running)).toBe(
317
+ true,
318
+ );
319
+ expect(writes).toBeGreaterThan(1);
320
+ });
321
+ test("customized persistent mounts are rejected before container removal or volume writes", async () => {
322
+ states.get(names.cesContainer)!.Mounts[0]!.Name = "custom-security";
323
+ await expect(
324
+ restoreHostTeleportTarget(entry, archive, recovery),
325
+ ).rejects.toThrow("customized Docker mounts");
326
+ expect(commands.calls.some((request) => request.args[0] === "rm")).toBe(
327
+ false,
328
+ );
329
+ expect(writes).toBe(0);
330
+ });
331
+ test("legacy vbundle is rejected before touching destination containers", async () => {
332
+ await expect(
333
+ restoreHostTeleportTarget(
334
+ entry,
335
+ join(directory, "legacy.vbundle"),
336
+ recovery,
337
+ ),
338
+ ).rejects.toThrow("vellum-to-cadet");
339
+ expect(commands.calls).toHaveLength(0);
340
+ });
341
+
342
+ test("reads the remote Docker runtime version through in-container health instead of host ports", async () => {
343
+ const { readHostRuntimeVersion } = await import("../lib/host-teleport.js");
344
+ commands.on(
345
+ "docker",
346
+ (args) => args[0] === "exec" && args.at(-1)?.endsWith("/healthz") === true,
347
+ ({ args }) => {
348
+ expect(args[1]).toBe(names.gatewayContainer);
349
+ expect(args.at(-1)).toBe("http://127.0.0.1:7830/healthz");
350
+ return { stdout: JSON.stringify({ version: "0.12.9" }) };
351
+ },
352
+ );
353
+ expect(await readHostRuntimeVersion(entry)).toBe("0.12.9");
354
+ });
355
+
356
+ test("sleeping remote Docker runtime uses its recorded image tag for the version guard", async () => {
357
+ const { readHostRuntimeVersion } = await import("../lib/host-teleport.js");
358
+ commands.on("docker", (args) => args[0] === "exec", {
359
+ code: 1,
360
+ stderr: "container is stopped",
361
+ });
362
+ expect(
363
+ await readHostRuntimeVersion({
364
+ ...entry,
365
+ containerInfo: {
366
+ ...entry.containerInfo!,
367
+ gatewayImage: "mycadet/gateway:0.12.8",
368
+ },
369
+ }),
370
+ ).toBe("0.12.8");
371
+ });