diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 509f5d3097c..78d72d3dbe8 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -6117,6 +6117,21 @@ const CONTROL_UI_PORT = DASHBOARD_PORT; // isLoopbackHostname — see urlUtils import above const { resolveDashboardForwardTarget, buildControlUiUrls } = dashboard; +// Parses `openshell forward list` output and returns the sandbox currently +// owning `portToStop`, or null. Exported for unit testing — see #2169. +// Columns: SANDBOX BIND PORT PID STATUS (whitespace-separated). +function findDashboardForwardOwner(forwardListOutput, portToStop) { + if (!forwardListOutput) return null; + const portLine = forwardListOutput + .split("\n") + .map((l) => l.trim()) + .find((l) => { + const parts = l.split(/\s+/); + return parts[2] === portToStop; + }); + return portLine ? (portLine.split(/\s+/)[0] ?? null) : null; +} + function ensureDashboardForward(sandboxName, chatUiUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`) { const portToStop = getDashboardForwardPort(chatUiUrl); const forwardTarget = getDashboardForwardTarget(chatUiUrl); @@ -6124,23 +6139,19 @@ function ensureDashboardForward(sandboxName, chatUiUrl = `http://127.0.0.1:${CON // actionable message rather than silently stealing that sandbox's forward. // (Same sandbox is always allowed — covers reconnect and resume paths.) const existingForwards = runCaptureOpenshell(["forward", "list"], { ignoreError: true }); - // Parse line-by-line to avoid false positives from substring matches. - // openshell forward list columns: SANDBOX BIND PORT PID STATUS - // Port is at column index 2; sandbox name is at column index 0. - const portLine = existingForwards - ?.split("\n") - .map((l) => l.trim()) - .find((l) => { - const parts = l.split(/\s+/); - return parts[2] === portToStop; - }); - const portOwner = portLine ? (portLine.split(/\s+/)[0] ?? null) : null; + const portOwner = findDashboardForwardOwner(existingForwards, portToStop); if (portOwner !== null && portOwner !== sandboxName) { - throw new Error( - `Port ${portToStop} is already forwarded for sandbox '${portOwner}'. ` + - `Set CHAT_UI_URL to a different local port (e.g. http://127.0.0.1:18790) ` + - `before onboarding a second sandbox.`, + // Match the preflight pattern (printed error + exit) instead of throwing, + // so the user sees a clean message rather than a raw Node stack trace + // from the top-level IIFE's unhandled rejection. See #2169. + console.error( + ` Port ${portToStop} is already forwarded for sandbox '${portOwner}'.`, + ); + console.error( + ` Set CHAT_UI_URL to a different local port (e.g. http://127.0.0.1:18790)`, ); + console.error(` before onboarding a second sandbox.`); + process.exit(1); } runOpenshell(["forward", "stop", portToStop], { ignoreError: true }); // Use stdio "ignore" to prevent spawnSync from waiting on inherited pipe fds. @@ -6993,6 +7004,7 @@ module.exports = { getDashboardForwardPort, getDashboardForwardStartCommand, getDashboardGuidanceLines, + findDashboardForwardOwner, startGatewayForRecovery, runCaptureOpenshell, setupInference, diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 42ef18d46b7..4be26de4f1f 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -50,6 +50,7 @@ import { summarizeProbeFailure, shouldIncludeBuildContextPath, writeSandboxConfigSyncFile, + findDashboardForwardOwner, formatOnboardConfigSummary, } from "../dist/lib/onboard"; import { stageOptimizedSandboxBuildContext } from "../dist/lib/sandbox-build-context"; @@ -5402,6 +5403,29 @@ const { createSandbox } = require(${onboardPath}); ); }); + it("findDashboardForwardOwner parses openshell forward list column format (#2169)", () => { + // Canonical openshell forward list output: SANDBOX BIND PORT PID STATUS + const forwardList = [ + "SANDBOX BIND PORT PID STATUS", + "test21 127.0.0.1 18789 42101 active", + "other 127.0.0.1 18790 42102 active", + ].join("\n"); + + // Port in use by another sandbox → return that sandbox's name + assert.equal(findDashboardForwardOwner(forwardList, "18789"), "test21"); + assert.equal(findDashboardForwardOwner(forwardList, "18790"), "other"); + // Port not in the list → null + assert.equal(findDashboardForwardOwner(forwardList, "18791"), null); + // Empty / missing input → null (no false positives) + assert.equal(findDashboardForwardOwner("", "18789"), null); + assert.equal(findDashboardForwardOwner(null, "18789"), null); + assert.equal(findDashboardForwardOwner(undefined, "18789"), null); + // Port string appearing as a substring somewhere other than column 2 must NOT + // match — guard against false-positive substring matches. + const falsePositive = "sandbox18789 127.0.0.1 42001 9999 active"; + assert.equal(findDashboardForwardOwner(falsePositive, "18789"), null); + }); + it("formatOnboardConfigSummary renders all collected fields (#2165)", () => { const summary = formatOnboardConfigSummary({ provider: "gemini-api",