@vellumai/cli 0.10.2 → 0.10.3-dev.202606251939.1e7d8ac

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.10.2",
3
+ "version": "0.10.3-dev.202606251939.1e7d8ac",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -84,4 +84,31 @@ describe("client --token (ephemeral)", () => {
84
84
  expect(parsed.assistantId).toBe("remote-xyz");
85
85
  expect(parsed.bearerToken).toBe("tok");
86
86
  });
87
+
88
+ test("auto-opens the browser by default", () => {
89
+ process.argv = [
90
+ "bun",
91
+ "vellum",
92
+ "client",
93
+ "--url",
94
+ REMOTE_URL,
95
+ "--token",
96
+ "tok",
97
+ ];
98
+ expect(parseArgs().openBrowser).toBe(true);
99
+ });
100
+
101
+ test("--no-open opts out of auto-opening the browser", () => {
102
+ process.argv = [
103
+ "bun",
104
+ "vellum",
105
+ "client",
106
+ "--url",
107
+ REMOTE_URL,
108
+ "--token",
109
+ "tok",
110
+ "--no-open",
111
+ ];
112
+ expect(parseArgs().openBrowser).toBe(false);
113
+ });
87
114
  });
@@ -111,6 +111,7 @@ describe("getBuilderManagedEnvKeys", () => {
111
111
  describe("buildServiceRunArgs extra env routing", () => {
112
112
  const opts: BuildServiceRunArgsOpts = {
113
113
  gatewayPort: 18080,
114
+ assistantPort: 18081,
114
115
  imageTags: {
115
116
  assistant: "assistant:test",
116
117
  gateway: "gateway:test",
@@ -60,6 +60,7 @@ import {
60
60
  import { tuiLog } from "../lib/tui-log";
61
61
  import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
62
62
  import { probePort } from "../lib/port-probe.js";
63
+ import { openBrowser } from "../lib/open-browser";
63
64
 
64
65
  const SUPPORTED_INTERFACES = ["cli", "web"] as const;
65
66
  type SupportedInterface = (typeof SUPPORTED_INTERFACES)[number];
@@ -90,6 +91,8 @@ interface ParsedArgs {
90
91
  /** Parsed --flag overrides: kebab-case key -> typed value (for web injection). */
91
92
  parsedFlagOverrides: Record<string, boolean | string>;
92
93
  disablePlatform: boolean;
94
+ /** Auto-open the web interface in the default browser (--interface web only). */
95
+ openBrowser: boolean;
93
96
  }
94
97
 
95
98
  function readAssistantName(entry: AssistantEntry | null): string | undefined {
@@ -136,6 +139,8 @@ export function parseArgs(): ParsedArgs {
136
139
  "--token",
137
140
  "-t",
138
141
  ]);
142
+ // Auto-open the web interface in the browser by default; --no-open opts out.
143
+ let openBrowserPref = true;
139
144
  const flagArgs: string[] = [];
140
145
  for (let i = 0; i < args.length; i++) {
141
146
  const arg = args[i];
@@ -144,6 +149,8 @@ export function parseArgs(): ParsedArgs {
144
149
  process.exit(0);
145
150
  } else if (arg === "--disable-platform") {
146
151
  disablePlatform = true;
152
+ } else if (arg === "--no-open") {
153
+ openBrowserPref = false;
147
154
  } else if (
148
155
  (arg === "--url" ||
149
156
  arg === "-u" ||
@@ -264,6 +271,7 @@ export function parseArgs(): ParsedArgs {
264
271
  flagEnvVars,
265
272
  parsedFlagOverrides,
266
273
  disablePlatform,
274
+ openBrowser: openBrowserPref,
267
275
  };
268
276
  }
269
277
 
@@ -283,6 +291,7 @@ ${ANSI.bold}OPTIONS:${ANSI.reset}
283
291
  not persisted.
284
292
  -a, --assistant-id <id> Assistant ID
285
293
  -i, --interface <id> Interface identifier: cli (default) or web
294
+ --no-open Don't auto-open the browser (--interface web)
286
295
  --flag <key=value> Feature flag override (repeatable, kebab-case key)
287
296
  --disable-platform Suppress all outbound platform API calls
288
297
  -h, --help Show this help message
@@ -816,10 +825,26 @@ async function findFreeDualLoopbackPort(preferred: number): Promise<number> {
816
825
  return preferred;
817
826
  }
818
827
 
828
+ /**
829
+ * Open `url` in the browser once `port` is accepting connections, polling for
830
+ * up to ~10s. Used for the Vite dev server, which binds the port asynchronously
831
+ * after spawn — opening immediately would load the tab before Vite is ready.
832
+ */
833
+ async function openBrowserWhenReady(url: string, port: number): Promise<void> {
834
+ for (let attempt = 0; attempt < 50; attempt++) {
835
+ if (await probePort(port, "127.0.0.1")) {
836
+ openBrowser(url);
837
+ return;
838
+ }
839
+ await new Promise((resolve) => setTimeout(resolve, 200));
840
+ }
841
+ }
842
+
819
843
  async function runWebInterface(
820
844
  flagEnvVars: Record<string, string>,
821
845
  parsedFlagOverrides: Record<string, boolean | string>,
822
846
  disablePlatform: boolean,
847
+ openInBrowser: boolean,
823
848
  ): Promise<void> {
824
849
  // Propagate flag env vars so child processes (e.g. hatch from the web UI) inherit them.
825
850
  Object.assign(process.env, flagEnvVars);
@@ -828,7 +853,12 @@ async function runWebInterface(
828
853
  // (HMR, __local endpoints, gateway proxy).
829
854
  const webSourceDir = findWebSourceDir();
830
855
  if (webSourceDir) {
831
- return runViteDevServer(webSourceDir, flagEnvVars, disablePlatform);
856
+ return runViteDevServer(
857
+ webSourceDir,
858
+ flagEnvVars,
859
+ disablePlatform,
860
+ openInBrowser,
861
+ );
832
862
  }
833
863
 
834
864
  const distDir = findWebDistDir();
@@ -895,14 +925,27 @@ async function runWebInterface(
895
925
  headers.delete("Origin");
896
926
  headers.delete("Referer");
897
927
 
898
- // Authenticate with the loopback session token the SPA registered. The
899
- // platform expects it both as the Django session cookie and as
900
- // X-Session-Token (for DRF views that accept header-based auth). Only
928
+ // The DRF API authenticates by header (X-Session-Token); the allauth /
929
+ // accounts session endpoints need the Django session cookie.
930
+ const isApiRequest = pathname.startsWith("/v1/");
931
+
932
+ // Authenticate with the loopback session token the SPA registered. Only
901
933
  // same-origin SPA traffic gets the credential — never a cross-site caller.
902
934
  const sessionToken = isSameOriginRequest(req)
903
935
  ? currentPlatformToken()
904
936
  : null;
905
- if (sessionToken) {
937
+ if (isApiRequest) {
938
+ // Header-only auth for the DRF API. Sending a `sessionid` cookie would
939
+ // engage Django's SessionAuthentication, which enforces CSRF — and the
940
+ // proxy strips Origin/Referer above, so the CSRF Referer check would
941
+ // reject every unsafe (POST/PUT/PATCH) request. Drop any browser cookie
942
+ // (localhost jar) so it can't re-engage that path.
943
+ headers.delete("Cookie");
944
+ if (sessionToken) {
945
+ headers.set("X-Session-Token", sessionToken);
946
+ }
947
+ } else if (sessionToken) {
948
+ // allauth / accounts: the platform expects the Django session cookie.
906
949
  headers.set(
907
950
  "Cookie",
908
951
  `sessionid=${sessionToken}; __Secure-sessionid=${sessionToken}`,
@@ -965,7 +1008,9 @@ async function runWebInterface(
965
1008
  // Advertise `localhost` (not `127.0.0.1`) so the app origin matches the host
966
1009
  // the platform hardcodes in its loopback callback. We bind both loopback
967
1010
  // families above so `localhost` reaches us whichever one it resolves to.
968
- console.log(`Vellum web interface: http://localhost:${port}${SPA_BASE}`);
1011
+ const webInterfaceUrl = `http://localhost:${port}${SPA_BASE}`;
1012
+ console.log(`Vellum web interface: ${webInterfaceUrl}`);
1013
+ if (openInBrowser) openBrowser(webInterfaceUrl);
969
1014
 
970
1015
  const shutdown = (): void => {
971
1016
  for (const server of servers) server.stop();
@@ -981,6 +1026,7 @@ async function runViteDevServer(
981
1026
  webSourceDir: string,
982
1027
  flagEnvVars: Record<string, string>,
983
1028
  disablePlatform: boolean,
1029
+ openInBrowser: boolean,
984
1030
  ): Promise<void> {
985
1031
  const platformUrl = getPlatformUrl();
986
1032
 
@@ -1014,6 +1060,12 @@ async function runViteDevServer(
1014
1060
  },
1015
1061
  });
1016
1062
 
1063
+ // Vite binds the port itself, so wait until it's listening before opening the
1064
+ // browser — otherwise the tab loads before the dev server is ready.
1065
+ if (openInBrowser) {
1066
+ void openBrowserWhenReady(`http://localhost:${port}${SPA_BASE}`, port);
1067
+ }
1068
+
1017
1069
  const shutdown = (): void => {
1018
1070
  child.kill();
1019
1071
  process.exit(0);
@@ -1086,6 +1138,7 @@ export async function client(): Promise<void> {
1086
1138
  flagEnvVars,
1087
1139
  parsedFlagOverrides,
1088
1140
  disablePlatform,
1141
+ openBrowser: openInBrowser,
1089
1142
  } = parseArgs();
1090
1143
 
1091
1144
  if (disablePlatform) {
@@ -1093,7 +1146,12 @@ export async function client(): Promise<void> {
1093
1146
  }
1094
1147
 
1095
1148
  if (interfaceId === WEB_INTERFACE_ID) {
1096
- await runWebInterface(flagEnvVars, parsedFlagOverrides, disablePlatform);
1149
+ await runWebInterface(
1150
+ flagEnvVars,
1151
+ parsedFlagOverrides,
1152
+ disablePlatform,
1153
+ openInBrowser,
1154
+ );
1097
1155
  return;
1098
1156
  }
1099
1157
 
@@ -1,4 +1,3 @@
1
- import { spawn } from "child_process";
2
1
  import { randomBytes } from "crypto";
3
2
  import { createServer } from "http";
4
3
  import type { AddressInfo } from "net";
@@ -11,6 +10,7 @@ import {
11
10
  setActiveAssistant,
12
11
  } from "../lib/assistant-config";
13
12
  import { computeDeviceId } from "../lib/guardian-token";
13
+ import { openBrowser } from "../lib/open-browser";
14
14
  import {
15
15
  clearPlatformToken,
16
16
  ensureSelfHostedLocalRegistration,
@@ -169,24 +169,6 @@ function renderLoginPage(
169
169
  </html>`;
170
170
  }
171
171
 
172
- /**
173
- * Open a URL in the user's default browser.
174
- */
175
- function openBrowser(url: string): void {
176
- const platform = process.platform;
177
- const cmd =
178
- platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
179
- const args =
180
- platform === "win32"
181
- ? ["/c", "start", '""', url.replace(/&/g, "^&")]
182
- : [url];
183
- const child = spawn(cmd, args, { stdio: "ignore", detached: true });
184
- child.on("error", () => {
185
- // Silently ignore — the user can still copy the URL from the console
186
- });
187
- child.unref();
188
- }
189
-
190
172
  export interface LoopbackListener {
191
173
  /** The full `http://127.0.0.1:<port>/auth/callback` redirect URI. */
192
174
  redirectUri: string;
@@ -8,6 +8,7 @@ import {
8
8
  import {
9
9
  captureImageRefs,
10
10
  GATEWAY_INTERNAL_PORT,
11
+ ASSISTANT_INTERNAL_PORT,
11
12
  dockerResourceNames,
12
13
  startContainers,
13
14
  stopContainers,
@@ -324,6 +325,9 @@ export async function rollback(): Promise<void> {
324
325
  // use default
325
326
  }
326
327
 
328
+ // Recover the assistant host port from the entry, fall back to default.
329
+ const assistantPort = entry.containerInfo?.assistantPort ?? ASSISTANT_INTERNAL_PORT;
330
+
327
331
  // Notify connected clients that a rollback is about to begin (best-effort)
328
332
  console.log("📢 Notifying connected clients...");
329
333
  await broadcastUpgradeEvent(
@@ -373,6 +377,7 @@ export async function rollback(): Promise<void> {
373
377
  extraAssistantEnv,
374
378
  extraGatewayEnv,
375
379
  gatewayPort,
380
+ assistantPort,
376
381
  imageTags: previousImageRefs,
377
382
  instanceName,
378
383
  res,
@@ -399,6 +404,7 @@ export async function rollback(): Promise<void> {
399
404
  gatewayDigest: newDigests?.gateway,
400
405
  cesDigest: newDigests?.["credential-executor"],
401
406
  networkName: res.network,
407
+ assistantPort,
402
408
  },
403
409
  previousContainerInfo: entry.containerInfo,
404
410
  // Clear the backup path — it belonged to the upgrade we just rolled back
@@ -14,6 +14,7 @@ import {
14
14
  import {
15
15
  captureImageRefs,
16
16
  GATEWAY_INTERNAL_PORT,
17
+ ASSISTANT_INTERNAL_PORT,
17
18
  dockerResourceNames,
18
19
  startContainers,
19
20
  stopContainers,
@@ -432,6 +433,9 @@ async function upgradeDocker(
432
433
  // use default
433
434
  }
434
435
 
436
+ // Recover the assistant host port from the entry, fall back to default.
437
+ const assistantPort = entry.containerInfo?.assistantPort ?? ASSISTANT_INTERNAL_PORT;
438
+
435
439
  // Create pre-upgrade backup (best-effort, daemon must be running)
436
440
  await broadcastUpgradeEvent(
437
441
  entry.runtimeUrl,
@@ -483,6 +487,7 @@ async function upgradeDocker(
483
487
  extraAssistantEnv,
484
488
  extraGatewayEnv,
485
489
  gatewayPort,
490
+ assistantPort,
486
491
  imageTags,
487
492
  instanceName,
488
493
  res,
@@ -506,6 +511,7 @@ async function upgradeDocker(
506
511
  gatewayDigest: newDigests?.gateway,
507
512
  cesDigest: newDigests?.["credential-executor"],
508
513
  networkName: res.network,
514
+ assistantPort,
509
515
  },
510
516
  previousContainerInfo: entry.containerInfo,
511
517
  previousDbMigrationVersion: preMigrationState.dbVersion,
@@ -589,6 +595,7 @@ async function upgradeDocker(
589
595
  extraAssistantEnv,
590
596
  extraGatewayEnv,
591
597
  gatewayPort,
598
+ assistantPort,
592
599
  imageTags: previousImageRefs,
593
600
  instanceName,
594
601
  res,
@@ -654,6 +661,7 @@ async function upgradeDocker(
654
661
  rollbackDigests?.["credential-executor"] ??
655
662
  previousImageRefs["credential-executor"],
656
663
  networkName: res.network,
664
+ assistantPort,
657
665
  },
658
666
  previousContainerInfo: undefined,
659
667
  previousDbMigrationVersion: undefined,
@@ -27,6 +27,7 @@ function buildAssistantArgs(
27
27
  const res = dockerResourceNames(instanceName);
28
28
  const builders = buildServiceRunArgs({
29
29
  gatewayPort: 7830,
30
+ assistantPort: 7821,
30
31
  imageTags,
31
32
  instanceName,
32
33
  res,
@@ -41,6 +42,7 @@ function buildGatewayArgs(
41
42
  const res = dockerResourceNames(instanceName);
42
43
  const builders = buildServiceRunArgs({
43
44
  gatewayPort: 7830,
45
+ assistantPort: 7821,
44
46
  imageTags,
45
47
  instanceName,
46
48
  res,
@@ -84,13 +86,15 @@ describe("buildServiceRunArgs — assistant", () => {
84
86
  });
85
87
 
86
88
  test("publishes the assistant HTTP port on all host interfaces so sibling bot containers can reach the daemon via host.docker.internal on both Docker Desktop and Linux", () => {
87
- const args = buildAssistantArgs();
89
+ const args = buildAssistantArgs({ assistantPort: 18000 });
88
90
  // The port mapping is expressed as two adjacent args: "-p" then the spec.
89
91
  // Bound to all interfaces (no `127.0.0.1:` prefix) because on vanilla
90
92
  // Linux Docker, host.docker.internal:host-gateway resolves to the Docker
91
93
  // bridge gateway IP — packets arrive at the bridge interface, not
92
94
  // loopback, so a 127.0.0.1 DNAT rule would not match.
93
- const portSpec = `${ASSISTANT_INTERNAL_PORT}:${ASSISTANT_INTERNAL_PORT}`;
95
+ // The host-side port is dynamically allocated (not fixed at 7821) so
96
+ // concurrent instances on the same host don't collide.
97
+ const portSpec = `18000:${ASSISTANT_INTERNAL_PORT}`;
94
98
  const portIndex = args.indexOf(portSpec);
95
99
  expect(portIndex).toBeGreaterThan(0);
96
100
  expect(args[portIndex - 1]).toBe("-p");
@@ -74,6 +74,14 @@ export interface ContainerInfo {
74
74
  cesDigest?: string;
75
75
  /** Docker network name for the service group */
76
76
  networkName?: string;
77
+ /**
78
+ * Host-side port the assistant HTTP API is published on. Dynamically
79
+ * allocated at hatch time so concurrent instances don't collide on the
80
+ * default (7821). Stored so rollback/upgrade can rebind the same port
81
+ * instead of re-allocating (which could grab a different port if another
82
+ * process took it in the interim).
83
+ */
84
+ assistantPort?: number;
77
85
  }
78
86
 
79
87
  /**
@@ -18,6 +18,9 @@ export function canPromptForConfirmation(): boolean {
18
18
  * Show `prompt` and resolve true on Enter, false on Esc/q/Ctrl-C. Restores the
19
19
  * prior stdin raw/paused state on exit. Caller must gate on
20
20
  * {@link canPromptForConfirmation} first.
21
+ *
22
+ * `unref()`s stdin on cleanup so the resumed handle doesn't keep the process
23
+ * alive after the prompt resolves.
21
24
  */
22
25
  export async function confirmAction(prompt: string): Promise<boolean> {
23
26
  const stdin = process.stdin;
@@ -36,6 +39,7 @@ export async function confirmAction(prompt: string): Promise<boolean> {
36
39
  if (wasPaused) {
37
40
  stdin.pause();
38
41
  }
42
+ stdin.unref?.();
39
43
  stdout.write("\n");
40
44
  };
41
45
 
package/src/lib/docker.ts CHANGED
@@ -23,7 +23,7 @@ import { buildHatchConfigValues, writeInitialConfig } from "./config-utils";
23
23
  import { buildServiceRunArgs } from "./statefulset.js";
24
24
  import type { Species } from "./constants";
25
25
  import { getOrCreateHostDeviceId } from "./device-id.js";
26
- import { getDefaultPorts } from "./environments/paths.js";
26
+ import { ASSISTANT_INTERNAL_PORT, getDefaultPorts } from "./environments/paths.js";
27
27
  import { getCurrentEnvironment } from "./environments/resolve.js";
28
28
  import { leaseGuardianToken } from "./guardian-token";
29
29
  import { logHatchNextSteps } from "./hatch-next-steps.js";
@@ -711,6 +711,7 @@ export async function startContainers(
711
711
  extraAssistantEnv?: Record<string, string>;
712
712
  extraGatewayEnv?: Record<string, string>;
713
713
  gatewayPort: number;
714
+ assistantPort: number;
714
715
  imageTags: Record<ServiceName, string>;
715
716
  instanceName: string;
716
717
  res: ReturnType<typeof dockerResourceNames>;
@@ -934,6 +935,7 @@ function startFileWatcher(opts: {
934
935
  extraAssistantEnv?: Record<string, string>;
935
936
  extraGatewayEnv?: Record<string, string>;
936
937
  gatewayPort: number;
938
+ assistantPort: number;
937
939
  imageTags: Record<ServiceName, string>;
938
940
  instanceName: string;
939
941
  repoRoot: string;
@@ -941,7 +943,7 @@ function startFileWatcher(opts: {
941
943
  netnsContainer?: string;
942
944
  assistantCaCertPath?: string;
943
945
  }): () => void {
944
- const { gatewayPort, imageTags, instanceName, repoRoot, res } = opts;
946
+ const { gatewayPort, assistantPort, imageTags, instanceName, repoRoot, res } = opts;
945
947
 
946
948
  const { dirs: watchDirs, files: watchFiles } = collectWatchTargets(repoRoot);
947
949
 
@@ -957,6 +959,7 @@ function startFileWatcher(opts: {
957
959
  extraAssistantEnv: opts.extraAssistantEnv,
958
960
  extraGatewayEnv: opts.extraGatewayEnv,
959
961
  gatewayPort,
962
+ assistantPort,
960
963
  imageTags,
961
964
  instanceName,
962
965
  res,
@@ -1136,6 +1139,30 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1136
1139
  }
1137
1140
  }
1138
1141
 
1142
+ // Allocate the assistant HTTP API host port. Same dynamic-allocation
1143
+ // strategy as the gateway port: the env-default (production 7821 /
1144
+ // non-prod overrides) is the *preferred* starting point, and we walk
1145
+ // upward until we find a free port. Without this, two concurrent
1146
+ // `vellum hatch --remote docker` on the same host collide on a fixed
1147
+ // 7821 bind ("port is already allocated"). Unused when netnsContainer
1148
+ // is set — no host ports are published in that mode.
1149
+ let assistantPort: number;
1150
+ if (params.netnsContainer) {
1151
+ assistantPort = ASSISTANT_INTERNAL_PORT;
1152
+ } else {
1153
+ const preferredAssistantPort = getDefaultPorts(
1154
+ getCurrentEnvironment(),
1155
+ ).daemon;
1156
+ assistantPort = await findOpenPort(preferredAssistantPort, {
1157
+ exclude: [gatewayPort],
1158
+ });
1159
+ if (assistantPort !== preferredAssistantPort) {
1160
+ log(
1161
+ `Preferred assistant port ${preferredAssistantPort} is in use; allocated ${assistantPort} for this instance.`,
1162
+ );
1163
+ }
1164
+ }
1165
+
1139
1166
  const imageTags: Record<ServiceName, string> = {
1140
1167
  assistant: "",
1141
1168
  "credential-executor": "",
@@ -1404,6 +1431,7 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1404
1431
  extraAssistantEnv,
1405
1432
  extraGatewayEnv,
1406
1433
  gatewayPort,
1434
+ assistantPort,
1407
1435
  imageTags,
1408
1436
  instanceName,
1409
1437
  res,
@@ -1432,6 +1460,7 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1432
1460
  gatewayDigest: imageDigests?.gateway,
1433
1461
  cesDigest: imageDigests?.["credential-executor"],
1434
1462
  networkName: res.network,
1463
+ assistantPort,
1435
1464
  },
1436
1465
  };
1437
1466
  emitProgress(5, 6, "Saving configuration...");
@@ -1502,6 +1531,7 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1502
1531
  extraAssistantEnv,
1503
1532
  extraGatewayEnv,
1504
1533
  gatewayPort,
1534
+ assistantPort,
1505
1535
  imageTags,
1506
1536
  instanceName,
1507
1537
  repoRoot,
@@ -0,0 +1,20 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ /**
4
+ * Open a URL in the user's default browser. Best-effort: a failure to launch is
5
+ * swallowed so the caller can still surface the URL for the user to copy.
6
+ */
7
+ export function openBrowser(url: string): void {
8
+ const platform = process.platform;
9
+ const cmd =
10
+ platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
11
+ const args =
12
+ platform === "win32"
13
+ ? ["/c", "start", '""', url.replace(/&/g, "^&")]
14
+ : [url];
15
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
16
+ child.on("error", () => {
17
+ // Silently ignore — the user can still copy the URL from the console.
18
+ });
19
+ child.unref();
20
+ }
@@ -21,10 +21,11 @@ import { createServer } from "net";
21
21
  */
22
22
  export async function findOpenPort(
23
23
  preferred: number,
24
- options: { maxAttempts?: number; host?: string } = {},
24
+ options: { maxAttempts?: number; host?: string; exclude?: number[] } = {},
25
25
  ): Promise<number> {
26
26
  const maxAttempts = options.maxAttempts ?? 50;
27
27
  const host = options.host ?? "0.0.0.0";
28
+ const exclude = new Set(options.exclude ?? []);
28
29
 
29
30
  if (!Number.isInteger(preferred) || preferred < 1 || preferred > 65535) {
30
31
  throw new Error(
@@ -41,6 +42,7 @@ export async function findOpenPort(
41
42
  for (let offset = 0; offset < maxAttempts; offset++) {
42
43
  const port = preferred + offset;
43
44
  if (port > 65535) break;
45
+ if (exclude.has(port)) continue;
44
46
  try {
45
47
  await probePort(port, host);
46
48
  return port;
@@ -85,8 +85,9 @@ interface VolumeMount {
85
85
  interface PortSpec {
86
86
  containerPort: number;
87
87
  /**
88
- * Host-side port. Literal string = use as-is. `"{{ gatewayPort }}"` is
89
- * a sentinel replaced with the instance-specific gateway port at build time.
88
+ * Host-side port. Literal string = use as-is. `"{{ gatewayPort }}"` and
89
+ * `"{{ assistantPort }}"` are sentinels replaced with the instance-specific
90
+ * gateway / assistant host ports at build time.
90
91
  */
91
92
  hostPort?: string;
92
93
  description?: string;
@@ -172,7 +173,7 @@ export const DOCKER_STATEFUL_SET_SPEC: DockerStatefulSetSpec = {
172
173
  },
173
174
  {
174
175
  containerPort: ASSISTANT_INTERNAL_PORT,
175
- hostPort: `${ASSISTANT_INTERNAL_PORT}`,
176
+ hostPort: "{{ assistantPort }}",
176
177
  description: "Assistant HTTP API",
177
178
  },
178
179
  ],
@@ -261,6 +262,13 @@ export const DOCKER_STATEFUL_SET_SPEC: DockerStatefulSetSpec = {
261
262
 
262
263
  export interface BuildServiceRunArgsOpts extends DockerRunSecrets {
263
264
  gatewayPort: number;
265
+ /**
266
+ * Host-side port for the assistant HTTP API. Allocated dynamically by
267
+ * `hatchDocker` (mirroring `gatewayPort`) so concurrent instances on the
268
+ * same host don't collide on a fixed port bind. Unused when
269
+ * `netnsContainer` is set (no host ports are published in that mode).
270
+ */
271
+ assistantPort: number;
264
272
  imageTags: Record<ServiceName, string>;
265
273
  instanceName: string;
266
274
  res: DockerResourceNames;
@@ -353,6 +361,7 @@ export function buildServiceRunArgs(
353
361
  ): Record<ServiceName, () => string[]> {
354
362
  const {
355
363
  gatewayPort,
364
+ assistantPort,
356
365
  imageTags,
357
366
  instanceName,
358
367
  res,
@@ -396,7 +405,9 @@ export function buildServiceRunArgs(
396
405
  const hostSide =
397
406
  port.hostPort === "{{ gatewayPort }}"
398
407
  ? `${gatewayPort}`
399
- : port.hostPort;
408
+ : port.hostPort === "{{ assistantPort }}"
409
+ ? `${assistantPort}`
410
+ : port.hostPort;
400
411
  if (hostSide !== undefined) {
401
412
  args.push("-p", `${hostSide}:${port.containerPort}`);
402
413
  }
@@ -13,6 +13,7 @@ import {
13
13
  DOCKER_READY_TIMEOUT_MS,
14
14
  dockerResourceNames,
15
15
  GATEWAY_INTERNAL_PORT,
16
+ ASSISTANT_INTERNAL_PORT,
16
17
  startContainers,
17
18
  stopContainers,
18
19
  } from "./docker.js";
@@ -685,6 +686,9 @@ export async function performDockerRollback(
685
686
  // use default
686
687
  }
687
688
 
689
+ // Recover the assistant host port from the entry, fall back to default.
690
+ const assistantPort = entry.containerInfo?.assistantPort ?? ASSISTANT_INTERNAL_PORT;
691
+
688
692
  // Broadcast SSE "starting" event
689
693
  console.log("📢 Notifying connected clients...");
690
694
  await broadcastUpgradeEvent(
@@ -746,6 +750,7 @@ export async function performDockerRollback(
746
750
  extraAssistantEnv,
747
751
  extraGatewayEnv,
748
752
  gatewayPort,
753
+ assistantPort,
749
754
  imageTags: targetImageTags,
750
755
  instanceName,
751
756
  res,
@@ -785,6 +790,7 @@ export async function performDockerRollback(
785
790
  gatewayDigest: newDigests?.gateway,
786
791
  cesDigest: newDigests?.["credential-executor"],
787
792
  networkName: res.network,
793
+ assistantPort,
788
794
  },
789
795
  previousContainerInfo: entry.containerInfo,
790
796
  previousDbMigrationVersion: preMigrationState.dbVersion,
@@ -867,6 +873,7 @@ export async function performDockerRollback(
867
873
  extraAssistantEnv,
868
874
  extraGatewayEnv,
869
875
  gatewayPort,
876
+ assistantPort,
870
877
  imageTags: currentImageRefs,
871
878
  instanceName,
872
879
  res,
@@ -919,6 +926,7 @@ export async function performDockerRollback(
919
926
  revertDigests?.["credential-executor"] ??
920
927
  currentImageRefs["credential-executor"],
921
928
  networkName: res.network,
929
+ assistantPort,
922
930
  },
923
931
  previousContainerInfo: undefined,
924
932
  previousDbMigrationVersion: undefined,