@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
@@ -26,6 +26,7 @@ import { z } from "zod";
26
26
 
27
27
  import * as httpClient from "../lib/http-client.js";
28
28
  import type { AssistantEntry } from "../lib/assistant-config.js";
29
+ import { saveShellMode } from "../lib/ingress-config.js";
29
30
 
30
31
  const realChildProcess = { ...childProcess };
31
32
  const realFs = { ...fsModule };
@@ -51,6 +52,10 @@ mock.module("node:fs", () => ({
51
52
  readFileSync: readFileSyncMock,
52
53
  }));
53
54
 
55
+ const fetchCurrentVersionMock = mock<typeof httpClient.fetchCurrentVersion>(
56
+ async () => "1.2.3",
57
+ );
58
+
54
59
  const waitForDaemonReadyMock = mock<typeof httpClient.waitForDaemonReady>(
55
60
  async () => true,
56
61
  );
@@ -58,6 +63,7 @@ const waitForDaemonReadyMock = mock<typeof httpClient.waitForDaemonReady>(
58
63
  mock.module("../lib/http-client.js", () => ({
59
64
  ...httpClient,
60
65
  waitForDaemonReady: waitForDaemonReadyMock,
66
+ fetchCurrentVersion: fetchCurrentVersionMock,
61
67
  }));
62
68
 
63
69
  // Restore the real modules once this file finishes so the mocks do not leak
@@ -72,10 +78,11 @@ import {
72
78
  buildIngressNginxConfig,
73
79
  hasIpv6Loopback,
74
80
  buildRemoteWebIndexHtml,
75
- cloudWebHubUrl,
76
81
  MAX_PRESERVED_MODULE_PRELOADS,
77
82
  ensureTunnelEdge,
78
83
  EDGE_TEMPLATE_VERSION,
84
+ findAppDistDir,
85
+ findWebDistDir,
79
86
  startRemoteWebIngress,
80
87
  stopContainerTunnelEdge,
81
88
  stopIngressNginx,
@@ -123,6 +130,8 @@ afterEach(() => {
123
130
  existsSyncMock.mockImplementation(realFs.existsSync);
124
131
  readFileSyncMock.mockReset();
125
132
  readFileSyncMock.mockImplementation(realFs.readFileSync);
133
+ fetchCurrentVersionMock.mockReset();
134
+ fetchCurrentVersionMock.mockImplementation(async () => "1.2.3");
126
135
  waitForDaemonReadyMock.mockReset();
127
136
  waitForDaemonReadyMock.mockImplementation(async () => true);
128
137
  for (const dir of workspaces.splice(0)) {
@@ -306,7 +315,7 @@ describe("buildIngressNginxConfig", () => {
306
315
  ).toEqual([]);
307
316
  });
308
317
 
309
- test("revalidates the shell and forbids storing only the inline config", () => {
318
+ test("revalidates the shell and forbids storing inline config", () => {
310
319
  const blocks = locationBlocks(remoteConf);
311
320
  const cacheControlOf = (matcher: string) =>
312
321
  blocks
@@ -317,11 +326,8 @@ describe("buildIngressNginxConfig", () => {
317
326
  expect(cacheControlOf("^~ /assistant/assets/")).toBe(
318
327
  "public, max-age=31536000, immutable",
319
328
  );
320
- // Only the inline __config return, which nginx gives no validator, still
321
- // refuses storage. A second no-store means a revalidating location
322
- // regressed to re-sending its whole body on every load.
329
+ // Inline config has no validator and must not be stored.
323
330
  expect(cacheControlOf("= /assistant/__config")).toBe("no-store");
324
- expect(remoteConf.match(/no-store/g)).toHaveLength(1);
325
331
  });
326
332
 
327
333
  test("serves remote web config for the SPA", () => {
@@ -456,9 +462,7 @@ describe("buildRemoteWebIndexHtml", () => {
456
462
  expect(result).toContain(
457
463
  'window.__CADET_CONFIG__={"remote":"docker","apiBaseUrl":"/v1"}',
458
464
  );
459
- expect(result.indexOf('"webUrl"')).toBeLessThan(
460
- result.indexOf('"docker"'),
461
- );
465
+ expect(result.indexOf('"webUrl"')).toBeLessThan(result.indexOf('"docker"'));
462
466
  });
463
467
 
464
468
  test("escapes config JSON before embedding it in a script tag", () => {
@@ -528,26 +532,6 @@ describe("buildRemoteWebIndexHtml", () => {
528
532
  });
529
533
  });
530
534
 
531
- describe("cloudWebHubUrl", () => {
532
- test("maps build environments to their cloud SPA base", () => {
533
- expect(cloudWebHubUrl("production")).toBe(
534
- "https://mycadet.ai/assistant",
535
- );
536
- expect(cloudWebHubUrl("staging")).toBe(
537
- "https://staging.mycadet.ai/assistant",
538
- );
539
- expect(cloudWebHubUrl("dev")).toBe(
540
- "https://dev.mycadet.ai/assistant",
541
- );
542
- expect(cloudWebHubUrl("local")).toBe(
543
- "https://dev.mycadet.ai/assistant",
544
- );
545
- expect(cloudWebHubUrl(undefined)).toBe(
546
- "https://dev.mycadet.ai/assistant",
547
- );
548
- });
549
- });
550
-
551
535
  describe("nginx ingress process state", () => {
552
536
  function writeIngressState(workspaceDir: string, listenPort: number): void {
553
537
  writeFileSync(
@@ -755,17 +739,19 @@ function mockWebDistMissing(): void {
755
739
  }
756
740
 
757
741
  /** Force findWebDistDir() to resolve a dist dir, regardless of checkout state. */
758
- function mockWebDistPresent(): void {
742
+ function mockWebDistPresent(version: string | null = "1.2.3"): void {
759
743
  existsSyncMock.mockImplementation((path) =>
760
744
  String(path).endsWith(WEB_INDEX_SUFFIX) ? true : realFs.existsSync(path),
761
745
  );
762
746
  readFileSyncMock.mockImplementation(((path, options) =>
763
747
  String(path).endsWith(WEB_INDEX_SUFFIX)
764
748
  ? FAKE_INDEX_HTML
765
- : realFs.readFileSync(
766
- path as never,
767
- options as never,
768
- )) as typeof readFileSync);
749
+ : String(path).endsWith("release.json")
750
+ ? JSON.stringify(version === null ? {} : { version })
751
+ : realFs.readFileSync(
752
+ path as never,
753
+ options as never,
754
+ )) as typeof readFileSync);
769
755
  }
770
756
 
771
757
  /** Record a running edge: ingress state, live pidfile, matching ps output. */
@@ -846,14 +832,19 @@ const PRODUCTION_HUB_URL = "https://mycadet.ai/assistant";
846
832
 
847
833
  /**
848
834
  * Mirror of the SPA config fingerprint the edge records in its ingress state
849
- * (sha256 over the edge template version and the injected config JSON). Pins
835
+ * (sha256 over the template, injected config, bundle directory and index). Pins
850
836
  * both the injected config shape and the hash format; assumes the
851
837
  * production-pinned environment. The version itself is imported rather than
852
838
  * mirrored: bumping it is the correct response to any template edit, so a
853
839
  * copy here would fail every such edit without naming a defect.
854
840
  */
855
841
  function spaConfigHash(
856
- opts: { assistantName?: string; assistantId?: string } = {},
842
+ opts: {
843
+ assistantName?: string;
844
+ assistantId?: string;
845
+ indexHtml?: string;
846
+ webDistDir?: string;
847
+ } = {},
857
848
  ): string {
858
849
  return createHash("sha256")
859
850
  .update(
@@ -866,6 +857,12 @@ function spaConfigHash(
866
857
  ...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
867
858
  hubUrl: PRODUCTION_HUB_URL,
868
859
  },
860
+ webDistDir: opts.webDistDir ?? findWebDistDir(),
861
+ version: "1.2.3",
862
+ // Web mode is the default shell; the fingerprint covers the mode so
863
+ // an edge built for the other shell is never mistaken for this one.
864
+ shellMode: "web",
865
+ indexHtml: opts.indexHtml ?? FAKE_INDEX_HTML,
869
866
  }),
870
867
  )
871
868
  .digest("hex");
@@ -890,6 +887,70 @@ function pinProductionEnvironment(): void {
890
887
  describe("startRemoteWebIngress", () => {
891
888
  pinProductionEnvironment();
892
889
 
890
+ for (const [webVersion, runtimeVersion, ready] of [
891
+ ["1.2.3", "1.2.3", true],
892
+ ["1.2.3", "v1.2.3", true],
893
+ ["1.2.2", "1.2.3", false],
894
+ ["1.2.3", undefined, false],
895
+ [null, "1.2.3", false],
896
+ ] as const) {
897
+ test(`release resolution: UI ${webVersion}, assistant ${runtimeVersion}`, async () => {
898
+ const ws = makeWorkspace();
899
+ mockNginxInstalled();
900
+ mockNginxSpawn();
901
+ mockWebDistPresent(webVersion);
902
+ fetchCurrentVersionMock.mockResolvedValue(runtimeVersion);
903
+ const result = await startRemoteWebIngress({
904
+ workspaceDir: ws,
905
+ gatewayPort: 7830,
906
+ listenPort: 7845,
907
+ assistantId: "assistant-1",
908
+ });
909
+ expect(result.status).toBe("started");
910
+ expect(fetchCurrentVersionMock).toHaveBeenCalledWith(
911
+ "http://127.0.0.1:7830",
912
+ );
913
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf8");
914
+ const detail = conf
915
+ .split('location = "/v1/assistants/assistant-1/" {')[1]
916
+ ?.split("\n }")[0];
917
+ expect(detail).toContain(`return ${ready ? 200 : 409} `);
918
+ if (ready) {
919
+ expect(detail).toContain('\\"version\\":\\"1.2.3\\"');
920
+ } else {
921
+ expect(detail).not.toContain('\\"url\\"');
922
+ }
923
+ });
924
+ }
925
+
926
+ test("restarts an edge when its verified assistant/UI release changes", async () => {
927
+ const ws = makeWorkspace();
928
+ mockNginxInstalled();
929
+ mockNginxSpawn();
930
+ mockWebDistPresent("1.2.3");
931
+ fetchCurrentVersionMock.mockResolvedValue("1.2.4");
932
+ const pid = mockRunningEdge(ws, {
933
+ listenPort: 7845,
934
+ includeWebApp: true,
935
+ gatewayPort: 7830,
936
+ remoteWebConfigHash: spaConfigHash({ assistantId: "assistant-1" }),
937
+ });
938
+ const edge = mockKillableNginx(pid);
939
+ const result = await startRemoteWebIngress({
940
+ workspaceDir: ws,
941
+ gatewayPort: 7830,
942
+ listenPort: 7845,
943
+ assistantId: "assistant-1",
944
+ });
945
+ expect(result.status).toBe("started");
946
+ expect(edge.killed()).toBe(true);
947
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf8");
948
+ const detail = conf
949
+ .split('location = "/v1/assistants/assistant-1/" {')[1]
950
+ ?.split("\n }")[0];
951
+ expect(detail).toContain("return 409 ");
952
+ });
953
+
893
954
  test("webhooks-only mode starts the denylist+proxy edge without a web dist", async () => {
894
955
  const ws = makeWorkspace();
895
956
  mockNginxInstalled();
@@ -1247,6 +1308,7 @@ describe("startRemoteWebIngress", () => {
1247
1308
  const ws = makeWorkspace();
1248
1309
  mockNginxInstalled();
1249
1310
  mockNginxSpawn();
1311
+ mockWebDistPresent();
1250
1312
  const pid = mockRunningEdge(ws, {
1251
1313
  listenPort: 7845,
1252
1314
  gatewayPort: 7830,
@@ -1300,6 +1362,44 @@ describe("startRemoteWebIngress", () => {
1300
1362
  expect(spawnMock).not.toHaveBeenCalled();
1301
1363
  });
1302
1364
 
1365
+ for (const drift of ["indexHtml", "webDistDir"] as const) {
1366
+ test(`restarts a detached edge when the web bundle ${drift} changes`, async () => {
1367
+ const ws = makeWorkspace();
1368
+ mockNginxInstalled();
1369
+ mockNginxSpawn();
1370
+ mockWebDistPresent();
1371
+ const pid = mockRunningEdge(ws, {
1372
+ listenPort: 7845,
1373
+ includeWebApp: true,
1374
+ gatewayPort: 7830,
1375
+ remoteWebConfigHash: spaConfigHash({
1376
+ ...(drift === "indexHtml"
1377
+ ? {
1378
+ indexHtml:
1379
+ '<html><head><script src="/assistant/assets/old.js"></script></head></html>',
1380
+ }
1381
+ : { webDistDir: "/old-release/dist" }),
1382
+ }),
1383
+ });
1384
+ const edge = mockKillableNginx(pid);
1385
+ const result = await startRemoteWebIngress({
1386
+ workspaceDir: ws,
1387
+ gatewayPort: 7830,
1388
+ });
1389
+ expect(result).toMatchObject({ status: "started", listenPort: 7845 });
1390
+ expect(edge.killed()).toBe(true);
1391
+ expect(readConfig(ws).ingress?.nginx?.remoteWebConfigHash).toBe(
1392
+ spaConfigHash(),
1393
+ );
1394
+ expect(
1395
+ realFs.readFileSync(
1396
+ join(ws, "data", "ingress", "assistant-index.html"),
1397
+ "utf-8",
1398
+ ),
1399
+ ).not.toContain("old.js");
1400
+ });
1401
+ }
1402
+
1303
1403
  test("restarts the edge when the injected SPA config drifts from the recorded one", async () => {
1304
1404
  const ws = makeWorkspace();
1305
1405
  mockNginxInstalled();
@@ -1801,6 +1901,7 @@ describe("ensureTunnelEdge", () => {
1801
1901
  const ws = makeWorkspace();
1802
1902
  mockNginxInstalled();
1803
1903
  mockNginxSpawn();
1904
+ mockWebDistPresent();
1804
1905
  const pid = mockRunningEdge(ws, {
1805
1906
  listenPort: 7845,
1806
1907
  includeWebApp: true,
@@ -1819,6 +1920,7 @@ describe("ensureTunnelEdge", () => {
1819
1920
  port: 7845,
1820
1921
  started: false,
1821
1922
  includesWebApp: true,
1923
+ shellMode: "web",
1822
1924
  });
1823
1925
  expect(edge.killed()).toBe(false);
1824
1926
  expect(spawnMock).not.toHaveBeenCalled();
@@ -1972,6 +2074,7 @@ describe("ensureTunnelEdge", () => {
1972
2074
  port: REQUESTED_PORT,
1973
2075
  started: true,
1974
2076
  includesWebApp: true,
2077
+ shellMode: "web",
1975
2078
  });
1976
2079
  expect(edge.killed()).toBe(true);
1977
2080
  const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
@@ -1996,6 +2099,7 @@ describe("ensureTunnelEdge", () => {
1996
2099
  port: REQUESTED_PORT,
1997
2100
  started: true,
1998
2101
  includesWebApp: true,
2102
+ shellMode: "web",
1999
2103
  });
2000
2104
  expect(edge.killed()).toBe(true);
2001
2105
  const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
@@ -2018,6 +2122,7 @@ describe("ensureTunnelEdge", () => {
2018
2122
  port: REQUESTED_PORT,
2019
2123
  started: true,
2020
2124
  includesWebApp: true,
2125
+ shellMode: "web",
2021
2126
  });
2022
2127
  const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
2023
2128
  expect(conf).toContain("location ^~ /assistant/ {");
@@ -2073,6 +2178,7 @@ describe("ensureTunnelEdge", () => {
2073
2178
  port: REQUESTED_PORT,
2074
2179
  started: true,
2075
2180
  includesWebApp: true,
2181
+ shellMode: "web",
2076
2182
  });
2077
2183
  const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
2078
2184
  expect(conf).toContain("location ^~ /assistant/ {");
@@ -2223,3 +2329,448 @@ describe("stopContainerTunnelEdge", () => {
2223
2329
  expect(await stopContainerTunnelEdge(containerEntry(7930))).toBe(false);
2224
2330
  });
2225
2331
  });
2332
+
2333
+ describe("assistant metadata API", () => {
2334
+ function config(id = "assistant-1") {
2335
+ return buildIngressNginxConfig({
2336
+ gatewayPort: 7830,
2337
+ listenPort: 7840,
2338
+ remoteWebIngress: {
2339
+ webDistDir: "/tmp/web",
2340
+ version: "1.2.3",
2341
+ assistantId: id,
2342
+ assistantName: "My assistant",
2343
+ config: { appAllowedOrigins: ["https://parent.example"] },
2344
+ },
2345
+ });
2346
+ }
2347
+ function body(conf: string, path: string, status = 200): unknown {
2348
+ const location = conf
2349
+ .split(`location = "${path}" {`)[1]
2350
+ ?.split("\n }")[0];
2351
+ const value = location?.match(/return (200|409) ("(?:\\.|[^"\\])*");/);
2352
+ expect(Number(value?.[1])).toBe(status);
2353
+ if (!value) {
2354
+ throw new Error(`Missing metadata for ${path}`);
2355
+ }
2356
+ return JSON.parse(JSON.parse(value[2]));
2357
+ }
2358
+ test("lists only this host's assistant and returns its actual UI version and URL", () => {
2359
+ const conf = config();
2360
+ expect(body(conf, "/v1/assistants/")).toEqual({
2361
+ results: [{ id: "assistant-1", name: "My assistant" }],
2362
+ next: null,
2363
+ });
2364
+ expect(body(conf, "/v1/assistants/assistant-1/")).toEqual({
2365
+ id: "assistant-1",
2366
+ name: "My assistant",
2367
+ status: "active",
2368
+ version: "1.2.3",
2369
+ url: "assistant/",
2370
+ });
2371
+ expect(conf).not.toContain('location = "/v1/assistants/other/"');
2372
+ });
2373
+ test("metadata supports both slash forms and only read methods", () => {
2374
+ const conf = config();
2375
+ for (const path of [
2376
+ "/v1/assistants",
2377
+ "/v1/assistants/",
2378
+ "/v1/assistants/assistant-1",
2379
+ "/v1/assistants/assistant-1/",
2380
+ ]) {
2381
+ expect(body(conf, path)).toBeDefined();
2382
+ }
2383
+ expect(conf).toContain(
2384
+ "if ($request_method !~ ^(GET|HEAD)$) { return 405; }",
2385
+ );
2386
+ expect(conf).toContain(
2387
+ '"https://parent.example" "https://parent.example";',
2388
+ );
2389
+ expect(conf).toContain(
2390
+ "add_header Access-Control-Allow-Origin $cadet_app_origin always;",
2391
+ );
2392
+ });
2393
+ test("unknown identity yields an empty list without inventing an assistant ID", () => {
2394
+ const conf = buildIngressNginxConfig({
2395
+ gatewayPort: 7830,
2396
+ listenPort: 7840,
2397
+ remoteWebIngress: { webDistDir: "/tmp/web" },
2398
+ });
2399
+ expect(body(conf, "/v1/assistants/")).toEqual({ results: [], next: null });
2400
+ });
2401
+ test("a known assistant without a UI bundle returns 409 on both detail paths", () => {
2402
+ const conf = buildIngressNginxConfig({
2403
+ gatewayPort: 7830,
2404
+ listenPort: 7840,
2405
+ remoteWebIngress: { webDistDir: "/tmp/web", assistantId: "assistant-1" },
2406
+ });
2407
+ expect(body(conf, "/v1/assistants/")).toEqual({
2408
+ results: [{ id: "assistant-1" }],
2409
+ next: null,
2410
+ });
2411
+ for (const path of [
2412
+ "/v1/assistants/assistant-1",
2413
+ "/v1/assistants/assistant-1/",
2414
+ ]) {
2415
+ expect(body(conf, path, 409)).toEqual({
2416
+ detail: "assistant is not ready",
2417
+ });
2418
+ }
2419
+ });
2420
+ test("invalid browser origins fail before nginx starts", () => {
2421
+ expect(() =>
2422
+ buildIngressNginxConfig({
2423
+ gatewayPort: 7830,
2424
+ listenPort: 7840,
2425
+ remoteWebIngress: {
2426
+ webDistDir: "/tmp/web",
2427
+ config: { appAllowedOrigins: ["https://parent.example/path"] },
2428
+ },
2429
+ }),
2430
+ ).toThrow("App origins");
2431
+ });
2432
+ });
2433
+
2434
+ const APP_INDEX_SUFFIX = join("standalone", "index.html");
2435
+ const FAKE_APP_INDEX_HTML =
2436
+ "<!doctype html><html><head><title>app</title></head><body>app-shell</body></html>";
2437
+
2438
+ /**
2439
+ * Force findAppDistDir() to resolve a standalone bundle. The web-dist mock
2440
+ * below always pairs with it: app mode serves both bundles.
2441
+ */
2442
+ function mockAppDistPresent(): void {
2443
+ const previousExists = existsSyncMock.getMockImplementation();
2444
+ existsSyncMock.mockImplementation((path) =>
2445
+ String(path).endsWith(APP_INDEX_SUFFIX)
2446
+ ? true
2447
+ : previousExists
2448
+ ? previousExists(path)
2449
+ : realFs.existsSync(path),
2450
+ );
2451
+ const previousRead = readFileSyncMock.getMockImplementation();
2452
+ readFileSyncMock.mockImplementation(((path, options) =>
2453
+ String(path).endsWith(APP_INDEX_SUFFIX)
2454
+ ? FAKE_APP_INDEX_HTML
2455
+ : previousRead
2456
+ ? previousRead(path as never, options as never)
2457
+ : realFs.readFileSync(
2458
+ path as never,
2459
+ options as never,
2460
+ )) as typeof readFileSync);
2461
+ }
2462
+
2463
+ /** The fingerprint the edge must record for an app-mode SPA config. */
2464
+ function appConfigHash(
2465
+ opts: {
2466
+ assistantName?: string;
2467
+ assistantId?: string;
2468
+ } = {},
2469
+ ): string {
2470
+ return createHash("sha256")
2471
+ .update(
2472
+ JSON.stringify({
2473
+ template: EDGE_TEMPLATE_VERSION,
2474
+ config: {
2475
+ remote: "custom",
2476
+ apiBaseUrl: "/v1",
2477
+ ...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
2478
+ ...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
2479
+ hubUrl: PRODUCTION_HUB_URL,
2480
+ },
2481
+ webDistDir: findWebDistDir(),
2482
+ version: "1.2.3",
2483
+ shellMode: "app",
2484
+ appDistDir: findAppDistDir(),
2485
+ appIndexHtml: FAKE_APP_INDEX_HTML,
2486
+ indexHtml: FAKE_INDEX_HTML,
2487
+ }),
2488
+ )
2489
+ .digest("hex");
2490
+ }
2491
+
2492
+ describe("app shell mode", () => {
2493
+ pinProductionEnvironment();
2494
+
2495
+ function appConf() {
2496
+ return buildIngressNginxConfig({
2497
+ gatewayPort: 7830,
2498
+ listenPort: 7840,
2499
+ remoteWebIngress: {
2500
+ webDistDir: "/tmp/cadet web/dist",
2501
+ appDistDir: "/tmp/cadet app/dist/standalone",
2502
+ version: "1.2.3",
2503
+ assistantId: "assistant-1",
2504
+ config: {},
2505
+ },
2506
+ });
2507
+ }
2508
+
2509
+ test("serves the app bundle as the outer shell from the origin root", () => {
2510
+ const conf = appConf();
2511
+ // The injected app document is internal-only and every shell route
2512
+ // (/, /account/*, /logout, and the /assistant/* host routes) falls back
2513
+ // to it through the named location.
2514
+ expect(conf).toContain("location = /__cadet-app-index.html {");
2515
+ // Without an explicit app index path, the config aliases the bundle's own
2516
+ // index; the edge start path points this at the ingress dir (below).
2517
+ expect(conf).toContain('alias "/tmp/cadet app/dist/standalone/index.html"');
2518
+ expect(conf).toContain("location @cadet_app_shell {");
2519
+ expect(conf).toContain("rewrite ^ /__cadet-app-index.html last;");
2520
+ expect(conf).toContain(
2521
+ "location = / {\n rewrite ^ /__cadet-app-index.html last;\n }",
2522
+ );
2523
+ expect(conf).toContain('alias "/tmp/cadet app/dist/standalone/assets/";');
2524
+ expect(conf).toContain('alias "/tmp/cadet app/dist/standalone/";');
2525
+ // The shell owns the /assistant/* host routes; the exact mounts load the
2526
+ // app document rather than the web SPA.
2527
+ expect(conf).toContain(
2528
+ "location = /assistant {\n rewrite ^ /__cadet-app-index.html last;\n }",
2529
+ );
2530
+ expect(conf).toContain(
2531
+ "location = /assistant/ {\n rewrite ^ /__cadet-app-index.html last;\n }",
2532
+ );
2533
+ expect(conf).toContain("try_files $uri @cadet_app_shell;");
2534
+ // No recursive loading: the web SPA's exact mount is gone from the shell.
2535
+ expect(conf).not.toContain(
2536
+ "location = /assistant/ {\n rewrite ^ /assistant/__remote-index.html last;\n }",
2537
+ );
2538
+ });
2539
+
2540
+ test("iframes the web UI from the distinct /web/assistant namespace", () => {
2541
+ const conf = appConf();
2542
+ expect(conf).toContain('location = "/v1/assistants/assistant-1/"');
2543
+ expect(conf).toContain('\\"url\\":\\"web/assistant/\\"');
2544
+ expect(conf).toContain('\\"version\\":\\"1.2.3\\"');
2545
+ expect(conf).toContain(
2546
+ "location = /web/assistant/ {\n rewrite ^ /web/assistant/__remote-index.html last;\n }",
2547
+ );
2548
+ expect(conf).toContain("location = /web/assistant/__remote-index.html {");
2549
+ expect(conf).toContain('alias "/tmp/cadet web/dist/index.html"');
2550
+ expect(conf).toContain("location = /web/assistant/__config {");
2551
+ expect(conf).toContain("location ^~ /web/assistant/ {");
2552
+ expect(conf).toContain(
2553
+ "try_files $uri $uri/ /web/assistant/__remote-index.html;",
2554
+ );
2555
+ // The web bundle's asset URLs keep their baked /assistant/ base.
2556
+ expect(conf).toContain(
2557
+ 'location ^~ /assistant/assets/ {\n gzip on;\n alias "/tmp/cadet web/dist/assets/"',
2558
+ );
2559
+ // Prefixed API paths the web UI builds from its serving prefix.
2560
+ expect(conf).toContain("location ^~ /web/v1/ {");
2561
+ expect(conf).toContain("proxy_pass http://127.0.0.1:7830/v1/;");
2562
+ // Nothing else under /web exists, so the namespace cannot serve the shell
2563
+ // (or anything else) recursively.
2564
+ expect(conf).toContain("location ^~ /web/ {\n return 404;\n }");
2565
+ });
2566
+
2567
+ test("blocks the sensitive gateway routes under both prefixes", () => {
2568
+ const conf = appConf();
2569
+ for (const route of ["/v1/pair", "/v1/devices", "/v1/guardian/init"]) {
2570
+ expect(conf).toContain(`location = ${route} { return 404; }`);
2571
+ expect(conf).toContain(`location = /web${route} { return 404; }`);
2572
+ }
2573
+ expect(conf).toContain(
2574
+ "location ^~ /web/assistant/__local/ { return 404; }",
2575
+ );
2576
+ });
2577
+
2578
+ test("proxies runtime API paths verbatim for both surfaces", () => {
2579
+ const conf = appConf();
2580
+ expect(conf).toContain(
2581
+ "location ^~ /v1/ {\n proxy_pass http://127.0.0.1:7830;",
2582
+ );
2583
+ expect(conf).toContain(
2584
+ "location ^~ /webhooks/ {\n proxy_pass http://127.0.0.1:7830;",
2585
+ );
2586
+ expect(conf).toContain(
2587
+ "location = /healthz {\n proxy_pass http://127.0.0.1:7830;",
2588
+ );
2589
+ });
2590
+
2591
+ test("app mode starts, injects both documents, and records the app fingerprint", async () => {
2592
+ const ws = makeWorkspace();
2593
+ mockNginxInstalled();
2594
+ mockNginxSpawn();
2595
+ mockWebDistPresent();
2596
+ mockAppDistPresent();
2597
+ fetchCurrentVersionMock.mockResolvedValue("1.2.3");
2598
+
2599
+ const result = await startRemoteWebIngress({
2600
+ workspaceDir: ws,
2601
+ gatewayPort: 7830,
2602
+ listenPort: 7845,
2603
+ shellMode: "app",
2604
+ assistantId: "assistant-1",
2605
+ });
2606
+
2607
+ expect(result.status).toBe("started");
2608
+ const ingressDir = join(ws, "data", "ingress");
2609
+ const appIndex = realFs.readFileSync(
2610
+ join(ingressDir, "app-index.html"),
2611
+ "utf-8",
2612
+ );
2613
+ expect(appIndex).toContain("__CADET_CONFIG__");
2614
+ expect(appIndex).toContain(`"assistantId":"assistant-1"`);
2615
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
2616
+ expect(conf).toContain("location = /__cadet-app-index.html {");
2617
+ expect(conf).toContain('\\"url\\":\\"web/assistant/\\"');
2618
+ expect(readConfig(ws).ingress?.nginx).toMatchObject({
2619
+ listenPort: 7845,
2620
+ includeWebApp: true,
2621
+ gatewayPort: 7830,
2622
+ remoteWebConfigHash: appConfigHash({ assistantId: "assistant-1" }),
2623
+ });
2624
+ });
2625
+
2626
+ test("fails fast when the app bundle is not built", async () => {
2627
+ const ws = makeWorkspace();
2628
+ mockNginxInstalled();
2629
+ mockNginxSpawn();
2630
+ mockWebDistPresent();
2631
+ // A real build in the checkout would satisfy the lookup; the failure
2632
+ // path needs the lookup to resolve nothing, regardless of checkout state.
2633
+ // Web dist stays present so the app-dist check is the one that fires.
2634
+ const previousExists = existsSyncMock.getMockImplementation();
2635
+ existsSyncMock.mockImplementation((path) =>
2636
+ String(path).endsWith(APP_INDEX_SUFFIX)
2637
+ ? false
2638
+ : previousExists
2639
+ ? previousExists(path)
2640
+ : realFs.existsSync(path),
2641
+ );
2642
+
2643
+ const result = await startRemoteWebIngress({
2644
+ workspaceDir: ws,
2645
+ gatewayPort: 7830,
2646
+ listenPort: 7845,
2647
+ shellMode: "app",
2648
+ });
2649
+
2650
+ expect(result).toEqual({ status: "app-dist-missing" });
2651
+ expect(realFs.existsSync(ingressConfPath(ws))).toBe(false);
2652
+ });
2653
+
2654
+ test("restarting an edge between shell modes rotates onto the requested mode", async () => {
2655
+ const ws = makeWorkspace();
2656
+ mockNginxInstalled();
2657
+ mockNginxSpawn();
2658
+ mockWebDistPresent();
2659
+ mockAppDistPresent();
2660
+ // A running web-mode edge whose fingerprint predates app mode.
2661
+ mockRunningEdge(ws, {
2662
+ listenPort: 7845,
2663
+ includeWebApp: true,
2664
+ gatewayPort: 7830,
2665
+ remoteWebConfigHash: spaConfigHash({ assistantId: "assistant-1" }),
2666
+ });
2667
+ const edge = mockKillableNginx(123_460);
2668
+
2669
+ const result = await startRemoteWebIngress({
2670
+ workspaceDir: ws,
2671
+ gatewayPort: 7830,
2672
+ listenPort: 7845,
2673
+ shellMode: "app",
2674
+ assistantId: "assistant-1",
2675
+ });
2676
+
2677
+ expect(result.status).toBe("started");
2678
+ expect(edge.killed()).toBe(true);
2679
+ expect(readConfig(ws).ingress?.nginx).toMatchObject({
2680
+ remoteWebConfigHash: appConfigHash({ assistantId: "assistant-1" }),
2681
+ });
2682
+ });
2683
+
2684
+ test("reuses an already-running app-mode edge", async () => {
2685
+ const ws = makeWorkspace();
2686
+ mockNginxInstalled();
2687
+ mockNginxSpawn();
2688
+ mockWebDistPresent();
2689
+ mockAppDistPresent();
2690
+ mockRunningEdge(ws, {
2691
+ listenPort: 7845,
2692
+ includeWebApp: true,
2693
+ gatewayPort: 7830,
2694
+ remoteWebConfigHash: appConfigHash({ assistantId: "assistant-1" }),
2695
+ });
2696
+ const edge = mockKillableNginx(123_460);
2697
+
2698
+ const result = await startRemoteWebIngress({
2699
+ workspaceDir: ws,
2700
+ gatewayPort: 7830,
2701
+ listenPort: 7845,
2702
+ shellMode: "app",
2703
+ assistantId: "assistant-1",
2704
+ });
2705
+
2706
+ expect(result).toMatchObject({
2707
+ status: "already-running",
2708
+ listenPort: 7845,
2709
+ });
2710
+ expect(edge.killed()).toBe(false);
2711
+ expect(spawnMock).not.toHaveBeenCalled();
2712
+ });
2713
+
2714
+ test("ensureTunnelEdge reads the persisted shell mode from the lockfile", async () => {
2715
+ const ws = makeWorkspace();
2716
+ mockNginxInstalled();
2717
+ mockNginxSpawn();
2718
+ mockWebDistPresent();
2719
+ mockAppDistPresent();
2720
+ const originalLockfileDir = process.env.CADET_LOCKFILE_DIR;
2721
+ const lockfileDir = join(ws, "lockfile");
2722
+ mkdirSync(lockfileDir, { recursive: true });
2723
+ process.env.CADET_LOCKFILE_DIR = lockfileDir;
2724
+ try {
2725
+ saveShellMode("app");
2726
+
2727
+ const result = await ensureTunnelEdge({
2728
+ assistantId: "assistant-1",
2729
+ workspaceDir: ws,
2730
+ gatewayPort: 7830,
2731
+ });
2732
+
2733
+ expect(result.shellMode).toBe("app");
2734
+ } finally {
2735
+ if (originalLockfileDir === undefined) {
2736
+ delete process.env.CADET_LOCKFILE_DIR;
2737
+ } else {
2738
+ process.env.CADET_LOCKFILE_DIR = originalLockfileDir;
2739
+ }
2740
+ }
2741
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
2742
+ expect(conf).toContain("location = /__cadet-app-index.html {");
2743
+ expect(conf).toContain('\\"url\\":\\"web/assistant/\\"');
2744
+ });
2745
+
2746
+ test("ensureTunnelEdge defaults to the web shell", async () => {
2747
+ const ws = makeWorkspace();
2748
+ mockNginxInstalled();
2749
+ mockNginxSpawn();
2750
+ mockWebDistPresent();
2751
+ // Hermetic lockfile dir: no edgeShell key means the web default, even if
2752
+ // the machine running the tests has its own shell selection persisted.
2753
+ const originalLockfileDir = process.env.CADET_LOCKFILE_DIR;
2754
+ const lockfileDir = join(ws, "lockfile");
2755
+ mkdirSync(lockfileDir, { recursive: true });
2756
+ process.env.CADET_LOCKFILE_DIR = lockfileDir;
2757
+ try {
2758
+ const result = await ensureTunnelEdge({
2759
+ assistantId: "assistant-1",
2760
+ workspaceDir: ws,
2761
+ gatewayPort: 7830,
2762
+ });
2763
+
2764
+ expect(result.shellMode).toBe("web");
2765
+ } finally {
2766
+ if (originalLockfileDir === undefined) {
2767
+ delete process.env.CADET_LOCKFILE_DIR;
2768
+ } else {
2769
+ process.env.CADET_LOCKFILE_DIR = originalLockfileDir;
2770
+ }
2771
+ }
2772
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
2773
+ expect(conf).not.toContain("__cadet-app-index");
2774
+ expect(conf).toContain('\\"url\\":\\"assistant/\\"');
2775
+ });
2776
+ });