@mycadet/cli 0.12.11 → 0.13.0-dev.202610171626.d97b8b8

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 (94) 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 +2 -2
  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 +2 -2
  5. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  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 +2 -2
  8. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  9. package/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
  10. package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  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-releases.test.ts +82 -0
  17. package/src/__tests__/commands-mock.ts +101 -0
  18. package/src/__tests__/debug.test.ts +17 -91
  19. package/src/__tests__/devices.test.ts +2 -2
  20. package/src/__tests__/gateway-token.test.ts +267 -0
  21. package/src/__tests__/host-teleport-docker.test.ts +371 -0
  22. package/src/__tests__/host-teleport.test.ts +405 -0
  23. package/src/__tests__/nginx-ingress.test.ts +536 -23
  24. package/src/__tests__/orphan-detection.test.ts +1 -1
  25. package/src/__tests__/paired-lifecycle.test.ts +2 -2
  26. package/src/__tests__/ps-cloud-status.test.ts +6 -6
  27. package/src/__tests__/root-backup-docker.test.ts +294 -0
  28. package/src/__tests__/root-backup.test.ts +527 -0
  29. package/src/__tests__/root-restore-command.test.ts +147 -0
  30. package/src/__tests__/root-signing-key.test.ts +50 -0
  31. package/src/__tests__/setup.test.ts +3 -3
  32. package/src/__tests__/shell-mode.test.ts +83 -0
  33. package/src/__tests__/sleep.test.ts +11 -0
  34. package/src/__tests__/teleport.test.ts +68 -2996
  35. package/src/__tests__/tunnel.test.ts +10 -2
  36. package/src/__tests__/upgrade-local.test.ts +21 -11
  37. package/src/__tests__/upgrade-replay-env.test.ts +2 -2
  38. package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
  39. package/src/__tests__/wake.test.ts +4 -1
  40. package/src/__tests__/windows-lifecycle.test.ts +25 -0
  41. package/src/commands/backup.ts +44 -399
  42. package/src/commands/client.ts +3 -3
  43. package/src/commands/completions.ts +0 -2
  44. package/src/commands/debug.ts +75 -131
  45. package/src/commands/devices.ts +51 -17
  46. package/src/commands/gateway/token.ts +77 -23
  47. package/src/commands/pair.ts +2 -2
  48. package/src/commands/restore.ts +77 -444
  49. package/src/commands/retire.ts +2 -3
  50. package/src/commands/sleep.ts +15 -2
  51. package/src/commands/teleport.ts +47 -1559
  52. package/src/commands/tunnel.ts +48 -7
  53. package/src/commands/upgrade.ts +78 -156
  54. package/src/commands/wake.ts +1 -1
  55. package/src/index.ts +0 -4
  56. package/src/lib/__tests__/http-client-version.test.ts +29 -0
  57. package/src/lib/__tests__/runtime-url.test.ts +3 -75
  58. package/src/lib/__tests__/web-dist.test.ts +27 -1
  59. package/src/lib/assistant-client.ts +1 -1
  60. package/src/lib/assistant-config.ts +23 -18
  61. package/src/lib/backup-ops.ts +37 -251
  62. package/src/lib/cloud-client.ts +10 -13
  63. package/src/lib/cloud-login.ts +2 -1
  64. package/src/lib/cloud-releases.ts +54 -10
  65. package/src/lib/cloud-values.ts +1 -1
  66. package/src/lib/confirm-action.ts +2 -2
  67. package/src/lib/docker.ts +59 -22
  68. package/src/lib/github-repository.ts +2 -0
  69. package/src/lib/guardian-token.ts +1 -23
  70. package/src/lib/hatch-local.ts +17 -1
  71. package/src/lib/host-teleport.ts +506 -0
  72. package/src/lib/http-client.ts +25 -0
  73. package/src/lib/ingress-config.ts +31 -0
  74. package/src/lib/live-voice/tunnel.test.ts +1 -1
  75. package/src/lib/live-voice/tunnel.ts +1 -1
  76. package/src/lib/local.ts +4 -2
  77. package/src/lib/nginx-ingress.ts +454 -79
  78. package/src/lib/process.ts +13 -0
  79. package/src/lib/root-backup.ts +917 -0
  80. package/src/lib/root-signing-key.ts +31 -0
  81. package/src/lib/runtime-url.ts +0 -29
  82. package/src/lib/teleport-backup.ts +3 -16
  83. package/src/lib/tunnel.ts +1 -0
  84. package/src/lib/upgrade-lifecycle.ts +63 -141
  85. package/src/lib/web-dist.ts +79 -1
  86. package/src/__tests__/bundle-staging.test.ts +0 -268
  87. package/src/__tests__/unpair.test.ts +0 -160
  88. package/src/commands/rollback.ts +0 -479
  89. package/src/commands/unpair.ts +0 -122
  90. package/src/lib/__tests__/job-polling.test.ts +0 -278
  91. package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
  92. package/src/lib/bundle-staging.ts +0 -220
  93. package/src/lib/job-polling.ts +0 -195
  94. 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,10 @@ 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,
79
85
  findWebDistDir,
80
86
  startRemoteWebIngress,
81
87
  stopContainerTunnelEdge,
@@ -124,6 +130,8 @@ afterEach(() => {
124
130
  existsSyncMock.mockImplementation(realFs.existsSync);
125
131
  readFileSyncMock.mockReset();
126
132
  readFileSyncMock.mockImplementation(realFs.readFileSync);
133
+ fetchCurrentVersionMock.mockReset();
134
+ fetchCurrentVersionMock.mockImplementation(async () => "1.2.3");
127
135
  waitForDaemonReadyMock.mockReset();
128
136
  waitForDaemonReadyMock.mockImplementation(async () => true);
129
137
  for (const dir of workspaces.splice(0)) {
@@ -307,7 +315,7 @@ describe("buildIngressNginxConfig", () => {
307
315
  ).toEqual([]);
308
316
  });
309
317
 
310
- test("revalidates the shell and forbids storing only the inline config", () => {
318
+ test("revalidates the shell and forbids storing inline config", () => {
311
319
  const blocks = locationBlocks(remoteConf);
312
320
  const cacheControlOf = (matcher: string) =>
313
321
  blocks
@@ -318,11 +326,8 @@ describe("buildIngressNginxConfig", () => {
318
326
  expect(cacheControlOf("^~ /assistant/assets/")).toBe(
319
327
  "public, max-age=31536000, immutable",
320
328
  );
321
- // Only the inline __config return, which nginx gives no validator, still
322
- // refuses storage. A second no-store means a revalidating location
323
- // regressed to re-sending its whole body on every load.
329
+ // Inline config has no validator and must not be stored.
324
330
  expect(cacheControlOf("= /assistant/__config")).toBe("no-store");
325
- expect(remoteConf.match(/no-store/g)).toHaveLength(1);
326
331
  });
327
332
 
328
333
  test("serves remote web config for the SPA", () => {
@@ -527,18 +532,6 @@ describe("buildRemoteWebIndexHtml", () => {
527
532
  });
528
533
  });
529
534
 
530
- describe("cloudWebHubUrl", () => {
531
- test("maps build environments to their cloud SPA base", () => {
532
- expect(cloudWebHubUrl("production")).toBe("https://mycadet.ai/assistant");
533
- expect(cloudWebHubUrl("staging")).toBe(
534
- "https://staging.mycadet.ai/assistant",
535
- );
536
- expect(cloudWebHubUrl("dev")).toBe("https://dev.mycadet.ai/assistant");
537
- expect(cloudWebHubUrl("local")).toBe("https://dev.mycadet.ai/assistant");
538
- expect(cloudWebHubUrl(undefined)).toBe("https://dev.mycadet.ai/assistant");
539
- });
540
- });
541
-
542
535
  describe("nginx ingress process state", () => {
543
536
  function writeIngressState(workspaceDir: string, listenPort: number): void {
544
537
  writeFileSync(
@@ -746,17 +739,19 @@ function mockWebDistMissing(): void {
746
739
  }
747
740
 
748
741
  /** Force findWebDistDir() to resolve a dist dir, regardless of checkout state. */
749
- function mockWebDistPresent(): void {
742
+ function mockWebDistPresent(version: string | null = "1.2.3"): void {
750
743
  existsSyncMock.mockImplementation((path) =>
751
744
  String(path).endsWith(WEB_INDEX_SUFFIX) ? true : realFs.existsSync(path),
752
745
  );
753
746
  readFileSyncMock.mockImplementation(((path, options) =>
754
747
  String(path).endsWith(WEB_INDEX_SUFFIX)
755
748
  ? FAKE_INDEX_HTML
756
- : realFs.readFileSync(
757
- path as never,
758
- options as never,
759
- )) 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);
760
755
  }
761
756
 
762
757
  /** Record a running edge: ingress state, live pidfile, matching ps output. */
@@ -863,6 +858,10 @@ function spaConfigHash(
863
858
  hubUrl: PRODUCTION_HUB_URL,
864
859
  },
865
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",
866
865
  indexHtml: opts.indexHtml ?? FAKE_INDEX_HTML,
867
866
  }),
868
867
  )
@@ -888,6 +887,70 @@ function pinProductionEnvironment(): void {
888
887
  describe("startRemoteWebIngress", () => {
889
888
  pinProductionEnvironment();
890
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
+
891
954
  test("webhooks-only mode starts the denylist+proxy edge without a web dist", async () => {
892
955
  const ws = makeWorkspace();
893
956
  mockNginxInstalled();
@@ -1857,6 +1920,7 @@ describe("ensureTunnelEdge", () => {
1857
1920
  port: 7845,
1858
1921
  started: false,
1859
1922
  includesWebApp: true,
1923
+ shellMode: "web",
1860
1924
  });
1861
1925
  expect(edge.killed()).toBe(false);
1862
1926
  expect(spawnMock).not.toHaveBeenCalled();
@@ -2010,6 +2074,7 @@ describe("ensureTunnelEdge", () => {
2010
2074
  port: REQUESTED_PORT,
2011
2075
  started: true,
2012
2076
  includesWebApp: true,
2077
+ shellMode: "web",
2013
2078
  });
2014
2079
  expect(edge.killed()).toBe(true);
2015
2080
  const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
@@ -2034,6 +2099,7 @@ describe("ensureTunnelEdge", () => {
2034
2099
  port: REQUESTED_PORT,
2035
2100
  started: true,
2036
2101
  includesWebApp: true,
2102
+ shellMode: "web",
2037
2103
  });
2038
2104
  expect(edge.killed()).toBe(true);
2039
2105
  const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
@@ -2056,6 +2122,7 @@ describe("ensureTunnelEdge", () => {
2056
2122
  port: REQUESTED_PORT,
2057
2123
  started: true,
2058
2124
  includesWebApp: true,
2125
+ shellMode: "web",
2059
2126
  });
2060
2127
  const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
2061
2128
  expect(conf).toContain("location ^~ /assistant/ {");
@@ -2111,6 +2178,7 @@ describe("ensureTunnelEdge", () => {
2111
2178
  port: REQUESTED_PORT,
2112
2179
  started: true,
2113
2180
  includesWebApp: true,
2181
+ shellMode: "web",
2114
2182
  });
2115
2183
  const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
2116
2184
  expect(conf).toContain("location ^~ /assistant/ {");
@@ -2261,3 +2329,448 @@ describe("stopContainerTunnelEdge", () => {
2261
2329
  expect(await stopContainerTunnelEdge(containerEntry(7930))).toBe(false);
2262
2330
  });
2263
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
+ });
@@ -107,7 +107,7 @@ describe("getKnownPidsFromAssistants", () => {
107
107
  const entry: AssistantEntry = {
108
108
  assistantId: "managed-1",
109
109
  runtimeUrl: "https://cloud.mycadet.ai/foo",
110
- cloud: "cadet",
110
+ cloud: "cloud",
111
111
  };
112
112
  const pids = getKnownPidsFromAssistants([entry]);
113
113
  expect(pids.size).toBe(0);