From d2689c53ff1a634876c2095449e5652d945efbb9 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 01:31:36 -0700 Subject: [PATCH 1/5] refactor(onboard): extract docker gateway runtime helpers --- src/lib/onboard.ts | 343 ++------------ .../onboard/docker-driver-gateway-runtime.ts | 429 ++++++++++++++++++ 2 files changed, 456 insertions(+), 316 deletions(-) create mode 100644 src/lib/onboard/docker-driver-gateway-runtime.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 6e1d70a4424..937831d0cf9 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -51,6 +51,7 @@ const dockerGpuPatch: typeof import("./onboard/docker-gpu-patch") = require("./o const dockerGpuLocalInference: typeof import("./onboard/docker-gpu-local-inference") = require("./onboard/docker-gpu-local-inference"); const dockerGpuSandboxCreate: typeof import("./onboard/docker-gpu-sandbox-create") = require("./onboard/docker-gpu-sandbox-create"); const dockerDriverGatewayLaunch: typeof import("./onboard/docker-driver-gateway-launch") = require("./onboard/docker-driver-gateway-launch"); +const dockerDriverGatewayRuntime: typeof import("./onboard/docker-driver-gateway-runtime") = require("./onboard/docker-driver-gateway-runtime"); const { findReadableNvidiaCdiSpecFiles, parseDockerCdiSpecDirs, @@ -513,7 +514,6 @@ const { getDockerDriverGatewayEndpoint } = dockerDriverGatewayEnv; const dockerDriverGatewayRuntimeMarker: typeof import("./onboard/docker-driver-gateway-runtime-marker") = require("./onboard/docker-driver-gateway-runtime-marker"); const gatewayBinding: typeof import("./onboard/gateway-binding") = require("./onboard/gateway-binding"); -const vmDriverProcess: typeof import("./onboard/vm-driver-process") = require("./onboard/vm-driver-process"); const preflightUtils: typeof import("./onboard/preflight") = require("./onboard/preflight"); const clusterImagePatch: typeof import("./cluster-image-patch") = require("./cluster-image-patch"); const { assessHost, checkPortAvailable, ensureSwap, getMemoryInfo, planHostRemediation } = @@ -604,6 +604,32 @@ const DIM = USE_COLOR ? "\x1b[2m" : ""; const RESET = USE_COLOR ? "\x1b[0m" : ""; let OPENSHELL_BIN: string | null = null; const GATEWAY_NAME = gatewayBinding.resolveGatewayName(GATEWAY_PORT); +const { + clearDockerDriverGatewayRuntimeFiles, + getDockerDriverGatewayEnv, + getDockerDriverGatewayPid, + getDockerDriverGatewayPortListenerPid, + getDockerDriverGatewayRuntimeDrift, + getDockerDriverGatewayRuntimeDriftFromSnapshot, + getDockerDriverGatewayStateDir, + isDockerDriverGatewayPortListener, + isDockerDriverGatewayProcess, + isDockerDriverGatewayProcessAlive, + isPidAlive, + rememberDockerDriverGatewayPid, + resolveOpenShellGatewayBinary, + resolveOpenShellSandboxBinary, + shouldRequireDockerDriverEnv, +} = dockerDriverGatewayRuntime.createDockerDriverGatewayRuntimeHelpers({ + gatewayPort: GATEWAY_PORT, + getCachedOpenshellBinary: () => OPENSHELL_BIN, + getBlueprintMaxOpenshellVersion, + getInstalledOpenshellVersion, + isOpenshellDevVersion, + runCapture, + shouldUseOpenshellDevChannel, + supportedOpenshellFallbackVersion: SUPPORTED_OPENSHELL_FALLBACK_VERSION, +}); import type { JsonObject as LooseObject } from "./core/json-types"; @@ -1396,321 +1422,6 @@ const { gatewayClusterHealthcheckPassed, repairGatewayBootstrapSecrets } = runCapture, }); -function getDockerDriverGatewayStateDir(): string { - const configured = process.env.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR; - if (configured && configured.trim()) return path.resolve(configured.trim()); - const dir = gatewayBinding.resolveGatewayStateDirName(GATEWAY_PORT); - return path.join(os.homedir(), ".local", "state", "nemoclaw", dir); -} - -function getDockerDriverGatewayPidFile(): string { - return path.join(getDockerDriverGatewayStateDir(), "openshell-gateway.pid"); -} - -function resolveSiblingBinary(binaryName: string): string | null { - const openshellBin = OPENSHELL_BIN || resolveOpenshell(); - if (typeof openshellBin !== "string" || openshellBin.length === 0) return null; - const sibling = path.join(path.dirname(openshellBin), binaryName); - if (fs.existsSync(sibling)) return sibling; - return null; -} - -function resolveOpenShellGatewayBinary(): string | null { - const configured = process.env.NEMOCLAW_OPENSHELL_GATEWAY_BIN; - if (configured && configured.trim()) return path.resolve(configured.trim()); - const sibling = resolveSiblingBinary("openshell-gateway"); - if (sibling) return sibling; - for (const candidate of [ - path.join(os.homedir(), ".local", "bin", "openshell-gateway"), - "/usr/local/bin/openshell-gateway", - "/usr/bin/openshell-gateway", - ]) { - if (fs.existsSync(candidate)) return candidate; - } - return null; -} - -function resolveOpenShellSandboxBinary(): string | null { - const configured = process.env.NEMOCLAW_OPENSHELL_SANDBOX_BIN; - if (configured && configured.trim()) return path.resolve(configured.trim()); - const sibling = resolveSiblingBinary("openshell-sandbox"); - if (sibling) return sibling; - for (const candidate of [ - path.join(os.homedir(), ".local", "bin", "openshell-sandbox"), - "/usr/local/bin/openshell-sandbox", - "/usr/bin/openshell-sandbox", - ]) { - if (fs.existsSync(candidate)) return candidate; - } - return null; -} - -function getOpenShellDockerSupervisorImage(versionOutput: string | null = null): string { - if (process.env.OPENSHELL_DOCKER_SUPERVISOR_IMAGE) { - return process.env.OPENSHELL_DOCKER_SUPERVISOR_IMAGE; - } - const installedVersion = getInstalledOpenshellVersion(versionOutput); - if (shouldUseOpenshellDevChannel() || isOpenshellDevVersion(versionOutput)) { - return "ghcr.io/nvidia/openshell/supervisor:dev"; - } - const supportedVersion = - installedVersion ?? getBlueprintMaxOpenshellVersion() ?? SUPPORTED_OPENSHELL_FALLBACK_VERSION; - return `ghcr.io/nvidia/openshell/supervisor:${supportedVersion}`; -} - -function getDockerDriverGatewayEnv( - versionOutput: string | null = null, - platform: NodeJS.Platform = process.platform, -): Record { - return dockerDriverGatewayEnv.buildDockerDriverGatewayEnv({ - platform, - stateDir: getDockerDriverGatewayStateDir(), - dockerNetworkName: process.env.OPENSHELL_DOCKER_NETWORK_NAME || "openshell-docker", - getDockerSupervisorImage: () => getOpenShellDockerSupervisorImage(versionOutput), - resolveSandboxBin: resolveOpenShellSandboxBinary, - }); -} - -function isPidAlive(pid: number): boolean { - if (!Number.isInteger(pid) || pid <= 0) return false; - try { - process.kill(pid, 0); - return true; - } catch (error) { - return isErrnoException(error) && error.code === "EPERM"; - } -} - -function getDockerDriverGatewayPid(): number | null { - try { - const raw = fs.readFileSync(getDockerDriverGatewayPidFile(), "utf-8").trim(); - const pid = Number.parseInt(raw, 10); - return Number.isInteger(pid) && pid > 0 ? pid : null; - } catch { - return null; - } -} - -function readProcessEnv(pid: number): Record | null { - const procEnvPath = `/proc/${pid}/environ`; - const env: Record = {}; - try { - if (!fs.existsSync(procEnvPath)) return null; - for (const entry of fs.readFileSync(procEnvPath, "utf-8").split("\0")) { - if (!entry) continue; - const idx = entry.indexOf("="); - if (idx <= 0) continue; - env[entry.slice(0, idx)] = entry.slice(idx + 1); - } - } catch { - return null; - } - return env; -} - -function hasDockerDriverGatewayEnv(pid: number): boolean { - const env = readProcessEnv(pid); - if (!env) return false; - return ( - env.OPENSHELL_DRIVERS === "docker" || - Boolean(env.OPENSHELL_DOCKER_SUPERVISOR_IMAGE) || - env.OPENSHELL_GRPC_ENDPOINT === getDockerDriverGatewayEndpoint() - ); -} - -function readProcessExe(pid: number): string | null { - try { - const procExePath = `/proc/${pid}/exe`; - if (!fs.existsSync(procExePath)) return null; - return fs.readlinkSync(procExePath); - } catch { - return null; - } -} - -function normalizeGatewayExecutablePath(value: string | null | undefined): string | null { - if (!value) return null; - const withoutDeletedSuffix = value.replace(/ \(deleted\)$/, ""); - try { - return fs.realpathSync.native(withoutDeletedSuffix); - } catch { - return path.resolve(withoutDeletedSuffix); - } -} - -type DockerDriverGatewayRuntimeDrift = { reason: string }; - -function shouldRequireDockerDriverEnv(platform: NodeJS.Platform = process.platform): boolean { - return platform === "linux"; -} - -function getDockerDriverGatewayRuntimeDriftFromSnapshot({ - processEnv, - processExe, - desiredEnv, - gatewayBin, -}: { - processEnv: Record | null; - processExe: string | null; - desiredEnv: Record; - gatewayBin?: string | null; -}): DockerDriverGatewayRuntimeDrift | null { - if (!processEnv) { - return { reason: "could not verify process environment" }; - } - for (const key of dockerDriverGatewayEnv.DOCKER_DRIVER_GATEWAY_RUNTIME_ENV_KEYS) { - const desired = desiredEnv[key]; - if (typeof desired !== "string") continue; - const actual = processEnv[key]; - if (actual !== desired) { - return { reason: `${key}=${actual || ""} (expected ${desired})` }; - } - } - - if (processExe === null) { - return { reason: "could not verify process executable" }; - } - if (processExe.endsWith(" (deleted)")) { - return { reason: "gateway executable was replaced on disk" }; - } - const expectedExe = normalizeGatewayExecutablePath(gatewayBin); - const actualExe = normalizeGatewayExecutablePath(processExe); - if (expectedExe && actualExe && actualExe !== expectedExe) { - return { reason: `executable=${actualExe} (expected ${expectedExe})` }; - } - return null; -} - -function getDockerDriverGatewayRuntimeDrift( - pid: number, - desiredEnv: Record, - gatewayBin?: string | null, - platform: NodeJS.Platform = process.platform, -): DockerDriverGatewayRuntimeDrift | null { - if (platform === "darwin" && desiredEnv.OPENSHELL_DRIVERS === "docker") { - const markerDrift = - dockerDriverGatewayRuntimeMarker.getDockerDriverGatewayRuntimeMarkerDriftForStateDir( - getDockerDriverGatewayStateDir(), - { - pid, - desiredEnv, - endpoint: getDockerDriverGatewayEndpoint(), - gatewayBin, - dockerHost: process.env.DOCKER_HOST || null, - platform, - arch: process.arch, - }, - ); - if (markerDrift) return markerDrift; - if ( - vmDriverProcess.hasOpenShellVmDriverChildProcess(pid, (args) => - runCapture([...args], { ignoreError: true }), - ) - ) { - return { reason: "VM driver child process is still attached to the gateway" }; - } - } - if (!shouldRequireDockerDriverEnv(platform)) return null; - return getDockerDriverGatewayRuntimeDriftFromSnapshot({ - processEnv: readProcessEnv(pid), - processExe: readProcessExe(pid), - desiredEnv, - gatewayBin, - }); -} - -function isDockerDriverGatewayProcess( - pid: number, - gatewayBin?: string | null, - opts: { requireDockerDriverEnv?: boolean } = {}, -): boolean { - const procCmdlinePath = `/proc/${pid}/cmdline`; - let identity = ""; - try { - if (fs.existsSync(procCmdlinePath)) { - identity = fs.readFileSync(procCmdlinePath, "utf-8").replace(/\0/g, " ").trim(); - } - } catch { - identity = ""; - } - if (!identity) { - identity = captureProcessArgs(pid); - } - if (!identity) return false; - const matchesGatewayBinary = - identity.includes("openshell-gateway") || - (typeof gatewayBin === "string" && gatewayBin.length > 0 && identity.includes(gatewayBin)); - if (!matchesGatewayBinary) return false; - if (opts.requireDockerDriverEnv && !hasDockerDriverGatewayEnv(pid)) return false; - return true; -} - -function isDockerDriverGatewayProcessAlive(): boolean { - const pid = getDockerDriverGatewayPid(); - if (pid === null || !isPidAlive(pid)) return false; - if ( - !isDockerDriverGatewayProcess(pid, resolveOpenShellGatewayBinary(), { - requireDockerDriverEnv: shouldRequireDockerDriverEnv(), - }) - ) { - clearDockerDriverGatewayRuntimeFiles(); - return false; - } - return true; -} - -function clearDockerDriverGatewayRuntimeFiles(): void { - fs.rmSync(getDockerDriverGatewayPidFile(), { force: true }); - dockerDriverGatewayRuntimeMarker.clearDockerDriverGatewayRuntimeMarker( - getDockerDriverGatewayStateDir(), - ); -} - -function rememberDockerDriverGatewayPid(pid: number): void { - dockerDriverGatewayRuntimeMarker.writeDockerDriverGatewayPidFile( - getDockerDriverGatewayPidFile(), - pid, - ); -} - -function getDockerDriverGatewayPortListenerPid( - portCheck: import("./onboard/preflight").PortProbeResult, - opts: { - platform?: NodeJS.Platform; - arch?: NodeJS.Architecture; - gatewayBin?: string | null; - isPidAliveFn?: (pid: number) => boolean; - isDockerDriverGatewayProcessFn?: (pid: number, gatewayBin?: string | null) => boolean; - } = {}, -): number | null { - if (portCheck.ok) return null; - if ( - !isLinuxDockerDriverGatewayEnabled(opts.platform ?? process.platform, opts.arch ?? process.arch) - ) - return null; - const pid = Number(portCheck.pid); - if (!Number.isInteger(pid) || pid <= 0) return null; - const proc = String(portCheck.process || "").toLowerCase(); - if (!proc.startsWith("openshell")) return null; - const alive = opts.isPidAliveFn ?? isPidAlive; - if (!alive(pid)) return null; - const isGateway = - opts.isDockerDriverGatewayProcessFn ?? - ((candidatePid: number, gatewayBin?: string | null) => - isDockerDriverGatewayProcess(candidatePid, gatewayBin, { - requireDockerDriverEnv: shouldRequireDockerDriverEnv(opts.platform ?? process.platform), - })); - if (!isGateway(pid, opts.gatewayBin)) return null; - return pid; -} - -function isDockerDriverGatewayPortListener( - portCheck: import("./onboard/preflight").PortProbeResult, - opts: Parameters[1] = {}, -): boolean { - return getDockerDriverGatewayPortListenerPid(portCheck, opts) !== null; -} - function registerDockerDriverGatewayEndpoint(): boolean { const selectExisting = runQuietOpenshell(["gateway", "select", GATEWAY_NAME]); if (selectExisting.status === 0) { diff --git a/src/lib/onboard/docker-driver-gateway-runtime.ts b/src/lib/onboard/docker-driver-gateway-runtime.ts new file mode 100644 index 00000000000..3585d408222 --- /dev/null +++ b/src/lib/onboard/docker-driver-gateway-runtime.ts @@ -0,0 +1,429 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { resolveOpenshell } from "../adapters/openshell/resolve"; +import { isErrnoException } from "../core/errno"; +import * as dockerDriverGatewayRuntimeMarker from "./docker-driver-gateway-runtime-marker"; +import { isLinuxDockerDriverGatewayEnabled } from "./docker-driver-platform"; +import * as gatewayBinding from "./gateway-binding"; +import type { PortProbeResult } from "./preflight"; +import * as vmDriverProcess from "./vm-driver-process"; + +export type DockerDriverGatewayRuntimeDrift = { reason: string }; + +type RunCapture = (args: string[], opts?: { ignoreError?: boolean }) => string; + +export interface DockerDriverGatewayRuntimeDeps { + gatewayPort: number; + getCachedOpenshellBinary(): string | null; + getBlueprintMaxOpenshellVersion(): string | null; + getInstalledOpenshellVersion(versionOutput?: string | null): string | null; + isOpenshellDevVersion(versionOutput: string | null | undefined): boolean; + runCapture: RunCapture; + shouldUseOpenshellDevChannel(): boolean; + supportedOpenshellFallbackVersion: string; +} + +export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewayRuntimeDeps): { + clearDockerDriverGatewayRuntimeFiles(): void; + getDockerDriverGatewayEnv( + versionOutput?: string | null, + platform?: NodeJS.Platform, + ): Record; + getDockerDriverGatewayPid(): number | null; + getDockerDriverGatewayPidFile(): string; + getDockerDriverGatewayPortListenerPid( + portCheck: PortProbeResult, + opts?: { + platform?: NodeJS.Platform; + arch?: NodeJS.Architecture; + gatewayBin?: string | null; + isPidAliveFn?: (pid: number) => boolean; + isDockerDriverGatewayProcessFn?: (pid: number, gatewayBin?: string | null) => boolean; + }, + ): number | null; + getDockerDriverGatewayRuntimeDrift( + pid: number, + desiredEnv: Record, + gatewayBin?: string | null, + platform?: NodeJS.Platform, + ): DockerDriverGatewayRuntimeDrift | null; + getDockerDriverGatewayRuntimeDriftFromSnapshot(snapshot: { + processEnv: Record | null; + processExe: string | null; + desiredEnv: Record; + gatewayBin?: string | null; + }): DockerDriverGatewayRuntimeDrift | null; + getDockerDriverGatewayStateDir(): string; + isDockerDriverGatewayPortListener( + portCheck: PortProbeResult, + opts?: Parameters< + ReturnType< + typeof createDockerDriverGatewayRuntimeHelpers + >["getDockerDriverGatewayPortListenerPid"] + >[1], + ): boolean; + isDockerDriverGatewayProcess( + pid: number, + gatewayBin?: string | null, + opts?: { requireDockerDriverEnv?: boolean }, + ): boolean; + isDockerDriverGatewayProcessAlive(): boolean; + isPidAlive(pid: number): boolean; + rememberDockerDriverGatewayPid(pid: number): void; + resolveOpenShellGatewayBinary(): string | null; + resolveOpenShellSandboxBinary(): string | null; + shouldRequireDockerDriverEnv(platform?: NodeJS.Platform): boolean; +} { + const dockerDriverGatewayEnv: typeof import("./docker-driver-gateway-env") = + require("./docker-driver-gateway-env"); + + function getDockerDriverGatewayStateDir(): string { + const configured = process.env.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR; + if (configured && configured.trim()) return path.resolve(configured.trim()); + const dir = gatewayBinding.resolveGatewayStateDirName(deps.gatewayPort); + return path.join(os.homedir(), ".local", "state", "nemoclaw", dir); + } + + function getDockerDriverGatewayPidFile(): string { + return path.join(getDockerDriverGatewayStateDir(), "openshell-gateway.pid"); + } + + function resolveSiblingBinary(binaryName: string): string | null { + const openshellBin = deps.getCachedOpenshellBinary() || resolveOpenshell(); + if (typeof openshellBin !== "string" || openshellBin.length === 0) return null; + const sibling = path.join(path.dirname(openshellBin), binaryName); + if (fs.existsSync(sibling)) return sibling; + return null; + } + + function resolveOpenShellGatewayBinary(): string | null { + const configured = process.env.NEMOCLAW_OPENSHELL_GATEWAY_BIN; + if (configured && configured.trim()) return path.resolve(configured.trim()); + const sibling = resolveSiblingBinary("openshell-gateway"); + if (sibling) return sibling; + for (const candidate of [ + path.join(os.homedir(), ".local", "bin", "openshell-gateway"), + "/usr/local/bin/openshell-gateway", + "/usr/bin/openshell-gateway", + ]) { + if (fs.existsSync(candidate)) return candidate; + } + return null; + } + + function resolveOpenShellSandboxBinary(): string | null { + const configured = process.env.NEMOCLAW_OPENSHELL_SANDBOX_BIN; + if (configured && configured.trim()) return path.resolve(configured.trim()); + const sibling = resolveSiblingBinary("openshell-sandbox"); + if (sibling) return sibling; + for (const candidate of [ + path.join(os.homedir(), ".local", "bin", "openshell-sandbox"), + "/usr/local/bin/openshell-sandbox", + "/usr/bin/openshell-sandbox", + ]) { + if (fs.existsSync(candidate)) return candidate; + } + return null; + } + + function getOpenShellDockerSupervisorImage(versionOutput: string | null = null): string { + if (process.env.OPENSHELL_DOCKER_SUPERVISOR_IMAGE) { + return process.env.OPENSHELL_DOCKER_SUPERVISOR_IMAGE; + } + const installedVersion = deps.getInstalledOpenshellVersion(versionOutput); + if (deps.shouldUseOpenshellDevChannel() || deps.isOpenshellDevVersion(versionOutput)) { + return "ghcr.io/nvidia/openshell/supervisor:dev"; + } + const supportedVersion = + installedVersion ?? + deps.getBlueprintMaxOpenshellVersion() ?? + deps.supportedOpenshellFallbackVersion; + return `ghcr.io/nvidia/openshell/supervisor:${supportedVersion}`; + } + + function getDockerDriverGatewayEnv( + versionOutput: string | null = null, + platform: NodeJS.Platform = process.platform, + ): Record { + return dockerDriverGatewayEnv.buildDockerDriverGatewayEnv({ + platform, + stateDir: getDockerDriverGatewayStateDir(), + dockerNetworkName: process.env.OPENSHELL_DOCKER_NETWORK_NAME || "openshell-docker", + getDockerSupervisorImage: () => getOpenShellDockerSupervisorImage(versionOutput), + resolveSandboxBin: resolveOpenShellSandboxBinary, + }); + } + + function isPidAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return isErrnoException(error) && error.code === "EPERM"; + } + } + + function getDockerDriverGatewayPid(): number | null { + try { + const raw = fs.readFileSync(getDockerDriverGatewayPidFile(), "utf-8").trim(); + const pid = Number.parseInt(raw, 10); + return Number.isInteger(pid) && pid > 0 ? pid : null; + } catch { + return null; + } + } + + function readProcessEnv(pid: number): Record | null { + const procEnvPath = `/proc/${pid}/environ`; + const env: Record = {}; + try { + if (!fs.existsSync(procEnvPath)) return null; + for (const entry of fs.readFileSync(procEnvPath, "utf-8").split("\0")) { + if (!entry) continue; + const idx = entry.indexOf("="); + if (idx <= 0) continue; + env[entry.slice(0, idx)] = entry.slice(idx + 1); + } + } catch { + return null; + } + return env; + } + + function hasDockerDriverGatewayEnv(pid: number): boolean { + const env = readProcessEnv(pid); + if (!env) return false; + return ( + env.OPENSHELL_DRIVERS === "docker" || + Boolean(env.OPENSHELL_DOCKER_SUPERVISOR_IMAGE) || + env.OPENSHELL_GRPC_ENDPOINT === dockerDriverGatewayEnv.getDockerDriverGatewayEndpoint() + ); + } + + function readProcessExe(pid: number): string | null { + try { + const procExePath = `/proc/${pid}/exe`; + if (!fs.existsSync(procExePath)) return null; + return fs.readlinkSync(procExePath); + } catch { + return null; + } + } + + function normalizeGatewayExecutablePath(value: string | null | undefined): string | null { + if (!value) return null; + const withoutDeletedSuffix = value.replace(/ \(deleted\)$/, ""); + try { + return fs.realpathSync.native(withoutDeletedSuffix); + } catch { + return path.resolve(withoutDeletedSuffix); + } + } + + function shouldRequireDockerDriverEnv(platform: NodeJS.Platform = process.platform): boolean { + return platform === "linux"; + } + + function getDockerDriverGatewayRuntimeDriftFromSnapshot({ + processEnv, + processExe, + desiredEnv, + gatewayBin, + }: { + processEnv: Record | null; + processExe: string | null; + desiredEnv: Record; + gatewayBin?: string | null; + }): DockerDriverGatewayRuntimeDrift | null { + if (!processEnv) { + return { reason: "could not verify process environment" }; + } + for (const key of dockerDriverGatewayEnv.DOCKER_DRIVER_GATEWAY_RUNTIME_ENV_KEYS) { + const desired = desiredEnv[key]; + if (typeof desired !== "string") continue; + const actual = processEnv[key]; + if (actual !== desired) { + return { reason: `${key}=${actual || ""} (expected ${desired})` }; + } + } + + if (processExe === null) { + return { reason: "could not verify process executable" }; + } + if (processExe.endsWith(" (deleted)")) { + return { reason: "gateway executable was replaced on disk" }; + } + const expectedExe = normalizeGatewayExecutablePath(gatewayBin); + const actualExe = normalizeGatewayExecutablePath(processExe); + if (expectedExe && actualExe && actualExe !== expectedExe) { + return { reason: `executable=${actualExe} (expected ${expectedExe})` }; + } + return null; + } + + function getDockerDriverGatewayRuntimeDrift( + pid: number, + desiredEnv: Record, + gatewayBin?: string | null, + platform: NodeJS.Platform = process.platform, + ): DockerDriverGatewayRuntimeDrift | null { + if (platform === "darwin" && desiredEnv.OPENSHELL_DRIVERS === "docker") { + const markerDrift = + dockerDriverGatewayRuntimeMarker.getDockerDriverGatewayRuntimeMarkerDriftForStateDir( + getDockerDriverGatewayStateDir(), + { + pid, + desiredEnv, + endpoint: dockerDriverGatewayEnv.getDockerDriverGatewayEndpoint(), + gatewayBin, + dockerHost: process.env.DOCKER_HOST || null, + platform, + arch: process.arch, + }, + ); + if (markerDrift) return markerDrift; + if ( + vmDriverProcess.hasOpenShellVmDriverChildProcess(pid, (args) => + deps.runCapture([...args], { ignoreError: true }), + ) + ) { + return { reason: "VM driver child process is still attached to the gateway" }; + } + } + if (!shouldRequireDockerDriverEnv(platform)) return null; + return getDockerDriverGatewayRuntimeDriftFromSnapshot({ + processEnv: readProcessEnv(pid), + processExe: readProcessExe(pid), + desiredEnv, + gatewayBin, + }); + } + + function captureProcessArgs(pid: number): string { + return deps + .runCapture(["ps", "-p", String(pid), "-o", "args="], { + ignoreError: true, + }) + .trim(); + } + + function isDockerDriverGatewayProcess( + pid: number, + gatewayBin?: string | null, + opts: { requireDockerDriverEnv?: boolean } = {}, + ): boolean { + const procCmdlinePath = `/proc/${pid}/cmdline`; + let identity = ""; + try { + if (fs.existsSync(procCmdlinePath)) { + identity = fs.readFileSync(procCmdlinePath, "utf-8").replace(/\0/g, " ").trim(); + } + } catch { + identity = ""; + } + if (!identity) { + identity = captureProcessArgs(pid); + } + if (!identity) return false; + const matchesGatewayBinary = + identity.includes("openshell-gateway") || + (typeof gatewayBin === "string" && gatewayBin.length > 0 && identity.includes(gatewayBin)); + if (!matchesGatewayBinary) return false; + if (opts.requireDockerDriverEnv && !hasDockerDriverGatewayEnv(pid)) return false; + return true; + } + + function isDockerDriverGatewayProcessAlive(): boolean { + const pid = getDockerDriverGatewayPid(); + if (pid === null || !isPidAlive(pid)) return false; + if ( + !isDockerDriverGatewayProcess(pid, resolveOpenShellGatewayBinary(), { + requireDockerDriverEnv: shouldRequireDockerDriverEnv(), + }) + ) { + clearDockerDriverGatewayRuntimeFiles(); + return false; + } + return true; + } + + function clearDockerDriverGatewayRuntimeFiles(): void { + fs.rmSync(getDockerDriverGatewayPidFile(), { force: true }); + dockerDriverGatewayRuntimeMarker.clearDockerDriverGatewayRuntimeMarker( + getDockerDriverGatewayStateDir(), + ); + } + + function rememberDockerDriverGatewayPid(pid: number): void { + dockerDriverGatewayRuntimeMarker.writeDockerDriverGatewayPidFile( + getDockerDriverGatewayPidFile(), + pid, + ); + } + + function getDockerDriverGatewayPortListenerPid( + portCheck: PortProbeResult, + opts: { + platform?: NodeJS.Platform; + arch?: NodeJS.Architecture; + gatewayBin?: string | null; + isPidAliveFn?: (pid: number) => boolean; + isDockerDriverGatewayProcessFn?: (pid: number, gatewayBin?: string | null) => boolean; + } = {}, + ): number | null { + if (portCheck.ok) return null; + if ( + !isLinuxDockerDriverGatewayEnabled( + opts.platform ?? process.platform, + opts.arch ?? process.arch, + ) + ) + return null; + const pid = Number(portCheck.pid); + if (!Number.isInteger(pid) || pid <= 0) return null; + const proc = String(portCheck.process || "").toLowerCase(); + if (!proc.startsWith("openshell")) return null; + const alive = opts.isPidAliveFn ?? isPidAlive; + if (!alive(pid)) return null; + const isGateway = + opts.isDockerDriverGatewayProcessFn ?? + ((candidatePid: number, gatewayBin?: string | null) => + isDockerDriverGatewayProcess(candidatePid, gatewayBin, { + requireDockerDriverEnv: shouldRequireDockerDriverEnv(opts.platform ?? process.platform), + })); + if (!isGateway(pid, opts.gatewayBin)) return null; + return pid; + } + + function isDockerDriverGatewayPortListener( + portCheck: PortProbeResult, + opts: Parameters[1] = {}, + ): boolean { + return getDockerDriverGatewayPortListenerPid(portCheck, opts) !== null; + } + + return { + clearDockerDriverGatewayRuntimeFiles, + getDockerDriverGatewayEnv, + getDockerDriverGatewayPid, + getDockerDriverGatewayPidFile, + getDockerDriverGatewayPortListenerPid, + getDockerDriverGatewayRuntimeDrift, + getDockerDriverGatewayRuntimeDriftFromSnapshot, + getDockerDriverGatewayStateDir, + isDockerDriverGatewayPortListener, + isDockerDriverGatewayProcess, + isDockerDriverGatewayProcessAlive, + isPidAlive, + rememberDockerDriverGatewayPid, + resolveOpenShellGatewayBinary, + resolveOpenShellSandboxBinary, + shouldRequireDockerDriverEnv, + }; +} From cfae23204af0ee632dc4b619fe86214123d32901 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 01:44:29 -0700 Subject: [PATCH 2/5] test(onboard): cover docker gateway runtime helpers --- .../docker-driver-gateway-runtime.test.ts | 224 ++++++++++++++++++ .../onboard/docker-driver-gateway-runtime.ts | 6 +- 2 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 src/lib/onboard/docker-driver-gateway-runtime.test.ts diff --git a/src/lib/onboard/docker-driver-gateway-runtime.test.ts b/src/lib/onboard/docker-driver-gateway-runtime.test.ts new file mode 100644 index 00000000000..866f1d8022b --- /dev/null +++ b/src/lib/onboard/docker-driver-gateway-runtime.test.ts @@ -0,0 +1,224 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + createDockerDriverGatewayRuntimeHelpers, + type DockerDriverGatewayRuntimeDeps, +} from "./docker-driver-gateway-runtime"; +import * as dockerDriverGatewayEnv from "./docker-driver-gateway-env"; +import { + getDockerDriverGatewayRuntimeMarkerPath, + writeDockerDriverGatewayRuntimeMarkerForStateDir, +} from "./docker-driver-gateway-runtime-marker"; + +function parseVersion(versionOutput: string | null | undefined): string | null { + return String(versionOutput ?? "").match(/\d+\.\d+\.\d+/)?.[0] ?? null; +} + +function makeHelpers(overrides: Partial = {}): { + helpers: ReturnType; + runCapture: ReturnType< + typeof vi.fn<(args: string[], opts?: { ignoreError?: boolean }) => string> + >; +} { + const runCapture = vi.fn(() => ""); + const deps: DockerDriverGatewayRuntimeDeps = { + gatewayPort: 18080, + getCachedOpenshellBinary: () => null, + getBlueprintMaxOpenshellVersion: () => null, + getInstalledOpenshellVersion: parseVersion, + isOpenshellDevVersion: () => false, + loadDockerDriverGatewayEnv: () => dockerDriverGatewayEnv, + runCapture, + shouldUseOpenshellDevChannel: () => false, + supportedOpenshellFallbackVersion: "0.0.44", + ...overrides, + }; + return { + helpers: createDockerDriverGatewayRuntimeHelpers(deps), + runCapture: deps.runCapture as typeof runCapture, + }; +} + +function withEnv(values: Record, callback: () => T): T { + const previous = new Map(); + for (const key of Object.keys(values)) { + previous.set(key, process.env[key]); + if (values[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = values[key]; + } + } + try { + return callback(); + } finally { + for (const [key, value] of previous) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +describe("docker-driver gateway runtime helpers", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("uses env-configured state, gateway, sandbox, network, and fallback version values", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-runtime-")); + const stateDir = path.join(tempDir, "state"); + const gatewayBin = path.join("relative-tools", "openshell-gateway"); + const sandboxBin = path.join("relative-tools", "openshell-sandbox"); + try { + withEnv( + { + NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR: stateDir, + NEMOCLAW_OPENSHELL_GATEWAY_BIN: gatewayBin, + NEMOCLAW_OPENSHELL_SANDBOX_BIN: sandboxBin, + OPENSHELL_DOCKER_NETWORK_NAME: "custom-openshell-docker", + }, + () => { + const { helpers } = makeHelpers({ + supportedOpenshellFallbackVersion: "0.0.99", + }); + + expect(helpers.getDockerDriverGatewayStateDir()).toBe(path.resolve(stateDir)); + expect(helpers.resolveOpenShellGatewayBinary()).toBe(path.resolve(gatewayBin)); + expect(helpers.resolveOpenShellSandboxBinary()).toBe(path.resolve(sandboxBin)); + + const env = helpers.getDockerDriverGatewayEnv(null, "linux"); + expect(env.OPENSHELL_DOCKER_NETWORK_NAME).toBe("custom-openshell-docker"); + expect(env.OPENSHELL_DOCKER_SUPERVISOR_BIN).toBe(path.resolve(sandboxBin)); + expect(env.OPENSHELL_DOCKER_SUPERVISOR_IMAGE).toBe( + "ghcr.io/nvidia/openshell/supervisor:0.0.99", + ); + expect(env.OPENSHELL_DB_URL).toBe( + `sqlite:${path.join(path.resolve(stateDir), "openshell.db")}`, + ); + }, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("clears custom state-dir PID and marker files when the recorded PID is not the gateway", () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-runtime-")); + const pid = 9_876_543; + try { + withEnv({ NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR: stateDir }, () => { + const { helpers, runCapture } = makeHelpers({ + runCapture: vi.fn(() => "node /tmp/not-openshell-gateway\n"), + }); + const desiredEnv = { OPENSHELL_DRIVERS: "docker" }; + helpers.rememberDockerDriverGatewayPid(pid); + writeDockerDriverGatewayRuntimeMarkerForStateDir(stateDir, { + pid, + desiredEnv, + endpoint: "http://127.0.0.1:8080", + platform: "linux", + arch: process.arch, + }); + const pidFile = path.join(stateDir, "openshell-gateway.pid"); + const markerPath = getDockerDriverGatewayRuntimeMarkerPath(stateDir); + expect(fs.existsSync(pidFile)).toBe(true); + expect(fs.existsSync(markerPath)).toBe(true); + + const originalExistsSync = fs.existsSync; + vi.spyOn(process, "kill").mockImplementation((() => true) as typeof process.kill); + vi.spyOn(fs, "existsSync").mockImplementation(((candidate) => { + if (String(candidate) === `/proc/${pid}/cmdline`) return false; + return originalExistsSync(candidate); + }) as typeof fs.existsSync); + + expect(helpers.isDockerDriverGatewayProcessAlive()).toBe(false); + + expect(runCapture).toHaveBeenCalledWith(["ps", "-p", String(pid), "-o", "args="], { + ignoreError: true, + }); + expect(fs.existsSync(pidFile)).toBe(false); + expect(fs.existsSync(markerPath)).toBe(false); + }); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it("reports macOS VM-driver child drift after the runtime marker matches", () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-runtime-")); + const pid = 98_765; + const gatewayBin = path.join(stateDir, "openshell-gateway"); + try { + withEnv( + { + DOCKER_HOST: "unix:///tmp/docker.sock", + NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR: stateDir, + }, + () => { + const { helpers, runCapture } = makeHelpers({ + runCapture: vi.fn((args) => + args.join(" ") === "ps -axo pid=,ppid=,command=" + ? [ + `${pid} 1 ${gatewayBin}`, + `${pid + 1} ${pid} /usr/local/bin/openshell-driver-vm --bind-socket /tmp/vm.sock`, + ].join("\n") + : "", + ), + }); + const desiredEnv = helpers.getDockerDriverGatewayEnv(null, "darwin"); + writeDockerDriverGatewayRuntimeMarkerForStateDir(stateDir, { + pid, + desiredEnv, + endpoint: desiredEnv.OPENSHELL_GRPC_ENDPOINT, + gatewayBin, + dockerHost: process.env.DOCKER_HOST, + platform: "darwin", + arch: process.arch, + }); + + expect( + helpers.getDockerDriverGatewayRuntimeDrift(pid, desiredEnv, gatewayBin, "darwin") + ?.reason, + ).toContain("VM driver child process is still attached"); + expect(runCapture).toHaveBeenCalledWith(["ps", "-axo", "pid=,ppid=,command="], { + ignoreError: true, + }); + }, + ); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it("rejects an openshell port listener when the injected gateway identity check fails", () => { + const { helpers } = makeHelpers(); + const isDockerDriverGatewayProcessFn = vi.fn(() => false); + + expect( + helpers.getDockerDriverGatewayPortListenerPid( + { ok: false, process: "openshell-gateway", pid: 1234 }, + { + platform: "linux", + gatewayBin: "/opt/openshell/openshell-gateway", + isPidAliveFn: () => true, + isDockerDriverGatewayProcessFn, + }, + ), + ).toBeNull(); + + expect(isDockerDriverGatewayProcessFn).toHaveBeenCalledWith( + 1234, + "/opt/openshell/openshell-gateway", + ); + }); +}); diff --git a/src/lib/onboard/docker-driver-gateway-runtime.ts b/src/lib/onboard/docker-driver-gateway-runtime.ts index 3585d408222..fee358faf72 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.ts @@ -16,6 +16,7 @@ import * as vmDriverProcess from "./vm-driver-process"; export type DockerDriverGatewayRuntimeDrift = { reason: string }; type RunCapture = (args: string[], opts?: { ignoreError?: boolean }) => string; +type DockerDriverGatewayEnvModule = typeof import("./docker-driver-gateway-env"); export interface DockerDriverGatewayRuntimeDeps { gatewayPort: number; @@ -23,6 +24,7 @@ export interface DockerDriverGatewayRuntimeDeps { getBlueprintMaxOpenshellVersion(): string | null; getInstalledOpenshellVersion(versionOutput?: string | null): string | null; isOpenshellDevVersion(versionOutput: string | null | undefined): boolean; + loadDockerDriverGatewayEnv?(): DockerDriverGatewayEnvModule; runCapture: RunCapture; shouldUseOpenshellDevChannel(): boolean; supportedOpenshellFallbackVersion: string; @@ -79,8 +81,8 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa resolveOpenShellSandboxBinary(): string | null; shouldRequireDockerDriverEnv(platform?: NodeJS.Platform): boolean; } { - const dockerDriverGatewayEnv: typeof import("./docker-driver-gateway-env") = - require("./docker-driver-gateway-env"); + const dockerDriverGatewayEnv: DockerDriverGatewayEnvModule = + deps.loadDockerDriverGatewayEnv?.() ?? require("./docker-driver-gateway-env"); function getDockerDriverGatewayStateDir(): string { const configured = process.env.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR; From 75f5a52e55a4c4f61c48de1f5003865699d1d1fd Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 01:50:06 -0700 Subject: [PATCH 3/5] docs(onboard): explain docker gateway runtime boundary --- src/lib/onboard/docker-driver-gateway-runtime.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lib/onboard/docker-driver-gateway-runtime.ts b/src/lib/onboard/docker-driver-gateway-runtime.ts index fee358faf72..2daea55cdaa 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.ts @@ -18,6 +18,14 @@ export type DockerDriverGatewayRuntimeDrift = { reason: string }; type RunCapture = (args: string[], opts?: { ignoreError?: boolean }) => string; type DockerDriverGatewayEnvModule = typeof import("./docker-driver-gateway-env"); +// Source boundary: OpenShell does not currently expose an authoritative local +// host-gateway identity/drift endpoint for the Docker-driver runtime NemoClaw +// started for this port/configuration. Until that exists, reuse must fail +// closed here for missing binaries or PID files, dead or foreign PIDs, +// unreadable Linux /proc env/exe state, replaced gateway executables, stale +// runtime markers, non-matching port owners, and macOS VM-driver children still +// attached to a Docker-driver gateway. These heuristics can be retired when +// OpenShell owns and reports the same runtime identity fields directly. export interface DockerDriverGatewayRuntimeDeps { gatewayPort: number; getCachedOpenshellBinary(): string | null; From 128ef56a6b15b017b3903bc3ef3aa4d99cdb0bac Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 02:28:34 -0700 Subject: [PATCH 4/5] refactor(onboard): extract dashboard port create resolver --- src/lib/onboard.ts | 57 +++++------------ src/lib/onboard/dashboard-port.test.ts | 89 ++++++++++++++++++++++++++ src/lib/onboard/dashboard-port.ts | 77 +++++++++++++++++++++- 3 files changed, 180 insertions(+), 43 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 937831d0cf9..e39d57cf5c4 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -475,8 +475,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 } = @@ -2565,46 +2568,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..477f699808a 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,94 @@ 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"); + }); +}); + 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 From 8d9f4c1b3ae4ff058d47e96b2b60a6fed8e4ee9a Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 02:37:27 -0700 Subject: [PATCH 5/5] test(onboard): document malformed dashboard URL resolution --- src/lib/onboard/dashboard-port.test.ts | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/lib/onboard/dashboard-port.test.ts b/src/lib/onboard/dashboard-port.test.ts index 477f699808a..5eaf2f88a13 100644 --- a/src/lib/onboard/dashboard-port.test.ts +++ b/src/lib/onboard/dashboard-port.test.ts @@ -182,6 +182,39 @@ describe("resolveCreateSandboxDashboardPort", () => { 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)", () => {