@vellumai/cli 0.10.2 → 0.10.3-dev.202606252046.9075fd5
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 +1 -1
- package/src/__tests__/client-token.test.ts +27 -0
- package/src/__tests__/statefulset.test.ts +1 -0
- package/src/commands/client.ts +65 -7
- package/src/commands/login.ts +1 -19
- package/src/commands/rollback.ts +6 -0
- package/src/commands/upgrade.ts +8 -0
- package/src/lib/__tests__/docker.test.ts +6 -2
- package/src/lib/assistant-config.ts +8 -0
- package/src/lib/confirm-action.ts +4 -0
- package/src/lib/docker.ts +32 -2
- package/src/lib/open-browser.ts +20 -0
- package/src/lib/port-allocator.ts +3 -1
- package/src/lib/statefulset.ts +15 -4
- package/src/lib/upgrade-lifecycle.ts +8 -0
package/package.json
CHANGED
|
@@ -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",
|
package/src/commands/client.ts
CHANGED
|
@@ -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(
|
|
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
|
-
//
|
|
899
|
-
//
|
|
900
|
-
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
1149
|
+
await runWebInterface(
|
|
1150
|
+
flagEnvVars,
|
|
1151
|
+
parsedFlagOverrides,
|
|
1152
|
+
disablePlatform,
|
|
1153
|
+
openInBrowser,
|
|
1154
|
+
);
|
|
1097
1155
|
return;
|
|
1098
1156
|
}
|
|
1099
1157
|
|
package/src/commands/login.ts
CHANGED
|
@@ -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;
|
package/src/commands/rollback.ts
CHANGED
|
@@ -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
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/lib/statefulset.ts
CHANGED
|
@@ -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 }}"`
|
|
89
|
-
*
|
|
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:
|
|
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,
|