diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 18522211bfc..7951c0c7233 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -491,6 +491,10 @@ const authoritativeRebuildTarget: typeof import("./onboard/authoritative-rebuild require("./onboard/authoritative-rebuild-target"); const { assertDashboardPortNotReserved, buildRequiredPreflightPorts } = require("./onboard/preflight-ports") as typeof import("./onboard/preflight-ports"); +const { failFastOnForeignGatewayPortConflict } = + require("./onboard/gateway-port-conflict") as typeof import("./onboard/gateway-port-conflict"); +const { printPortConflictReport } = + require("./onboard/port-conflict-report") as typeof import("./onboard/port-conflict-report"); const { tryCleanupOrphanedDashboardForward } = require("./onboard/orphaned-dashboard-forward") as typeof import("./onboard/orphaned-dashboard-forward"); const { destroyGatewayForReuse } = @@ -1496,6 +1500,13 @@ async function preflight( }); ensureOpenshellForOnboard(); + await failFastOnForeignGatewayPortConflict({ + gatewayPort: GATEWAY_PORT, + checkPortAvailable, + getGatewayPortCheckOptions: dockerDriverGatewayEnv.getGatewayPortCheckOptions, + isDockerDriverGatewayPortListener, + exitProcess: (code) => process.exit(code), + }); // Classify gateway state before port checks. Legacy non-Docker-driver // path destroys stale/unnamed gateways here so the port frees up for @@ -1648,34 +1659,13 @@ async function preflight( if (outcome.kind === "killed-still-blocked") portCheck = outcome.portCheck; else if (outcome.kind !== "not-openshell") continue; } - console.error(""); - console.error(` !! Port ${port} is not available.`); - console.error(` ${label} needs this port.`); - console.error(""); - if (portCheck.process && portCheck.process !== "unknown") { - console.error( - ` Blocked by: ${portCheck.process}${portCheck.pid ? ` (PID ${portCheck.pid})` : ""}`, - ); - console.error(""); - console.error(" To fix, stop the conflicting process:"); - console.error(""); - if (portCheck.pid) { - console.error(` sudo kill ${portCheck.pid}`); - } else { - console.error(` sudo lsof -i :${port} -sTCP:LISTEN -P -n`); - } - for (const hint of getPortConflictServiceHints()) { - console.error(hint); - } - } else { - console.error(` Could not identify the process using port ${port}.`); - console.error(` Run: sudo lsof -i :${port} -sTCP:LISTEN`); - } - console.error(""); - console.error(` Or rerun with a different port:`); - console.error(` ${envVar}= nemoclaw onboard`); - console.error(""); - console.error(` Detail: ${portCheck.reason}`); + printPortConflictReport({ + port, + label, + envVar, + portCheck, + serviceHints: getPortConflictServiceHints(), + }); process.exit(1); } console.log(` ✓ Port ${port} available (${label})`); diff --git a/src/lib/onboard/gateway-port-conflict.test.ts b/src/lib/onboard/gateway-port-conflict.test.ts new file mode 100644 index 00000000000..ce5a6f9ad4d --- /dev/null +++ b/src/lib/onboard/gateway-port-conflict.test.ts @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + couldBeNemoClawGatewayPortListener, + failFastOnForeignGatewayPortConflict, +} from "./gateway-port-conflict"; +import type { PortProbeResult } from "./preflight"; + +function blockedPort(processName: string): PortProbeResult { + return { + ok: false, + process: processName, + pid: 1234, + reason: "listener is already using the port", + }; +} + +describe("gateway port conflict", () => { + it("exits with a report for an identifiable foreign gateway port listener", async () => { + const checkPortAvailable = vi.fn().mockResolvedValue(blockedPort("python3")); + const exitProcess = vi.fn(); + const lines: string[] = []; + + await failFastOnForeignGatewayPortConflict({ + gatewayPort: 8080, + checkPortAvailable, + getGatewayPortCheckOptions: () => ({ host: "127.0.0.1" }), + isDockerDriverGatewayPortListener: () => false, + exitProcess, + serviceHints: [" systemctl --user stop openshell-gateway.service"], + writeError: (line) => lines.push(line), + }); + + expect(checkPortAvailable).toHaveBeenCalledWith(8080, { host: "127.0.0.1" }); + expect(exitProcess).toHaveBeenCalledWith(1); + expect(lines.join("\n")).toContain("Port 8080 is not available."); + expect(lines.join("\n")).toContain("Blocked by: python3 (PID 1234)"); + expect(lines.join("\n")).toContain("NEMOCLAW_GATEWAY_PORT= nemoclaw onboard"); + }); + + it("keeps OpenShell-like listeners on the gateway reuse path", async () => { + const checkPortAvailable = vi.fn().mockResolvedValue(blockedPort("openshell-gateway")); + const exitProcess = vi.fn(); + + await failFastOnForeignGatewayPortConflict({ + gatewayPort: 8080, + checkPortAvailable, + getGatewayPortCheckOptions: () => ({ host: "127.0.0.1" }), + isDockerDriverGatewayPortListener: () => false, + exitProcess, + }); + + expect(exitProcess).not.toHaveBeenCalled(); + }); + + it("keeps Docker-driver gateway listeners on the gateway reuse path", () => { + expect(couldBeNemoClawGatewayPortListener(blockedPort("python3"), () => true)).toBe(true); + }); +}); diff --git a/src/lib/onboard/gateway-port-conflict.ts b/src/lib/onboard/gateway-port-conflict.ts new file mode 100644 index 00000000000..abc7501c2ab --- /dev/null +++ b/src/lib/onboard/gateway-port-conflict.ts @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { printPortConflictReport } from "./port-conflict-report"; +import type { CheckPortOpts, PortProbeResult } from "./preflight"; +import { getPortConflictServiceHints } from "./remediation"; + +type CheckPortAvailable = (port: number, opts?: CheckPortOpts) => Promise; +type DockerGatewayPortListenerClassifier = (portCheck: PortProbeResult) => boolean; + +export interface GatewayPortConflictDeps { + gatewayPort: number; + checkPortAvailable: CheckPortAvailable; + getGatewayPortCheckOptions: () => CheckPortOpts; + isDockerDriverGatewayPortListener: DockerGatewayPortListenerClassifier; + exitProcess?: (code: number) => void; + serviceHints?: string[]; + writeError?: (line: string) => void; +} + +const GATEWAY_PORT_LISTENER_CANDIDATE_PROCESSES = [ + "openshell", + "openshell-gateway", + "docker-proxy", + "com.docker.backend", + "vpnkit", + "rootlesskit", + "slirp4netns", +]; + +export function couldBeNemoClawGatewayPortListener( + portCheck: PortProbeResult, + isDockerDriverGatewayPortListener: DockerGatewayPortListenerClassifier, +): boolean { + if (portCheck.ok) return false; + const processName = String(portCheck.process || "").toLowerCase(); + if (!processName || processName === "unknown") return true; + if (isDockerDriverGatewayPortListener(portCheck)) return true; + return GATEWAY_PORT_LISTENER_CANDIDATE_PROCESSES.some( + (candidate) => + processName === candidate || + processName.startsWith(`${candidate}-`) || + processName.startsWith(`${candidate}.`), + ); +} + +export async function failFastOnForeignGatewayPortConflict({ + gatewayPort, + checkPortAvailable, + getGatewayPortCheckOptions, + isDockerDriverGatewayPortListener, + exitProcess = (code) => process.exit(code), + serviceHints = getPortConflictServiceHints(), + writeError, +}: GatewayPortConflictDeps): Promise { + const portCheck = await checkPortAvailable(gatewayPort, getGatewayPortCheckOptions()); + if ( + portCheck.ok || + couldBeNemoClawGatewayPortListener(portCheck, isDockerDriverGatewayPortListener) + ) { + return; + } + + printPortConflictReport( + { + port: gatewayPort, + label: "OpenShell gateway", + envVar: "NEMOCLAW_GATEWAY_PORT", + portCheck, + serviceHints, + }, + writeError, + ); + exitProcess(1); +} diff --git a/src/lib/onboard/gateway-reuse.test.ts b/src/lib/onboard/gateway-reuse.test.ts new file mode 100644 index 00000000000..7ff661113a3 --- /dev/null +++ b/src/lib/onboard/gateway-reuse.test.ts @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts"; +import { createGatewayReuseHelpers } from "./gateway-reuse"; + +describe("gateway reuse snapshot", () => { + it("bounds OpenShell gateway inspection probes (#6752)", () => { + const runCaptureOpenshell = vi.fn(() => ""); + const helpers = createGatewayReuseHelpers({ + gatewayName: "nemoclaw", + runCaptureOpenshell, + runOpenshell: vi.fn(() => ({ status: 0 })), + cliDisplayName: () => "NemoClaw", + }); + + helpers.getGatewayReuseSnapshot(); + + expect(runCaptureOpenshell).toHaveBeenCalledWith(["status"], { + ignoreError: true, + timeout: OPENSHELL_PROBE_TIMEOUT_MS, + }); + expect(runCaptureOpenshell).toHaveBeenCalledWith(["gateway", "info", "-g", "nemoclaw"], { + ignoreError: true, + timeout: OPENSHELL_PROBE_TIMEOUT_MS, + }); + expect(runCaptureOpenshell).toHaveBeenCalledWith(["gateway", "info"], { + ignoreError: true, + timeout: OPENSHELL_PROBE_TIMEOUT_MS, + }); + }); +}); diff --git a/src/lib/onboard/gateway-reuse.ts b/src/lib/onboard/gateway-reuse.ts index 8dd41c8522f..f3141ae1144 100644 --- a/src/lib/onboard/gateway-reuse.ts +++ b/src/lib/onboard/gateway-reuse.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts"; import { getGatewayReuseState, shouldSelectNamedGatewayForReuse } from "../state/gateway"; export type GatewayReuseSnapshot = { @@ -28,11 +29,12 @@ export function createGatewayReuseHelpers(deps: GatewayReuseDeps): GatewayReuseH function getGatewayReuseSnapshot(): GatewayReuseSnapshot { const gatewayName = currentGatewayName(); - const gatewayStatus = deps.runCaptureOpenshell(["status"], { ignoreError: true }); + const probeOptions = { ignoreError: true, timeout: OPENSHELL_PROBE_TIMEOUT_MS }; + const gatewayStatus = deps.runCaptureOpenshell(["status"], probeOptions); const gwInfo = deps.runCaptureOpenshell(["gateway", "info", "-g", gatewayName], { - ignoreError: true, + ...probeOptions, }); - const activeGatewayInfo = deps.runCaptureOpenshell(["gateway", "info"], { ignoreError: true }); + const activeGatewayInfo = deps.runCaptureOpenshell(["gateway", "info"], probeOptions); return { gatewayStatus, gwInfo, diff --git a/src/lib/onboard/port-conflict-report.test.ts b/src/lib/onboard/port-conflict-report.test.ts new file mode 100644 index 00000000000..09fbd7628e3 --- /dev/null +++ b/src/lib/onboard/port-conflict-report.test.ts @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { formatPortConflictReport } from "./port-conflict-report"; + +const ORIGINAL_STDERR = { + isTTY: process.stderr.isTTY, + getColorDepth: process.stderr.getColorDepth, +}; + +function stubStderr(isTTY: boolean, colorDepth: number): void { + Object.defineProperty(process.stderr, "isTTY", { value: isTTY, configurable: true }); + Object.defineProperty(process.stderr, "getColorDepth", { + value: () => colorDepth, + configurable: true, + }); +} + +function restoreStderr(): void { + Object.defineProperty(process.stderr, "isTTY", { + value: ORIGINAL_STDERR.isTTY, + configurable: true, + }); + Object.defineProperty(process.stderr, "getColorDepth", { + value: ORIGINAL_STDERR.getColorDepth, + configurable: true, + }); +} + +const RED = (text: string) => `\x1b[31m${text}\x1b[39m`; + +describe("port conflict report", () => { + afterEach(() => { + restoreStderr(); + vi.unstubAllEnvs(); + }); + + it("uses the shared error presentation for the conflict heading (#6752)", () => { + vi.stubEnv("NO_COLOR", ""); + stubStderr(true, 24); + + const report = formatPortConflictReport({ + port: 8080, + label: "OpenShell gateway", + envVar: "NEMOCLAW_GATEWAY_PORT", + portCheck: { + ok: false, + process: "python3", + pid: 1234, + reason: "lsof reports python3 (PID 1234) listening on port 8080", + }, + serviceHints: [" systemctl --user stop openclaw-gateway.service"], + }).join("\n"); + + expect(report).toContain(` ${RED("✗ Port 8080 is not available.")}`); + expect(report).toContain("Blocked by: python3 (PID 1234)"); + expect(report).toContain("sudo kill 1234"); + expect(report).toContain("NEMOCLAW_GATEWAY_PORT= nemoclaw onboard"); + }); + + it("does not emit raw ANSI escapes when stderr is not color-capable (#6752)", () => { + vi.stubEnv("NO_COLOR", ""); + stubStderr(false, 1); + + const report = formatPortConflictReport({ + port: 8080, + label: "OpenShell gateway", + envVar: "NEMOCLAW_GATEWAY_PORT", + portCheck: { + ok: false, + process: "unknown", + pid: null, + reason: "port 8080 is in use (EADDRINUSE)", + }, + }).join("\n"); + + expect(report).toContain(" ✗ Port 8080 is not available."); + expect(report).not.toMatch(/\x1b\[[0-9;]*m/); + expect(report).toContain("Could not identify the process using port 8080."); + }); +}); diff --git a/src/lib/onboard/port-conflict-report.ts b/src/lib/onboard/port-conflict-report.ts new file mode 100644 index 00000000000..db9936eb41f --- /dev/null +++ b/src/lib/onboard/port-conflict-report.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { failLine } from "../cli/terminal-style"; +import type { PortProbeResult } from "./preflight"; + +export interface PortConflictReportInput { + port: number; + label: string; + envVar: string; + portCheck: PortProbeResult; + serviceHints?: string[]; +} + +export function formatPortConflictReport(input: PortConflictReportInput): string[] { + const { port, label, envVar, portCheck, serviceHints = [] } = input; + const lines = [ + "", + failLine(`Port ${port} is not available.`), + ` ${label} needs this port.`, + "", + ]; + + if (portCheck.process && portCheck.process !== "unknown") { + lines.push( + ` Blocked by: ${portCheck.process}${portCheck.pid ? ` (PID ${portCheck.pid})` : ""}`, + "", + " To fix, stop the conflicting process:", + "", + portCheck.pid + ? ` sudo kill ${portCheck.pid}` + : ` sudo lsof -i :${port} -sTCP:LISTEN -P -n`, + ...serviceHints, + ); + } else { + lines.push( + ` Could not identify the process using port ${port}.`, + ` Run: sudo lsof -i :${port} -sTCP:LISTEN`, + ); + } + + lines.push( + "", + " Or rerun with a different port:", + ` ${envVar}= nemoclaw onboard`, + "", + ` Detail: ${portCheck.reason}`, + ); + + return lines; +} + +export function printPortConflictReport( + input: PortConflictReportInput, + writeError: (line: string) => void = console.error, +): void { + for (const line of formatPortConflictReport(input)) { + writeError(line); + } +} diff --git a/test/onboard-gateway-port-conflict-fast-fail.test.ts b/test/onboard-gateway-port-conflict-fast-fail.test.ts new file mode 100644 index 00000000000..f8d04a34adb --- /dev/null +++ b/test/onboard-gateway-port-conflict-fast-fail.test.ts @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { testTimeoutOptions } from "./helpers/timeouts"; + +const CLI = path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"); +const GATEWAY_PORT = "18080"; + +describe("onboard gateway port conflict fast-fail (#6752)", () => { + let home: string; + let binDir: string; + let openshellCallLog: string; + + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-6752-")); + binDir = path.join(home, "bin"); + openshellCallLog = path.join(home, "openshell-calls.log"); + fs.mkdirSync(binDir, { recursive: true }); + + for (const component of ["openshell", "openshell-gateway", "openshell-sandbox"]) { + fs.writeFileSync( + path.join(binDir, component), + [ + "#!/usr/bin/env bash", + "# openshell capabilities: request-body-credential-rewrite websocket-credential-rewrite allow_all_known_mcp_methods", + `printf '%s\\n' "$*" >> ${JSON.stringify(openshellCallLog)}`, + 'case "$*" in', + ' --version|-V) printf "%s 0.0.72\\n" "${0##*/}"; exit 0;;', + ' status|"gateway info"|"gateway info -g nemoclaw"*) sleep 20; exit 0;;', + "esac", + "exit 1", + ].join("\n"), + { mode: 0o755 }, + ); + } + + fs.writeFileSync( + path.join(binDir, "docker"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = info ]; then echo "Server Version: 24.0.0"; exit 0; fi', + 'if [ "$1" = ps ]; then exit 0; fi', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + fs.writeFileSync( + path.join(binDir, "lsof"), + [ + "#!/usr/bin/env bash", + 'port=""', + 'for arg in "$@"; do', + ' case "$arg" in :*) port="${arg#:}";; esac', + "done", + `if [ "$port" = ${JSON.stringify(GATEWAY_PORT)} ]; then`, + ` echo "python3 1234 test 1u IPv4 TCP 127.0.0.1:${GATEWAY_PORT} (LISTEN)"`, + " exit 0", + "fi", + "exit 1", + ].join("\n"), + { mode: 0o755 }, + ); + }); + + afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); + }); + + it( + "reports a foreign listener before OpenShell gateway inspection can hang", + testTimeoutOptions(10_000), + () => { + const result = spawnSync( + process.execPath, + [CLI, "onboard", "--name", "foreign-port", "--no-gpu", "--non-interactive"], + { + encoding: "utf-8", + timeout: 5_000, + env: { + ...process.env, + HOME: home, + PATH: `${binDir}:${process.env.PATH || ""}`, + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NEMOCLAW_GATEWAY_PORT: GATEWAY_PORT, + NEMOCLAW_OPENSHELL_BIN: path.join(binDir, "openshell"), + NEMOCLAW_OPENSHELL_CHANNEL: "stable", + NEMOCLAW_OPENSHELL_GATEWAY_BIN: path.join(binDir, "openshell-gateway"), + NEMOCLAW_OPENSHELL_SANDBOX_BIN: path.join(binDir, "openshell-sandbox"), + NEMOCLAW_TEST_NO_SLEEP: "1", + }, + }, + ); + + const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + const calls = fs.existsSync(openshellCallLog) + ? fs.readFileSync(openshellCallLog, "utf8") + : ""; + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.status).toBeGreaterThan(0); + expect(combined).toContain(`Port ${GATEWAY_PORT} is not available.`); + expect(combined).toContain("Blocked by: python3 (PID 1234)"); + expect(combined).toContain("NEMOCLAW_GATEWAY_PORT= nemoclaw onboard"); + expect(calls).not.toMatch(/^(status|gateway info(?: -g nemoclaw)?)$/m); + }, + ); +});