@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
@@ -17,6 +17,8 @@ import { createServer } from "node:net";
17
17
  import { networkInterfaces } from "node:os";
18
18
  import { join } from "node:path";
19
19
 
20
+ import { z } from "zod";
21
+
20
22
  import { cloudAssistantHubUrl } from "@mycadet/environments";
21
23
 
22
24
  import {
@@ -28,17 +30,24 @@ import {
28
30
  } from "./assistant-config.js";
29
31
  import type { RemoteHost } from "./constants.js";
30
32
  import { getCurrentEnvironment } from "./environments/resolve.js";
31
- import { waitForDaemonReady } from "./http-client.js";
33
+ import { fetchCurrentVersion, waitForDaemonReady } from "./http-client.js";
32
34
  import {
33
35
  getDefaultWorkspaceDir,
34
36
  isLocalContainerEntry,
35
37
  loadRawConfig,
36
38
  parseGatewayPortFromEntryUrls,
39
+ readShellMode,
37
40
  saveRawConfig,
38
41
  } from "./ingress-config.js";
39
- import { findWebDistDir } from "./web-dist.js";
42
+ import { stripVersionPrefix } from "./version-compat.js";
43
+ import {
44
+ findAppDistDir,
45
+ findWebDistDir,
46
+ readWebReleaseVersion,
47
+ } from "./web-dist.js";
40
48
 
41
49
  export { findWebDistDir } from "./web-dist.js";
50
+ export { findAppDistDir } from "./web-dist.js";
42
51
 
43
52
  /**
44
53
  * CLI-managed nginx reverse proxy that fronts the gateway as the canonical
@@ -93,8 +102,8 @@ function nginxDirPath(dir: string): string {
93
102
  return dir.endsWith("/") ? dir : `${dir}/`;
94
103
  }
95
104
 
96
- function gatewayProxyBlock(gatewayPort: number): string {
97
- return ` proxy_pass http://127.0.0.1:${gatewayPort};
105
+ function gatewayProxyBlock(gatewayPort: number, upstreamPath = ""): string {
106
+ return ` proxy_pass http://127.0.0.1:${gatewayPort}${upstreamPath};
98
107
  proxy_http_version 1.1;
99
108
  proxy_request_buffering off;
100
109
  proxy_buffering off;
@@ -107,40 +116,71 @@ function gatewayProxyBlock(gatewayPort: number): string {
107
116
  }
108
117
 
109
118
  /**
110
- * Sensitive local-only routes the edge must never expose to the internet,
111
- * regardless of whether the SPA is being served.
119
+ * Sensitive local-only gateway paths the edge must never expose to the
120
+ * internet, regardless of which client bundles are being served. Each exact
121
+ * path is blocked with and without its trailing slash.
122
+ */
123
+ const SENSITIVE_GATEWAY_ROUTES = [
124
+ "/auth/token",
125
+ "/v1/pair",
126
+ "/v1/pair/web-init",
127
+ "/v1/devices",
128
+ "/v1/devices/revoke",
129
+ "/v1/guardian/init",
130
+ "/v1/guardian/reset-bootstrap",
131
+ "/v1/remote-web/pairing-requests",
132
+ "/v1/remote-web/pairing-requests/approve",
133
+ "/v1/remote-web/pairing-requests/deny",
134
+ "/v1/remote-web/pairing-verification",
135
+ "/v1/shared/invites/redeem",
136
+ ] as const;
137
+
138
+ /** Sensitive path prefixes, blocked with a prefix match. */
139
+ const SENSITIVE_GATEWAY_PREFIXES = [
140
+ "/assistant/__local/",
141
+ "/assistant/__gateway/",
142
+ "/assistant/__gateway-paired/",
143
+ ] as const;
144
+
145
+ /**
146
+ * Denylist locations for gateway paths under one public prefix. The edge
147
+ * serves from the origin root (web mode) and — in app mode — from the
148
+ * `/web` iframe prefix, where the web UI's prefix-aware API paths
149
+ * (`remoteGatewayApiPath`) put the same gateway routes at `/web/v1/...`;
150
+ * the sensitive ones must be blocked in both spellings.
112
151
  */
113
- const DENYLIST_LOCATIONS = ` location = /auth/token { return 404; }
114
- location = /auth/token/ { return 404; }
115
- location = /v1/pair { return 404; }
116
- location = /v1/pair/ { return 404; }
117
- location = /v1/pair/web-init { return 404; }
118
- location = /v1/pair/web-init/ { return 404; }
119
- location = /v1/devices { return 404; }
120
- location = /v1/devices/ { return 404; }
121
- location = /v1/devices/revoke { return 404; }
122
- location = /v1/devices/revoke/ { return 404; }
123
- location = /v1/guardian/init { return 404; }
124
- location = /v1/guardian/init/ { return 404; }
125
- location = /v1/guardian/reset-bootstrap { return 404; }
126
- location = /v1/guardian/reset-bootstrap/ { return 404; }
127
- location = /v1/remote-web/pairing-requests { return 404; }
128
- location = /v1/remote-web/pairing-requests/ { return 404; }
129
- location = /v1/remote-web/pairing-requests/approve { return 404; }
130
- location = /v1/remote-web/pairing-requests/approve/ { return 404; }
131
- location = /v1/remote-web/pairing-requests/deny { return 404; }
132
- location = /v1/remote-web/pairing-requests/deny/ { return 404; }
133
- location = /v1/remote-web/pairing-verification { return 404; }
134
- location = /v1/remote-web/pairing-verification/ { return 404; }
135
- location = /v1/shared/invites/redeem { return 404; }
136
- location = /v1/shared/invites/redeem/ { return 404; }
137
- location ^~ /assistant/__local/ { return 404; }
138
- location ^~ /assistant/__gateway/ { return 404; }
139
- location ^~ /assistant/__gateway-paired/ { return 404; }`;
152
+ function denylistLocations(prefix = ""): string {
153
+ const exact = SENSITIVE_GATEWAY_ROUTES.map((route) => {
154
+ const block = (path: string) =>
155
+ ` location = ${prefix}${path} { return 404; }`;
156
+ return `${block(route)}\n${block(`${route}/`)}`;
157
+ });
158
+ const prefixes = SENSITIVE_GATEWAY_PREFIXES.map((route) => {
159
+ return ` location ^~ ${prefix}${route} { return 404; }`;
160
+ });
161
+ return [...exact, ...prefixes].join("\n");
162
+ }
163
+
164
+ const DENYLIST_LOCATIONS = denylistLocations();
140
165
 
141
166
  export interface RemoteWebIngressOptions {
142
167
  webDistDir: string;
168
+ /**
169
+ * Standalone app-shell bundle. Present only in app shell mode: the edge
170
+ * serves this bundle as the outer account/client shell from the origin
171
+ * root and iframes the web SPA from /web/assistant/. Absent (web mode)
172
+ * keeps serving the web SPA directly from /assistant/.
173
+ */
174
+ appDistDir?: string;
175
+ /**
176
+ * The config-injected app-shell document. Defaults to the app bundle's own
177
+ * index; the edge start path points this at the ingress dir, where the
178
+ * injected copy is written.
179
+ */
180
+ appIndexHtmlPath?: string;
143
181
  indexHtmlPath?: string;
182
+ /** Assistant release verified against the served UI artifact. */
183
+ version?: string;
144
184
  config?: Record<string, unknown>;
145
185
  /** Serving assistant's display name, stamped into the served config so
146
186
  * remote clients can label this origin. Absent in older served configs. */
@@ -150,17 +190,18 @@ export interface RemoteWebIngressOptions {
150
190
  * configs. */
151
191
  assistantId?: string;
152
192
  /** Cloud web SPA base the remote client can hand this origin to (see
153
- * `cloudWebHubUrl`). Absent in older served configs. */
193
+ * `cloudAssistantHubUrl`). Absent in older served configs. */
154
194
  hubUrl?: string;
155
195
  }
156
196
 
157
197
  /**
158
- * Cloud web SPA base URL for a build environment. The mapping lives in
159
- * `@mycadet/environments` (`cloudAssistantHubUrl`) and is shared with the
160
- * Capacitor shell's `server.url`, so the two consumers cannot drift.
198
+ * The web UI entry the assistant detail API hands the shell, relative to the
199
+ * origin root. Web mode serves the web SPA directly at /assistant/; app mode
200
+ * serves it from the distinct /web/assistant/ iframe namespace so the shell's
201
+ * own /assistant/* host routes can never load the shell recursively.
161
202
  */
162
- export function cloudWebHubUrl(env: string | undefined): string {
163
- return cloudAssistantHubUrl(env);
203
+ export function assistantEntryUrl(appDistDir: string | undefined): string {
204
+ return appDistDir ? "web/assistant/" : "assistant/";
164
205
  }
165
206
 
166
207
  // The edge serves remote browsers, so the bundle's remote-gateway machinery
@@ -208,17 +249,19 @@ function remoteWebIngressConfig(
208
249
  * fingerprint matches, so this must change whenever the generated index or
209
250
  * nginx template does.
210
251
  */
211
- export const EDGE_TEMPLATE_VERSION = 10;
252
+ export const EDGE_TEMPLATE_VERSION = 13;
212
253
 
213
254
  /**
214
- * Stable fingerprint of the injected SPA config, its source bundle, and
215
- * the template that renders them. Recorded
255
+ * Stable fingerprint of the injected SPA config, its source bundles, the
256
+ * shell mode, and the template that renders them. Recorded
216
257
  * alongside the edge state so a reuse decision can tell whether a running edge
217
258
  * already serves the requested config (see `IngressState.remoteWebConfigHash`).
218
259
  */
219
260
  function remoteWebConfigFingerprint(
220
261
  config: Record<string, unknown>,
221
262
  webDistDir: string,
263
+ version?: string,
264
+ appDistDir?: string,
222
265
  ): string {
223
266
  return createHash("sha256")
224
267
  .update(
@@ -226,6 +269,20 @@ function remoteWebConfigFingerprint(
226
269
  template: EDGE_TEMPLATE_VERSION,
227
270
  config,
228
271
  webDistDir,
272
+ version,
273
+ // The shell mode is part of edge identity: flipping the persisted
274
+ // shell mode must rotate a running edge onto the requested shell
275
+ // even though every other fingerprint input may be unchanged.
276
+ shellMode: appDistDir ? "app" : "web",
277
+ ...(appDistDir
278
+ ? {
279
+ appDistDir,
280
+ appIndexHtml: readFileSync(
281
+ join(appDistDir, "index.html"),
282
+ "utf-8",
283
+ ),
284
+ }
285
+ : {}),
229
286
  indexHtml: readFileSync(join(webDistDir, "index.html"), "utf-8"),
230
287
  }),
231
288
  )
@@ -345,9 +402,102 @@ export async function findAvailableIngressPort(
345
402
  return null;
346
403
  }
347
404
 
348
- /**
349
- * Build the nginx config that forwards tunnel web traffic to the gateway.
350
- */
405
+ const AssistantHostConfigSchema = z.object({
406
+ assistantId: z.string().min(1).optional(),
407
+ assistantName: z.string().optional(),
408
+ version: z.string().min(1).optional(),
409
+ hubUrl: z.string().url().optional(),
410
+ appAllowedOrigins: z.array(z.string().url()).default([]),
411
+ });
412
+
413
+ function assistantHostConfig(config: Record<string, unknown>) {
414
+ const result = AssistantHostConfigSchema.safeParse(config);
415
+ if (!result.success) {
416
+ throw new Error("Invalid assistant host configuration.");
417
+ }
418
+ return result.data;
419
+ }
420
+
421
+ function appOrigins(config: Record<string, unknown>): string[] {
422
+ const metadata = assistantHostConfig(config);
423
+ const origins = [
424
+ ...metadata.appAllowedOrigins,
425
+ ...(metadata.hubUrl ? [new URL(metadata.hubUrl).origin] : []),
426
+ ];
427
+ return [...new Set(origins)].map((origin) => {
428
+ const url = new URL(origin);
429
+ if (
430
+ (url.protocol !== "https:" &&
431
+ !(
432
+ url.protocol === "http:" &&
433
+ ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
434
+ )) ||
435
+ url.origin !== origin.replace(/\/$/, "")
436
+ ) {
437
+ throw new Error(
438
+ "App origins must be HTTPS origins or loopback HTTP origins.",
439
+ );
440
+ }
441
+ return url.origin;
442
+ });
443
+ }
444
+
445
+ function assistantMetadataLocations(config: Record<string, unknown>): string {
446
+ const metadata = assistantHostConfig(config);
447
+ const id = metadata.assistantId;
448
+ const entryUrl =
449
+ typeof config.entryUrl === "string" ? config.entryUrl : undefined;
450
+ const choice = id
451
+ ? {
452
+ id,
453
+ ...(metadata.assistantName ? { name: metadata.assistantName } : {}),
454
+ }
455
+ : undefined;
456
+ const responses = [
457
+ {
458
+ path: "/v1/assistants",
459
+ status: 200,
460
+ body: { results: choice ? [choice] : [], next: null },
461
+ },
462
+ ...(choice && id
463
+ ? [
464
+ {
465
+ path: `/v1/assistants/${encodeURIComponent(id)}`,
466
+ status: metadata.version ? 200 : 409,
467
+ body: metadata.version
468
+ ? {
469
+ ...choice,
470
+ status: "active",
471
+ version: metadata.version,
472
+ url: entryUrl ?? "assistant/",
473
+ }
474
+ : { detail: "assistant is not ready" },
475
+ },
476
+ ]
477
+ : []),
478
+ ];
479
+ return responses
480
+ .flatMap(({ path, status, body }) =>
481
+ [path, `${path}/`].map(
482
+ (route) => `
483
+ location = ${nginxQuoted(route, "assistant API path")} {
484
+ default_type application/json;
485
+ add_header Cache-Control "no-store" always;
486
+ add_header Access-Control-Allow-Origin $cadet_app_origin always;
487
+ add_header Access-Control-Allow-Credentials "true" always;
488
+ add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
489
+ add_header Access-Control-Allow-Headers "Accept, Cadet-Organization-Id" always;
490
+ add_header Vary "Origin" always;
491
+ if ($request_method = OPTIONS) { return 204; }
492
+ if ($request_method !~ ^(GET|HEAD)$) { return 405; }
493
+ return ${status} ${nginxQuoted(JSON.stringify(body), "assistant metadata")};
494
+ }`,
495
+ ),
496
+ )
497
+ .join("\n");
498
+ }
499
+
500
+ /** Build the nginx config for the tunnel edge and its public assistant metadata. */
351
501
  export function buildIngressNginxConfig(opts: {
352
502
  gatewayPort: number;
353
503
  listenPort: number;
@@ -363,12 +513,18 @@ export function buildIngressNginxConfig(opts: {
363
513
  ? ` listen [::1]:${opts.listenPort};\n`
364
514
  : "";
365
515
  const remoteWebIngress = opts.remoteWebIngress;
516
+ const allowedOrigins = remoteWebIngress
517
+ ? appOrigins(remoteWebIngressConfig(remoteWebIngress))
518
+ : [];
366
519
  const serverLocations = remoteWebIngress
367
520
  ? buildRemoteWebIngressLocations({
368
521
  gatewayPort: opts.gatewayPort,
369
522
  webDistDir: remoteWebIngress.webDistDir,
523
+ appDistDir: remoteWebIngress.appDistDir,
524
+ appIndexHtmlPath: remoteWebIngress.appIndexHtmlPath,
370
525
  indexHtmlPath: remoteWebIngress.indexHtmlPath,
371
526
  config: remoteWebIngressConfig(remoteWebIngress),
527
+ version: remoteWebIngress.version,
372
528
  })
373
529
  : `${DENYLIST_LOCATIONS}
374
530
 
@@ -417,6 +573,11 @@ http {
417
573
  text/plain txt;
418
574
  }
419
575
 
576
+ map $http_origin $cadet_app_origin {
577
+ default "";
578
+ ${allowedOrigins.map((origin) => ` ${nginxQuoted(origin, "app origin")} ${nginxQuoted(origin, "app origin")};`).join("\n")}
579
+ }
580
+
420
581
  map $http_upgrade $connection_upgrade {
421
582
  default upgrade;
422
583
  "" close;
@@ -452,22 +613,13 @@ ${serverLocations}
452
613
  `;
453
614
  }
454
615
 
455
- function buildRemoteWebIngressLocations(opts: {
456
- gatewayPort: number;
457
- webDistDir: string;
458
- indexHtmlPath?: string;
459
- config: Record<string, unknown>;
460
- }): string {
461
- const proxyBlock = gatewayProxyBlock(opts.gatewayPort);
462
- const webDistDir = nginxDirPath(opts.webDistDir);
463
- const webAssetsDir = join(opts.webDistDir, "assets");
464
- const indexHtmlPath =
465
- opts.indexHtmlPath ?? join(opts.webDistDir, "index.html");
466
- const configJson = JSON.stringify(opts.config);
467
-
468
- return `${DENYLIST_LOCATIONS}
469
-
470
- location = /healthz {
616
+ /**
617
+ * Gateway-facing locations shared by both shell modes: liveness probes and
618
+ * the runtime API surface, all proxied verbatim.
619
+ */
620
+ function gatewayProxyLocations(gatewayPort: number): string {
621
+ const proxyBlock = gatewayProxyBlock(gatewayPort);
622
+ return ` location = /healthz {
471
623
  ${proxyBlock}
472
624
  }
473
625
 
@@ -481,18 +633,34 @@ ${proxyBlock}
481
633
 
482
634
  location ^~ /webhooks/ {
483
635
  ${proxyBlock}
484
- }
636
+ }`;
637
+ }
485
638
 
486
- location = /assistant {
487
- return 302 /assistant/;
639
+ /**
640
+ * Serve the web SPA from one path prefix: the exact-mount rewrites, the
641
+ * injected index, the inline config, and the SPA fallback. Used for the
642
+ * /assistant/ mount in web mode and the /web/assistant/ iframe mount in
643
+ * app mode. `prefix` carries both a leading and trailing slash.
644
+ */
645
+ function webSpaLocations(opts: {
646
+ prefix: string;
647
+ webDistDir: string;
648
+ indexHtmlPath: string;
649
+ config: Record<string, unknown>;
650
+ }): string {
651
+ const indexHtmlPath = opts.indexHtmlPath;
652
+ const configJson = JSON.stringify(opts.config);
653
+ const prefix = opts.prefix.replace(/\/+$/, "");
654
+ return ` location = ${prefix} {
655
+ return 302 ${prefix}/;
488
656
  }
489
657
 
490
- location = /assistant/ {
491
- rewrite ^ /assistant/__remote-index.html last;
658
+ location = ${prefix}/ {
659
+ rewrite ^ ${prefix}/__remote-index.html last;
492
660
  }
493
661
 
494
- location = /assistant/index.html {
495
- rewrite ^ /assistant/__remote-index.html last;
662
+ location = ${prefix}/index.html {
663
+ rewrite ^ ${prefix}/__remote-index.html last;
496
664
  }
497
665
 
498
666
  # The shell and the unhashed files beside it revalidate rather than
@@ -500,39 +668,173 @@ ${proxyBlock}
500
668
  # disk with a validator, so a repeat load costs a 304 instead of the whole
501
669
  # document. __config is the exception below: it is returned inline, so
502
670
  # nginx attaches no validator for a revalidation to match against.
503
- location = /assistant/__remote-index.html {
671
+ location = ${prefix}/__remote-index.html {
504
672
  internal;
505
673
  gzip on;
506
674
  alias ${nginxQuoted(indexHtmlPath, "remote web ingress index path")};
507
675
  add_header Cache-Control "no-cache";
508
676
  }
509
677
 
510
- location = /assistant/__config {
678
+ location = ${prefix}/__config {
511
679
  default_type application/json;
512
680
  add_header Cache-Control "no-store";
513
681
  return 200 ${nginxQuoted(configJson, "remote web ingress config")};
514
682
  }
515
683
 
516
- location ^~ /assistant/assets/ {
684
+ location ^~ ${prefix}/ {
517
685
  gzip on;
518
- alias ${nginxQuoted(nginxDirPath(webAssetsDir), "web assets path")};
686
+ alias ${nginxQuoted(nginxDirPath(opts.webDistDir), "web dist path")};
687
+ try_files $uri $uri/ ${prefix}/__remote-index.html;
688
+ add_header Cache-Control "no-cache";
689
+ }`;
690
+ }
691
+
692
+ /** The web bundle's hashed chunks, cached immutably under its dist root. */
693
+ function webAssetsLocations(webDistDir: string): string {
694
+ return ` location ^~ /assistant/assets/ {
695
+ gzip on;
696
+ alias ${nginxQuoted(
697
+ nginxDirPath(join(webDistDir, "assets")),
698
+ "web assets path",
699
+ )};
519
700
  try_files $uri =404;
520
701
  add_header Cache-Control "public, max-age=31536000, immutable";
702
+ }`;
703
+ }
704
+
705
+ function buildRemoteWebIngressLocations(opts: {
706
+ gatewayPort: number;
707
+ webDistDir: string;
708
+ appDistDir?: string;
709
+ appIndexHtmlPath?: string;
710
+ indexHtmlPath?: string;
711
+ config: Record<string, unknown>;
712
+ version?: string;
713
+ }): string {
714
+ const webDistDir = nginxDirPath(opts.webDistDir);
715
+ const indexHtmlPath =
716
+ opts.indexHtmlPath ?? join(opts.webDistDir, "index.html");
717
+
718
+ if (!opts.appDistDir) {
719
+ // Web mode: the web SPA IS the served surface, at /assistant/.
720
+ return `${DENYLIST_LOCATIONS}
721
+
722
+ ${assistantMetadataLocations({ ...opts.config, version: opts.version })}
723
+
724
+ ${gatewayProxyLocations(opts.gatewayPort)}
725
+
726
+ ${webAssetsLocations(opts.webDistDir)}
727
+
728
+ ${webSpaLocations({
729
+ prefix: "/assistant",
730
+ webDistDir: opts.webDistDir,
731
+ indexHtmlPath,
732
+ config: opts.config,
733
+ })}
734
+
735
+ location = / {
736
+ return 302 /assistant/;
737
+ }
738
+
739
+ location / {
740
+ return 404;
741
+ }`;
742
+ }
743
+
744
+ // App mode: the app bundle is the outer shell (origin root, including the
745
+ // app's own /assistant/* host routes) and the web SPA is iframed from the
746
+ // distinct /web/assistant/ namespace, so neither surface can load the
747
+ // other recursively. The web bundle's asset URLs keep their baked
748
+ // /assistant/ base, so its hashed chunks and public files stay served
749
+ // under that prefix even though the document now mounts from /web/.
750
+ const appIndexHtmlPath =
751
+ opts.appIndexHtmlPath ?? join(opts.appDistDir, "index.html");
752
+ return `${denylistLocations()}
753
+
754
+ ${denylistLocations("/web")}
755
+
756
+ ${assistantMetadataLocations({
757
+ ...opts.config,
758
+ version: opts.version,
759
+ entryUrl: assistantEntryUrl(opts.appDistDir),
760
+ })}
761
+
762
+ ${gatewayProxyLocations(opts.gatewayPort)}
763
+
764
+ location ^~ /web/v1/ {
765
+ ${gatewayProxyBlock(opts.gatewayPort, "/v1/")}
766
+ }
767
+
768
+ ${webSpaLocations({
769
+ prefix: "/web/assistant",
770
+ webDistDir: opts.webDistDir,
771
+ indexHtmlPath,
772
+ config: opts.config,
773
+ })}
774
+
775
+ location = /web {
776
+ return 302 /web/assistant/;
777
+ }
778
+
779
+ # The iframe namespace has no other routes: anything under /web that is
780
+ # not the assistant mount or its prefixed API path is a probe, not a page.
781
+ location ^~ /web/ {
782
+ return 404;
783
+ }
784
+
785
+ # The web bundle's hashed chunks and unhashed public files under its baked
786
+ # build base. Real files win; anything else falls through to the app
787
+ # shell, which owns the /assistant/* host routes.
788
+ location = /assistant {
789
+ rewrite ^ /__cadet-app-index.html last;
790
+ }
791
+
792
+ location = /assistant/ {
793
+ rewrite ^ /__cadet-app-index.html last;
521
794
  }
522
795
 
796
+ ${webAssetsLocations(opts.webDistDir)}
797
+
523
798
  location ^~ /assistant/ {
524
799
  gzip on;
525
800
  alias ${nginxQuoted(webDistDir, "web dist path")};
526
- try_files $uri $uri/ /assistant/__remote-index.html;
801
+ try_files $uri @cadet_app_shell;
527
802
  add_header Cache-Control "no-cache";
528
803
  }
529
804
 
805
+ # The app shell: its document is the config-injected standalone index, and
806
+ # every shell route (/, /account/*, /logout, and the /assistant/* host
807
+ # routes) resolves through the SPA fallback.
808
+ location = /__cadet-app-index.html {
809
+ internal;
810
+ gzip on;
811
+ alias ${nginxQuoted(appIndexHtmlPath, "app shell ingress index path")};
812
+ add_header Cache-Control "no-cache";
813
+ }
814
+
815
+ location @cadet_app_shell {
816
+ rewrite ^ /__cadet-app-index.html last;
817
+ }
818
+
530
819
  location = / {
531
- return 302 /assistant/;
820
+ rewrite ^ /__cadet-app-index.html last;
821
+ }
822
+
823
+ location ^~ /assets/ {
824
+ gzip on;
825
+ alias ${nginxQuoted(
826
+ nginxDirPath(join(opts.appDistDir, "assets")),
827
+ "app assets path",
828
+ )};
829
+ try_files $uri =404;
830
+ add_header Cache-Control "public, max-age=31536000, immutable";
532
831
  }
533
832
 
534
833
  location / {
535
- return 404;
834
+ gzip on;
835
+ alias ${nginxQuoted(nginxDirPath(opts.appDistDir), "app dist path")};
836
+ try_files $uri @cadet_app_shell;
837
+ add_header Cache-Control "no-cache";
536
838
  }`;
537
839
  }
538
840
 
@@ -728,6 +1030,19 @@ export function startIngressNginx(opts: {
728
1030
  remoteWebIngress.indexHtmlPath,
729
1031
  buildRemoteWebIndexHtml(rawIndexHtml, remoteWebIngress.config),
730
1032
  );
1033
+ if (remoteWebIngress.appDistDir) {
1034
+ // The app shell gets its own injected document: same remote-web config
1035
+ // (its readers ignore the web-only apiBaseUrl key), written beside the
1036
+ // web one so the generated locations can alias it internally.
1037
+ const rawAppIndexHtml = readFileSync(
1038
+ join(remoteWebIngress.appDistDir, "index.html"),
1039
+ "utf-8",
1040
+ );
1041
+ writeFileSync(
1042
+ join(paths.dir, "app-index.html"),
1043
+ buildRemoteWebIndexHtml(rawAppIndexHtml, remoteWebIngress.config),
1044
+ );
1045
+ }
731
1046
  }
732
1047
  writeFileSync(
733
1048
  paths.confPath,
@@ -756,6 +1071,8 @@ export function startIngressNginx(opts: {
756
1071
  remoteWebConfigHash: remoteWebConfigFingerprint(
757
1072
  remoteWebIngress.config,
758
1073
  remoteWebIngress.webDistDir,
1074
+ remoteWebIngress.version,
1075
+ remoteWebIngress.appDistDir,
759
1076
  ),
760
1077
  }
761
1078
  : {}),
@@ -868,6 +1185,7 @@ export type StartRemoteWebIngressResult =
868
1185
  }
869
1186
  | { status: "nginx-missing" }
870
1187
  | { status: "web-dist-missing" }
1188
+ | { status: "app-dist-missing" }
871
1189
  | { status: "unreachable"; listenPort: number; logPath: string }
872
1190
  | { status: "port-conflict"; listenPort: number; logPath: string };
873
1191
 
@@ -900,6 +1218,13 @@ export async function startRemoteWebIngress(opts: {
900
1218
  * sensitive-route denylist (webhooks-only mode).
901
1219
  */
902
1220
  includeWebApp?: boolean;
1221
+ /**
1222
+ * Which client bundle is the outer shell (default "web"). "app" additionally
1223
+ * serves the standalone app bundle as the outer account/client shell and
1224
+ * hands the shell a /web/assistant/ iframe URL for the web UI; "web" keeps
1225
+ * serving the web SPA directly from /assistant/.
1226
+ */
1227
+ shellMode?: "web" | "app";
903
1228
  /**
904
1229
  * Serving assistant's display name, stamped into the served remote-web
905
1230
  * config (`__CADET_CONFIG__.assistantName`) so remote clients can label
@@ -927,6 +1252,7 @@ export async function startRemoteWebIngress(opts: {
927
1252
  }): Promise<StartRemoteWebIngressResult> {
928
1253
  let listenPort = opts.listenPort ?? getNginxIngressPort();
929
1254
  const includeWebApp = opts.includeWebApp ?? true;
1255
+ const shellMode = opts.shellMode ?? "web";
930
1256
 
931
1257
  const version = getNginxVersion();
932
1258
  if (!version) {
@@ -947,7 +1273,7 @@ export async function startRemoteWebIngress(opts: {
947
1273
  // reuse decision and the actual spawn share one value and cannot drift.
948
1274
  const spaOptions = includeWebApp
949
1275
  ? {
950
- hubUrl: cloudWebHubUrl(getCurrentEnvironment().name),
1276
+ hubUrl: cloudAssistantHubUrl(getCurrentEnvironment().name),
951
1277
  ...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
952
1278
  ...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
953
1279
  }
@@ -956,11 +1282,31 @@ export async function startRemoteWebIngress(opts: {
956
1282
  if (includeWebApp && !webDistDir) {
957
1283
  return { status: "web-dist-missing" };
958
1284
  }
1285
+ const appDistDir =
1286
+ includeWebApp && shellMode === "app"
1287
+ ? (findAppDistDir() ?? undefined)
1288
+ : undefined;
1289
+ if (includeWebApp && shellMode === "app" && !appDistDir) {
1290
+ return { status: "app-dist-missing" };
1291
+ }
1292
+ const runtimeVersion = webDistDir
1293
+ ? await fetchCurrentVersion(`http://127.0.0.1:${opts.gatewayPort}`)
1294
+ : undefined;
1295
+ const webVersion = webDistDir ? readWebReleaseVersion(webDistDir) : undefined;
1296
+ const assistantVersion = runtimeVersion
1297
+ ? stripVersionPrefix(runtimeVersion)
1298
+ : undefined;
1299
+ const matchedVersion =
1300
+ assistantVersion && webVersion === assistantVersion
1301
+ ? assistantVersion
1302
+ : undefined;
959
1303
  const requestedConfigHash =
960
1304
  spaOptions && webDistDir
961
1305
  ? remoteWebConfigFingerprint(
962
1306
  remoteWebIngressConfig(spaOptions),
963
1307
  webDistDir,
1308
+ matchedVersion,
1309
+ appDistDir,
964
1310
  )
965
1311
  : undefined;
966
1312
  // Both sides are undefined in webhooks-only mode; an SPA record without a
@@ -1024,7 +1370,14 @@ export async function startRemoteWebIngress(opts: {
1024
1370
  workspaceDir: opts.workspaceDir,
1025
1371
  gatewayPort: opts.gatewayPort,
1026
1372
  listenPort,
1027
- remoteWebIngress: webDistDir ? { webDistDir, ...spaOptions } : undefined,
1373
+ remoteWebIngress: webDistDir
1374
+ ? {
1375
+ webDistDir,
1376
+ ...spaOptions,
1377
+ version: matchedVersion,
1378
+ ...(appDistDir ? { appDistDir } : {}),
1379
+ }
1380
+ : undefined,
1028
1381
  });
1029
1382
  let exited = false;
1030
1383
  child.once("exit", () => {
@@ -1097,8 +1450,14 @@ function lockfileAssistantName(assistantId: string): string | undefined {
1097
1450
  }
1098
1451
 
1099
1452
  /** User-facing label for the edge mode, shared by every edge status line. */
1100
- export function formatEdgeMode(includesWebApp: boolean): string {
1101
- return includesWebApp ? "remote web + webhooks" : "webhooks only";
1453
+ export function formatEdgeMode(
1454
+ includesWebApp: boolean,
1455
+ shellMode: "web" | "app" = "web",
1456
+ ): string {
1457
+ if (!includesWebApp) return "webhooks only";
1458
+ return shellMode === "app"
1459
+ ? "app shell + remote web + webhooks"
1460
+ : "remote web + webhooks";
1102
1461
  }
1103
1462
 
1104
1463
  /**
@@ -1153,6 +1512,8 @@ function stampAppProcessEntry(
1153
1512
  /** True when this call started the edge, false when a running one was reused. */
1154
1513
  started: boolean;
1155
1514
  includesWebApp: boolean;
1515
+ /** The shell mode the edge serves (the persisted lockfile setting). */
1516
+ shellMode: "web" | "app";
1156
1517
  }
1157
1518
 
1158
1519
  /**
@@ -1199,10 +1560,15 @@ export async function ensureTunnelEdge(opts: {
1199
1560
  const assistantName = opts.assistantId
1200
1561
  ? lockfileAssistantName(opts.assistantId)
1201
1562
  : undefined;
1563
+ // The persisted lockfile setting owns the shell choice; every edge caller
1564
+ // (tunnel, wake restore, upgrade restore) reads it through here so a mode
1565
+ // flip only needs `cadet tunnel --shell` once.
1566
+ const shellMode = readShellMode();
1202
1567
  const result = await startRemoteWebIngress({
1203
1568
  workspaceDir: opts.workspaceDir,
1204
1569
  gatewayPort: opts.gatewayPort,
1205
1570
  includeWebApp: true,
1571
+ shellMode,
1206
1572
  ...(assistantName ? { assistantName } : {}),
1207
1573
  ...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
1208
1574
  ...(opts.onStarting ? { onStarting: opts.onStarting } : {}),
@@ -1219,6 +1585,7 @@ export async function ensureTunnelEdge(opts: {
1219
1585
  port: result.listenPort,
1220
1586
  started: true,
1221
1587
  includesWebApp: true,
1588
+ shellMode,
1222
1589
  };
1223
1590
  case "already-running": {
1224
1591
  // `already-running` also covers a drifted edge whose restart failed, so
@@ -1257,6 +1624,7 @@ export async function ensureTunnelEdge(opts: {
1257
1624
  port: result.listenPort,
1258
1625
  started: false,
1259
1626
  includesWebApp: result.includeWebApp,
1627
+ shellMode,
1260
1628
  };
1261
1629
  }
1262
1630
  case "nginx-missing":
@@ -1271,6 +1639,13 @@ export async function ensureTunnelEdge(opts: {
1271
1639
  "Build the SPA with `cd clients/web && VITE_CLOUD_MODE=false bun run build`, " +
1272
1640
  "or install @mycadet/web so its packaged dist directory is available.",
1273
1641
  );
1642
+ case "app-dist-missing":
1643
+ throw new Error(
1644
+ "The app shell edge mode is selected but no " +
1645
+ "built app bundle is available. Build it with `cd clients/app && bun run build`, " +
1646
+ "or install @mycadet/app so its packaged standalone directory is available. " +
1647
+ "Switch back to the web shell with `cadet tunnel --shell web`.",
1648
+ );
1274
1649
  case "unreachable":
1275
1650
  throw new Error(
1276
1651
  `nginx edge did not become reachable on 127.0.0.1:${result.listenPort}. ` +