From d2689c53ff1a634876c2095449e5652d945efbb9 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 01:31:36 -0700 Subject: [PATCH 01/14] 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 6e1d70a442..937831d0cf 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 0000000000..3585d40822 --- /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 02/14] 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 0000000000..866f1d8022 --- /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 3585d40822..fee358faf7 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 03/14] 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 fee358faf7..2daea55cda 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 04/14] 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 937831d0cf..e39d57cf5c 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 72149abbc0..477f699808 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 5919eaa191..cbb0b6455d 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 05/14] 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 477f699808..5eaf2f88a1 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)", () => { From b6ea38f16d5d089896bb2d8d2beb47aa3e13cdcc Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 03:11:24 -0700 Subject: [PATCH 06/14] refactor(onboard): centralize reused dashboard metadata --- src/lib/onboard.ts | 50 ++++++--------- src/lib/onboard/sandbox-reuse.test.ts | 89 +++++++++++++++++++++++++++ src/lib/onboard/sandbox-reuse.ts | 68 ++++++++++++++++++++ 3 files changed, 175 insertions(+), 32 deletions(-) create mode 100644 src/lib/onboard/sandbox-reuse.test.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index e39d57cf5c..3528ad6d92 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2885,28 +2885,21 @@ async function createSandbox( " Pass --recreate-sandbox or set NEMOCLAW_RECREATE_SANDBOX=1 to force recreation.", ); } - const reusedPort = ensureDashboardForward(sandboxName, chatUiUrl); - chatUiUrl = `http://127.0.0.1:${reusedPort}`; - process.env.CHAT_UI_URL = chatUiUrl; - const reusedHermesDashboardState = - hermesDashboardForwarding.resolveStateForPort(reusedPort); - hermesDashboardForwarding.ensureForState(reusedHermesDashboardState, sandboxName); - updateReusedSandboxMetadata( + ({ chatUiUrl } = sandboxReuse.applyReusedSandboxDashboardState({ sandboxName, + chatUiUrl, + env: process.env, agent, model, provider, - reusedPort, - !selectionDrift.unknown, - effectiveSandboxGpuConfig, - ); - registry.updateSandbox(sandboxName, { - ...onboardHermesDashboard.getHermesDashboardRegistryFields( - reusedHermesDashboardState, - ), + selectionVerified: !selectionDrift.unknown, + sandboxGpuConfig: effectiveSandboxGpuConfig, gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, - }); + ensureDashboardForward, + hermesDashboardForwarding, + updateReusedSandboxMetadata, + })); return sandboxName; } } else { @@ -2934,28 +2927,21 @@ async function createSandbox( if (await promptYesNoOrDefault(" Reuse existing sandbox?", null, true)) { policyPresetCarry.seedReusedSandboxPolicyPresets(sandboxName, isNonInteractive()); upsertMessagingProviders(messagingTokenDefs); - const reusedPort2 = ensureDashboardForward(sandboxName, chatUiUrl); - chatUiUrl = `http://127.0.0.1:${reusedPort2}`; - process.env.CHAT_UI_URL = chatUiUrl; - const reusedHermesDashboardState2 = - hermesDashboardForwarding.resolveStateForPort(reusedPort2); - hermesDashboardForwarding.ensureForState(reusedHermesDashboardState2, sandboxName); - updateReusedSandboxMetadata( + ({ chatUiUrl } = sandboxReuse.applyReusedSandboxDashboardState({ sandboxName, + chatUiUrl, + env: process.env, agent, model, provider, - reusedPort2, - !selectionDrift.unknown, - effectiveSandboxGpuConfig, - ); - registry.updateSandbox(sandboxName, { - ...onboardHermesDashboard.getHermesDashboardRegistryFields( - reusedHermesDashboardState2, - ), + selectionVerified: !selectionDrift.unknown, + sandboxGpuConfig: effectiveSandboxGpuConfig, gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, - }); + ensureDashboardForward, + hermesDashboardForwarding, + updateReusedSandboxMetadata, + })); return sandboxName; } } diff --git a/src/lib/onboard/sandbox-reuse.test.ts b/src/lib/onboard/sandbox-reuse.test.ts new file mode 100644 index 0000000000..7c20764d32 --- /dev/null +++ b/src/lib/onboard/sandbox-reuse.test.ts @@ -0,0 +1,89 @@ +// 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 { applyReusedSandboxDashboardState } from "../../../dist/lib/onboard/sandbox-reuse"; +import type { SandboxGpuConfig } from "./sandbox-gpu-mode"; + +describe("applyReusedSandboxDashboardState", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("updates dashboard URL, Hermes forwarding, reuse metadata, and gateway registry fields", () => { + const updateSandbox = vi.fn(); + const env: NodeJS.ProcessEnv = {}; + const sandboxGpuConfig: SandboxGpuConfig = { + hostGpuDetected: true, + hostGpuPlatform: "linux", + sandboxGpuEnabled: true, + mode: "auto", + sandboxGpuDevice: null, + errors: [], + sandboxGpuProof: null, + }; + const hermesDashboardState = { + enabled: true, + config: { + enabled: true, + port: 9123, + internalPort: 19123, + tuiEnabled: true, + }, + }; + const hermesDashboardForwarding = { + resolveStateForPort: vi.fn(() => hermesDashboardState), + ensureForState: vi.fn(), + }; + const ensureDashboardForward = vi.fn(() => 18790); + const updateReusedSandboxMetadata = vi.fn(); + + const result = applyReusedSandboxDashboardState({ + sandboxName: "reuse-me", + chatUiUrl: "http://127.0.0.1:18789", + env, + agent: null, + model: "test-model", + provider: "openai-compatible", + selectionVerified: false, + sandboxGpuConfig, + gatewayName: "nemoclaw-19080", + gatewayPort: 19080, + ensureDashboardForward, + hermesDashboardForwarding, + updateSandbox, + updateReusedSandboxMetadata, + }); + + expect(ensureDashboardForward).toHaveBeenCalledWith("reuse-me", "http://127.0.0.1:18789"); + expect(env.CHAT_UI_URL).toBe("http://127.0.0.1:18790"); + expect(hermesDashboardForwarding.resolveStateForPort).toHaveBeenCalledWith(18790); + expect(hermesDashboardForwarding.ensureForState).toHaveBeenCalledWith( + hermesDashboardState, + "reuse-me", + ); + expect(updateReusedSandboxMetadata).toHaveBeenCalledWith( + "reuse-me", + null, + "test-model", + "openai-compatible", + 18790, + false, + sandboxGpuConfig, + ); + expect(updateSandbox).toHaveBeenCalledWith("reuse-me", { + hermesDashboardEnabled: true, + hermesDashboardPort: 9123, + hermesDashboardInternalPort: 19123, + hermesDashboardTui: true, + gatewayName: "nemoclaw-19080", + gatewayPort: 19080, + }); + expect(result).toEqual({ + chatUiUrl: "http://127.0.0.1:18790", + dashboardPort: 18790, + hermesDashboardState, + }); + }); +}); diff --git a/src/lib/onboard/sandbox-reuse.ts b/src/lib/onboard/sandbox-reuse.ts index 9a115ea801..f816fdc24f 100644 --- a/src/lib/onboard/sandbox-reuse.ts +++ b/src/lib/onboard/sandbox-reuse.ts @@ -2,8 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 import { DASHBOARD_PORT } from "../core/ports"; +import type { AgentDefinition } from "../agent/defs"; +import type { SandboxEntry } from "../state/registry"; import * as registry from "../state/registry"; import { bestEffortForwardStop } from "./forward-cleanup"; +import { + getHermesDashboardRegistryFields, + type HermesDashboardOnboardState, +} from "./hermes-dashboard"; +import type { SandboxGpuConfig } from "./sandbox-gpu-mode"; export interface SandboxReuseDeps { runCaptureOpenshell(args: string[], opts?: Record): string; @@ -17,6 +24,67 @@ export interface SandboxReuseHelpers { repairRecordedSandbox(sandboxName: string | null): void; } +export interface ReusedSandboxDashboardForwarding { + resolveStateForPort(effectivePort: number): HermesDashboardOnboardState; + ensureForState(state: HermesDashboardOnboardState, sandboxName: string): void; +} + +export interface ReusedSandboxDashboardStateInput { + sandboxName: string; + chatUiUrl: string; + env: NodeJS.ProcessEnv; + agent: AgentDefinition | null | undefined; + model: string; + provider: string; + selectionVerified: boolean; + sandboxGpuConfig: SandboxGpuConfig; + gatewayName: string; + gatewayPort: number; + ensureDashboardForward(sandboxName: string, chatUiUrl: string): number; + hermesDashboardForwarding: ReusedSandboxDashboardForwarding; + updateSandbox?(sandboxName: string, updates: Partial): unknown; + updateReusedSandboxMetadata( + sandboxName: string, + agent: AgentDefinition | null | undefined, + model: string, + provider: string, + dashboardPort: number, + selectionVerified: boolean, + sandboxGpuConfig: SandboxGpuConfig, + ): void; +} + +export interface ReusedSandboxDashboardStateResult { + chatUiUrl: string; + dashboardPort: number; + hermesDashboardState: HermesDashboardOnboardState; +} + +export function applyReusedSandboxDashboardState( + input: ReusedSandboxDashboardStateInput, +): ReusedSandboxDashboardStateResult { + const dashboardPort = input.ensureDashboardForward(input.sandboxName, input.chatUiUrl); + const chatUiUrl = `http://127.0.0.1:${dashboardPort}`; + input.env.CHAT_UI_URL = chatUiUrl; + const hermesDashboardState = input.hermesDashboardForwarding.resolveStateForPort(dashboardPort); + input.hermesDashboardForwarding.ensureForState(hermesDashboardState, input.sandboxName); + input.updateReusedSandboxMetadata( + input.sandboxName, + input.agent, + input.model, + input.provider, + dashboardPort, + input.selectionVerified, + input.sandboxGpuConfig, + ); + (input.updateSandbox ?? registry.updateSandbox)(input.sandboxName, { + ...getHermesDashboardRegistryFields(hermesDashboardState), + gatewayName: input.gatewayName, + gatewayPort: input.gatewayPort, + }); + return { chatUiUrl, dashboardPort, hermesDashboardState }; +} + export function createSandboxReuseHelpers(deps: SandboxReuseDeps): SandboxReuseHelpers { function getSandboxReuseState(sandboxName: string | null): string { if (!sandboxName) return "missing"; From 2d9d81a84a057931fee7d5610aac731a5d8145c7 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 03:16:44 -0700 Subject: [PATCH 07/14] test(onboard): cover disabled Hermes dashboard reuse metadata --- src/lib/onboard/sandbox-reuse.test.ts | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/lib/onboard/sandbox-reuse.test.ts b/src/lib/onboard/sandbox-reuse.test.ts index 7c20764d32..827af70342 100644 --- a/src/lib/onboard/sandbox-reuse.test.ts +++ b/src/lib/onboard/sandbox-reuse.test.ts @@ -86,4 +86,46 @@ describe("applyReusedSandboxDashboardState", () => { hermesDashboardState, }); }); + + it("clears Hermes dashboard registry fields when the reused sandbox has it disabled", () => { + const updateSandbox = vi.fn(); + const sandboxGpuConfig: SandboxGpuConfig = { + hostGpuDetected: false, + hostGpuPlatform: null, + sandboxGpuEnabled: false, + mode: "auto", + sandboxGpuDevice: null, + errors: [], + }; + const hermesDashboardState = { enabled: false, config: null }; + const result = applyReusedSandboxDashboardState({ + sandboxName: "reuse-me", + chatUiUrl: "http://127.0.0.1:18789", + env: {}, + agent: null, + model: "test-model", + provider: "openai-compatible", + selectionVerified: true, + sandboxGpuConfig, + gatewayName: "nemoclaw", + gatewayPort: 8080, + ensureDashboardForward: vi.fn(() => 18789), + hermesDashboardForwarding: { + resolveStateForPort: vi.fn(() => hermesDashboardState), + ensureForState: vi.fn(), + }, + updateSandbox, + updateReusedSandboxMetadata: vi.fn(), + }); + + expect(updateSandbox).toHaveBeenCalledWith("reuse-me", { + hermesDashboardEnabled: undefined, + hermesDashboardPort: undefined, + hermesDashboardInternalPort: undefined, + hermesDashboardTui: undefined, + gatewayName: "nemoclaw", + gatewayPort: 8080, + }); + expect(result.hermesDashboardState).toBe(hermesDashboardState); + }); }); From 318a002a098a7a69861007f9fa05cf69e279fae4 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 04:00:34 -0700 Subject: [PATCH 08/14] refactor(onboard): extract sandbox registration payload --- src/lib/onboard.ts | 34 ++--- src/lib/onboard/sandbox-registration.test.ts | 146 +++++++++++++++++++ src/lib/onboard/sandbox-registration.ts | 87 +++++++++++ 3 files changed, 250 insertions(+), 17 deletions(-) create mode 100644 src/lib/onboard/sandbox-registration.test.ts create mode 100644 src/lib/onboard/sandbox-registration.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 3528ad6d92..84e337bb25 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -418,6 +418,8 @@ const sandboxAgent: typeof import("./onboard/sandbox-agent") = require("./onboar const sandboxLifecycle: typeof import("./onboard/sandbox-lifecycle") = require("./onboard/sandbox-lifecycle"); const sandboxRegistryMetadata: typeof import("./onboard/sandbox-registry-metadata") = require("./onboard/sandbox-registry-metadata"); const sandboxReuse: typeof import("./onboard/sandbox-reuse") = require("./onboard/sandbox-reuse"); +const sandboxRegistration: typeof import("./onboard/sandbox-registration") = + require("./onboard/sandbox-registration"); const { RESERVED_SANDBOX_NAMES, formatSandboxAgentName, @@ -3550,29 +3552,27 @@ async function createSandbox( const resolvedImageTag = resolveSandboxImageTagFromCreateOutput(createResult.output, buildId); const sandboxRuntimeFields = getSandboxRuntimeRegistryFields(effectiveSandboxGpuConfig); - const plannedMessagingState = MessagingHostStateApplier.readPlanStateFromEnv(); - const messagingState = - plannedMessagingState?.plan.sandboxName === sandboxName ? plannedMessagingState : undefined; - registry.registerSandbox({ - name: sandboxName, - model: model || null, - provider: provider || null, - ...sandboxRuntimeFields, - ...getSandboxAgentRegistryFields(agent, !fromDockerfile), + sandboxRegistration.registerCreatedSandbox({ + sandboxName, + model, + provider, + runtimeFields: sandboxRuntimeFields, + agent, + agentVersionKnown: !fromDockerfile, imageTag: resolvedImageTag, - policies: initialSandboxPolicy.appliedPresets, + appliedPolicies: initialSandboxPolicy.appliedPresets, // Persist the operator's configured channel set, not the post-disabled-filter // active set. After `channels stop X` + rebuild, activeMessagingChannels drops // X, but X is still configured — losing it here means a later `channels start // X` has nothing to re-enable (the next rebuild sees an empty channel set and // never reattaches the gateway bridge). See #3381. - messagingChannels: - enabledChannels != null ? [...new Set(enabledChannels)] : activeMessagingChannels, - messagingChannelConfig: messagingChannelConfig || undefined, - messaging: messagingState, - disabledChannels: disabledChannels.length > 0 ? [...disabledChannels] : undefined, - hermesToolGateways: hermesToolGateways.length > 0 ? [...hermesToolGateways] : undefined, - ...onboardHermesDashboard.getHermesDashboardRegistryFields(finalHermesDashboardState), + configuredMessagingChannels: enabledChannels, + activeMessagingChannels, + messagingChannelConfig, + plannedMessagingState: MessagingHostStateApplier.readPlanStateFromEnv(), + disabledChannels, + hermesToolGateways, + hermesDashboardState: finalHermesDashboardState, dashboardPort: actualDashboardPort, gatewayName: GATEWAY_NAME, gatewayPort: GATEWAY_PORT, diff --git a/src/lib/onboard/sandbox-registration.test.ts b/src/lib/onboard/sandbox-registration.test.ts new file mode 100644 index 0000000000..163b2f6435 --- /dev/null +++ b/src/lib/onboard/sandbox-registration.test.ts @@ -0,0 +1,146 @@ +// 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 { + buildCreatedSandboxRegistryEntry, + registerCreatedSandbox, +} from "../../../dist/lib/onboard/sandbox-registration"; + +const runtimeFields = { + gpuEnabled: true, + hostGpuDetected: true, + sandboxGpuEnabled: true, + sandboxGpuMode: "auto", + sandboxGpuDevice: null, + openshellDriver: "docker", + openshellVersion: "0.1.2", +}; + +describe("buildCreatedSandboxRegistryEntry", () => { + it("records the final created sandbox metadata with configured messaging channels", () => { + const plannedMessagingState = { + schemaVersion: 1 as const, + plan: { sandboxName: "demo" }, + }; + const messagingChannelConfig = { DISCORD_ALLOWED_USER_IDS: "123" }; + + const entry = buildCreatedSandboxRegistryEntry({ + sandboxName: "demo", + model: "llama", + provider: "openai-compatible", + runtimeFields, + agent: null, + agentVersionKnown: true, + imageTag: "nemoclaw-demo:123", + appliedPolicies: ["discord", "slack"], + configuredMessagingChannels: ["slack", "discord", "slack"], + activeMessagingChannels: ["discord"], + messagingChannelConfig, + plannedMessagingState: plannedMessagingState as any, + disabledChannels: ["telegram"], + hermesToolGateways: ["filesystem"], + hermesDashboardState: { + enabled: true, + config: { enabled: true, port: 18790, internalPort: 19123, tuiEnabled: true }, + }, + dashboardPort: 18789, + gatewayName: "nemoclaw-19080", + gatewayPort: 19080, + }); + + expect(entry).toMatchObject({ + name: "demo", + model: "llama", + provider: "openai-compatible", + imageTag: "nemoclaw-demo:123", + policies: ["discord", "slack"], + messagingChannels: ["slack", "discord"], + messagingChannelConfig, + disabledChannels: ["telegram"], + hermesToolGateways: ["filesystem"], + hermesDashboardEnabled: true, + hermesDashboardPort: 18790, + hermesDashboardInternalPort: 19123, + hermesDashboardTui: true, + dashboardPort: 18789, + gatewayName: "nemoclaw-19080", + gatewayPort: 19080, + gpuEnabled: true, + openshellDriver: "docker", + openshellVersion: "0.1.2", + }); + expect(entry.agent).toBeNull(); + expect(entry.messaging).toBe(plannedMessagingState); + }); + + it("uses active channels and skips stale messaging plans when no configured channel set exists", () => { + const entry = buildCreatedSandboxRegistryEntry({ + sandboxName: "demo", + model: "", + provider: "", + runtimeFields, + agent: null, + agentVersionKnown: false, + imageTag: null, + appliedPolicies: [], + configuredMessagingChannels: null, + activeMessagingChannels: ["telegram"], + messagingChannelConfig: null, + plannedMessagingState: { + schemaVersion: 1 as const, + plan: { sandboxName: "other" }, + } as any, + disabledChannels: [], + hermesToolGateways: [], + hermesDashboardState: { enabled: false, config: null }, + dashboardPort: 18789, + gatewayName: "nemoclaw", + gatewayPort: 8080, + }); + + expect(entry.model).toBeNull(); + expect(entry.provider).toBeNull(); + expect(entry.messagingChannels).toEqual(["telegram"]); + expect(entry.messagingChannelConfig).toBeUndefined(); + expect(entry.messaging).toBeUndefined(); + expect(entry.disabledChannels).toBeUndefined(); + expect(entry.hermesToolGateways).toBeUndefined(); + expect(entry.hermesDashboardEnabled).toBeUndefined(); + expect(entry.hermesDashboardPort).toBeUndefined(); + expect(entry.hermesDashboardInternalPort).toBeUndefined(); + expect(entry.hermesDashboardTui).toBeUndefined(); + }); +}); + +describe("registerCreatedSandbox", () => { + it("passes the built entry to the supplied registry writer", () => { + const registerSandbox = vi.fn(); + + const entry = registerCreatedSandbox({ + sandboxName: "demo", + model: "llama", + provider: "openai-compatible", + runtimeFields, + agent: null, + agentVersionKnown: true, + imageTag: null, + appliedPolicies: [], + configuredMessagingChannels: null, + activeMessagingChannels: [], + messagingChannelConfig: undefined, + plannedMessagingState: undefined, + disabledChannels: [], + hermesToolGateways: [], + hermesDashboardState: { enabled: false, config: null }, + dashboardPort: 18789, + gatewayName: "nemoclaw", + gatewayPort: 8080, + registerSandbox, + }); + + expect(registerSandbox).toHaveBeenCalledWith(entry); + expect(entry.name).toBe("demo"); + }); +}); diff --git a/src/lib/onboard/sandbox-registration.ts b/src/lib/onboard/sandbox-registration.ts new file mode 100644 index 0000000000..40645768a8 --- /dev/null +++ b/src/lib/onboard/sandbox-registration.ts @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { AgentDefinition } from "../agent/defs"; +import type { SandboxEntry, SandboxMessagingState } from "../state/registry"; +import * as registry from "../state/registry"; +import type { MessagingChannelConfig } from "../messaging-channel-config"; +import { + getHermesDashboardRegistryFields, + type HermesDashboardOnboardState, +} from "./hermes-dashboard"; +import { getSandboxAgentRegistryFields } from "./sandbox-agent"; + +export type CreatedSandboxRuntimeFields = Pick< + SandboxEntry, + | "gpuEnabled" + | "hostGpuDetected" + | "sandboxGpuEnabled" + | "sandboxGpuMode" + | "sandboxGpuDevice" + | "sandboxGpuProof" + | "openshellDriver" + | "openshellVersion" +>; + +export interface CreatedSandboxRegistryEntryInput { + sandboxName: string; + model: string; + provider: string; + runtimeFields: CreatedSandboxRuntimeFields; + agent: AgentDefinition | null | undefined; + agentVersionKnown: boolean; + imageTag: string | null; + appliedPolicies: string[]; + configuredMessagingChannels: string[] | null; + activeMessagingChannels: string[]; + messagingChannelConfig: MessagingChannelConfig | null | undefined; + plannedMessagingState: SandboxMessagingState | undefined; + disabledChannels: string[]; + hermesToolGateways: string[]; + hermesDashboardState: HermesDashboardOnboardState; + dashboardPort: number; + gatewayName: string; + gatewayPort: number; +} + +export interface CreatedSandboxRegistrationInput extends CreatedSandboxRegistryEntryInput { + registerSandbox?(entry: SandboxEntry): void; +} + +export function buildCreatedSandboxRegistryEntry( + input: CreatedSandboxRegistryEntryInput, +): SandboxEntry { + const messagingState = + input.plannedMessagingState?.plan.sandboxName === input.sandboxName + ? input.plannedMessagingState + : undefined; + + return { + name: input.sandboxName, + model: input.model || null, + provider: input.provider || null, + ...input.runtimeFields, + ...getSandboxAgentRegistryFields(input.agent, input.agentVersionKnown), + imageTag: input.imageTag, + policies: input.appliedPolicies, + messagingChannels: + input.configuredMessagingChannels != null + ? [...new Set(input.configuredMessagingChannels)] + : input.activeMessagingChannels, + messagingChannelConfig: input.messagingChannelConfig || undefined, + messaging: messagingState, + disabledChannels: input.disabledChannels.length > 0 ? [...input.disabledChannels] : undefined, + hermesToolGateways: + input.hermesToolGateways.length > 0 ? [...input.hermesToolGateways] : undefined, + ...getHermesDashboardRegistryFields(input.hermesDashboardState), + dashboardPort: input.dashboardPort, + gatewayName: input.gatewayName, + gatewayPort: input.gatewayPort, + }; +} + +export function registerCreatedSandbox(input: CreatedSandboxRegistrationInput): SandboxEntry { + const entry = buildCreatedSandboxRegistryEntry(input); + (input.registerSandbox ?? registry.registerSandbox)(entry); + return entry; +} From 106d27dce2e4a725203164e087ad37bb8ccd7c03 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 04:55:42 -0700 Subject: [PATCH 09/14] refactor(onboard): extract messaging preparation --- src/lib/onboard.ts | 115 +++++-------------- src/lib/onboard/messaging-prep.test.ts | 119 ++++++++++++++++++++ src/lib/onboard/messaging-prep.ts | 146 +++++++++++++++++++++++++ 3 files changed, 292 insertions(+), 88 deletions(-) create mode 100644 src/lib/onboard/messaging-prep.test.ts create mode 100644 src/lib/onboard/messaging-prep.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 84e337bb25..6a5403d7c2 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -27,6 +27,7 @@ const { abortNonInteractive, }: typeof import("./onboard/non-interactive-abort") = require("./onboard/non-interactive-abort"); const { stopStaleDashboardListenersForSandbox } = require("./onboard/stale-gateway-cleanup"); +const extraPlaceholderKeysModule: typeof import("./onboard/extra-placeholder-keys") = require("./onboard/extra-placeholder-keys"); const { ensureOllamaLoopbackSystemdOverride, }: typeof import("./onboard/ollama-systemd") = require("./onboard/ollama-systemd"); @@ -414,6 +415,7 @@ const { messagingChannelConfigsEqual, persistMessagingChannelConfigToSession, } = messagingConfig; +const messagingPrep: typeof import("./onboard/messaging-prep") = require("./onboard/messaging-prep"); const sandboxAgent: typeof import("./onboard/sandbox-agent") = require("./onboard/sandbox-agent"); const sandboxLifecycle: typeof import("./onboard/sandbox-lifecycle") = require("./onboard/sandbox-lifecycle"); const sandboxRegistryMetadata: typeof import("./onboard/sandbox-registry-metadata") = require("./onboard/sandbox-registry-metadata"); @@ -596,7 +598,7 @@ import type { SelectionDrift } from "./onboard/selection-drift"; import { formatOnboardConfigSummary, formatSandboxBuildEstimateNote } from "./onboard/summary"; import type { ModelValidationResult, ValidationFailureLike } from "./onboard/types"; import type { ContainerRuntime } from "./platform"; -import { getChannelTokenKeys, listChannels } from "./sandbox/channels"; +import { listChannels } from "./sandbox/channels"; import type { GatewayReuseState } from "./state/gateway"; import type { Session, SessionUpdates } from "./state/onboard-session"; import type { SandboxEntry } from "./state/registry"; @@ -927,12 +929,7 @@ function upsertProvider( return result; } -type MessagingTokenDef = { - name: string; - envKey: string; - token: string | null; - providerType?: string; -}; +type MessagingTokenDef = import("./onboard/messaging-prep").MessagingTokenDef; type EndpointValidationResult = | { ok: true; api: string | null; retry?: undefined } @@ -2645,101 +2642,43 @@ async function createSandbox( } } - // When enabledChannels is provided (from the toggle picker), only include - // channels the user selected. When null (backward compat), include all. - const enabledEnvKeys = - enabledChannels != null - ? new Set( - MESSAGING_CHANNELS.filter((c) => enabledChannels.includes(c.name)).flatMap((c) => - getChannelTokenKeys(c), - ), - ) - : null; - // Drop channels the operator disabled via `nemoclaw channels stop`. // Credentials stay in the keychain; the bridge simply isn't registered with // the gateway on the next rebuild. `channels start` removes the entry and // the bridge comes back. const disabledChannels: string[] = require("./onboard/channel-state").resolveDisabledChannels(sandboxName); - const disabledChannelNames = new Set(disabledChannels); - const disabledEnvKeys = new Set( - MESSAGING_CHANNELS.filter((c) => disabledChannelNames.has(c.name)).flatMap((c) => - getChannelTokenKeys(c), - ), - ); - - const messagingTokenDefs: MessagingTokenDef[] = [ - { - name: `${sandboxName}-discord-bridge`, - envKey: "DISCORD_BOT_TOKEN", - token: getValidatedMessagingTokenByEnvKey(MESSAGING_CHANNELS, "DISCORD_BOT_TOKEN"), - }, - { - name: `${sandboxName}-slack-bridge`, - envKey: "SLACK_BOT_TOKEN", - token: getValidatedMessagingTokenByEnvKey(MESSAGING_CHANNELS, "SLACK_BOT_TOKEN"), - }, - { - name: `${sandboxName}-slack-app`, - envKey: "SLACK_APP_TOKEN", - token: getValidatedMessagingTokenByEnvKey(MESSAGING_CHANNELS, "SLACK_APP_TOKEN"), - }, - { - name: `${sandboxName}-telegram-bridge`, - envKey: "TELEGRAM_BOT_TOKEN", - token: getValidatedMessagingTokenByEnvKey(MESSAGING_CHANNELS, "TELEGRAM_BOT_TOKEN"), - }, - { - name: `${sandboxName}-wechat-bridge`, - envKey: "WECHAT_BOT_TOKEN", - token: getValidatedMessagingTokenByEnvKey(MESSAGING_CHANNELS, "WECHAT_BOT_TOKEN"), - }, - ] - .filter(({ envKey }) => !enabledEnvKeys || enabledEnvKeys.has(envKey)) - .filter(({ envKey }) => !disabledEnvKeys.has(envKey)); - - const braveWebSearchEnabled = braveProviderProfile.shouldEnableBraveWebSearch(webSearchConfig); - const braveApiKey = braveWebSearchEnabled - ? getCredential(webSearch.BRAVE_API_KEY_ENV) || - normalizeCredentialValue(process.env[webSearch.BRAVE_API_KEY_ENV]) - : null; + const { + disabledChannelNames, + messagingTokenDefs, + extraPlaceholderKeys, + hasMessagingTokens, + reusableMessagingProviders, + reusableMessagingChannels, + missingBraveApiKey, + } = messagingPrep.prepareCreateSandboxMessaging({ + sandboxName, + channels: MESSAGING_CHANNELS, + enabledChannels, + disabledChannels, + webSearchConfig, + env: process.env, + getValidatedMessagingTokenByEnvKey, + getCredential, + normalizeCredentialValue, + registerExtraPlaceholderProviders: extraPlaceholderKeysModule.registerExtraPlaceholderProviders, + getMessagingChannelForEnvKey, + providerExistsInGateway, + }); // Fail before any recreate/delete path runs: otherwise a missing key would // destroy the existing sandbox first and only then surface the abort (#3626). - if (braveWebSearchEnabled && !braveApiKey) { + if (missingBraveApiKey) { console.error(" Brave Search is enabled, but BRAVE_API_KEY is not available in this process."); console.error( " Re-run with BRAVE_API_KEY set, or disable Brave Search before recreating the sandbox.", ); process.exit(1); } - if (braveWebSearchEnabled) - messagingTokenDefs.push({ - name: `${sandboxName}-brave-search`, - envKey: webSearch.BRAVE_API_KEY_ENV, - token: braveApiKey, - providerType: braveProviderProfile.BRAVE_PROVIDER_PROFILE_ID, - }); - const extraPlaceholderKeys: string[] = - require("./onboard/extra-placeholder-keys").registerExtraPlaceholderProviders( - sandboxName, - messagingTokenDefs, - ); - const hasMessagingTokens = messagingTokenDefs.some(({ token }) => !!token); - const reusableMessagingProviders: string[] = []; - const reusableMessagingChannels: string[] = []; - if (enabledChannels != null) { - for (const { name, envKey, token } of messagingTokenDefs) { - if (token) continue; - const channel = envKey === "SLACK_APP_TOKEN" ? "slack" : getMessagingChannelForEnvKey(envKey); - if (!channel || !enabledChannels.includes(channel)) continue; - if (!providerExistsInGateway(name)) continue; - reusableMessagingProviders.push(name); - if (!reusableMessagingChannels.includes(channel)) { - reusableMessagingChannels.push(channel); - } - } - } const existingRegistryEntryBeforePrune = registry.getSandbox(sandboxName); diff --git a/src/lib/onboard/messaging-prep.test.ts b/src/lib/onboard/messaging-prep.test.ts new file mode 100644 index 0000000000..3bbf5e2cca --- /dev/null +++ b/src/lib/onboard/messaging-prep.test.ts @@ -0,0 +1,119 @@ +// 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 { BRAVE_API_KEY_ENV } from "../../../dist/lib/inference/web-search"; +import { + prepareCreateSandboxMessaging, + type CreateSandboxMessagingPrepInput, +} from "../../../dist/lib/onboard/messaging-prep"; +import { listChannels } from "../../../dist/lib/sandbox/channels"; + +function normalizeCredentialValue(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function createInput( + overrides: Partial = {}, +): CreateSandboxMessagingPrepInput { + return { + sandboxName: "demo", + channels: listChannels(), + enabledChannels: null, + disabledChannels: [], + webSearchConfig: null, + env: {}, + getValidatedMessagingTokenByEnvKey: () => null, + getCredential: () => null, + normalizeCredentialValue, + registerExtraPlaceholderProviders: vi.fn(() => []), + getMessagingChannelForEnvKey: (envKey) => { + if (envKey === "DISCORD_BOT_TOKEN") return "discord"; + if (envKey === "SLACK_BOT_TOKEN") return "slack"; + if (envKey === "TELEGRAM_BOT_TOKEN") return "telegram"; + if (envKey === "WECHAT_BOT_TOKEN") return "wechat"; + return null; + }, + providerExistsInGateway: () => false, + ...overrides, + }; +} + +describe("prepareCreateSandboxMessaging", () => { + it("filters token definitions by selected and disabled channels and reuses attached missing-token providers", () => { + const registerExtraPlaceholderProviders = vi.fn(() => ["SLACK_BOT_TOKEN_AGENT_A"]); + const providerExistsInGateway = vi.fn((name: string) => name === "demo-slack-bridge"); + + const result = prepareCreateSandboxMessaging( + createInput({ + enabledChannels: ["slack", "telegram"], + disabledChannels: ["telegram"], + getValidatedMessagingTokenByEnvKey: (_channels, envKey) => + envKey === "SLACK_APP_TOKEN" ? "xapp-valid" : null, + registerExtraPlaceholderProviders, + providerExistsInGateway, + }), + ); + + expect(result.messagingTokenDefs).toMatchObject([ + { name: "demo-slack-bridge", envKey: "SLACK_BOT_TOKEN", token: null }, + { name: "demo-slack-app", envKey: "SLACK_APP_TOKEN", token: "xapp-valid" }, + ]); + expect([...result.disabledChannelNames]).toEqual(["telegram"]); + expect(result.extraPlaceholderKeys).toEqual(["SLACK_BOT_TOKEN_AGENT_A"]); + expect(result.hasMessagingTokens).toBe(true); + expect(result.reusableMessagingProviders).toEqual(["demo-slack-bridge"]); + expect(result.reusableMessagingChannels).toEqual(["slack"]); + expect(providerExistsInGateway).toHaveBeenCalledWith("demo-slack-bridge"); + expect(registerExtraPlaceholderProviders).toHaveBeenCalledWith( + "demo", + result.messagingTokenDefs, + ); + }); + + it("reports missing Brave API keys before registering extra placeholder providers", () => { + const registerExtraPlaceholderProviders = vi.fn(() => ["BRAVE_API_KEY_AGENT_A"]); + + const result = prepareCreateSandboxMessaging( + createInput({ + webSearchConfig: { fetchEnabled: true }, + env: { [BRAVE_API_KEY_ENV]: " " }, + registerExtraPlaceholderProviders, + }), + ); + + expect(result.missingBraveApiKey).toBe(true); + expect(result.extraPlaceholderKeys).toEqual([]); + expect(result.messagingTokenDefs.some(({ envKey }) => envKey === BRAVE_API_KEY_ENV)).toBe( + false, + ); + expect(registerExtraPlaceholderProviders).not.toHaveBeenCalled(); + }); + + it("adds the Brave provider token from the credential store before host env fallback", () => { + const registerExtraPlaceholderProviders = vi.fn(() => []); + + const result = prepareCreateSandboxMessaging( + createInput({ + webSearchConfig: { fetchEnabled: true }, + env: { [BRAVE_API_KEY_ENV]: "brv-host" }, + getCredential: (envKey) => (envKey === BRAVE_API_KEY_ENV ? "brv-store" : null), + registerExtraPlaceholderProviders, + }), + ); + + expect(result.missingBraveApiKey).toBe(false); + expect(result.hasMessagingTokens).toBe(true); + expect(result.messagingTokenDefs).toContainEqual({ + name: "demo-brave-search", + envKey: BRAVE_API_KEY_ENV, + token: "brv-store", + providerType: "brave", + }); + expect(registerExtraPlaceholderProviders).toHaveBeenCalledWith( + "demo", + result.messagingTokenDefs, + ); + }); +}); diff --git a/src/lib/onboard/messaging-prep.ts b/src/lib/onboard/messaging-prep.ts new file mode 100644 index 0000000000..b10a37a44d --- /dev/null +++ b/src/lib/onboard/messaging-prep.ts @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as webSearch from "../inference/web-search"; +import type { WebSearchConfig } from "../inference/web-search"; +import { getChannelTokenKeys, type ChannelDef } from "../sandbox/channels"; +import * as braveProviderProfile from "./brave-provider-profile"; + +export type NamedMessagingChannel = { name: string } & ChannelDef; + +export interface MessagingTokenDef { + name: string; + envKey: string; + token: string | null; + providerType?: string; +} + +export interface CreateSandboxMessagingPrepInput { + sandboxName: string; + channels: readonly NamedMessagingChannel[]; + enabledChannels: readonly string[] | null; + disabledChannels: readonly string[]; + webSearchConfig: WebSearchConfig | null; + env: NodeJS.ProcessEnv | Record; + getValidatedMessagingTokenByEnvKey( + channels: readonly NamedMessagingChannel[], + envKey: string, + ): string | null; + getCredential(envKey: string): string | null; + normalizeCredentialValue(value: unknown): string; + registerExtraPlaceholderProviders( + sandboxName: string, + messagingTokenDefs: MessagingTokenDef[], + ): string[]; + getMessagingChannelForEnvKey(envKey: string): string | null; + providerExistsInGateway(name: string): boolean; +} + +export interface CreateSandboxMessagingPrepResult { + disabledChannelNames: Set; + messagingTokenDefs: MessagingTokenDef[]; + extraPlaceholderKeys: string[]; + hasMessagingTokens: boolean; + reusableMessagingProviders: string[]; + reusableMessagingChannels: string[]; + missingBraveApiKey: boolean; +} + +const STATIC_MESSAGING_PROVIDER_ENVS = [ + ["discord-bridge", "DISCORD_BOT_TOKEN"], + ["slack-bridge", "SLACK_BOT_TOKEN"], + ["slack-app", "SLACK_APP_TOKEN"], + ["telegram-bridge", "TELEGRAM_BOT_TOKEN"], + ["wechat-bridge", "WECHAT_BOT_TOKEN"], +] as const; + +export function prepareCreateSandboxMessaging( + input: CreateSandboxMessagingPrepInput, +): CreateSandboxMessagingPrepResult { + const enabledEnvKeys = + input.enabledChannels != null + ? new Set( + input.channels + .filter((c) => input.enabledChannels?.includes(c.name)) + .flatMap((c) => getChannelTokenKeys(c)), + ) + : null; + + const disabledChannelNames = new Set(input.disabledChannels); + const disabledEnvKeys = new Set( + input.channels + .filter((c) => disabledChannelNames.has(c.name)) + .flatMap((c) => getChannelTokenKeys(c)), + ); + + const messagingTokenDefs: MessagingTokenDef[] = STATIC_MESSAGING_PROVIDER_ENVS.map( + ([suffix, envKey]) => ({ + name: `${input.sandboxName}-${suffix}`, + envKey, + token: input.getValidatedMessagingTokenByEnvKey(input.channels, envKey), + }), + ) + .filter(({ envKey }) => !enabledEnvKeys || enabledEnvKeys.has(envKey)) + .filter(({ envKey }) => !disabledEnvKeys.has(envKey)); + + const braveWebSearchEnabled = braveProviderProfile.shouldEnableBraveWebSearch( + input.webSearchConfig, + ); + const braveApiKey = braveWebSearchEnabled + ? input.getCredential(webSearch.BRAVE_API_KEY_ENV) || + input.normalizeCredentialValue(input.env[webSearch.BRAVE_API_KEY_ENV]) + : null; + const missingBraveApiKey = braveWebSearchEnabled && !braveApiKey; + if (missingBraveApiKey) { + return { + disabledChannelNames, + messagingTokenDefs, + extraPlaceholderKeys: [], + hasMessagingTokens: messagingTokenDefs.some(({ token }) => !!token), + reusableMessagingProviders: [], + reusableMessagingChannels: [], + missingBraveApiKey, + }; + } + + if (braveWebSearchEnabled) { + messagingTokenDefs.push({ + name: `${input.sandboxName}-brave-search`, + envKey: webSearch.BRAVE_API_KEY_ENV, + token: braveApiKey, + providerType: braveProviderProfile.BRAVE_PROVIDER_PROFILE_ID, + }); + } + + const extraPlaceholderKeys = input.registerExtraPlaceholderProviders( + input.sandboxName, + messagingTokenDefs, + ); + const hasMessagingTokens = messagingTokenDefs.some(({ token }) => !!token); + const reusableMessagingProviders: string[] = []; + const reusableMessagingChannels: string[] = []; + + if (input.enabledChannels != null) { + for (const { name, envKey, token } of messagingTokenDefs) { + if (token) continue; + const channel = + envKey === "SLACK_APP_TOKEN" ? "slack" : input.getMessagingChannelForEnvKey(envKey); + if (!channel || !input.enabledChannels.includes(channel)) continue; + if (!input.providerExistsInGateway(name)) continue; + reusableMessagingProviders.push(name); + if (!reusableMessagingChannels.includes(channel)) { + reusableMessagingChannels.push(channel); + } + } + } + + return { + disabledChannelNames, + messagingTokenDefs, + extraPlaceholderKeys, + hasMessagingTokens, + reusableMessagingProviders, + reusableMessagingChannels, + missingBraveApiKey, + }; +} From 45b449b7196b01e25569d70204e86f024a787e70 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 05:02:47 -0700 Subject: [PATCH 10/14] test(onboard): cover messaging preparation edge cases --- src/lib/onboard/messaging-prep.test.ts | 64 ++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/lib/onboard/messaging-prep.test.ts b/src/lib/onboard/messaging-prep.test.ts index 3bbf5e2cca..b5beeae629 100644 --- a/src/lib/onboard/messaging-prep.test.ts +++ b/src/lib/onboard/messaging-prep.test.ts @@ -116,4 +116,68 @@ describe("prepareCreateSandboxMessaging", () => { result.messagingTokenDefs, ); }); + + it("removes both Slack bot and app token definitions when Slack is disabled", () => { + const result = prepareCreateSandboxMessaging( + createInput({ + disabledChannels: ["slack"], + getValidatedMessagingTokenByEnvKey: (_channels, envKey) => + envKey === "SLACK_BOT_TOKEN" || envKey === "SLACK_APP_TOKEN" ? `${envKey}-value` : null, + }), + ); + + expect(result.disabledChannelNames.has("slack")).toBe(true); + expect(result.messagingTokenDefs.map(({ envKey }) => envKey)).not.toContain("SLACK_BOT_TOKEN"); + expect(result.messagingTokenDefs.map(({ envKey }) => envKey)).not.toContain("SLACK_APP_TOKEN"); + }); + + it("includes all static token-backed channels by default without probing reusable providers", () => { + const providerExistsInGateway = vi.fn(() => true); + + const result = prepareCreateSandboxMessaging( + createInput({ + enabledChannels: null, + providerExistsInGateway, + }), + ); + + expect(result.messagingTokenDefs.map(({ envKey }) => envKey)).toEqual([ + "DISCORD_BOT_TOKEN", + "SLACK_BOT_TOKEN", + "SLACK_APP_TOKEN", + "TELEGRAM_BOT_TOKEN", + "WECHAT_BOT_TOKEN", + ]); + expect(result.reusableMessagingProviders).toEqual([]); + expect(result.reusableMessagingChannels).toEqual([]); + expect(providerExistsInGateway).not.toHaveBeenCalled(); + }); + + it("uses BRAVE_API_KEY from host env when the credential store has no value", () => { + const result = prepareCreateSandboxMessaging( + createInput({ + webSearchConfig: { fetchEnabled: true }, + env: { [BRAVE_API_KEY_ENV]: " brv-host " }, + }), + ); + + expect(result.missingBraveApiKey).toBe(false); + expect(result.messagingTokenDefs).toContainEqual({ + name: "demo-brave-search", + envKey: BRAVE_API_KEY_ENV, + token: "brv-host", + providerType: "brave", + }); + }); + + it("does not create static token definitions for tokenless QR channels", () => { + const result = prepareCreateSandboxMessaging( + createInput({ + enabledChannels: ["whatsapp"], + }), + ); + + expect(result.messagingTokenDefs).toEqual([]); + expect(result.hasMessagingTokens).toBe(false); + }); }); From 97b99bc917509d4f99786a25daa6cdeb4fd89703 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 05:54:02 -0700 Subject: [PATCH 11/14] refactor(onboard): extract build context staging --- src/lib/onboard.ts | 106 ++-------------- src/lib/onboard/build-context-stage.test.ts | 115 +++++++++++++++++ src/lib/onboard/build-context-stage.ts | 133 ++++++++++++++++++++ 3 files changed, 260 insertions(+), 94 deletions(-) create mode 100644 src/lib/onboard/build-context-stage.test.ts create mode 100644 src/lib/onboard/build-context-stage.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 6a5403d7c2..ca1fdeee41 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -28,15 +28,11 @@ const { }: typeof import("./onboard/non-interactive-abort") = require("./onboard/non-interactive-abort"); const { stopStaleDashboardListenersForSandbox } = require("./onboard/stale-gateway-cleanup"); const extraPlaceholderKeysModule: typeof import("./onboard/extra-placeholder-keys") = require("./onboard/extra-placeholder-keys"); +const buildContextStage: typeof import("./onboard/build-context-stage") = require("./onboard/build-context-stage"); const { ensureOllamaLoopbackSystemdOverride, }: typeof import("./onboard/ollama-systemd") = require("./onboard/ollama-systemd"); const { bestEffortForwardStop } = require("./onboard/forward-cleanup"); -const { - CUSTOM_BUILD_CONTEXT_WARN_BYTES, - createCustomBuildContextFilter, - isInsideIgnoredCustomBuildContextPath, -}: typeof import("./onboard/custom-build-context") = require("./onboard/custom-build-context"); const { buildCompatibleEndpointSandboxSmokeCommand, buildCompatibleEndpointSandboxSmokeScript, @@ -175,8 +171,6 @@ const { getStableGatewayImageRef, pullAndResolveBaseImageDigest, }: typeof import("./onboard/base-image") = require("./onboard/base-image"); -const errnoUtils: typeof import("./core/errno") = require("./core/errno"); -const { isErrnoException } = errnoUtils; const { requireValue }: typeof import("./core/require-value") = require("./core/require-value"); const { logMissingNvidiaApiKeyHelp, @@ -191,10 +185,6 @@ type RunnerOptions = { openshellBinary?: string; }; -const { - collectBuildContextStats, - stageOptimizedSandboxBuildContext, -} = require("./sandbox/build-context"); const { buildSubprocessEnv } = require("./subprocess-env"); const { DASHBOARD_PORT, @@ -2972,93 +2962,21 @@ async function createSandbox( // in env args, so it must not persist in /tmp after a failed sandbox create. // run() calls process.exit() on failure (bypassing normal control flow), so // we register a process 'exit' handler to guarantee cleanup in all cases. - let buildCtx: string, stagedDockerfile: string; - if (fromDockerfile) { - const fromResolved = path.resolve(fromDockerfile); - if (!fs.existsSync(fromResolved)) { - console.error(` Custom Dockerfile not found: ${fromResolved}`); - process.exit(1); - } - if (!fs.statSync(fromResolved).isFile()) { - console.error(` Custom Dockerfile path is not a file: ${fromResolved}`); - process.exit(1); - } - const buildContextDir = path.dirname(fromResolved); - if (isInsideIgnoredCustomBuildContextPath(buildContextDir)) { - console.error( - ` Custom Dockerfile is inside an ignored build-context path: ${buildContextDir}`, - ); - console.error(" Move your Dockerfile to a dedicated directory and retry."); - process.exit(1); - } - console.log(` Using custom Dockerfile: ${fromResolved}`); - console.log(` Docker build context: ${buildContextDir}`); - const shouldIncludeCustomContextPath = createCustomBuildContextFilter(buildContextDir); - const buildContextStats = collectBuildContextStats( - buildContextDir, - shouldIncludeCustomContextPath, - ); - if (buildContextStats.totalBytes > CUSTOM_BUILD_CONTEXT_WARN_BYTES) { - const sizeMb = (buildContextStats.totalBytes / 1_000_000).toFixed(1); - console.warn( - ` WARN: build context contains about ${sizeMb} MB across ${buildContextStats.fileCount} files.`, - ); - console.warn( - " The --from flag sends the Dockerfile's parent directory to Docker; use a dedicated directory if this is not intentional.", - ); - } - buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-")); - stagedDockerfile = path.join(buildCtx, "Dockerfile"); - const cleanupCustomBuildCtx = (): void => { - try { - fs.rmSync(buildCtx, { recursive: true, force: true }); - } catch { - // Best effort cleanup; the original error is more useful to the caller. - } - }; - // Copy the entire parent directory as build context. - try { - fs.cpSync(buildContextDir, buildCtx, { - recursive: true, - filter: shouldIncludeCustomContextPath, - }); - // If the caller pointed at a file not named "Dockerfile", copy it to the - // location openshell expects (buildCtx/Dockerfile). - if (path.basename(fromResolved) !== "Dockerfile") { - fs.copyFileSync(fromResolved, stagedDockerfile); - } - } catch (err) { - cleanupCustomBuildCtx(); - const errorObject = typeof err === "object" && err !== null ? err : null; - if (isErrnoException(errorObject) && errorObject.code === "EACCES") { - console.error(` Permission denied while copying build context from: ${buildContextDir}`); - console.error( - " The --from flag uses the Dockerfile's parent directory as the Docker build context.", - ); - console.error(" Move your Dockerfile to a dedicated directory and retry."); - process.exit(1); - } - throw err; - } - } else if (agent) { - const agentBuild = agentOnboard.createAgentSandbox(agent); - buildCtx = agentBuild.buildCtx; - stagedDockerfile = agentBuild.stagedDockerfile; - } else { - ({ buildCtx, stagedDockerfile } = stageOptimizedSandboxBuildContext(ROOT)); - } + const { buildCtx, stagedDockerfile, cleanupBuildCtx } = + buildContextStage.stageCreateSandboxBuildContext({ + root: ROOT, + fromDockerfile, + agent, + createAgentSandbox: agentOnboard.createAgentSandbox, + log: console.log, + warn: console.warn, + error: console.error, + exit: process.exit, + }); // Returns true if the build context was fully removed, false otherwise. // The caller uses this to decide whether the process 'exit' safety net // can be deregistered — if inline cleanup fails, we leave the handler // armed so the temp dir is still removed on process exit. - const cleanupBuildCtx = (): boolean => { - try { - fs.rmSync(buildCtx, { recursive: true, force: true }); - return true; - } catch { - return false; - } - }; process.on("exit", cleanupBuildCtx); const defaultPolicyPath = path.join( diff --git a/src/lib/onboard/build-context-stage.test.ts b/src/lib/onboard/build-context-stage.test.ts new file mode 100644 index 0000000000..c996ce480e --- /dev/null +++ b/src/lib/onboard/build-context-stage.test.ts @@ -0,0 +1,115 @@ +// 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 { stageCreateSandboxBuildContext } from "../../../dist/lib/onboard/build-context-stage"; + +const tmpDirs: string[] = []; + +function makeTmpDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tmpDirs.push(dir); + return dir; +} + +function throwingExit(code?: number): never { + throw new Error(`exit ${code ?? 0}`); +} + +describe("stageCreateSandboxBuildContext", () => { + afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tmpDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("stages a custom Dockerfile context, filters ignored entries, and returns cleanup", () => { + const buildContextDir = makeTmpDir("nemoclaw-custom-context-"); + const customDockerfile = path.join(buildContextDir, "Containerfile"); + fs.writeFileSync(customDockerfile, "FROM scratch\n"); + fs.writeFileSync(path.join(buildContextDir, "extra.txt"), "included\n"); + fs.mkdirSync(path.join(buildContextDir, ".ssh")); + fs.writeFileSync(path.join(buildContextDir, ".ssh", "id_rsa"), "secret\n"); + const logs: string[] = []; + + const result = stageCreateSandboxBuildContext({ + root: "/unused", + fromDockerfile: customDockerfile, + agent: null, + createAgentSandbox: vi.fn(), + log: (message) => logs.push(message), + exit: throwingExit, + }); + tmpDirs.push(result.buildCtx); + + expect(logs).toEqual([ + ` Using custom Dockerfile: ${customDockerfile}`, + ` Docker build context: ${buildContextDir}`, + ]); + expect(fs.readFileSync(result.stagedDockerfile, "utf-8")).toBe("FROM scratch\n"); + expect(fs.existsSync(path.join(result.buildCtx, "extra.txt"))).toBe(true); + expect(fs.existsSync(path.join(result.buildCtx, ".ssh"))).toBe(false); + expect(result.cleanupBuildCtx()).toBe(true); + expect(fs.existsSync(result.buildCtx)).toBe(false); + }); + + it("exits when the custom Dockerfile path is missing", () => { + const errors: string[] = []; + const missingDockerfile = path.join(makeTmpDir("nemoclaw-missing-context-"), "Dockerfile"); + + expect(() => + stageCreateSandboxBuildContext({ + root: "/unused", + fromDockerfile: missingDockerfile, + agent: null, + createAgentSandbox: vi.fn(), + error: (message) => errors.push(message), + exit: throwingExit, + }), + ).toThrow("exit 1"); + + expect(errors).toEqual([` Custom Dockerfile not found: ${missingDockerfile}`]); + }); + + it("delegates to agent or default build-context staging when no custom Dockerfile is supplied", () => { + const agentBuild = { + buildCtx: makeTmpDir("nemoclaw-agent-build-"), + stagedDockerfile: path.join(os.tmpdir(), "agent.Dockerfile"), + }; + const defaultBuild = { + buildCtx: makeTmpDir("nemoclaw-default-build-"), + stagedDockerfile: path.join(os.tmpdir(), "default.Dockerfile"), + }; + const createAgentSandbox = vi.fn(() => agentBuild); + const stageDefaultSandboxBuildContext = vi.fn(() => defaultBuild); + + const agentResult = stageCreateSandboxBuildContext({ + root: "/repo", + fromDockerfile: null, + agent: { name: "hermes" } as any, + createAgentSandbox, + stageDefaultSandboxBuildContext, + }); + + expect(agentResult.buildCtx).toBe(agentBuild.buildCtx); + expect(createAgentSandbox).toHaveBeenCalledWith({ name: "hermes" }); + expect(stageDefaultSandboxBuildContext).not.toHaveBeenCalled(); + + const defaultResult = stageCreateSandboxBuildContext({ + root: "/repo", + fromDockerfile: null, + agent: null, + createAgentSandbox, + stageDefaultSandboxBuildContext, + }); + + expect(defaultResult.buildCtx).toBe(defaultBuild.buildCtx); + expect(stageDefaultSandboxBuildContext).toHaveBeenCalledWith("/repo"); + }); +}); diff --git a/src/lib/onboard/build-context-stage.ts b/src/lib/onboard/build-context-stage.ts new file mode 100644 index 0000000000..3a250d5aa8 --- /dev/null +++ b/src/lib/onboard/build-context-stage.ts @@ -0,0 +1,133 @@ +// 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 type { AgentDefinition } from "../agent/defs"; +import { isErrnoException } from "../core/errno"; +import { + collectBuildContextStats, + type StagedBuildContext, + stageOptimizedSandboxBuildContext, +} from "../sandbox/build-context"; +import { + createCustomBuildContextFilter, + CUSTOM_BUILD_CONTEXT_WARN_BYTES, + isInsideIgnoredCustomBuildContextPath, +} from "./custom-build-context"; + +export interface CreateSandboxBuildContextInput { + root: string; + fromDockerfile: string | null; + agent: AgentDefinition | null | undefined; + createAgentSandbox(agent: AgentDefinition): StagedBuildContext; + log?(message: string): void; + warn?(message: string): void; + error?(message: string): void; + exit?(code?: number): never; + stageDefaultSandboxBuildContext?(rootDir: string): StagedBuildContext; +} + +export interface CreateSandboxBuildContextResult extends StagedBuildContext { + cleanupBuildCtx(): boolean; +} + +function createCleanupBuildContext(buildCtx: string): () => boolean { + return () => { + try { + fs.rmSync(buildCtx, { recursive: true, force: true }); + return true; + } catch { + return false; + } + }; +} + +export function stageCreateSandboxBuildContext( + input: CreateSandboxBuildContextInput, +): CreateSandboxBuildContextResult { + const log = input.log ?? console.log; + const warn = input.warn ?? console.warn; + const error = input.error ?? console.error; + const exit = input.exit ?? ((code?: number): never => process.exit(code)); + + let build: StagedBuildContext; + + if (input.fromDockerfile) { + const fromResolved = path.resolve(input.fromDockerfile); + if (!fs.existsSync(fromResolved)) { + error(` Custom Dockerfile not found: ${fromResolved}`); + exit(1); + } + if (!fs.statSync(fromResolved).isFile()) { + error(` Custom Dockerfile path is not a file: ${fromResolved}`); + exit(1); + } + const buildContextDir = path.dirname(fromResolved); + if (isInsideIgnoredCustomBuildContextPath(buildContextDir)) { + error(` Custom Dockerfile is inside an ignored build-context path: ${buildContextDir}`); + error(" Move your Dockerfile to a dedicated directory and retry."); + exit(1); + } + log(` Using custom Dockerfile: ${fromResolved}`); + log(` Docker build context: ${buildContextDir}`); + const shouldIncludeCustomContextPath = createCustomBuildContextFilter(buildContextDir); + const buildContextStats = collectBuildContextStats( + buildContextDir, + shouldIncludeCustomContextPath, + ); + if (buildContextStats.totalBytes > CUSTOM_BUILD_CONTEXT_WARN_BYTES) { + const sizeMb = (buildContextStats.totalBytes / 1_000_000).toFixed(1); + warn( + ` WARN: build context contains about ${sizeMb} MB across ${buildContextStats.fileCount} files.`, + ); + warn( + " The --from flag sends the Dockerfile's parent directory to Docker; use a dedicated directory if this is not intentional.", + ); + } + const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-")); + const stagedDockerfile = path.join(buildCtx, "Dockerfile"); + const cleanupCustomBuildCtx = (): void => { + try { + fs.rmSync(buildCtx, { recursive: true, force: true }); + } catch { + // Best effort cleanup; the original error is more useful to the caller. + } + }; + try { + fs.cpSync(buildContextDir, buildCtx, { + recursive: true, + filter: shouldIncludeCustomContextPath, + }); + if (path.basename(fromResolved) !== "Dockerfile") { + fs.copyFileSync(fromResolved, stagedDockerfile); + } + } catch (err) { + cleanupCustomBuildCtx(); + const errorObject = typeof err === "object" && err !== null ? err : null; + if (isErrnoException(errorObject) && errorObject.code === "EACCES") { + error(` Permission denied while copying build context from: ${buildContextDir}`); + error( + " The --from flag uses the Dockerfile's parent directory as the Docker build context.", + ); + error(" Move your Dockerfile to a dedicated directory and retry."); + exit(1); + } + throw err; + } + build = { buildCtx, stagedDockerfile }; + } else if (input.agent) { + build = input.createAgentSandbox(input.agent); + } else { + build = (input.stageDefaultSandboxBuildContext ?? stageOptimizedSandboxBuildContext)( + input.root, + ); + } + + return { + ...build, + cleanupBuildCtx: createCleanupBuildContext(build.buildCtx), + }; +} From a928abd80b1eee70e26fd31bf7842ef2b3288e17 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 06:04:25 -0700 Subject: [PATCH 12/14] test(onboard): cover build context staging edge cases --- src/lib/onboard/build-context-stage.test.ts | 101 ++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/src/lib/onboard/build-context-stage.test.ts b/src/lib/onboard/build-context-stage.test.ts index c996ce480e..25e782a9af 100644 --- a/src/lib/onboard/build-context-stage.test.ts +++ b/src/lib/onboard/build-context-stage.test.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { CUSTOM_BUILD_CONTEXT_WARN_BYTES } from "../../../dist/lib/onboard/custom-build-context"; import { stageCreateSandboxBuildContext } from "../../../dist/lib/onboard/build-context-stage"; const tmpDirs: string[] = []; @@ -77,6 +78,106 @@ describe("stageCreateSandboxBuildContext", () => { expect(errors).toEqual([` Custom Dockerfile not found: ${missingDockerfile}`]); }); + it("exits when the custom Dockerfile path is a directory", () => { + const errors: string[] = []; + const dockerfileDir = path.join(makeTmpDir("nemoclaw-dir-context-"), "Dockerfile"); + fs.mkdirSync(dockerfileDir); + + expect(() => + stageCreateSandboxBuildContext({ + root: "/unused", + fromDockerfile: dockerfileDir, + agent: null, + createAgentSandbox: vi.fn(), + error: (message) => errors.push(message), + exit: throwingExit, + }), + ).toThrow("exit 1"); + + expect(errors).toEqual([` Custom Dockerfile path is not a file: ${dockerfileDir}`]); + }); + + it("exits when the custom Dockerfile is inside an ignored build-context path", () => { + const errors: string[] = []; + const ignoredContextDir = path.join(makeTmpDir("nemoclaw-ignored-context-"), ".ssh"); + fs.mkdirSync(ignoredContextDir); + const ignoredDockerfile = path.join(ignoredContextDir, "Dockerfile"); + fs.writeFileSync(ignoredDockerfile, "FROM scratch\n"); + + expect(() => + stageCreateSandboxBuildContext({ + root: "/unused", + fromDockerfile: ignoredDockerfile, + agent: null, + createAgentSandbox: vi.fn(), + error: (message) => errors.push(message), + exit: throwingExit, + }), + ).toThrow("exit 1"); + + expect(errors).toEqual([ + ` Custom Dockerfile is inside an ignored build-context path: ${ignoredContextDir}`, + " Move your Dockerfile to a dedicated directory and retry.", + ]); + }); + + it("warns when the custom Dockerfile build context is large", () => { + const buildContextDir = makeTmpDir("nemoclaw-large-context-"); + const dockerfile = path.join(buildContextDir, "Dockerfile"); + const largeFile = path.join(buildContextDir, "large.bin"); + fs.writeFileSync(dockerfile, "FROM scratch\n"); + fs.closeSync(fs.openSync(largeFile, "w")); + fs.truncateSync(largeFile, CUSTOM_BUILD_CONTEXT_WARN_BYTES + 1); + const warnings: string[] = []; + + const result = stageCreateSandboxBuildContext({ + root: "/unused", + fromDockerfile: dockerfile, + agent: null, + createAgentSandbox: vi.fn(), + log: vi.fn(), + warn: (message) => warnings.push(message), + exit: throwingExit, + }); + tmpDirs.push(result.buildCtx); + + expect(warnings).toEqual([ + " WARN: build context contains about 100.0 MB across 2 files.", + " The --from flag sends the Dockerfile's parent directory to Docker; use a dedicated directory if this is not intentional.", + ]); + }); + + it("cleans up the temporary build context when copying fails with EACCES", () => { + const buildContextDir = makeTmpDir("nemoclaw-eacces-context-"); + const dockerfile = path.join(buildContextDir, "Dockerfile"); + fs.writeFileSync(dockerfile, "FROM scratch\n"); + const stagedBuildCtx = makeTmpDir("nemoclaw-staged-eacces-"); + const errors: string[] = []; + vi.spyOn(fs, "mkdtempSync").mockReturnValueOnce(stagedBuildCtx); + vi.spyOn(fs, "cpSync").mockImplementationOnce(() => { + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + }); + + expect(() => + stageCreateSandboxBuildContext({ + root: "/unused", + fromDockerfile: dockerfile, + agent: null, + createAgentSandbox: vi.fn(), + log: vi.fn(), + error: (message) => errors.push(message), + exit: throwingExit, + }), + ).toThrow("exit 1"); + + expect(errors).toEqual([ + ` Permission denied while copying build context from: ${buildContextDir}`, + " The --from flag uses the Dockerfile's parent directory as the Docker build context.", + " Move your Dockerfile to a dedicated directory and retry.", + ]); + expect(fs.existsSync(stagedBuildCtx)).toBe(false); + }); + it("delegates to agent or default build-context staging when no custom Dockerfile is supplied", () => { const agentBuild = { buildCtx: makeTmpDir("nemoclaw-agent-build-"), From 152170200754e96ab721674ae37b7907a2690abe Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 06:45:19 -0700 Subject: [PATCH 13/14] refactor(onboard): extract sandbox launch envelope --- src/lib/onboard.ts | 85 ++----------- src/lib/onboard/sandbox-create-launch.test.ts | 112 ++++++++++++++++++ src/lib/onboard/sandbox-create-launch.ts | 99 ++++++++++++++++ 3 files changed, 223 insertions(+), 73 deletions(-) create mode 100644 src/lib/onboard/sandbox-create-launch.test.ts create mode 100644 src/lib/onboard/sandbox-create-launch.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index ca1fdeee41..b475ce9562 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -29,6 +29,7 @@ const { const { stopStaleDashboardListenersForSandbox } = require("./onboard/stale-gateway-cleanup"); const extraPlaceholderKeysModule: typeof import("./onboard/extra-placeholder-keys") = require("./onboard/extra-placeholder-keys"); const buildContextStage: typeof import("./onboard/build-context-stage") = require("./onboard/build-context-stage"); +const sandboxCreateLaunch: typeof import("./onboard/sandbox-create-launch") = require("./onboard/sandbox-create-launch"); const { ensureOllamaLoopbackSystemdOverride, }: typeof import("./onboard/ollama-systemd") = require("./onboard/ollama-systemd"); @@ -62,8 +63,6 @@ const { selectResourceProfileForSandbox, }: typeof import("./onboard/resource-profile-selection") = require("./onboard/resource-profile-selection"); const { - isValidProxyHost, - isValidProxyPort, patchStagedDockerfile, }: typeof import("./onboard/dockerfile-patch") = require("./onboard/dockerfile-patch"); const { @@ -185,7 +184,6 @@ type RunnerOptions = { openshellBinary?: string; }; -const { buildSubprocessEnv } = require("./subprocess-env"); const { DASHBOARD_PORT, GATEWAY_PORT, @@ -3172,77 +3170,18 @@ async function createSandbox( hermesToolGateways, slackConfig, ); - // Only pass non-sensitive env vars to the sandbox. Credentials flow through - // OpenShell providers — the gateway injects them as placeholders and the L7 - // proxy rewrites Authorization headers with real secrets at egress. - // See: crates/openshell-sandbox/src/secrets.rs (placeholder rewriting), - // crates/openshell-router/src/backend.rs (inference auth injection). - // - // Use the shared allowlist (subprocess-env.ts) instead of the old - // blocklist. The blocklist only blocked 12 specific credential names - // and passed EVERYTHING else — including GITHUB_TOKEN, - // AWS_SECRET_ACCESS_KEY, SSH_AUTH_SOCK, KUBECONFIG, NPM_TOKEN, and - // any CI/CD secrets that happened to be in the host environment. - // The allowlist inverts the default: only known-safe env vars are forwarded. - // For sandbox create, also strip KUBECONFIG and SSH_AUTH_SOCK: the generic - // allowlist needs them for host-side subprocesses, but sandbox code must not - // access host Kubernetes or SSH-agent credentials. - const envArgs = [formatEnvAssignment("CHAT_UI_URL", chatUiUrl)]; - // Always pass the effective dashboard port into the sandbox so - // nemoclaw-start.sh starts the gateway on the correct port. When the - // user sets CHAT_UI_URL with a custom port (e.g. :18790), the port - // must reach the container — otherwise _DASHBOARD_PORT defaults to - // 18789 and the gateway listens on the wrong port. (#2267, #1925) - const effectiveDashboardPort = getDashboardForwardPort(chatUiUrl); - envArgs.push(formatEnvAssignment("NEMOCLAW_DASHBOARD_PORT", effectiveDashboardPort)); - require("./onboard/openclaw-runtime-env").appendOpenClawRuntimeEnvArgs(envArgs, agent); - onboardHermesDashboard.appendHermesDashboardEnvArgs( - envArgs, - hermesDashboardState, - formatEnvAssignment, - ); - require("./onboard/host-proxy-env").appendHostProxyEnvArgs(envArgs); - // Propagate NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT to the runtime - // sandbox container. patchStagedDockerfile() already substitutes them - // into the build-time Dockerfile ARG/ENV, but `openshell sandbox create - // -- env … nemoclaw-start` only forwards the explicitly listed env vars - // — image-baked ENV does not propagate into the running pod. Without - // this, nemoclaw-start.sh:898 falls back to the default 10.200.0.1:3128 - // and `HTTPS_PROXY` inside the sandbox ignores the host override. The - // build-time substitution and runtime env stay in sync as a result. - // Fixes #2424. Uses the shared isValidProxyHost / isValidProxyPort - // helpers so build-time and runtime validation stay aligned. - const sandboxProxyHost = process.env.NEMOCLAW_PROXY_HOST; - if (sandboxProxyHost && isValidProxyHost(sandboxProxyHost)) { - envArgs.push(formatEnvAssignment("NEMOCLAW_PROXY_HOST", sandboxProxyHost)); - } - const sandboxProxyPort = process.env.NEMOCLAW_PROXY_PORT; - if (sandboxProxyPort && isValidProxyPort(sandboxProxyPort)) { - envArgs.push(formatEnvAssignment("NEMOCLAW_PROXY_PORT", sandboxProxyPort)); - } - require("./onboard/extra-placeholder-keys").appendExtraPlaceholderKeysEnvArg( - envArgs, - extraPlaceholderKeys, - formatEnvAssignment, - ); const sandboxReadyTimeoutSecs = getSandboxReadyTimeoutSecs(effectiveSandboxGpuConfig); - const sandboxEnv = buildSubprocessEnv(); - // Remove host-infrastructure credentials that the generic allowlist - // permits for host-side processes but that must not enter the sandbox. - delete sandboxEnv.KUBECONFIG; - delete sandboxEnv.SSH_AUTH_SOCK; - // Run without piping through awk — the pipe masked non-zero exit codes - // from openshell because bash returns the status of the last pipeline - // command (awk, always 0) unless pipefail is set. Removing the pipe - // lets the real exit code flow through to run(). - const sandboxStartupCommand = ["env", ...envArgs, "nemoclaw-start"]; - const createCommand = `${openshellShellCommand([ - "sandbox", - "create", - ...createArgs, - "--", - ...sandboxStartupCommand, - ])} 2>&1`; + const { createCommand, effectiveDashboardPort, sandboxEnv, sandboxStartupCommand } = + sandboxCreateLaunch.prepareSandboxCreateLaunch({ + agent, + chatUiUrl, + createArgs, + env: process.env, + extraPlaceholderKeys, + getDashboardForwardPort, + hermesDashboardState, + openshellShellCommand, + }); const dockerGpuCreatePatch = dockerGpuSandboxCreate.createDockerGpuSandboxCreatePatch({ enabled: useDockerGpuPatch, sandboxName, diff --git a/src/lib/onboard/sandbox-create-launch.test.ts b/src/lib/onboard/sandbox-create-launch.test.ts new file mode 100644 index 0000000000..97b33dd1de --- /dev/null +++ b/src/lib/onboard/sandbox-create-launch.test.ts @@ -0,0 +1,112 @@ +// 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 { prepareSandboxCreateLaunch } from "../../../dist/lib/onboard/sandbox-create-launch"; + +const disabledHermesDashboardState = { config: null, enabled: false }; + +describe("prepareSandboxCreateLaunch", () => { + it("builds the sandbox create command and runtime env envelope", () => { + const openshellShellCommand = vi.fn((args: string[]) => `openshell ${args.join(" ")}`); + const result = prepareSandboxCreateLaunch({ + agent: { name: "openclaw", configPaths: { dir: "/sandbox/.custom-openclaw" } } as any, + chatUiUrl: "http://127.0.0.1:19000/", + createArgs: ["--from", "/tmp/build/Dockerfile", "--name", "demo"], + env: { + HTTP_PROXY: " http://proxy.example:8080 ", + NEMOCLAW_MINIMAL_BOOTSTRAP: "1", + NEMOCLAW_PROXY_HOST: "host.docker.internal", + NEMOCLAW_PROXY_PORT: "3129", + }, + extraPlaceholderKeys: ["TELEGRAM_BOT_TOKEN_AGENT_A"], + getDashboardForwardPort: () => "19000", + hermesDashboardState: disabledHermesDashboardState, + openshellShellCommand, + buildEnv: () => + ({ + HOME: "/home/user", + KUBECONFIG: "/home/user/.kube/config", + SSH_AUTH_SOCK: "/tmp/agent.sock", + }) as Record, + }); + + expect(result.effectiveDashboardPort).toBe("19000"); + expect(result.envArgs).toEqual([ + "CHAT_UI_URL=http://127.0.0.1:19000/", + "NEMOCLAW_DASHBOARD_PORT=19000", + "OPENCLAW_HOME=/sandbox", + "OPENCLAW_STATE_DIR=/sandbox/.custom-openclaw", + "OPENCLAW_WORKSPACE_DIR=/sandbox/.custom-openclaw/workspace", + "NEMOCLAW_MINIMAL_BOOTSTRAP=1", + "HTTP_PROXY=http://proxy.example:8080", + "NO_PROXY=localhost,127.0.0.1,host.docker.internal,host.containers.internal,::1,0.0.0.0,inference.local", + "no_proxy=localhost,127.0.0.1,host.docker.internal,host.containers.internal,::1,0.0.0.0,inference.local", + "NEMOCLAW_PROXY_HOST=host.docker.internal", + "NEMOCLAW_PROXY_PORT=3129", + "NEMOCLAW_EXTRA_PLACEHOLDER_KEYS=TELEGRAM_BOT_TOKEN_AGENT_A", + ]); + expect(result.sandboxEnv).toEqual({ HOME: "/home/user" }); + expect(result.sandboxStartupCommand).toEqual(["env", ...result.envArgs, "nemoclaw-start"]); + expect(openshellShellCommand).toHaveBeenCalledWith([ + "sandbox", + "create", + "--from", + "/tmp/build/Dockerfile", + "--name", + "demo", + "--", + ...result.sandboxStartupCommand, + ]); + expect(result.createCommand).toBe( + `openshell sandbox create --from /tmp/build/Dockerfile --name demo -- ${result.sandboxStartupCommand.join(" ")} 2>&1`, + ); + }); + + it("adds Hermes dashboard env and skips OpenClaw env for non-OpenClaw agents", () => { + const result = prepareSandboxCreateLaunch({ + agent: { name: "hermes" } as any, + chatUiUrl: "http://127.0.0.1:18789/", + createArgs: [], + env: {}, + extraPlaceholderKeys: [], + getDashboardForwardPort: () => "18789", + hermesDashboardState: { + config: { enabled: true, internalPort: 8643, port: 18790, tuiEnabled: true }, + enabled: true, + }, + openshellShellCommand: (args) => args.join(" "), + buildEnv: () => ({}), + }); + + expect(result.envArgs).toEqual([ + "CHAT_UI_URL=http://127.0.0.1:18789/", + "NEMOCLAW_DASHBOARD_PORT=18789", + "NEMOCLAW_HERMES_DASHBOARD=1", + "NEMOCLAW_HERMES_DASHBOARD_PORT=18790", + "NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT=8643", + "NEMOCLAW_HERMES_DASHBOARD_TUI=1", + ]); + }); + + it("ignores invalid runtime proxy overrides", () => { + const result = prepareSandboxCreateLaunch({ + agent: null, + chatUiUrl: "http://127.0.0.1:18789/", + createArgs: [], + env: { + NEMOCLAW_PROXY_HOST: "bad:ipv6::host", + NEMOCLAW_PROXY_PORT: "70000", + }, + extraPlaceholderKeys: [], + getDashboardForwardPort: () => "18789", + hermesDashboardState: disabledHermesDashboardState, + openshellShellCommand: (args) => args.join(" "), + buildEnv: () => ({}), + }); + + expect(result.envArgs).not.toContain("NEMOCLAW_PROXY_HOST=bad:ipv6::host"); + expect(result.envArgs).not.toContain("NEMOCLAW_PROXY_PORT=70000"); + }); +}); diff --git a/src/lib/onboard/sandbox-create-launch.ts b/src/lib/onboard/sandbox-create-launch.ts new file mode 100644 index 0000000000..9c224dd88c --- /dev/null +++ b/src/lib/onboard/sandbox-create-launch.ts @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { AgentDefinition } from "../agent/defs"; +import { formatEnvAssignment } from "../core/url-utils"; +import { buildSubprocessEnv } from "../subprocess-env"; +import { isValidProxyHost, isValidProxyPort } from "./dockerfile-patch"; +import { appendExtraPlaceholderKeysEnvArg } from "./extra-placeholder-keys"; +import type { HermesDashboardOnboardState } from "./hermes-dashboard"; +import { appendHermesDashboardEnvArgs } from "./hermes-dashboard"; +import { appendHostProxyEnvArgs } from "./host-proxy-env"; +import { appendOpenClawRuntimeEnvArgs } from "./openclaw-runtime-env"; + +type OpenshellShellCommand = (args: string[]) => string; + +export interface SandboxCreateLaunchInput { + agent: AgentDefinition | null | undefined; + chatUiUrl: string; + createArgs: readonly string[]; + env?: NodeJS.ProcessEnv; + extraPlaceholderKeys: readonly string[]; + getDashboardForwardPort(chatUiUrl: string): string; + hermesDashboardState: HermesDashboardOnboardState; + openshellShellCommand: OpenshellShellCommand; + buildEnv?(): Record; +} + +export interface SandboxCreateLaunch { + createCommand: string; + effectiveDashboardPort: string; + envArgs: string[]; + sandboxEnv: Record; + sandboxStartupCommand: string[]; +} + +export function prepareSandboxCreateLaunch(input: SandboxCreateLaunchInput): SandboxCreateLaunch { + const env = input.env ?? process.env; + const envArgs = [formatEnvAssignment("CHAT_UI_URL", input.chatUiUrl)]; + + // Always pass the effective dashboard port into the sandbox so + // nemoclaw-start.sh starts the gateway on the correct port. When the + // user sets CHAT_UI_URL with a custom port (e.g. :18790), the port + // must reach the container; otherwise _DASHBOARD_PORT defaults to + // 18789 and the gateway listens on the wrong port. (#2267, #1925) + const effectiveDashboardPort = input.getDashboardForwardPort(input.chatUiUrl); + envArgs.push(formatEnvAssignment("NEMOCLAW_DASHBOARD_PORT", effectiveDashboardPort)); + + appendOpenClawRuntimeEnvArgs(envArgs, input.agent ?? null); + appendHermesDashboardEnvArgs(envArgs, input.hermesDashboardState, formatEnvAssignment); + appendHostProxyEnvArgs(envArgs, env); + + // Propagate NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT to the runtime + // sandbox container. patchStagedDockerfile() already substitutes them + // into the build-time Dockerfile ARG/ENV, but `openshell sandbox create + // -- env ... nemoclaw-start` only forwards the explicitly listed env vars; + // image-baked ENV does not propagate into the running pod. Without + // this, nemoclaw-start.sh falls back to the default 10.200.0.1:3128 + // and `HTTPS_PROXY` inside the sandbox ignores the host override. The + // build-time substitution and runtime env stay in sync as a result. + // Fixes #2424. Uses the shared isValidProxyHost / isValidProxyPort + // helpers so build-time and runtime validation stay aligned. + const sandboxProxyHost = env.NEMOCLAW_PROXY_HOST; + if (sandboxProxyHost && isValidProxyHost(sandboxProxyHost)) { + envArgs.push(formatEnvAssignment("NEMOCLAW_PROXY_HOST", sandboxProxyHost)); + } + const sandboxProxyPort = env.NEMOCLAW_PROXY_PORT; + if (sandboxProxyPort && isValidProxyPort(sandboxProxyPort)) { + envArgs.push(formatEnvAssignment("NEMOCLAW_PROXY_PORT", sandboxProxyPort)); + } + + appendExtraPlaceholderKeysEnvArg(envArgs, input.extraPlaceholderKeys, formatEnvAssignment); + + const sandboxEnv = (input.buildEnv ?? buildSubprocessEnv)(); + // Remove host-infrastructure credentials that the generic allowlist + // permits for host-side processes but that must not enter the sandbox. + delete sandboxEnv.KUBECONFIG; + delete sandboxEnv.SSH_AUTH_SOCK; + + // Run without piping through awk; the pipe masked non-zero exit codes + // from openshell because bash returns the status of the last pipeline + // command (awk, always 0) unless pipefail is set. Removing the pipe + // lets the real exit code flow through to run(). + const sandboxStartupCommand = ["env", ...envArgs, "nemoclaw-start"]; + const createCommand = `${input.openshellShellCommand([ + "sandbox", + "create", + ...input.createArgs, + "--", + ...sandboxStartupCommand, + ])} 2>&1`; + + return { + createCommand, + effectiveDashboardPort, + envArgs, + sandboxEnv, + sandboxStartupCommand, + }; +} From d2c9122c67bcdd9790cb5fbd05f27e874ee2afd8 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 06:52:33 -0700 Subject: [PATCH 14/14] test(onboard): cover sandbox launch shell boundaries --- src/lib/onboard/sandbox-create-launch.test.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/lib/onboard/sandbox-create-launch.test.ts b/src/lib/onboard/sandbox-create-launch.test.ts index 97b33dd1de..841edc1730 100644 --- a/src/lib/onboard/sandbox-create-launch.test.ts +++ b/src/lib/onboard/sandbox-create-launch.test.ts @@ -1,8 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { describe, expect, it, vi } from "vitest"; +import { createOpenshellCliHelpers } from "../../../dist/lib/onboard/openshell-cli"; import { prepareSandboxCreateLaunch } from "../../../dist/lib/onboard/sandbox-create-launch"; const disabledHermesDashboardState = { config: null, enabled: false }; @@ -109,4 +115,64 @@ describe("prepareSandboxCreateLaunch", () => { expect(result.envArgs).not.toContain("NEMOCLAW_PROXY_HOST=bad:ipv6::host"); expect(result.envArgs).not.toContain("NEMOCLAW_PROXY_PORT=70000"); }); + + it("preserves argv boundaries when the production renderer shells out", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-launch-shell-")); + try { + const fakeOpenshell = path.join(tmpDir, "fake openshell"); + const capturedArgsPath = path.join(tmpDir, "argv.bin"); + const injectedFromPath = path.join(tmpDir, "from-injected"); + const injectedUrlPath = path.join(tmpDir, "url-injected"); + const injectedProxyPath = path.join(tmpDir, "proxy-injected"); + fs.writeFileSync( + fakeOpenshell, + '#!/usr/bin/env bash\nprintf \'%s\\0\' "$@" > "$CAPTURE_ARGS"\n', + ); + fs.chmodSync(fakeOpenshell, 0o755); + + const helpers = createOpenshellCliHelpers({ + getCachedBinary: () => fakeOpenshell, + setCachedBinary: vi.fn(), + getGatewayPort: () => 31818, + getDockerDriverGatewayEndpoint: () => "http://127.0.0.1:31818", + }); + const dangerousDockerfile = `${tmpDir}/Dockerfile; touch ${injectedFromPath}`; + const dangerousChatUiUrl = `http://127.0.0.1:19000/?q='; touch ${injectedUrlPath} #`; + const dangerousProxy = `http://proxy.example:8080/'; touch ${injectedProxyPath} #`; + const result = prepareSandboxCreateLaunch({ + agent: null, + chatUiUrl: dangerousChatUiUrl, + createArgs: ["--from", dangerousDockerfile, "--name", "demo; echo pwned"], + env: { HTTP_PROXY: dangerousProxy }, + extraPlaceholderKeys: ["TELEGRAM_BOT_TOKEN_AGENT_A"], + getDashboardForwardPort: () => "19000", + hermesDashboardState: disabledHermesDashboardState, + openshellShellCommand: helpers.openshellShellCommand, + buildEnv: () => ({}), + }); + + execFileSync("bash", ["-lc", result.createCommand], { + env: { ...process.env, CAPTURE_ARGS: capturedArgsPath }, + }); + + const capturedArgs = fs.readFileSync(capturedArgsPath, "utf-8").split("\0").filter(Boolean); + expect(capturedArgs).toEqual([ + "sandbox", + "create", + "--from", + dangerousDockerfile, + "--name", + "demo; echo pwned", + "--", + "env", + ...result.envArgs, + "nemoclaw-start", + ]); + expect(fs.existsSync(injectedFromPath)).toBe(false); + expect(fs.existsSync(injectedUrlPath)).toBe(false); + expect(fs.existsSync(injectedProxyPath)).toBe(false); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); });