Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 15 additions & 42 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
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