@mycadet/cli 0.12.10 → 0.13.0

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 (105) 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/ingress.ts +3 -3
  4. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  5. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  6. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  7. package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  8. package/package.json +3 -2
  9. package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
  10. package/src/__tests__/backup-ops-matching.test.ts +15 -13
  11. package/src/__tests__/backup-ops.test.ts +105 -0
  12. package/src/__tests__/backup.test.ts +114 -673
  13. package/src/__tests__/cloud-login-poll.test.ts +69 -0
  14. package/src/__tests__/cloud-releases.test.ts +82 -0
  15. package/src/__tests__/commands-mock.ts +101 -0
  16. package/src/__tests__/debug.test.ts +17 -91
  17. package/src/__tests__/devices.test.ts +64 -2
  18. package/src/__tests__/ensure-binary.test.ts +52 -0
  19. package/src/__tests__/gateway-token.test.ts +267 -0
  20. package/src/__tests__/hatch-next-steps.test.ts +38 -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__/multi-local.test.ts +3 -3
  24. package/src/__tests__/nginx-ingress.test.ts +233 -36
  25. package/src/__tests__/orphan-detection.test.ts +1 -1
  26. package/src/__tests__/paired-lifecycle.test.ts +2 -2
  27. package/src/__tests__/ps-cloud-status.test.ts +8 -7
  28. package/src/__tests__/root-backup-docker.test.ts +294 -0
  29. package/src/__tests__/root-backup.test.ts +527 -0
  30. package/src/__tests__/root-restore-command.test.ts +147 -0
  31. package/src/__tests__/root-signing-key.test.ts +50 -0
  32. package/src/__tests__/setup.test.ts +17 -7
  33. package/src/__tests__/sleep.test.ts +11 -0
  34. package/src/__tests__/teleport.test.ts +68 -2996
  35. package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
  36. package/src/__tests__/tunnel-stop.test.ts +226 -0
  37. package/src/__tests__/tunnel.test.ts +13 -3
  38. package/src/__tests__/upgrade-local.test.ts +21 -11
  39. package/src/__tests__/upgrade-replay-env.test.ts +2 -2
  40. package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
  41. package/src/__tests__/windows-lifecycle.test.ts +25 -0
  42. package/src/commands/backup.ts +44 -399
  43. package/src/commands/client.ts +3 -3
  44. package/src/commands/completions.ts +0 -2
  45. package/src/commands/debug.ts +75 -131
  46. package/src/commands/devices.ts +117 -47
  47. package/src/commands/gateway/token.ts +77 -23
  48. package/src/commands/hatch.ts +10 -52
  49. package/src/commands/pair.ts +9 -3
  50. package/src/commands/ps.ts +4 -2
  51. package/src/commands/restore.ts +77 -444
  52. package/src/commands/retire.ts +2 -3
  53. package/src/commands/sleep.ts +15 -2
  54. package/src/commands/teleport.ts +47 -1559
  55. package/src/commands/tunnel.ts +43 -4
  56. package/src/commands/upgrade.ts +84 -157
  57. package/src/commands/wake.ts +1 -1
  58. package/src/index.ts +0 -4
  59. package/src/lib/__tests__/http-client-version.test.ts +29 -0
  60. package/src/lib/__tests__/runtime-url.test.ts +3 -75
  61. package/src/lib/__tests__/web-dist.test.ts +27 -1
  62. package/src/lib/assistant-client.ts +1 -1
  63. package/src/lib/assistant-config.ts +20 -23
  64. package/src/lib/backup-ops.ts +37 -251
  65. package/src/lib/cloud-client.ts +10 -13
  66. package/src/lib/cloud-login-poll.ts +75 -0
  67. package/src/lib/cloud-login.ts +5 -2
  68. package/src/lib/cloud-releases.ts +54 -10
  69. package/src/lib/cloud-values.ts +1 -1
  70. package/src/lib/confirm-action.ts +2 -2
  71. package/src/lib/docker.ts +65 -34
  72. package/src/lib/ensure-binary.ts +65 -6
  73. package/src/lib/github-repository.ts +2 -0
  74. package/src/lib/guardian-token.ts +1 -23
  75. package/src/lib/hatch-local.ts +18 -52
  76. package/src/lib/hatch-next-steps.ts +15 -2
  77. package/src/lib/host-teleport.ts +506 -0
  78. package/src/lib/http-client.ts +25 -0
  79. package/src/lib/live-voice/tunnel.test.ts +1 -1
  80. package/src/lib/live-voice/tunnel.ts +3 -3
  81. package/src/lib/local.ts +10 -8
  82. package/src/lib/nginx-ingress.ts +254 -36
  83. package/src/lib/process.ts +13 -0
  84. package/src/lib/root-backup.ts +917 -0
  85. package/src/lib/root-signing-key.ts +31 -0
  86. package/src/lib/runtime-url.ts +0 -29
  87. package/src/lib/teleport-backup.ts +3 -16
  88. package/src/lib/tunnel.ts +86 -34
  89. package/src/lib/tunnels/cadet.ts +12 -1
  90. package/src/lib/tunnels/cloudflare.ts +36 -1
  91. package/src/lib/tunnels/ngrok.ts +16 -2
  92. package/src/lib/tunnels/process-lifecycle.ts +26 -0
  93. package/src/lib/tunnels/tailscale.ts +79 -1
  94. package/src/lib/upgrade-lifecycle.ts +63 -141
  95. package/src/lib/web-dist.ts +23 -1
  96. package/src/lib/xdg-log.ts +11 -0
  97. package/src/__tests__/bundle-staging.test.ts +0 -268
  98. package/src/__tests__/unpair.test.ts +0 -160
  99. package/src/commands/rollback.ts +0 -479
  100. package/src/commands/unpair.ts +0 -122
  101. package/src/lib/__tests__/job-polling.test.ts +0 -278
  102. package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
  103. package/src/lib/bundle-staging.ts +0 -220
  104. package/src/lib/job-polling.ts +0 -195
  105. 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,7 +30,7 @@ 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,
@@ -35,7 +38,8 @@ import {
35
38
  parseGatewayPortFromEntryUrls,
36
39
  saveRawConfig,
37
40
  } from "./ingress-config.js";
38
- import { findWebDistDir } from "./web-dist.js";
41
+ import { stripVersionPrefix } from "./version-compat.js";
42
+ import { findWebDistDir, readWebReleaseVersion } from "./web-dist.js";
39
43
 
40
44
  export { findWebDistDir } from "./web-dist.js";
41
45
 
@@ -140,6 +144,8 @@ const DENYLIST_LOCATIONS = ` location = /auth/token { return 404; }
140
144
  export interface RemoteWebIngressOptions {
141
145
  webDistDir: string;
142
146
  indexHtmlPath?: string;
147
+ /** Assistant release verified against the served UI artifact. */
148
+ version?: string;
143
149
  config?: Record<string, unknown>;
144
150
  /** Serving assistant's display name, stamped into the served config so
145
151
  * remote clients can label this origin. Absent in older served configs. */
@@ -149,24 +155,31 @@ export interface RemoteWebIngressOptions {
149
155
  * configs. */
150
156
  assistantId?: string;
151
157
  /** Cloud web SPA base the remote client can hand this origin to (see
152
- * `cloudWebHubUrl`). Absent in older served configs. */
158
+ * `cloudAssistantHubUrl`). Absent in older served configs. */
153
159
  hubUrl?: string;
154
160
  }
155
161
 
156
- /**
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.
160
- */
161
- export function cloudWebHubUrl(env: string | undefined): string {
162
- return cloudAssistantHubUrl(env);
163
- }
164
-
165
162
  // The edge serves remote browsers, so the bundle's remote-gateway machinery
166
163
  // must always run: it is reached through this self-hosted edge however the
167
164
  // cadet itself is hosted. "cloud" is never stamped (the cloud serves its
168
165
  // own bundles).
169
- const EDGE_SERVED_REMOTE: RemoteHost = "custom";
166
+ /** The lockfile's recorded remote kind for the assistant, if one is found. */
167
+ function lockfileAssistantRemote(assistantId: string): string | undefined {
168
+ const result = lookupAssistantByIdentifier(assistantId);
169
+ return result.status === "found" ? result.entry.cloud : undefined;
170
+ }
171
+
172
+ /**
173
+ * The remote kind stamped into the served SPA config. Docker and custom
174
+ * cadets are stamped truthfully; every other hosting shape — a local cadet
175
+ * reached through this edge, paired remotes, unknown entries — reads as
176
+ * "custom" so the served bundle always runs the remote-gateway machinery
177
+ * (this edge serves remote browsers however the cadet itself is hosted).
178
+ * "cloud" is never stamped: the cloud serves its own bundles.
179
+ */
180
+ export function edgeServedRemote(recorded: string | undefined): RemoteHost {
181
+ return recorded === "docker" ? "docker" : "custom";
182
+ }
170
183
 
171
184
  function remoteWebIngressConfig(
172
185
  opts: Pick<
@@ -175,7 +188,9 @@ function remoteWebIngressConfig(
175
188
  >,
176
189
  ): Record<string, unknown> {
177
190
  return {
178
- remote: EDGE_SERVED_REMOTE,
191
+ remote: edgeServedRemote(
192
+ opts.assistantId ? lockfileAssistantRemote(opts.assistantId) : undefined,
193
+ ),
179
194
  apiBaseUrl: "/v1",
180
195
  ...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
181
196
  ...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
@@ -189,17 +204,29 @@ function remoteWebIngressConfig(
189
204
  * fingerprint matches, so this must change whenever the generated index or
190
205
  * nginx template does.
191
206
  */
192
- export const EDGE_TEMPLATE_VERSION = 10;
207
+ export const EDGE_TEMPLATE_VERSION = 12;
193
208
 
194
209
  /**
195
- * Stable fingerprint of the SPA config injected into the served index and
196
- * `/assistant/__config`, plus the template that renders them. Recorded
210
+ * Stable fingerprint of the injected SPA config, its source bundle, and
211
+ * the template that renders them. Recorded
197
212
  * alongside the edge state so a reuse decision can tell whether a running edge
198
213
  * already serves the requested config (see `IngressState.remoteWebConfigHash`).
199
214
  */
200
- function remoteWebConfigFingerprint(config: Record<string, unknown>): string {
215
+ function remoteWebConfigFingerprint(
216
+ config: Record<string, unknown>,
217
+ webDistDir: string,
218
+ version?: string,
219
+ ): string {
201
220
  return createHash("sha256")
202
- .update(JSON.stringify({ template: EDGE_TEMPLATE_VERSION, config }))
221
+ .update(
222
+ JSON.stringify({
223
+ template: EDGE_TEMPLATE_VERSION,
224
+ config,
225
+ webDistDir,
226
+ version,
227
+ indexHtml: readFileSync(join(webDistDir, "index.html"), "utf-8"),
228
+ }),
229
+ )
203
230
  .digest("hex");
204
231
  }
205
232
 
@@ -278,9 +305,138 @@ export function hasIpv6Loopback(): boolean {
278
305
  .some((addr) => addr.address === "::1");
279
306
  }
280
307
 
308
+ /** Whether a TCP listener already holds `port` on the given loopback host. */
309
+ async function isPortInUse(
310
+ port: number,
311
+ host: "127.0.0.1" | "::1",
312
+ ): Promise<boolean> {
313
+ return await new Promise((resolve) => {
314
+ const probe = createServer();
315
+ probe.once("error", () => resolve(true));
316
+ probe.once("listening", () => {
317
+ probe.close(() => resolve(false));
318
+ });
319
+ probe.listen(port, host);
320
+ });
321
+ }
322
+
281
323
  /**
282
- * Build the nginx config that forwards tunnel web traffic to the gateway.
324
+ * The first free loopback port at or above `startPort`, scanning a short band,
325
+ * or null when the whole band is busy. Both stacks the edge may bind are
326
+ * probed (the IPv4 loopback always, the IPv6 loopback when the host has one),
327
+ * so the returned port is one nginx can actually take.
283
328
  */
329
+ export async function findAvailableIngressPort(
330
+ startPort: number,
331
+ attempts = 20,
332
+ ): Promise<number | null> {
333
+ const checkIpv6 = hasIpv6Loopback();
334
+ for (let port = startPort; port < startPort + attempts; port++) {
335
+ if (await isPortInUse(port, "127.0.0.1")) {
336
+ continue;
337
+ }
338
+ if (checkIpv6 && (await isPortInUse(port, "::1"))) {
339
+ continue;
340
+ }
341
+ return port;
342
+ }
343
+ return null;
344
+ }
345
+
346
+ const AssistantHostConfigSchema = z.object({
347
+ assistantId: z.string().min(1).optional(),
348
+ assistantName: z.string().optional(),
349
+ version: z.string().min(1).optional(),
350
+ hubUrl: z.string().url().optional(),
351
+ appAllowedOrigins: z.array(z.string().url()).default([]),
352
+ });
353
+
354
+ function assistantHostConfig(config: Record<string, unknown>) {
355
+ const result = AssistantHostConfigSchema.safeParse(config);
356
+ if (!result.success) {
357
+ throw new Error("Invalid assistant host configuration.");
358
+ }
359
+ return result.data;
360
+ }
361
+
362
+ function appOrigins(config: Record<string, unknown>): string[] {
363
+ const metadata = assistantHostConfig(config);
364
+ const origins = [
365
+ ...metadata.appAllowedOrigins,
366
+ ...(metadata.hubUrl ? [new URL(metadata.hubUrl).origin] : []),
367
+ ];
368
+ return [...new Set(origins)].map((origin) => {
369
+ const url = new URL(origin);
370
+ if (
371
+ (url.protocol !== "https:" &&
372
+ !(
373
+ url.protocol === "http:" &&
374
+ ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
375
+ )) ||
376
+ url.origin !== origin.replace(/\/$/, "")
377
+ ) {
378
+ throw new Error(
379
+ "App origins must be HTTPS origins or loopback HTTP origins.",
380
+ );
381
+ }
382
+ return url.origin;
383
+ });
384
+ }
385
+
386
+ function assistantMetadataLocations(config: Record<string, unknown>): string {
387
+ const metadata = assistantHostConfig(config);
388
+ const id = metadata.assistantId;
389
+ const choice = id
390
+ ? {
391
+ id,
392
+ ...(metadata.assistantName ? { name: metadata.assistantName } : {}),
393
+ }
394
+ : undefined;
395
+ const responses = [
396
+ {
397
+ path: "/v1/assistants",
398
+ status: 200,
399
+ body: { results: choice ? [choice] : [], next: null },
400
+ },
401
+ ...(choice && id
402
+ ? [
403
+ {
404
+ path: `/v1/assistants/${encodeURIComponent(id)}`,
405
+ status: metadata.version ? 200 : 409,
406
+ body: metadata.version
407
+ ? {
408
+ ...choice,
409
+ status: "active",
410
+ version: metadata.version,
411
+ url: "assistant/",
412
+ }
413
+ : { detail: "assistant is not ready" },
414
+ },
415
+ ]
416
+ : []),
417
+ ];
418
+ return responses
419
+ .flatMap(({ path, status, body }) =>
420
+ [path, `${path}/`].map(
421
+ (route) => `
422
+ location = ${nginxQuoted(route, "assistant API path")} {
423
+ default_type application/json;
424
+ add_header Cache-Control "no-store" always;
425
+ add_header Access-Control-Allow-Origin $cadet_app_origin always;
426
+ add_header Access-Control-Allow-Credentials "true" always;
427
+ add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
428
+ add_header Access-Control-Allow-Headers "Accept, Cadet-Organization-Id" always;
429
+ add_header Vary "Origin" always;
430
+ if ($request_method = OPTIONS) { return 204; }
431
+ if ($request_method !~ ^(GET|HEAD)$) { return 405; }
432
+ return ${status} ${nginxQuoted(JSON.stringify(body), "assistant metadata")};
433
+ }`,
434
+ ),
435
+ )
436
+ .join("\n");
437
+ }
438
+
439
+ /** Build the nginx config for the tunnel edge and its public assistant metadata. */
284
440
  export function buildIngressNginxConfig(opts: {
285
441
  gatewayPort: number;
286
442
  listenPort: number;
@@ -296,12 +452,16 @@ export function buildIngressNginxConfig(opts: {
296
452
  ? ` listen [::1]:${opts.listenPort};\n`
297
453
  : "";
298
454
  const remoteWebIngress = opts.remoteWebIngress;
455
+ const allowedOrigins = remoteWebIngress
456
+ ? appOrigins(remoteWebIngressConfig(remoteWebIngress))
457
+ : [];
299
458
  const serverLocations = remoteWebIngress
300
459
  ? buildRemoteWebIngressLocations({
301
460
  gatewayPort: opts.gatewayPort,
302
461
  webDistDir: remoteWebIngress.webDistDir,
303
462
  indexHtmlPath: remoteWebIngress.indexHtmlPath,
304
463
  config: remoteWebIngressConfig(remoteWebIngress),
464
+ version: remoteWebIngress.version,
305
465
  })
306
466
  : `${DENYLIST_LOCATIONS}
307
467
 
@@ -350,6 +510,11 @@ http {
350
510
  text/plain txt;
351
511
  }
352
512
 
513
+ map $http_origin $cadet_app_origin {
514
+ default "";
515
+ ${allowedOrigins.map((origin) => ` ${nginxQuoted(origin, "app origin")} ${nginxQuoted(origin, "app origin")};`).join("\n")}
516
+ }
517
+
353
518
  map $http_upgrade $connection_upgrade {
354
519
  default upgrade;
355
520
  "" close;
@@ -390,6 +555,7 @@ function buildRemoteWebIngressLocations(opts: {
390
555
  webDistDir: string;
391
556
  indexHtmlPath?: string;
392
557
  config: Record<string, unknown>;
558
+ version?: string;
393
559
  }): string {
394
560
  const proxyBlock = gatewayProxyBlock(opts.gatewayPort);
395
561
  const webDistDir = nginxDirPath(opts.webDistDir);
@@ -400,6 +566,8 @@ function buildRemoteWebIngressLocations(opts: {
400
566
 
401
567
  return `${DENYLIST_LOCATIONS}
402
568
 
569
+ ${assistantMetadataLocations({ ...opts.config, version: opts.version })}
570
+
403
571
  location = /healthz {
404
572
  ${proxyBlock}
405
573
  }
@@ -574,7 +742,7 @@ export interface IngressState {
574
742
  */
575
743
  gatewayPort?: number;
576
744
  /**
577
- * Fingerprint of the SPA config injected into the served index. Undefined
745
+ * Fingerprint of the injected SPA config and its source bundle. Undefined
578
746
  * for webhooks-only edges and for SPA records that predate the field; an
579
747
  * SPA edge without a recorded fingerprint is treated as drifted and
580
748
  * restarted so the served index provably carries the requested config.
@@ -688,6 +856,8 @@ export function startIngressNginx(opts: {
688
856
  ? {
689
857
  remoteWebConfigHash: remoteWebConfigFingerprint(
690
858
  remoteWebIngress.config,
859
+ remoteWebIngress.webDistDir,
860
+ remoteWebIngress.version,
691
861
  ),
692
862
  }
693
863
  : {}),
@@ -857,7 +1027,7 @@ export async function startRemoteWebIngress(opts: {
857
1027
  listenPort: number;
858
1028
  }) => void;
859
1029
  }): Promise<StartRemoteWebIngressResult> {
860
- const listenPort = opts.listenPort ?? getNginxIngressPort();
1030
+ let listenPort = opts.listenPort ?? getNginxIngressPort();
861
1031
  const includeWebApp = opts.includeWebApp ?? true;
862
1032
 
863
1033
  const version = getNginxVersion();
@@ -868,18 +1038,45 @@ export async function startRemoteWebIngress(opts: {
868
1038
  const running = isIngressRunning(opts.workspaceDir);
869
1039
  const recorded = running ? readIngressState(opts.workspaceDir) : null;
870
1040
  const recordedMode = recorded?.includeWebApp ?? true;
1041
+ if (
1042
+ recorded &&
1043
+ opts.listenPort === undefined &&
1044
+ process.env.CADET_NGINX_INGRESS_PORT === undefined
1045
+ ) {
1046
+ listenPort = recorded.listenPort;
1047
+ }
871
1048
  // The SPA config the served index must carry, computed up front so the
872
1049
  // reuse decision and the actual spawn share one value and cannot drift.
873
1050
  const spaOptions = includeWebApp
874
1051
  ? {
875
- hubUrl: cloudWebHubUrl(getCurrentEnvironment().name),
1052
+ hubUrl: cloudAssistantHubUrl(getCurrentEnvironment().name),
876
1053
  ...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
877
1054
  ...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
878
1055
  }
879
1056
  : undefined;
880
- const requestedConfigHash = spaOptions
881
- ? remoteWebConfigFingerprint(remoteWebIngressConfig(spaOptions))
1057
+ const webDistDir = includeWebApp ? findWebDistDir() : null;
1058
+ if (includeWebApp && !webDistDir) {
1059
+ return { status: "web-dist-missing" };
1060
+ }
1061
+ const runtimeVersion = webDistDir
1062
+ ? await fetchCurrentVersion(`http://127.0.0.1:${opts.gatewayPort}`)
1063
+ : undefined;
1064
+ const webVersion = webDistDir ? readWebReleaseVersion(webDistDir) : undefined;
1065
+ const assistantVersion = runtimeVersion
1066
+ ? stripVersionPrefix(runtimeVersion)
882
1067
  : undefined;
1068
+ const matchedVersion =
1069
+ assistantVersion && webVersion === assistantVersion
1070
+ ? assistantVersion
1071
+ : undefined;
1072
+ const requestedConfigHash =
1073
+ spaOptions && webDistDir
1074
+ ? remoteWebConfigFingerprint(
1075
+ remoteWebIngressConfig(spaOptions),
1076
+ webDistDir,
1077
+ matchedVersion,
1078
+ )
1079
+ : undefined;
883
1080
  // Both sides are undefined in webhooks-only mode; an SPA record without a
884
1081
  // fingerprint predates the field and counts as drifted (see IngressState).
885
1082
  const configMatches = recorded?.remoteWebConfigHash === requestedConfigHash;
@@ -906,14 +1103,6 @@ export async function startRemoteWebIngress(opts: {
906
1103
  return alreadyRunning();
907
1104
  }
908
1105
 
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
1106
  // The running edge serves the other mode, targets a different gateway
918
1107
  // port, or injects a stale SPA config; restart it with the requested
919
1108
  // config. A false stop can mean the old edge exited on its own after the
@@ -926,13 +1115,32 @@ export async function startRemoteWebIngress(opts: {
926
1115
  return alreadyRunning();
927
1116
  }
928
1117
 
1118
+ // A foreign process squatting the default edge port (another assistant's
1119
+ // edge, a stale dev server) must not kill the tunnel: the edge port is
1120
+ // loopback-local and the public tunnel URL never carries it, so moving to
1121
+ // the next free port is invisible to remote clients. Explicit pins
1122
+ // (opts.listenPort / CADET_NGINX_INGRESS_PORT) keep their intent and still
1123
+ // fail on a collision.
1124
+ if (
1125
+ opts.listenPort === undefined &&
1126
+ process.env.CADET_NGINX_INGRESS_PORT === undefined
1127
+ ) {
1128
+ const discovered = await findAvailableIngressPort(listenPort);
1129
+ if (discovered !== null && discovered !== listenPort) {
1130
+ console.log(`Port ${listenPort} in use; using ${discovered} instead.`);
1131
+ listenPort = discovered;
1132
+ }
1133
+ }
1134
+
929
1135
  opts.onStarting?.({ version, webDistDir, listenPort });
930
1136
 
931
1137
  const child = startIngressNginx({
932
1138
  workspaceDir: opts.workspaceDir,
933
1139
  gatewayPort: opts.gatewayPort,
934
1140
  listenPort,
935
- remoteWebIngress: webDistDir ? { webDistDir, ...spaOptions } : undefined,
1141
+ remoteWebIngress: webDistDir
1142
+ ? { webDistDir, ...spaOptions, version: matchedVersion }
1143
+ : undefined,
936
1144
  });
937
1145
  let exited = false;
938
1146
  child.once("exit", () => {
@@ -1042,6 +1250,7 @@ export async function stopContainerTunnelEdge(
1042
1250
  function stampAppProcessEntry(
1043
1251
  workspaceDir: string,
1044
1252
  assistantId?: string,
1253
+ port = getNginxIngressPort(),
1045
1254
  ): void {
1046
1255
  const pid = getIngressPid(workspaceDir);
1047
1256
  if (pid !== null) {
@@ -1049,6 +1258,7 @@ function stampAppProcessEntry(
1049
1258
  pid,
1050
1259
  startedAt: new Date().toISOString(),
1051
1260
  assistantId,
1261
+ port,
1052
1262
  });
1053
1263
  }
1054
1264
  }
@@ -1116,7 +1326,11 @@ export async function ensureTunnelEdge(opts: {
1116
1326
 
1117
1327
  switch (result.status) {
1118
1328
  case "started":
1119
- stampAppProcessEntry(opts.workspaceDir, opts.assistantId);
1329
+ stampAppProcessEntry(
1330
+ opts.workspaceDir,
1331
+ opts.assistantId,
1332
+ result.listenPort,
1333
+ );
1120
1334
  return {
1121
1335
  port: result.listenPort,
1122
1336
  started: true,
@@ -1150,7 +1364,11 @@ export async function ensureTunnelEdge(opts: {
1150
1364
  stuckEdgeRecoveryHint(opts.workspaceDir),
1151
1365
  );
1152
1366
  }
1153
- stampAppProcessEntry(opts.workspaceDir, opts.assistantId);
1367
+ stampAppProcessEntry(
1368
+ opts.workspaceDir,
1369
+ opts.assistantId,
1370
+ result.listenPort,
1371
+ );
1154
1372
  return {
1155
1373
  port: result.listenPort,
1156
1374
  started: false,
@@ -1,3 +1,4 @@
1
+ import { DAEMON_STOP_TIMEOUT_MS } from "@mycadet/local-mode";
1
2
  import { execFileSync } from "child_process";
2
3
  import { existsSync, readFileSync, unlinkSync } from "fs";
3
4
  import { platform } from "os";
@@ -324,6 +325,7 @@ export async function stopProcess(
324
325
  });
325
326
  },
326
327
  readIdentity: (pid: number) => string | null = readPosixCommandLine,
328
+ gracefulOnly = false,
327
329
  ): Promise<boolean> {
328
330
  try {
329
331
  process.kill(pid, 0);
@@ -339,6 +341,9 @@ export async function stopProcess(
339
341
  console.log(`Stopping ${label} (pid ${pid})...`);
340
342
  let waitForGracefulExit = true;
341
343
  if (hostPlatform === "win32") {
344
+ if (gracefulOnly) {
345
+ throw new Error("Graceful-only shutdown is not supported for Windows local services.");
346
+ }
342
347
  try {
343
348
  runTaskkill(["/PID", String(pid), "/T"], timeoutMs);
344
349
  } catch {
@@ -363,6 +368,7 @@ export async function stopProcess(
363
368
  } catch {
364
369
  return true;
365
370
  }
371
+ if (gracefulOnly) return false;
366
372
  if (hostPlatform === "win32") {
367
373
  console.log(`${label} did not exit, terminating its process tree...`);
368
374
  try {
@@ -415,6 +421,13 @@ export async function stopProcess(
415
421
  }
416
422
  }
417
423
 
424
+ /** Stop a service without escalation. Failure preserves PID tracking and aborts capture/replacement. */
425
+ export async function stopProcessGracefully(pid: number, label: string, timeoutMs = DAEMON_STOP_TIMEOUT_MS): Promise<boolean> {
426
+ const stopped = await stopProcess(pid, label, timeoutMs, undefined, undefined, undefined, true);
427
+ if (!stopped) throw new Error(`${label} did not stop gracefully.`);
428
+ return stopped;
429
+ }
430
+
418
431
  /** Remove one or more files, ignoring missing-file errors. */
419
432
  function removeFiles(...files: (string | string[] | undefined)[]): void {
420
433
  for (const entry of files) {