@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
@@ -13,9 +13,12 @@ import {
13
13
  rmSync,
14
14
  writeFileSync,
15
15
  } from "node:fs";
16
+ import { createServer } from "node:net";
16
17
  import { networkInterfaces } from "node:os";
17
18
  import { join } from "node:path";
18
19
 
20
+ import { z } from "zod";
21
+
19
22
  import { cloudAssistantHubUrl } from "@mycadet/environments";
20
23
 
21
24
  import {
@@ -27,17 +30,24 @@ import {
27
30
  } from "./assistant-config.js";
28
31
  import type { RemoteHost } from "./constants.js";
29
32
  import { getCurrentEnvironment } from "./environments/resolve.js";
30
- import { waitForDaemonReady } from "./http-client.js";
33
+ import { fetchCurrentVersion, waitForDaemonReady } from "./http-client.js";
31
34
  import {
32
35
  getDefaultWorkspaceDir,
33
36
  isLocalContainerEntry,
34
37
  loadRawConfig,
35
38
  parseGatewayPortFromEntryUrls,
39
+ readShellMode,
36
40
  saveRawConfig,
37
41
  } from "./ingress-config.js";
38
- 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";
39
48
 
40
49
  export { findWebDistDir } from "./web-dist.js";
50
+ export { findAppDistDir } from "./web-dist.js";
41
51
 
42
52
  /**
43
53
  * CLI-managed nginx reverse proxy that fronts the gateway as the canonical
@@ -92,8 +102,8 @@ function nginxDirPath(dir: string): string {
92
102
  return dir.endsWith("/") ? dir : `${dir}/`;
93
103
  }
94
104
 
95
- function gatewayProxyBlock(gatewayPort: number): string {
96
- 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};
97
107
  proxy_http_version 1.1;
98
108
  proxy_request_buffering off;
99
109
  proxy_buffering off;
@@ -106,40 +116,71 @@ function gatewayProxyBlock(gatewayPort: number): string {
106
116
  }
107
117
 
108
118
  /**
109
- * Sensitive local-only routes the edge must never expose to the internet,
110
- * 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.
111
122
  */
112
- const DENYLIST_LOCATIONS = ` location = /auth/token { return 404; }
113
- location = /auth/token/ { return 404; }
114
- location = /v1/pair { return 404; }
115
- location = /v1/pair/ { return 404; }
116
- location = /v1/pair/web-init { return 404; }
117
- location = /v1/pair/web-init/ { return 404; }
118
- location = /v1/devices { return 404; }
119
- location = /v1/devices/ { return 404; }
120
- location = /v1/devices/revoke { return 404; }
121
- location = /v1/devices/revoke/ { return 404; }
122
- location = /v1/guardian/init { return 404; }
123
- location = /v1/guardian/init/ { return 404; }
124
- location = /v1/guardian/reset-bootstrap { return 404; }
125
- location = /v1/guardian/reset-bootstrap/ { return 404; }
126
- location = /v1/remote-web/pairing-requests { return 404; }
127
- location = /v1/remote-web/pairing-requests/ { return 404; }
128
- location = /v1/remote-web/pairing-requests/approve { return 404; }
129
- location = /v1/remote-web/pairing-requests/approve/ { return 404; }
130
- location = /v1/remote-web/pairing-requests/deny { return 404; }
131
- location = /v1/remote-web/pairing-requests/deny/ { return 404; }
132
- location = /v1/remote-web/pairing-verification { return 404; }
133
- location = /v1/remote-web/pairing-verification/ { return 404; }
134
- location = /v1/shared/invites/redeem { return 404; }
135
- location = /v1/shared/invites/redeem/ { return 404; }
136
- location ^~ /assistant/__local/ { return 404; }
137
- location ^~ /assistant/__gateway/ { return 404; }
138
- location ^~ /assistant/__gateway-paired/ { return 404; }`;
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.
151
+ */
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();
139
165
 
140
166
  export interface RemoteWebIngressOptions {
141
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;
142
181
  indexHtmlPath?: string;
182
+ /** Assistant release verified against the served UI artifact. */
183
+ version?: string;
143
184
  config?: Record<string, unknown>;
144
185
  /** Serving assistant's display name, stamped into the served config so
145
186
  * remote clients can label this origin. Absent in older served configs. */
@@ -149,24 +190,41 @@ export interface RemoteWebIngressOptions {
149
190
  * configs. */
150
191
  assistantId?: string;
151
192
  /** Cloud web SPA base the remote client can hand this origin to (see
152
- * `cloudWebHubUrl`). Absent in older served configs. */
193
+ * `cloudAssistantHubUrl`). Absent in older served configs. */
153
194
  hubUrl?: string;
154
195
  }
155
196
 
156
197
  /**
157
- * Cloud web SPA base URL for a build environment. The mapping lives in
158
- * `@mycadet/environments` (`cloudAssistantHubUrl`) and is shared with the
159
- * 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.
160
202
  */
161
- export function cloudWebHubUrl(env: string | undefined): string {
162
- return cloudAssistantHubUrl(env);
203
+ export function assistantEntryUrl(appDistDir: string | undefined): string {
204
+ return appDistDir ? "web/assistant/" : "assistant/";
163
205
  }
164
206
 
165
207
  // The edge serves remote browsers, so the bundle's remote-gateway machinery
166
208
  // must always run: it is reached through this self-hosted edge however the
167
209
  // cadet itself is hosted. "cloud" is never stamped (the cloud serves its
168
210
  // own bundles).
169
- const EDGE_SERVED_REMOTE: RemoteHost = "custom";
211
+ /** The lockfile's recorded remote kind for the assistant, if one is found. */
212
+ function lockfileAssistantRemote(assistantId: string): string | undefined {
213
+ const result = lookupAssistantByIdentifier(assistantId);
214
+ return result.status === "found" ? result.entry.cloud : undefined;
215
+ }
216
+
217
+ /**
218
+ * The remote kind stamped into the served SPA config. Docker and custom
219
+ * cadets are stamped truthfully; every other hosting shape — a local cadet
220
+ * reached through this edge, paired remotes, unknown entries — reads as
221
+ * "custom" so the served bundle always runs the remote-gateway machinery
222
+ * (this edge serves remote browsers however the cadet itself is hosted).
223
+ * "cloud" is never stamped: the cloud serves its own bundles.
224
+ */
225
+ export function edgeServedRemote(recorded: string | undefined): RemoteHost {
226
+ return recorded === "docker" ? "docker" : "custom";
227
+ }
170
228
 
171
229
  function remoteWebIngressConfig(
172
230
  opts: Pick<
@@ -175,7 +233,9 @@ function remoteWebIngressConfig(
175
233
  >,
176
234
  ): Record<string, unknown> {
177
235
  return {
178
- remote: EDGE_SERVED_REMOTE,
236
+ remote: edgeServedRemote(
237
+ opts.assistantId ? lockfileAssistantRemote(opts.assistantId) : undefined,
238
+ ),
179
239
  apiBaseUrl: "/v1",
180
240
  ...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
181
241
  ...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
@@ -189,17 +249,43 @@ function remoteWebIngressConfig(
189
249
  * fingerprint matches, so this must change whenever the generated index or
190
250
  * nginx template does.
191
251
  */
192
- export const EDGE_TEMPLATE_VERSION = 10;
252
+ export const EDGE_TEMPLATE_VERSION = 13;
193
253
 
194
254
  /**
195
- * Stable fingerprint of the SPA config injected into the served index and
196
- * `/assistant/__config`, plus 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
197
257
  * alongside the edge state so a reuse decision can tell whether a running edge
198
258
  * already serves the requested config (see `IngressState.remoteWebConfigHash`).
199
259
  */
200
- function remoteWebConfigFingerprint(config: Record<string, unknown>): string {
260
+ function remoteWebConfigFingerprint(
261
+ config: Record<string, unknown>,
262
+ webDistDir: string,
263
+ version?: string,
264
+ appDistDir?: string,
265
+ ): string {
201
266
  return createHash("sha256")
202
- .update(JSON.stringify({ template: EDGE_TEMPLATE_VERSION, config }))
267
+ .update(
268
+ JSON.stringify({
269
+ template: EDGE_TEMPLATE_VERSION,
270
+ config,
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
+ : {}),
286
+ indexHtml: readFileSync(join(webDistDir, "index.html"), "utf-8"),
287
+ }),
288
+ )
203
289
  .digest("hex");
204
290
  }
205
291
 
@@ -278,9 +364,140 @@ export function hasIpv6Loopback(): boolean {
278
364
  .some((addr) => addr.address === "::1");
279
365
  }
280
366
 
367
+ /** Whether a TCP listener already holds `port` on the given loopback host. */
368
+ async function isPortInUse(
369
+ port: number,
370
+ host: "127.0.0.1" | "::1",
371
+ ): Promise<boolean> {
372
+ return await new Promise((resolve) => {
373
+ const probe = createServer();
374
+ probe.once("error", () => resolve(true));
375
+ probe.once("listening", () => {
376
+ probe.close(() => resolve(false));
377
+ });
378
+ probe.listen(port, host);
379
+ });
380
+ }
381
+
281
382
  /**
282
- * Build the nginx config that forwards tunnel web traffic to the gateway.
383
+ * The first free loopback port at or above `startPort`, scanning a short band,
384
+ * or null when the whole band is busy. Both stacks the edge may bind are
385
+ * probed (the IPv4 loopback always, the IPv6 loopback when the host has one),
386
+ * so the returned port is one nginx can actually take.
283
387
  */
388
+ export async function findAvailableIngressPort(
389
+ startPort: number,
390
+ attempts = 20,
391
+ ): Promise<number | null> {
392
+ const checkIpv6 = hasIpv6Loopback();
393
+ for (let port = startPort; port < startPort + attempts; port++) {
394
+ if (await isPortInUse(port, "127.0.0.1")) {
395
+ continue;
396
+ }
397
+ if (checkIpv6 && (await isPortInUse(port, "::1"))) {
398
+ continue;
399
+ }
400
+ return port;
401
+ }
402
+ return null;
403
+ }
404
+
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. */
284
501
  export function buildIngressNginxConfig(opts: {
285
502
  gatewayPort: number;
286
503
  listenPort: number;
@@ -296,12 +513,18 @@ export function buildIngressNginxConfig(opts: {
296
513
  ? ` listen [::1]:${opts.listenPort};\n`
297
514
  : "";
298
515
  const remoteWebIngress = opts.remoteWebIngress;
516
+ const allowedOrigins = remoteWebIngress
517
+ ? appOrigins(remoteWebIngressConfig(remoteWebIngress))
518
+ : [];
299
519
  const serverLocations = remoteWebIngress
300
520
  ? buildRemoteWebIngressLocations({
301
521
  gatewayPort: opts.gatewayPort,
302
522
  webDistDir: remoteWebIngress.webDistDir,
523
+ appDistDir: remoteWebIngress.appDistDir,
524
+ appIndexHtmlPath: remoteWebIngress.appIndexHtmlPath,
303
525
  indexHtmlPath: remoteWebIngress.indexHtmlPath,
304
526
  config: remoteWebIngressConfig(remoteWebIngress),
527
+ version: remoteWebIngress.version,
305
528
  })
306
529
  : `${DENYLIST_LOCATIONS}
307
530
 
@@ -350,6 +573,11 @@ http {
350
573
  text/plain txt;
351
574
  }
352
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
+
353
581
  map $http_upgrade $connection_upgrade {
354
582
  default upgrade;
355
583
  "" close;
@@ -385,22 +613,13 @@ ${serverLocations}
385
613
  `;
386
614
  }
387
615
 
388
- function buildRemoteWebIngressLocations(opts: {
389
- gatewayPort: number;
390
- webDistDir: string;
391
- indexHtmlPath?: string;
392
- config: Record<string, unknown>;
393
- }): string {
394
- const proxyBlock = gatewayProxyBlock(opts.gatewayPort);
395
- const webDistDir = nginxDirPath(opts.webDistDir);
396
- const webAssetsDir = join(opts.webDistDir, "assets");
397
- const indexHtmlPath =
398
- opts.indexHtmlPath ?? join(opts.webDistDir, "index.html");
399
- const configJson = JSON.stringify(opts.config);
400
-
401
- return `${DENYLIST_LOCATIONS}
402
-
403
- 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 {
404
623
  ${proxyBlock}
405
624
  }
406
625
 
@@ -414,18 +633,34 @@ ${proxyBlock}
414
633
 
415
634
  location ^~ /webhooks/ {
416
635
  ${proxyBlock}
417
- }
636
+ }`;
637
+ }
418
638
 
419
- location = /assistant {
420
- 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}/;
421
656
  }
422
657
 
423
- location = /assistant/ {
424
- rewrite ^ /assistant/__remote-index.html last;
658
+ location = ${prefix}/ {
659
+ rewrite ^ ${prefix}/__remote-index.html last;
425
660
  }
426
661
 
427
- location = /assistant/index.html {
428
- rewrite ^ /assistant/__remote-index.html last;
662
+ location = ${prefix}/index.html {
663
+ rewrite ^ ${prefix}/__remote-index.html last;
429
664
  }
430
665
 
431
666
  # The shell and the unhashed files beside it revalidate rather than
@@ -433,39 +668,173 @@ ${proxyBlock}
433
668
  # disk with a validator, so a repeat load costs a 304 instead of the whole
434
669
  # document. __config is the exception below: it is returned inline, so
435
670
  # nginx attaches no validator for a revalidation to match against.
436
- location = /assistant/__remote-index.html {
671
+ location = ${prefix}/__remote-index.html {
437
672
  internal;
438
673
  gzip on;
439
674
  alias ${nginxQuoted(indexHtmlPath, "remote web ingress index path")};
440
675
  add_header Cache-Control "no-cache";
441
676
  }
442
677
 
443
- location = /assistant/__config {
678
+ location = ${prefix}/__config {
444
679
  default_type application/json;
445
680
  add_header Cache-Control "no-store";
446
681
  return 200 ${nginxQuoted(configJson, "remote web ingress config")};
447
682
  }
448
683
 
449
- location ^~ /assistant/assets/ {
684
+ location ^~ ${prefix}/ {
685
+ gzip on;
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/ {
450
695
  gzip on;
451
- alias ${nginxQuoted(nginxDirPath(webAssetsDir), "web assets path")};
696
+ alias ${nginxQuoted(
697
+ nginxDirPath(join(webDistDir, "assets")),
698
+ "web assets path",
699
+ )};
452
700
  try_files $uri =404;
453
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/")}
454
766
  }
455
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;
794
+ }
795
+
796
+ ${webAssetsLocations(opts.webDistDir)}
797
+
456
798
  location ^~ /assistant/ {
457
799
  gzip on;
458
800
  alias ${nginxQuoted(webDistDir, "web dist path")};
459
- try_files $uri $uri/ /assistant/__remote-index.html;
801
+ try_files $uri @cadet_app_shell;
460
802
  add_header Cache-Control "no-cache";
461
803
  }
462
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
+
463
819
  location = / {
464
- 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";
465
831
  }
466
832
 
467
833
  location / {
468
- 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";
469
838
  }`;
470
839
  }
471
840
 
@@ -574,7 +943,7 @@ export interface IngressState {
574
943
  */
575
944
  gatewayPort?: number;
576
945
  /**
577
- * Fingerprint of the SPA config injected into the served index. Undefined
946
+ * Fingerprint of the injected SPA config and its source bundle. Undefined
578
947
  * for webhooks-only edges and for SPA records that predate the field; an
579
948
  * SPA edge without a recorded fingerprint is treated as drifted and
580
949
  * restarted so the served index provably carries the requested config.
@@ -661,6 +1030,19 @@ export function startIngressNginx(opts: {
661
1030
  remoteWebIngress.indexHtmlPath,
662
1031
  buildRemoteWebIndexHtml(rawIndexHtml, remoteWebIngress.config),
663
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
+ }
664
1046
  }
665
1047
  writeFileSync(
666
1048
  paths.confPath,
@@ -688,6 +1070,9 @@ export function startIngressNginx(opts: {
688
1070
  ? {
689
1071
  remoteWebConfigHash: remoteWebConfigFingerprint(
690
1072
  remoteWebIngress.config,
1073
+ remoteWebIngress.webDistDir,
1074
+ remoteWebIngress.version,
1075
+ remoteWebIngress.appDistDir,
691
1076
  ),
692
1077
  }
693
1078
  : {}),
@@ -800,6 +1185,7 @@ export type StartRemoteWebIngressResult =
800
1185
  }
801
1186
  | { status: "nginx-missing" }
802
1187
  | { status: "web-dist-missing" }
1188
+ | { status: "app-dist-missing" }
803
1189
  | { status: "unreachable"; listenPort: number; logPath: string }
804
1190
  | { status: "port-conflict"; listenPort: number; logPath: string };
805
1191
 
@@ -832,6 +1218,13 @@ export async function startRemoteWebIngress(opts: {
832
1218
  * sensitive-route denylist (webhooks-only mode).
833
1219
  */
834
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";
835
1228
  /**
836
1229
  * Serving assistant's display name, stamped into the served remote-web
837
1230
  * config (`__CADET_CONFIG__.assistantName`) so remote clients can label
@@ -857,8 +1250,9 @@ export async function startRemoteWebIngress(opts: {
857
1250
  listenPort: number;
858
1251
  }) => void;
859
1252
  }): Promise<StartRemoteWebIngressResult> {
860
- const listenPort = opts.listenPort ?? getNginxIngressPort();
1253
+ let listenPort = opts.listenPort ?? getNginxIngressPort();
861
1254
  const includeWebApp = opts.includeWebApp ?? true;
1255
+ const shellMode = opts.shellMode ?? "web";
862
1256
 
863
1257
  const version = getNginxVersion();
864
1258
  if (!version) {
@@ -868,18 +1262,53 @@ export async function startRemoteWebIngress(opts: {
868
1262
  const running = isIngressRunning(opts.workspaceDir);
869
1263
  const recorded = running ? readIngressState(opts.workspaceDir) : null;
870
1264
  const recordedMode = recorded?.includeWebApp ?? true;
1265
+ if (
1266
+ recorded &&
1267
+ opts.listenPort === undefined &&
1268
+ process.env.CADET_NGINX_INGRESS_PORT === undefined
1269
+ ) {
1270
+ listenPort = recorded.listenPort;
1271
+ }
871
1272
  // The SPA config the served index must carry, computed up front so the
872
1273
  // reuse decision and the actual spawn share one value and cannot drift.
873
1274
  const spaOptions = includeWebApp
874
1275
  ? {
875
- hubUrl: cloudWebHubUrl(getCurrentEnvironment().name),
1276
+ hubUrl: cloudAssistantHubUrl(getCurrentEnvironment().name),
876
1277
  ...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
877
1278
  ...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
878
1279
  }
879
1280
  : undefined;
880
- const requestedConfigHash = spaOptions
881
- ? remoteWebConfigFingerprint(remoteWebIngressConfig(spaOptions))
1281
+ const webDistDir = includeWebApp ? findWebDistDir() : null;
1282
+ if (includeWebApp && !webDistDir) {
1283
+ return { status: "web-dist-missing" };
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)
882
1298
  : undefined;
1299
+ const matchedVersion =
1300
+ assistantVersion && webVersion === assistantVersion
1301
+ ? assistantVersion
1302
+ : undefined;
1303
+ const requestedConfigHash =
1304
+ spaOptions && webDistDir
1305
+ ? remoteWebConfigFingerprint(
1306
+ remoteWebIngressConfig(spaOptions),
1307
+ webDistDir,
1308
+ matchedVersion,
1309
+ appDistDir,
1310
+ )
1311
+ : undefined;
883
1312
  // Both sides are undefined in webhooks-only mode; an SPA record without a
884
1313
  // fingerprint predates the field and counts as drifted (see IngressState).
885
1314
  const configMatches = recorded?.remoteWebConfigHash === requestedConfigHash;
@@ -906,14 +1335,6 @@ export async function startRemoteWebIngress(opts: {
906
1335
  return alreadyRunning();
907
1336
  }
908
1337
 
909
- let webDistDir: string | null = null;
910
- if (includeWebApp) {
911
- webDistDir = findWebDistDir();
912
- if (!webDistDir) {
913
- return { status: "web-dist-missing" };
914
- }
915
- }
916
-
917
1338
  // The running edge serves the other mode, targets a different gateway
918
1339
  // port, or injects a stale SPA config; restart it with the requested
919
1340
  // config. A false stop can mean the old edge exited on its own after the
@@ -926,13 +1347,37 @@ export async function startRemoteWebIngress(opts: {
926
1347
  return alreadyRunning();
927
1348
  }
928
1349
 
1350
+ // A foreign process squatting the default edge port (another assistant's
1351
+ // edge, a stale dev server) must not kill the tunnel: the edge port is
1352
+ // loopback-local and the public tunnel URL never carries it, so moving to
1353
+ // the next free port is invisible to remote clients. Explicit pins
1354
+ // (opts.listenPort / CADET_NGINX_INGRESS_PORT) keep their intent and still
1355
+ // fail on a collision.
1356
+ if (
1357
+ opts.listenPort === undefined &&
1358
+ process.env.CADET_NGINX_INGRESS_PORT === undefined
1359
+ ) {
1360
+ const discovered = await findAvailableIngressPort(listenPort);
1361
+ if (discovered !== null && discovered !== listenPort) {
1362
+ console.log(`Port ${listenPort} in use; using ${discovered} instead.`);
1363
+ listenPort = discovered;
1364
+ }
1365
+ }
1366
+
929
1367
  opts.onStarting?.({ version, webDistDir, listenPort });
930
1368
 
931
1369
  const child = startIngressNginx({
932
1370
  workspaceDir: opts.workspaceDir,
933
1371
  gatewayPort: opts.gatewayPort,
934
1372
  listenPort,
935
- remoteWebIngress: webDistDir ? { webDistDir, ...spaOptions } : undefined,
1373
+ remoteWebIngress: webDistDir
1374
+ ? {
1375
+ webDistDir,
1376
+ ...spaOptions,
1377
+ version: matchedVersion,
1378
+ ...(appDistDir ? { appDistDir } : {}),
1379
+ }
1380
+ : undefined,
936
1381
  });
937
1382
  let exited = false;
938
1383
  child.once("exit", () => {
@@ -1005,8 +1450,14 @@ function lockfileAssistantName(assistantId: string): string | undefined {
1005
1450
  }
1006
1451
 
1007
1452
  /** User-facing label for the edge mode, shared by every edge status line. */
1008
- export function formatEdgeMode(includesWebApp: boolean): string {
1009
- 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";
1010
1461
  }
1011
1462
 
1012
1463
  /**
@@ -1042,6 +1493,7 @@ export async function stopContainerTunnelEdge(
1042
1493
  function stampAppProcessEntry(
1043
1494
  workspaceDir: string,
1044
1495
  assistantId?: string,
1496
+ port = getNginxIngressPort(),
1045
1497
  ): void {
1046
1498
  const pid = getIngressPid(workspaceDir);
1047
1499
  if (pid !== null) {
@@ -1049,6 +1501,7 @@ function stampAppProcessEntry(
1049
1501
  pid,
1050
1502
  startedAt: new Date().toISOString(),
1051
1503
  assistantId,
1504
+ port,
1052
1505
  });
1053
1506
  }
1054
1507
  }
@@ -1059,6 +1512,8 @@ function stampAppProcessEntry(
1059
1512
  /** True when this call started the edge, false when a running one was reused. */
1060
1513
  started: boolean;
1061
1514
  includesWebApp: boolean;
1515
+ /** The shell mode the edge serves (the persisted lockfile setting). */
1516
+ shellMode: "web" | "app";
1062
1517
  }
1063
1518
 
1064
1519
  /**
@@ -1105,10 +1560,15 @@ export async function ensureTunnelEdge(opts: {
1105
1560
  const assistantName = opts.assistantId
1106
1561
  ? lockfileAssistantName(opts.assistantId)
1107
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();
1108
1567
  const result = await startRemoteWebIngress({
1109
1568
  workspaceDir: opts.workspaceDir,
1110
1569
  gatewayPort: opts.gatewayPort,
1111
1570
  includeWebApp: true,
1571
+ shellMode,
1112
1572
  ...(assistantName ? { assistantName } : {}),
1113
1573
  ...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
1114
1574
  ...(opts.onStarting ? { onStarting: opts.onStarting } : {}),
@@ -1116,11 +1576,16 @@ export async function ensureTunnelEdge(opts: {
1116
1576
 
1117
1577
  switch (result.status) {
1118
1578
  case "started":
1119
- stampAppProcessEntry(opts.workspaceDir, opts.assistantId);
1579
+ stampAppProcessEntry(
1580
+ opts.workspaceDir,
1581
+ opts.assistantId,
1582
+ result.listenPort,
1583
+ );
1120
1584
  return {
1121
1585
  port: result.listenPort,
1122
1586
  started: true,
1123
1587
  includesWebApp: true,
1588
+ shellMode,
1124
1589
  };
1125
1590
  case "already-running": {
1126
1591
  // `already-running` also covers a drifted edge whose restart failed, so
@@ -1150,11 +1615,16 @@ export async function ensureTunnelEdge(opts: {
1150
1615
  stuckEdgeRecoveryHint(opts.workspaceDir),
1151
1616
  );
1152
1617
  }
1153
- stampAppProcessEntry(opts.workspaceDir, opts.assistantId);
1618
+ stampAppProcessEntry(
1619
+ opts.workspaceDir,
1620
+ opts.assistantId,
1621
+ result.listenPort,
1622
+ );
1154
1623
  return {
1155
1624
  port: result.listenPort,
1156
1625
  started: false,
1157
1626
  includesWebApp: result.includeWebApp,
1627
+ shellMode,
1158
1628
  };
1159
1629
  }
1160
1630
  case "nginx-missing":
@@ -1169,6 +1639,13 @@ export async function ensureTunnelEdge(opts: {
1169
1639
  "Build the SPA with `cd clients/web && VITE_CLOUD_MODE=false bun run build`, " +
1170
1640
  "or install @mycadet/web so its packaged dist directory is available.",
1171
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
+ );
1172
1649
  case "unreachable":
1173
1650
  throw new Error(
1174
1651
  `nginx edge did not become reachable on 127.0.0.1:${result.listenPort}. ` +