Skip to content
107 changes: 33 additions & 74 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,8 +474,11 @@ const policyPresetCarry: typeof import("./onboard/policy-preset-persistence") =
const tiers: typeof import("./policy/tiers") = require("./policy/tiers");
const policyTierEnv: typeof import("./onboard/policy-tier-env") = require("./onboard/policy-tier-env");
const { ensureUsageNoticeConsent } = require("./onboard/usage-notice");
const { findAvailableDashboardPort, preflightDashboardPortRangeAvailability } =
require("./onboard/dashboard-port") as typeof import("./onboard/dashboard-port");
const {
findAvailableDashboardPort,
preflightDashboardPortRangeAvailability,
resolveCreateSandboxDashboardPort,
} = require("./onboard/dashboard-port") as typeof import("./onboard/dashboard-port");
const { tryCleanupOrphanedDashboardForward } =
require("./onboard/orphaned-dashboard-forward") as typeof import("./onboard/orphaned-dashboard-forward");
const { destroyGatewayForReuse } =
Expand Down Expand Up @@ -2564,46 +2567,16 @@ async function createSandbox(
const effectiveSandboxGpuConfig =
sandboxGpuConfig ?? resolveSandboxGpuConfig(gpu, { flag: null, device: null });

// Port priority: --control-ui-port > CHAT_UI_URL env > registry (resume) > agent.forwardPort > default
// Pre-resolve port availability so CHAT_UI_URL baked into the Dockerfile,
// the sandbox env, and the readiness probe all use the final forwarded port.
const persistedPort = registry.getSandbox(sandboxName)?.dashboardPort ?? null;
// When CHAT_UI_URL is set, extract its port so the allocator and the URL stay in sync.
let envPort: number | null = null;
if (process.env.CHAT_UI_URL) {
try {
const u = new URL(
process.env.CHAT_UI_URL.includes("://")
? process.env.CHAT_UI_URL
: `http://${process.env.CHAT_UI_URL}`,
);
const p = Number(u.port);
if (p > 0) envPort = p;
} catch {
/* malformed URL — ignore */
}
}
const preferredPort =
controlUiPort ?? envPort ?? persistedPort ?? (agent ? agent.forwardPort : DASHBOARD_PORT);
const earlyForwards = runCaptureOpenshell(["forward", "list"], { ignoreError: true });
const effectivePort = findAvailableDashboardPort(sandboxName, preferredPort, earlyForwards);
if (effectivePort !== preferredPort) {
console.warn(` ! Port ${preferredPort} is taken. Using port ${effectivePort} instead.`);
}
// Build chatUiUrl: preserve the hostname from CHAT_UI_URL when set, but
// always use effectivePort so the Dockerfile, env, and readiness probe agree.
let chatUiUrl: string;
if (process.env.CHAT_UI_URL && controlUiPort == null) {
const parsed = new URL(
process.env.CHAT_UI_URL.includes("://")
? process.env.CHAT_UI_URL
: `http://${process.env.CHAT_UI_URL}`,
);
parsed.port = String(effectivePort);
chatUiUrl = parsed.toString().replace(/\/$/, "");
} else {
chatUiUrl = `http://127.0.0.1:${effectivePort}`;
}
let { effectivePort, chatUiUrl } = resolveCreateSandboxDashboardPort({
sandboxName,
controlUiPort,
chatUiUrlEnv: process.env.CHAT_UI_URL,
persistedPort: registry.getSandbox(sandboxName)?.dashboardPort ?? null,
agentForwardPort: agent?.forwardPort,
defaultPort: DASHBOARD_PORT,
forwardListOutput: runCaptureOpenshell(["forward", "list"], { ignoreError: true }),
warn: (message) => console.warn(message),
});
const hermesDashboardForwarding = onboardHermesDashboard.createHermesDashboardOnboardForwarding({
agentName: agent?.name,
env: process.env,
Expand Down Expand Up @@ -2893,28 +2866,21 @@ async function createSandbox(
" Pass --recreate-sandbox or set NEMOCLAW_RECREATE_SANDBOX=1 to force recreation.",
);
}
const reusedPort = ensureDashboardForward(sandboxName, chatUiUrl);
chatUiUrl = `http://127.0.0.1:${reusedPort}`;
process.env.CHAT_UI_URL = chatUiUrl;
const reusedHermesDashboardState =
hermesDashboardForwarding.resolveStateForPort(reusedPort);
hermesDashboardForwarding.ensureForState(reusedHermesDashboardState, sandboxName);
updateReusedSandboxMetadata(
({ chatUiUrl } = sandboxReuse.applyReusedSandboxDashboardState({
sandboxName,
chatUiUrl,
env: process.env,
agent,
model,
provider,
reusedPort,
!selectionDrift.unknown,
effectiveSandboxGpuConfig,
);
registry.updateSandbox(sandboxName, {
...onboardHermesDashboard.getHermesDashboardRegistryFields(
reusedHermesDashboardState,
),
selectionVerified: !selectionDrift.unknown,
sandboxGpuConfig: effectiveSandboxGpuConfig,
gatewayName: GATEWAY_NAME,
gatewayPort: GATEWAY_PORT,
});
ensureDashboardForward,
hermesDashboardForwarding,
updateReusedSandboxMetadata,
}));
return sandboxName;
}
} else {
Expand Down Expand Up @@ -2942,28 +2908,21 @@ async function createSandbox(
if (await promptYesNoOrDefault(" Reuse existing sandbox?", null, true)) {
policyPresetCarry.seedReusedSandboxPolicyPresets(sandboxName, isNonInteractive());
upsertMessagingProviders(messagingTokenDefs);
const reusedPort2 = ensureDashboardForward(sandboxName, chatUiUrl);
chatUiUrl = `http://127.0.0.1:${reusedPort2}`;
process.env.CHAT_UI_URL = chatUiUrl;
const reusedHermesDashboardState2 =
hermesDashboardForwarding.resolveStateForPort(reusedPort2);
hermesDashboardForwarding.ensureForState(reusedHermesDashboardState2, sandboxName);
updateReusedSandboxMetadata(
({ chatUiUrl } = sandboxReuse.applyReusedSandboxDashboardState({
sandboxName,
chatUiUrl,
env: process.env,
agent,
model,
provider,
reusedPort2,
!selectionDrift.unknown,
effectiveSandboxGpuConfig,
);
registry.updateSandbox(sandboxName, {
...onboardHermesDashboard.getHermesDashboardRegistryFields(
reusedHermesDashboardState2,
),
selectionVerified: !selectionDrift.unknown,
sandboxGpuConfig: effectiveSandboxGpuConfig,
gatewayName: GATEWAY_NAME,
gatewayPort: GATEWAY_PORT,
});
ensureDashboardForward,
hermesDashboardForwarding,
updateReusedSandboxMetadata,
}));
return sandboxName;
}
}
Expand Down
122 changes: 122 additions & 0 deletions src/lib/onboard/dashboard-port.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
findAvailableDashboardPort,
findDashboardForwardOwner,
preflightDashboardPortRangeAvailability,
resolveCreateSandboxDashboardPort,
} from "../../../dist/lib/onboard/dashboard-port";

describe("findDashboardForwardOwner", () => {
Expand Down Expand Up @@ -95,6 +96,127 @@ describe("findAvailableDashboardPort port-conflict detection (#3260)", () => {
});
});

describe("resolveCreateSandboxDashboardPort", () => {
it("lets --control-ui-port override CHAT_UI_URL, registry, agent, and default ports", () => {
let preferredSeen: number | null = null;
const result = resolveCreateSandboxDashboardPort({
sandboxName: "cursor",
controlUiPort: 19000,
chatUiUrlEnv: "http://127.0.0.1:18790",
persistedPort: 18791,
agentForwardPort: 18792,
defaultPort: 18793,
forwardListOutput: "",
findAvailablePort: (_sandboxName, preferredPort) => {
preferredSeen = preferredPort;
return preferredPort;
},
});

assert.equal(preferredSeen, 19000);
assert.equal(result.preferredPort, 19000);
assert.equal(result.effectivePort, 19000);
assert.equal(result.chatUiUrl, "http://127.0.0.1:19000");
});

it("uses CHAT_UI_URL port before registry and rewrites the URL to the allocated port", () => {
const warnings: string[] = [];
const result = resolveCreateSandboxDashboardPort({
sandboxName: "cursor",
controlUiPort: null,
chatUiUrlEnv: "https://chat.example.test:18790/ui/",
persistedPort: 18791,
agentForwardPort: 18792,
defaultPort: 18793,
forwardListOutput: "FORWARDS",
findAvailablePort: (sandboxName, preferredPort, forwardListOutput) => {
assert.equal(sandboxName, "cursor");
assert.equal(preferredPort, 18790);
assert.equal(forwardListOutput, "FORWARDS");
return 18794;
},
warn: (message) => warnings.push(message),
});

assert.equal(result.preferredPort, 18790);
assert.equal(result.effectivePort, 18794);
assert.equal(result.chatUiUrl, "https://chat.example.test:18794/ui");
assert.deepEqual(warnings, [" ! Port 18790 is taken. Using port 18794 instead."]);
});

it("falls back through registry, agent, and default ports", () => {
const preferredPorts: number[] = [];
const resolve = (persistedPort: number | null, agentForwardPort: number | null | undefined) =>
resolveCreateSandboxDashboardPort({
sandboxName: "cursor",
controlUiPort: null,
chatUiUrlEnv: null,
persistedPort,
agentForwardPort,
defaultPort: 18793,
forwardListOutput: "",
findAvailablePort: (_sandboxName, preferredPort) => {
preferredPorts.push(preferredPort);
return preferredPort;
},
});

assert.equal(resolve(18791, 18792).preferredPort, 18791);
assert.equal(resolve(null, 18792).preferredPort, 18792);
assert.equal(resolve(null, null).preferredPort, 18793);
assert.deepEqual(preferredPorts, [18791, 18792, 18793]);
});

it("normalizes schemeless CHAT_UI_URL values before preserving their host", () => {
const result = resolveCreateSandboxDashboardPort({
sandboxName: "cursor",
controlUiPort: null,
chatUiUrlEnv: "remote.example.test:18790",
persistedPort: null,
agentForwardPort: null,
defaultPort: 18789,
forwardListOutput: "",
findAvailablePort: (_sandboxName, preferredPort) => preferredPort,
});

assert.equal(result.preferredPort, 18790);
assert.equal(result.chatUiUrl, "http://remote.example.test:18790");
});

it("preserves malformed CHAT_UI_URL failure when the env URL would be used", () => {
assert.throws(
() =>
resolveCreateSandboxDashboardPort({
sandboxName: "cursor",
controlUiPort: null,
chatUiUrlEnv: "https://example.test:abc",
persistedPort: 18791,
agentForwardPort: null,
defaultPort: 18789,
forwardListOutput: "",
findAvailablePort: (_sandboxName, preferredPort) => preferredPort,
}),
/Invalid URL/,
);
});

it("ignores malformed CHAT_UI_URL when --control-ui-port supplies the URL", () => {
const result = resolveCreateSandboxDashboardPort({
sandboxName: "cursor",
controlUiPort: 19000,
chatUiUrlEnv: "https://example.test:abc",
persistedPort: 18791,
agentForwardPort: null,
defaultPort: 18789,
forwardListOutput: "",
findAvailablePort: (_sandboxName, preferredPort) => preferredPort,
});

assert.equal(result.preferredPort, 19000);
assert.equal(result.chatUiUrl, "http://127.0.0.1:19000");
});
});

describe("preflightDashboardPortRangeAvailability (#3953)", () => {
const allBound = (_p: number) => true;
const noneBound = (_p: number) => false;
Expand Down
77 changes: 76 additions & 1 deletion src/lib/onboard/dashboard-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@

import { spawnSync } from "node:child_process";

import { DASHBOARD_PORT_RANGE_END, DASHBOARD_PORT_RANGE_START } from "../core/ports";
import {
DASHBOARD_PORT,
DASHBOARD_PORT_RANGE_END,
DASHBOARD_PORT_RANGE_START,
} from "../core/ports";

// runner.ts is still CommonJS — use require so module shape matches.
const { runCapture } = require("../runner");
Expand Down Expand Up @@ -190,6 +194,77 @@ export function findAvailableDashboardPort(
);
}

export interface CreateSandboxDashboardPortInput {
sandboxName: string;
controlUiPort: number | null;
chatUiUrlEnv: string | null | undefined;
persistedPort: number | null;
agentForwardPort: number | null | undefined;
forwardListOutput: string | null;
defaultPort?: number;
findAvailablePort?: typeof findAvailableDashboardPort;
warn?: (message: string) => void;
}

export interface CreateSandboxDashboardPortResult {
preferredPort: number;
effectivePort: number;
chatUiUrl: string;
}

function normalizeChatUiUrlForParsing(chatUiUrl: string): string {
return chatUiUrl.includes("://") ? chatUiUrl : `http://${chatUiUrl}`;
}

function parseChatUiUrlPort(chatUiUrlEnv: string | null | undefined): number | null {
if (!chatUiUrlEnv) return null;
try {
const parsed = new URL(normalizeChatUiUrlForParsing(chatUiUrlEnv));
const port = Number(parsed.port);
return port > 0 ? port : null;
} catch {
return null;
}
}

function buildCreateSandboxChatUiUrl(
chatUiUrlEnv: string | null | undefined,
controlUiPort: number | null,
effectivePort: number,
): string {
if (chatUiUrlEnv && controlUiPort == null) {
const parsed = new URL(normalizeChatUiUrlForParsing(chatUiUrlEnv));
parsed.port = String(effectivePort);
return parsed.toString().replace(/\/$/, "");
}
return `http://127.0.0.1:${effectivePort}`;
}

export function resolveCreateSandboxDashboardPort(
input: CreateSandboxDashboardPortInput,
): CreateSandboxDashboardPortResult {
const preferredPort =
input.controlUiPort ??
parseChatUiUrlPort(input.chatUiUrlEnv) ??
input.persistedPort ??
input.agentForwardPort ??
input.defaultPort ??
DASHBOARD_PORT;
const effectivePort = (input.findAvailablePort ?? findAvailableDashboardPort)(
input.sandboxName,
preferredPort,
input.forwardListOutput,
);
if (effectivePort !== preferredPort) {
input.warn?.(` ! Port ${preferredPort} is taken. Using port ${effectivePort} instead.`);
}
return {
preferredPort,
effectivePort,
chatUiUrl: buildCreateSandboxChatUiUrl(input.chatUiUrlEnv, input.controlUiPort, effectivePort),
};
}

/**
* Preflight scan of the dashboard port range. If every port in
* [DASHBOARD_PORT_RANGE_START, DASHBOARD_PORT_RANGE_END] is bound on
Expand Down
Loading