Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3c62e66
fix(onboard): detect bound dashboard ports more reliably; rollback on…
laitingsheng May 9, 2026
024a24a
fix(onboard): preserve real forward-start error and surface host-boun…
laitingsheng May 9, 2026
b4639ff
refactor(onboard): extract dashboard-port helpers to src/lib/onboard/…
laitingsheng May 12, 2026
dfdae32
merge: branch 'main' into fix/3260-retry-dashboard-forward-on-port-fa…
laitingsheng May 12, 2026
30f301c
fix(onboard): roll back the sandbox when create-path dashboard port b…
laitingsheng May 12, 2026
78cc57c
Merge branch 'main' into fix/3260-retry-dashboard-forward-on-port-fai…
laitingsheng May 12, 2026
50bd4ff
fix(onboard): clean up stale openclaw-gateway listeners and surface g…
laitingsheng May 12, 2026
3f40e23
fix(onboard,status): preserve live forwards on --fresh sweep and skip…
laitingsheng May 12, 2026
8fcfeb6
Merge branch 'main' into fix/3260-retry-dashboard-forward-on-port-fai…
jyaunches May 12, 2026
0bc7741
chore: extract status exit code and stale-gateway cleanup into separa…
laitingsheng May 12, 2026
2d6d070
Merge remote-tracking branch 'origin/fix/3260-retry-dashboard-forward…
laitingsheng May 12, 2026
44a4983
Merge branch 'main' into fix/3260-retry-dashboard-forward-on-port-fai…
laitingsheng May 12, 2026
c42ba7c
merge: current main into dashboard port fix
ericksoa May 13, 2026
cb7fd5a
Merge remote-tracking branch 'origin/main' into pr-3313-review
ericksoa May 13, 2026
099e133
fix(onboard): avoid pipe hang starting dashboard forward
ericksoa May 13, 2026
3f97b4b
refactor(onboard): extract dashboard forward diagnostics
ericksoa May 13, 2026
2071006
fix(onboard): avoid diagnostic file race
ericksoa May 13, 2026
0c876eb
Merge remote-tracking branch 'origin/main' into pr-3313-review
ericksoa May 13, 2026
da4a439
fix(onboard): remove duplicate web search import
ericksoa May 13, 2026
521e77d
Apply suggestions from code review
cv May 13, 2026
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
205 changes: 88 additions & 117 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ const {
}: typeof import("./onboard/branding") = require("./onboard/branding");
const { cleanupTempDir }: typeof import("./onboard/temp-files") = require("./onboard/temp-files");
const { stopStaleDashboardListenersForSandbox } = require("./onboard/stale-gateway-cleanup");
const {
runBackgroundForwardStartWithDiagnostics,
}: typeof import("./onboard/forward-start") = require("./onboard/forward-start");
const {
ensureOllamaLoopbackSystemdOverride,
}: typeof import("./onboard/ollama-systemd") = require("./onboard/ollama-systemd");
Expand Down Expand Up @@ -53,9 +56,6 @@ const {
const {
verifyWebSearchInsideSandbox: verifyWebSearchInsideSandboxWithDeps,
}: typeof import("./onboard/web-search-verify") = require("./onboard/web-search-verify");
const {
verifyWebSearchInsideSandbox: verifyWebSearchInsideSandboxWithDeps,
}: typeof import("./onboard/web-search-verify") = require("./onboard/web-search-verify");
const {
buildDirectGpuPolicyYaml,
buildDirectSandboxGpuProofCommands,
Expand Down Expand Up @@ -260,6 +260,11 @@ const policies: typeof import("./policy") = require("./policy");
const shields = require("./shields");
const tiers: typeof import("./policy/tiers") = require("./policy/tiers");
const { ensureUsageNoticeConsent } = require("./onboard/usage-notice");
const {
findAvailableDashboardPort,
getOccupiedPorts,
isLiveForwardStatus,
} = require("./onboard/dashboard-port") as typeof import("./onboard/dashboard-port");
const {
destroyGatewayForReuse,
warnIfGatewayDestroyFails,
Expand Down Expand Up @@ -9402,10 +9407,6 @@ function findForwardEntry(
return null;
}

function isLiveForwardStatus(status: string): boolean {
return status === "running" || status === "active";
}

function getRunningForwardPorts(forwardListOutput: string | null | undefined): string[] {
const ports = new Set<string>();
if (!forwardListOutput) return [];
Expand All @@ -9429,85 +9430,6 @@ function stopAllDashboardForwards(): void {
}
}

/**
* Parse `openshell forward list` output into a Map<port, sandboxName>.
* Only includes running forwards — stopped/stale entries are ignored so
* they don't block port allocation or cause false "range exhausted" errors.
*
* Output format (columns separated by whitespace):
* SANDBOX BIND PORT PID STATUS
*/
function getOccupiedPorts(forwardListOutput: string | null): Map<string, string> {
const occupied = new Map();
if (!forwardListOutput) return occupied;
for (const rawLine of forwardListOutput.split("\n")) {
const line = rawLine.replace(ANSI_RE, "");
if (/^\s*SANDBOX\s/i.test(line)) continue;
const parts = line.trim().split(/\s+/);
// parts: [sandbox, bind, port, pid, status...]
if (parts.length < 3 || !/^\d+$/.test(parts[2])) continue;
const status = (parts[4] || "").toLowerCase();
if (!isLiveForwardStatus(status)) continue;
occupied.set(parts[2], parts[0]);
}
return occupied;
}

/**
* Quick synchronous check whether a TCP port has an active listener on the host.
* Uses lsof when available; returns false (optimistic) if lsof is missing.
*/
function isPortBoundOnHost(port: number): boolean {
try {
const out = runCapture(["lsof", "-i", `:${port}`, "-sTCP:LISTEN", "-P", "-n"], {
ignoreError: true,
});
return !!out && out.trim().length > 0;
} catch {
return false;
}
}

/**
* Find the next available dashboard port for the given sandbox.
* Returns the preferred port if free or already owned by this sandbox,
* otherwise scans DASHBOARD_PORT_RANGE_START..END for a free port.
* Validates host-port availability (via lsof) so ports bound by
* non-OpenShell processes are skipped.
* Throws if the entire range is exhausted.
*/
function findAvailableDashboardPort(
sandboxName: string,
preferredPort: number,
forwardListOutput: string | null,
): number {
const occupied = getOccupiedPorts(forwardListOutput);
const preferredStr = String(preferredPort);
const owner = occupied.get(preferredStr) ?? null;
// If this sandbox already owns the forward, keep it.
if (owner === sandboxName) return preferredPort;
// If no forward claims the port, also check the host so we don't collide
// with non-OpenShell processes.
if (owner === null && !isPortBoundOnHost(preferredPort)) return preferredPort;

for (let p = DASHBOARD_PORT_RANGE_START; p <= DASHBOARD_PORT_RANGE_END; p++) {
const pStr = String(p);
const pOwner = occupied.get(pStr) ?? null;
if (pOwner === sandboxName) return p;
if (pOwner === null && !isPortBoundOnHost(p)) return p;
}

const owners = [...occupied.entries()]
.filter(
([p]) => Number(p) >= DASHBOARD_PORT_RANGE_START && Number(p) <= DASHBOARD_PORT_RANGE_END,
)
.map(([p, s]) => ` ${p} → ${s}`)
.join("\n");
throw new Error(
`All dashboard ports in range ${DASHBOARD_PORT_RANGE_START}-${DASHBOARD_PORT_RANGE_END} are occupied:\n${owners}\n` +
`Free a sandbox or use --control-ui-port <N> with a port outside this range.`,
);
}

/**
* Build the actionable error lines printed when the just-created openshell
Expand Down Expand Up @@ -9579,6 +9501,34 @@ function ensureDashboardForward(
}

if (actualPort !== preferredPort) {
if (rollbackSandboxOnFailure) {
// Create path: the sandbox was just built with CHAT_UI_URL and
// NEMOCLAW_DASHBOARD_PORT baked from `preferredPort` (see the
// `formatEnvAssignment("CHAT_UI_URL", …)` call in createSandbox). If
// the port was bound during the build window (TOCTOU), picking a new
// host port would leave the sandbox serving the dashboard on
// `preferredPort` internally while the forward listens on `actualPort`
// — reproducing the original "onboard exits but dashboard is
// unreachable" failure on the newly selected port. Reallocation is
// only safe on reuse paths where the sandbox image is fixed; on the
// create path we must roll back so the next onboard re-bakes with a
// clean port. (#3260)
const err = new Error(
`Dashboard port ${preferredPort} became host-bound during sandbox build; ` +
`cannot reallocate to ${actualPort} after the sandbox has been created with ` +
`CHAT_UI_URL=${preferredPort}. Free the port and re-run \`${cliName()} onboard\`, ` +
`or pass \`--control-ui-port <N>\` to pick a different dashboard port.`,
);
const delResult = runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true });
for (const line of buildOrphanedSandboxRollbackMessage(
sandboxName,
err,
delResult.status === 0,
)) {
console.error(line);
}
process.exit(1);
}
console.warn(` ! Port ${preferredPort} is taken. Using port ${actualPort} instead.`);
}

Expand All @@ -9596,18 +9546,58 @@ function ensureDashboardForward(
parsedUrl.port = String(actualPort);
const actualTarget = getDashboardForwardTarget(parsedUrl.toString());
runOpenshell(["forward", "stop", String(actualPort)], { ignoreError: true });
const fwdResult = runOpenshell(["forward", "start", "--background", actualTarget, sandboxName], {
ignoreError: true,
stdio: ["ignore", "ignore", "ignore"],
});
if (fwdResult && fwdResult.status !== 0) {
console.warn(
`! Port ${actualPort} forward did not start — port may be in use by another process.`,
);
console.warn(
` Check: docker ps --format 'table {{.Names}}\\t{{.Ports}}' | grep ${actualPort}`,
const { result: fwdResult, diagnostic: fwdDiagnostic } =
runBackgroundForwardStartWithDiagnostics((stdio, timeout) =>
runOpenshell(
["forward", "start", "--background", actualTarget, sandboxName],
{ ignoreError: true, suppressOutput: true, stdio, timeout },
),
);
console.warn(` Free the port, then reconnect: ${cliName()} ${sandboxName} connect`);
if (fwdResult && fwdResult.status !== 0) {
const looksLikePortConflict =
fwdDiagnostic === "" ||
/eaddrinuse|address already in use|port .* in use|bind: .*in use/i.test(fwdDiagnostic);
if (rollbackSandboxOnFailure) {
// The sandbox was just created, committed to actualPort via its
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
// baked-in CHAT_UI_URL and NEMOCLAW_DASHBOARD_PORT env. Silently
// returning here leaves the user with a dashboard URL that points
// at a port held by another process — a TOCTOU race where the
// proactive probe in findAvailableDashboardPort missed the
// conflict (e.g., another listener bound during the multi-minute
// image build). Roll back so the next `onboard` retry's allocator
// observes the bound port and picks a different one. Only the
// EADDRINUSE-style failure gets the port-conflict wording; other
// errors (gateway / transport) propagate the real diagnostic so
// users aren't pointed at the wrong fix (#3260).
const err = new Error(
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
looksLikePortConflict
? `Failed to start dashboard forward on port ${actualPort} — the host port ` +
`is held by another process. Free it and run \`${cliName()} onboard\` again, ` +
`or pass \`--control-ui-port <N>\` to pick a different dashboard port.`
: `Failed to start dashboard forward on port ${actualPort}: ${fwdDiagnostic.slice(0, 240)}`,
);
const delResult = runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true });
for (const line of buildOrphanedSandboxRollbackMessage(
sandboxName,
err,
delResult.status === 0,
)) {
console.error(line);
}
process.exit(1);
}
if (looksLikePortConflict) {
console.warn(
`! Port ${actualPort} forward did not start — port may be in use by another process.`,
);
console.warn(
` Check: docker ps --format 'table {{.Names}}\\t{{.Ports}}' | grep ${actualPort}`,
);
console.warn(` Free the port, then reconnect: ${cliName()} ${sandboxName} connect`);
} else {
console.warn(`! Port ${actualPort} forward did not start: ${fwdDiagnostic.slice(0, 240)}`);
console.warn(` Reconnect after resolving the issue: ${cliName()} ${sandboxName} connect`);
}
}
return actualPort;
}
Expand Down Expand Up @@ -9722,26 +9712,6 @@ function getWslHostAddress(
return dashboardAccess.getWslHostAddress({ ...options, runCapture: options.runCapture || runCapture });
}

function getDashboardAccessInfo(
sandboxName: string,
options: Parameters<typeof dashboardAccess.getDashboardAccessInfo>[1] = {},
) {
return dashboardAccess.getDashboardAccessInfo(sandboxName, {
...options,
runCapture: options.runCapture || runCapture,
fetchGatewayAuthToken: fetchGatewayAuthTokenFromSandbox,
});
}

function getDashboardGuidanceLines(
access: Parameters<typeof dashboardAccess.getDashboardGuidanceLines>[0] = [],
options: Parameters<typeof dashboardAccess.getDashboardGuidanceLines>[1] = {},
): string[] {
return dashboardAccess.getDashboardGuidanceLines(access, {
...options,
runCapture: options.runCapture || runCapture,
});
}
/** Print the post-onboard dashboard with sandbox status and reconfiguration hints. */
function printDashboard(
sandboxName: string,
Expand Down Expand Up @@ -11072,6 +11042,7 @@ module.exports = {
buildControlUiUrls,

startGateway,
findAvailableDashboardPort,
findDashboardForwardOwner,
startGatewayForRecovery,
openshellArgv,
Expand Down
Loading
Loading