Skip to content

Commit a875ce9

Browse files
ericksoaclaudejyaunches
authored andcommitted
feat(onboard): auto-allocate dashboard port for multi-sandbox (NVIDIA#2411)
## Summary Fixes NVIDIA#2174. When onboarding a second sandbox while the first holds port 18789, the dashboard forward now auto-allocates the next free port instead of crashing. ## Related Issue Fixes NVIDIA#2174 ## Changes - **`--control-ui-port <N>` CLI flag**: Explicit port override (takes precedence over `CHAT_UI_URL` env var and defaults) - **Auto-allocation**: When the preferred port is taken by another sandbox, scans 18789-18799 for the next free port and warns the user - **Registry persistence**: `dashboardPort` stored in `sandboxes.json` so it survives across sessions and `--resume` flows - **`nemoclaw list`**: Shows `dashboard: http://127.0.0.1:{port}/` per sandbox - **`nemoclaw status`**: Shows `:port` suffix per sandbox line - **Range exhaustion**: Throws with actionable error listing each port's owner and suggesting `--control-ui-port` with a port outside the range - **Pre-existing test fix**: Updated `local-inference` preset test to match current YAML (node binary was added) ## Type of Change - [x] Code change (feature, bug fix, or refactor) ## Verification - [x] `npx prek run --all-files` passes - [x] `npm test` passes (CLI project: 1965/1965) - [x] Tests added or updated for new or changed behavior - [x] No secrets, API keys, or credentials committed ## AI Disclosure - [x] AI-assisted — tool: Claude Code --- Signed-off-by: Aaron Erickson <aerickson@nvidia.com> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Added `--control-ui-port` CLI flag to specify the dashboard port during sandbox onboarding * Dashboard port information now displayed in `nemoclaw status` and `nemoclaw list` commands * **Improvements** * Enhanced dashboard port allocation to automatically find the next available port instead of failing when the preferred port is occupied * Improved port management and persistence across sandbox operations <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Julie Yaunches <jyaunches@nvidia.com>
1 parent 6ee1bfa commit a875ce9

9 files changed

Lines changed: 305 additions & 43 deletions

src/lib/inventory-commands.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface SandboxEntry {
1111
policies?: string[] | null;
1212
messagingChannels?: string[] | null;
1313
agent?: string | null;
14+
dashboardPort?: number | null;
1415
}
1516

1617
export interface MessagingBridgeHealth {
@@ -123,6 +124,9 @@ export async function listSandboxesCommand(deps: ListSandboxesCommandDeps): Prom
123124
if (providerDrifted) parts.push(`provider=${sb.provider || "unknown"}`);
124125
log(` (onboarded: ${parts.join(", ")})`);
125126
}
127+
if (sb.dashboardPort != null) {
128+
log(` dashboard: http://127.0.0.1:${sb.dashboardPort}/`);
129+
}
126130
}
127131
log("");
128132
log(" * = default sandbox");
@@ -151,7 +155,8 @@ export function showStatusCommand(deps: ShowStatusCommandDeps): void {
151155
// agrees with `openshell inference get` (#2369).
152156
const liveModel = isDefault && live ? live.model : null;
153157
const model = liveModel || sb.model;
154-
log(` ${sb.name}${def}${model ? ` (${model})` : ""}`);
158+
const portSuffix = sb.dashboardPort != null ? ` :${sb.dashboardPort}` : "";
159+
log(` ${sb.name}${def}${model ? ` (${model})` : ""}${portSuffix}`);
155160
if (isDefault && liveModel && liveModel !== sb.model) {
156161
log(` (onboarded: ${sb.model || "unknown"})`);
157162
}

src/lib/onboard-command.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ describe("onboard command", () => {
3939
acceptThirdPartySoftware: true,
4040
agent: null,
4141
dangerouslySkipPermissions: false,
42+
controlUiPort: null,
4243
});
4344
});
4445

@@ -63,6 +64,7 @@ describe("onboard command", () => {
6364
acceptThirdPartySoftware: true,
6465
agent: null,
6566
dangerouslySkipPermissions: false,
67+
controlUiPort: null,
6668
});
6769
});
6870

@@ -86,6 +88,7 @@ describe("onboard command", () => {
8688
acceptThirdPartySoftware: false,
8789
agent: null,
8890
dangerouslySkipPermissions: false,
91+
controlUiPort: null,
8992
});
9093
});
9194

@@ -130,6 +133,7 @@ describe("onboard command", () => {
130133
acceptThirdPartySoftware: false,
131134
agent: null,
132135
dangerouslySkipPermissions: false,
136+
controlUiPort: null,
133137
});
134138
});
135139

@@ -154,6 +158,7 @@ describe("onboard command", () => {
154158
acceptThirdPartySoftware: false,
155159
agent: null,
156160
dangerouslySkipPermissions: false,
161+
controlUiPort: null,
157162
});
158163
});
159164

@@ -229,6 +234,7 @@ describe("onboard command", () => {
229234
acceptThirdPartySoftware: false,
230235
agent: "openclaw",
231236
dangerouslySkipPermissions: true,
237+
controlUiPort: null,
232238
});
233239
});
234240

@@ -251,6 +257,91 @@ describe("onboard command", () => {
251257
expect(errors.join("\n")).toContain("Usage: nemoclaw onboard");
252258
});
253259

260+
it("parses --control-ui-port with a valid port", () => {
261+
const result = parseOnboardArgs(
262+
["--control-ui-port", "18790"],
263+
"--yes-i-accept-third-party-software",
264+
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
265+
{
266+
env: {},
267+
error: () => {},
268+
exit: ((code: number) => {
269+
throw new Error(String(code));
270+
}) as never,
271+
},
272+
);
273+
expect(result.controlUiPort).toBe(18790);
274+
});
275+
276+
it("exits when --control-ui-port is missing its value", () => {
277+
expect(() =>
278+
parseOnboardArgs(
279+
["--control-ui-port"],
280+
"--yes-i-accept-third-party-software",
281+
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
282+
{
283+
env: {},
284+
error: () => {},
285+
exit: ((code: number) => {
286+
throw new Error(`exit:${code}`);
287+
}) as never,
288+
},
289+
),
290+
).toThrow("exit:1");
291+
});
292+
293+
it("exits when --control-ui-port value is out of range", () => {
294+
const errors: string[] = [];
295+
expect(() =>
296+
parseOnboardArgs(
297+
["--control-ui-port", "80"],
298+
"--yes-i-accept-third-party-software",
299+
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
300+
{
301+
env: {},
302+
error: (message = "") => errors.push(message),
303+
exit: ((code: number) => {
304+
throw new Error(`exit:${code}`);
305+
}) as never,
306+
},
307+
),
308+
).toThrow("exit:1");
309+
expect(errors.join("\n")).toContain("1024-65535");
310+
});
311+
312+
it("--control-ui-port takes precedence over CHAT_UI_URL env", () => {
313+
const result = parseOnboardArgs(
314+
["--control-ui-port", "19000"],
315+
"--yes-i-accept-third-party-software",
316+
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
317+
{
318+
env: { CHAT_UI_URL: "http://127.0.0.1:18790" },
319+
error: () => {},
320+
exit: ((code: number) => {
321+
throw new Error(String(code));
322+
}) as never,
323+
},
324+
);
325+
expect(result.controlUiPort).toBe(19000);
326+
});
327+
328+
it("--help includes --control-ui-port in usage", async () => {
329+
const lines: string[] = [];
330+
await runOnboardCommand({
331+
args: ["--help"],
332+
noticeAcceptFlag: "--yes-i-accept-third-party-software",
333+
noticeAcceptEnv: "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
334+
env: {},
335+
runOnboard: vi.fn(async () => {}),
336+
log: (message = "") => lines.push(message),
337+
error: () => {},
338+
exit: ((code: number) => {
339+
throw new Error(String(code));
340+
}) as never,
341+
});
342+
expect(lines.join("\n")).toContain("--control-ui-port");
343+
});
344+
254345
it("prints the setup-spark deprecation text before delegating", async () => {
255346
const lines: string[] = [];
256347
const runOnboard = vi.fn(async () => {});
@@ -277,6 +368,7 @@ describe("onboard command", () => {
277368
acceptThirdPartySoftware: false,
278369
agent: null,
279370
dangerouslySkipPermissions: false,
371+
controlUiPort: null,
280372
});
281373
});
282374

src/lib/onboard-command.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface OnboardCommandOptions {
1010
acceptThirdPartySoftware: boolean;
1111
agent: string | null;
1212
dangerouslySkipPermissions: boolean;
13+
controlUiPort: number | null;
1314
}
1415

1516
export interface RunOnboardCommandDeps {
@@ -38,7 +39,7 @@ const ONBOARD_BASE_ARGS = [
3839

3940
function onboardUsageLines(noticeAcceptFlag: string): string[] {
4041
return [
41-
` Usage: nemoclaw onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--from <Dockerfile>] [--agent <name>] [--dangerously-skip-permissions] [${noticeAcceptFlag}]`,
42+
` Usage: nemoclaw onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--from <Dockerfile>] [--agent <name>] [--control-ui-port <N>] [--dangerously-skip-permissions] [${noticeAcceptFlag}]`,
4243
"",
4344
];
4445
}
@@ -90,6 +91,25 @@ export function parseOnboardArgs(
9091
parsedArgs.splice(agentIdx, 2);
9192
}
9293

94+
let controlUiPort: number | null = null;
95+
const portIdx = parsedArgs.indexOf("--control-ui-port");
96+
if (portIdx !== -1) {
97+
const portValue = parsedArgs[portIdx + 1];
98+
if (typeof portValue !== "string" || portValue.startsWith("--")) {
99+
error(" --control-ui-port requires a port number");
100+
printOnboardUsage(error, noticeAcceptFlag);
101+
exit(1);
102+
}
103+
const parsed = Number(portValue);
104+
if (!Number.isInteger(parsed) || parsed < 1024 || parsed > 65535) {
105+
error(` --control-ui-port: ${portValue} is not a valid port (1024-65535)`);
106+
printOnboardUsage(error, noticeAcceptFlag);
107+
exit(1);
108+
}
109+
controlUiPort = parsed;
110+
parsedArgs.splice(portIdx, 2);
111+
}
112+
93113
const allowedArgs = new Set([...ONBOARD_BASE_ARGS, noticeAcceptFlag]);
94114
const unknownArgs = parsedArgs.filter((arg) => !allowedArgs.has(arg));
95115
if (unknownArgs.length > 0) {
@@ -116,6 +136,7 @@ export function parseOnboardArgs(
116136
parsedArgs.includes(noticeAcceptFlag) || String(deps.env[noticeAcceptEnv] || "") === "1",
117137
agent,
118138
dangerouslySkipPermissions: parsedArgs.includes("--dangerously-skip-permissions"),
139+
controlUiPort,
119140
};
120141
}
121142

0 commit comments

Comments
 (0)