diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 37ddb4d0508..cd6410c330c 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -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 } = @@ -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, diff --git a/src/lib/onboard/dashboard-port.test.ts b/src/lib/onboard/dashboard-port.test.ts index 72149abbc06..5eaf2f88a13 100644 --- a/src/lib/onboard/dashboard-port.test.ts +++ b/src/lib/onboard/dashboard-port.test.ts @@ -9,6 +9,7 @@ import { findAvailableDashboardPort, findDashboardForwardOwner, preflightDashboardPortRangeAvailability, + resolveCreateSandboxDashboardPort, } from "../../../dist/lib/onboard/dashboard-port"; describe("findDashboardForwardOwner", () => { @@ -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; diff --git a/src/lib/onboard/dashboard-port.ts b/src/lib/onboard/dashboard-port.ts index 5919eaa1912..cbb0b6455d6 100644 --- a/src/lib/onboard/dashboard-port.ts +++ b/src/lib/onboard/dashboard-port.ts @@ -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"); @@ -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