diff --git a/docs/reference/commands.md b/docs/reference/commands.md index d7ddf0098e6..f9ad6b04dd3 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -190,7 +190,8 @@ It verifies that Docker is reachable, warns on untested runtimes such as Podman, The preflight also enforces the OpenShell version range declared in the blueprint (`min_openshell_version` and `max_openshell_version`). If the installed OpenShell version falls outside this range, onboarding exits with an actionable error and a link to compatible releases. -When an existing gateway is detected for reuse, NemoClaw probes the host gateway HTTP endpoint (`http://127.0.0.1:${NEMOCLAW_GATEWAY_PORT}/`) before declaring it reusable, so a gateway whose container is running but whose upstream is still warming up (e.g. immediately after a Docker daemon restart) is rebuilt instead of trusted. +When NemoClaw finds an existing gateway to reuse, it probes the host gateway HTTP endpoint before declaring the gateway reusable. +If the container is running but the upstream is still warming up (for example, immediately after a Docker daemon restart), NemoClaw rebuilds the gateway instead of trusting stale metadata. Tune the wait via `NEMOCLAW_REUSE_HEALTH_POLL_COUNT` (default `6`) and `NEMOCLAW_REUSE_HEALTH_POLL_INTERVAL` (default `5` seconds). The poll count is clamped to a minimum of `1` so the probe always runs at least once, and the interval is clamped to a minimum of `0` (no sleep between attempts). @@ -1070,21 +1071,30 @@ All ports must be non-privileged integers between 1024 and 65535. | Variable | Default | Service | |----------|---------|---------| -| `NEMOCLAW_GATEWAY_PORT` | 8080 | OpenShell gateway | +| `NEMOCLAW_GATEWAY_PORT` | 8080 | OpenShell gateway port | +| `NEMOCLAW_GATEWAY_BIND_ADDRESS` | 127.0.0.1 | OpenShell gateway bind address (`127.0.0.1` or `0.0.0.0`) | | `NEMOCLAW_DASHBOARD_PORT` | 18789 (auto-derived from `CHAT_UI_URL` port if set) | Dashboard UI | | `NEMOCLAW_VLLM_PORT` | 8000 | vLLM / NIM inference | | `NEMOCLAW_OLLAMA_PORT` | 11434 | Ollama inference | | `NEMOCLAW_OLLAMA_PROXY_PORT` | 11435 | Ollama auth proxy | If a port value is not a valid integer or falls outside the allowed range, the CLI exits with an error. +`NEMOCLAW_GATEWAY_PORT` also cannot overlap the configured dashboard, vLLM, Ollama, or Ollama proxy ports, and cannot use the dashboard auto-allocation range `18789` through `18799` or the default inference/proxy ports `8000`, `11434`, and `11435`. On non-WSL hosts, `NEMOCLAW_OLLAMA_PORT` and `NEMOCLAW_OLLAMA_PROXY_PORT` must be different. If you run Ollama on port 11435, set `NEMOCLAW_OLLAMA_PROXY_PORT` to another free port before onboarding. +`NEMOCLAW_GATEWAY_BIND_ADDRESS` accepts only `127.0.0.1` and `0.0.0.0`. +Binding the OpenShell gateway to `0.0.0.0` may make it reachable from other hosts on the network. + ```console $ export NEMOCLAW_DASHBOARD_PORT=19000 $ nemoclaw onboard ``` +```console +$ NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0 NEMOCLAW_GATEWAY_PORT=8990 nemoclaw onboard +``` + These overrides apply to onboarding, status checks, health probes, and the uninstaller. Defaults are unchanged when no variable is set. If `NEMOCLAW_DASHBOARD_PORT` or the port from `CHAT_UI_URL` is already occupied by another sandbox, onboarding scans `18789` through `18799` and uses the next free dashboard port. diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index dd275277450..9eb2dbbf1c8 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -206,6 +206,23 @@ Or set the port directly: $ NEMOCLAW_DASHBOARD_PORT=19000 nemoclaw onboard ``` +For an OpenShell gateway port conflict, set `NEMOCLAW_GATEWAY_PORT` to a free +non-privileged port that does not overlap NemoClaw's dashboard, vLLM, Ollama, +or Ollama proxy ports: + +```console +$ NEMOCLAW_GATEWAY_PORT=8990 nemoclaw onboard +``` + +Remote/headless hosts can bind the OpenShell gateway to all IPv4 interfaces: + +```console +$ NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0 NEMOCLAW_GATEWAY_PORT=8990 nemoclaw onboard +``` + +Use `NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0` only when other hosts on the +network should be able to reach the gateway. + See [Environment Variables](commands.md#environment-variables) for the full list of port overrides. ### Running multiple sandboxes simultaneously diff --git a/docs/security/best-practices.md b/docs/security/best-practices.md index bf56c97b6da..07110a79785 100644 --- a/docs/security/best-practices.md +++ b/docs/security/best-practices.md @@ -423,6 +423,17 @@ Device authentication requires each connecting device to go through a pairing fl | Risk if relaxed | Disabling device auth allows any device on the network to connect to the gateway without proving identity. This is dangerous when combined with LAN-bind changes or cloudflared tunnels in remote deployments, resulting in an unauthenticated, publicly reachable dashboard. | | Recommendation | Keep device auth enabled (the default). Only disable it for headless or development environments where no untrusted devices can reach the gateway. | +### Gateway Bind Address + +NemoClaw binds the OpenShell gateway to loopback by default. + +| Aspect | Detail | +|---|---| +| Default | `NEMOCLAW_GATEWAY_BIND_ADDRESS=127.0.0.1`. | +| What you can change | Set `NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0` before onboarding to listen on all IPv4 interfaces. | +| Risk if relaxed | Other hosts on the network may be able to reach the OpenShell gateway. | +| Recommendation | Keep the loopback default unless the gateway must be reachable from another host. | + ### Insecure Auth Derivation The `allowInsecureAuth` setting controls whether the gateway permits non-HTTPS authentication. diff --git a/src/lib/core/gateway-address.test.ts b/src/lib/core/gateway-address.test.ts new file mode 100644 index 00000000000..b5e7e4e491d --- /dev/null +++ b/src/lib/core/gateway-address.test.ts @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it } from "vitest"; + +import { + getGatewayConnectHost, + getGatewayHttpEndpoint, + getGatewayHttpsEndpoint, + parseGatewayBindAddress, +} from "../../../dist/lib/core/gateway-address"; + +const ENV_KEY = "TEST_GATEWAY_BIND_ADDRESS"; + +afterEach(() => { + delete process.env[ENV_KEY]; +}); + +describe("parseGatewayBindAddress", () => { + it("defaults to loopback", () => { + expect(parseGatewayBindAddress(ENV_KEY)).toBe("127.0.0.1"); + }); + + it("accepts loopback", () => { + process.env[ENV_KEY] = "127.0.0.1"; + expect(parseGatewayBindAddress(ENV_KEY)).toBe("127.0.0.1"); + }); + + it("accepts all IPv4 interfaces", () => { + process.env[ENV_KEY] = "0.0.0.0"; + expect(parseGatewayBindAddress(ENV_KEY)).toBe("0.0.0.0"); + }); + + it("rejects comma-separated addresses", () => { + process.env[ENV_KEY] = "0.0.0.0,127.0.0.1"; + expect(() => parseGatewayBindAddress(ENV_KEY)).toThrow("must be either"); + }); + + it.each(["localhost", "10.0.0.5", "::", "::1"])("rejects %s", (value) => { + process.env[ENV_KEY] = value; + expect(() => parseGatewayBindAddress(ENV_KEY)).toThrow("must be either"); + }); +}); + +describe("gateway endpoint helpers", () => { + it("keeps loopback endpoints unchanged", () => { + expect(getGatewayConnectHost("127.0.0.1")).toBe("127.0.0.1"); + expect(getGatewayHttpEndpoint(8080, "127.0.0.1")).toBe("http://127.0.0.1:8080"); + expect(getGatewayHttpsEndpoint(8080, "127.0.0.1")).toBe("https://127.0.0.1:8080"); + }); + + it("does not advertise wildcard bind addresses as client endpoints", () => { + expect(getGatewayConnectHost("0.0.0.0")).toBe("127.0.0.1"); + expect(getGatewayHttpEndpoint(8990, "0.0.0.0")).toBe("http://127.0.0.1:8990"); + expect(getGatewayHttpsEndpoint(8990, "0.0.0.0")).toBe("https://127.0.0.1:8990"); + }); +}); diff --git a/src/lib/core/gateway-address.ts b/src/lib/core/gateway-address.ts new file mode 100644 index 00000000000..89cbd67b582 --- /dev/null +++ b/src/lib/core/gateway-address.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { GATEWAY_PORT } from "./ports"; + +export const DEFAULT_GATEWAY_BIND_ADDRESS = "127.0.0.1"; +export const WILDCARD_GATEWAY_BIND_ADDRESS = "0.0.0.0"; + +export type GatewayBindAddress = + | typeof DEFAULT_GATEWAY_BIND_ADDRESS + | typeof WILDCARD_GATEWAY_BIND_ADDRESS; + +export function parseGatewayBindAddress( + envVar = "NEMOCLAW_GATEWAY_BIND_ADDRESS", + fallback: GatewayBindAddress = DEFAULT_GATEWAY_BIND_ADDRESS, +): GatewayBindAddress { + const raw = process.env[envVar]; + if (raw === undefined || raw === "") return fallback; + const trimmed = String(raw).trim(); + if (trimmed === DEFAULT_GATEWAY_BIND_ADDRESS) return DEFAULT_GATEWAY_BIND_ADDRESS; + if (trimmed === WILDCARD_GATEWAY_BIND_ADDRESS) return WILDCARD_GATEWAY_BIND_ADDRESS; + throw new Error( + `Invalid gateway bind address: ${envVar}="${raw}" — must be either ${DEFAULT_GATEWAY_BIND_ADDRESS} or ${WILDCARD_GATEWAY_BIND_ADDRESS}`, + ); +} + +export const GATEWAY_BIND_ADDRESS = parseGatewayBindAddress(); + +export function getGatewayConnectHost( + bindAddress: GatewayBindAddress = GATEWAY_BIND_ADDRESS, +): string { + return bindAddress === WILDCARD_GATEWAY_BIND_ADDRESS + ? DEFAULT_GATEWAY_BIND_ADDRESS + : bindAddress; +} + +export function getGatewayHttpEndpoint( + port: number = GATEWAY_PORT, + bindAddress: GatewayBindAddress = GATEWAY_BIND_ADDRESS, +): string { + return `http://${getGatewayConnectHost(bindAddress)}:${port}`; +} + +export function getGatewayHttpsEndpoint( + port: number = GATEWAY_PORT, + bindAddress: GatewayBindAddress = GATEWAY_BIND_ADDRESS, +): string { + return `https://${getGatewayConnectHost(bindAddress)}:${port}`; +} diff --git a/src/lib/core/ports.test.ts b/src/lib/core/ports.test.ts index 117d219072f..7d991dd362a 100644 --- a/src/lib/core/ports.test.ts +++ b/src/lib/core/ports.test.ts @@ -3,7 +3,16 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; // Import from compiled dist/ so coverage is attributed correctly. -import { parsePort } from "../../../dist/lib/core/ports"; +import { parseGatewayPort, parsePort } from "../../../dist/lib/core/ports"; + +const GATEWAY_VALIDATION_OPTIONS = { + dashboardPort: 18789, + dashboardRangeStart: 18789, + dashboardRangeEnd: 18799, + vllmPort: 8000, + ollamaPort: 11434, + ollamaProxyPort: 11435, +}; describe("parsePort", () => { const ENV_KEY = "TEST_PORT"; @@ -70,3 +79,71 @@ describe("parsePort", () => { expect(() => parsePort(ENV_KEY, 8080)).toThrow("Invalid port"); }); }); + +describe("parseGatewayPort", () => { + const ENV_KEY = "TEST_GATEWAY_PORT"; + + beforeEach(() => { + delete process.env[ENV_KEY]; + }); + + afterEach(() => { + delete process.env[ENV_KEY]; + }); + + it("allows the default gateway port when no override is set", () => { + expect(parseGatewayPort(ENV_KEY, 8080, GATEWAY_VALIDATION_OPTIONS)).toBe(8080); + }); + + it("rejects the default gateway port when another service is configured there", () => { + expect(() => + parseGatewayPort(ENV_KEY, 8080, { + ...GATEWAY_VALIDATION_OPTIONS, + vllmPort: 8080, + }), + ).toThrow("NEMOCLAW_VLLM_PORT"); + }); + + it("accepts a non-conflicting gateway port override", () => { + process.env[ENV_KEY] = "8990"; + expect(parseGatewayPort(ENV_KEY, 8080, GATEWAY_VALIDATION_OPTIONS)).toBe(8990); + }); + + it("rejects the dashboard auto-allocation range", () => { + process.env[ENV_KEY] = "18790"; + expect(() => parseGatewayPort(ENV_KEY, 8080, GATEWAY_VALIDATION_OPTIONS)).toThrow( + "18789-18799", + ); + }); + + it("rejects overlap with the configured dashboard port", () => { + process.env[ENV_KEY] = "19000"; + expect(() => + parseGatewayPort(ENV_KEY, 8080, { + ...GATEWAY_VALIDATION_OPTIONS, + dashboardPort: 19000, + }), + ).toThrow("NEMOCLAW_DASHBOARD_PORT"); + }); + + it("rejects overlap with a configured non-default service port", () => { + process.env[ENV_KEY] = "19001"; + expect(() => + parseGatewayPort(ENV_KEY, 8080, { + ...GATEWAY_VALIDATION_OPTIONS, + vllmPort: 19001, + }), + ).toThrow("NEMOCLAW_VLLM_PORT"); + }); + + it.each([ + ["8000", "vLLM / NIM inference"], + ["11434", "Ollama inference"], + ["11435", "Ollama auth proxy"], + ])("rejects overlap with default port %s", (port, label) => { + process.env[ENV_KEY] = port; + expect(() => parseGatewayPort(ENV_KEY, 8080, GATEWAY_VALIDATION_OPTIONS)).toThrow( + label, + ); + }); +}); diff --git a/src/lib/core/ports.ts b/src/lib/core/ports.ts index 15c741c4555..ddb8caec0f7 100644 --- a/src/lib/core/ports.ts +++ b/src/lib/core/ports.ts @@ -28,8 +28,15 @@ export function parsePort(envVar: string, fallback: number): number { return parsed; } -/** OpenShell gateway port (default 8080, override via NEMOCLAW_GATEWAY_PORT). */ -export const GATEWAY_PORT = parsePort("NEMOCLAW_GATEWAY_PORT", 8080); +export interface GatewayPortValidationOptions { + dashboardPort: number; + dashboardRangeStart: number; + dashboardRangeEnd: number; + vllmPort: number; + ollamaPort: number; + ollamaProxyPort: number; +} + /** * The default port the OpenClaw dashboard listens on inside the sandbox. * The sandbox image is built with CHAT_UI_URL=http://127.0.0.1:SANDBOX_DASHBOARD_PORT @@ -50,3 +57,60 @@ export const VLLM_PORT = parsePort("NEMOCLAW_VLLM_PORT", 8000); export const OLLAMA_PORT = parsePort("NEMOCLAW_OLLAMA_PORT", 11434); /** Ollama auth proxy port (default 11435, override via NEMOCLAW_OLLAMA_PROXY_PORT). */ export const OLLAMA_PROXY_PORT = parsePort("NEMOCLAW_OLLAMA_PROXY_PORT", 11435); + +export function validateGatewayPort( + envVar: string, + port: number, + options: GatewayPortValidationOptions, +): void { + if (port >= options.dashboardRangeStart && port <= options.dashboardRangeEnd) { + throw new Error( + `Invalid port: ${envVar}="${port}" — must not overlap the ${options.dashboardRangeStart}-${options.dashboardRangeEnd} dashboard port range`, + ); + } + + const reservedDefaults = [ + { label: "vLLM / NIM inference", port: 8000 }, + { label: "Ollama inference", port: 11434 }, + { label: "Ollama auth proxy", port: 11435 }, + ]; + const reservedDefault = reservedDefaults.find((entry) => entry.port === port); + if (reservedDefault) { + throw new Error( + `Invalid port: ${envVar}="${port}" — must not overlap the ${reservedDefault.label} default port (${reservedDefault.port})`, + ); + } + + const conflicts = [ + { envVar: "NEMOCLAW_DASHBOARD_PORT", port: options.dashboardPort }, + { envVar: "NEMOCLAW_VLLM_PORT", port: options.vllmPort }, + { envVar: "NEMOCLAW_OLLAMA_PORT", port: options.ollamaPort }, + { envVar: "NEMOCLAW_OLLAMA_PROXY_PORT", port: options.ollamaProxyPort }, + ]; + const conflict = conflicts.find((entry) => entry.port === port); + if (conflict) { + throw new Error( + `Invalid port: ${envVar}="${port}" — conflicts with ${conflict.envVar} (${conflict.port})`, + ); + } +} + +export function parseGatewayPort( + envVar: string, + fallback: number, + options: GatewayPortValidationOptions, +): number { + const port = parsePort(envVar, fallback); + validateGatewayPort(envVar, port, options); + return port; +} + +/** OpenShell gateway port (default 8080, override via NEMOCLAW_GATEWAY_PORT). */ +export const GATEWAY_PORT = parseGatewayPort("NEMOCLAW_GATEWAY_PORT", 8080, { + dashboardPort: DASHBOARD_PORT, + dashboardRangeStart: DASHBOARD_PORT_RANGE_START, + dashboardRangeEnd: DASHBOARD_PORT_RANGE_END, + vllmPort: VLLM_PORT, + ollamaPort: OLLAMA_PORT, + ollamaProxyPort: OLLAMA_PROXY_PORT, +}); diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index eceb5824e02..fb0b1145925 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -267,6 +267,9 @@ const { trackChildExit } = require("./onboard/child-exit-tracker") as typeof import("./onboard/child-exit-tracker"); const { reportDockerDriverGatewayStartFailure } = require("./onboard/docker-driver-gateway-failure") as typeof import("./onboard/docker-driver-gateway-failure"); +const dockerDriverGatewayEnv: typeof import("./onboard/docker-driver-gateway-env") = + require("./onboard/docker-driver-gateway-env"); +const { getDockerDriverGatewayEndpoint } = dockerDriverGatewayEnv; const preflightUtils: typeof import("./onboard/preflight") = require("./onboard/preflight"); const clusterImagePatch: typeof import("./cluster-image-patch") = require("./cluster-image-patch"); const { @@ -1477,6 +1480,21 @@ function runCaptureOpenshell( return runCapture(openshellArgv(args, opts), opts); } +function safeOpenShellArgument(value: string, label: string): string { + if (!/^[A-Za-z0-9._~:/-]+$/.test(value)) { + throw new Error(`Invalid ${label}: contains characters unsafe for OpenShell CLI args`); + } + return value; +} + +function getGatewayPortArg(): string { + return safeOpenShellArgument(String(GATEWAY_PORT), "gateway port"); +} + +function getDockerDriverGatewayEndpointArg(): string { + return safeOpenShellArgument(getDockerDriverGatewayEndpoint(), "gateway endpoint"); +} + /** * Execute a shell command inside a sandbox for post-deployment verification. * Returns a structured result with status, stdout, stderr — or null if @@ -3011,7 +3029,7 @@ async function refreshDockerDriverGatewayReuseState( return gatewayReuseState; } - const portCheck = await checkPortAvailable(GATEWAY_PORT); + const portCheck = await checkGatewayPortAvailable(); const dockerGatewayPid = getDockerDriverGatewayPortListenerPid(portCheck, { gatewayBin, }); @@ -3220,8 +3238,12 @@ function captureProcessArgs(pid: number): string { }).trim(); } +function checkGatewayPortAvailable() { + return checkPortAvailable(GATEWAY_PORT, dockerDriverGatewayEnv.getGatewayPortCheckOptions()); +} + function getGatewayLocalEndpoint(): string { - return `https://127.0.0.1:${GATEWAY_PORT}`; + return dockerDriverGatewayEnv.getGatewayHttpsEndpoint(); } function isLinuxDockerDriverGatewayEnabled( @@ -3237,10 +3259,6 @@ function isLinuxDockerDriverGatewayPlatform( return platform === "linux"; } -function getDockerDriverGatewayEndpoint(): string { - return `http://127.0.0.1:${GATEWAY_PORT}`; -} - function getDockerDriverGatewayStateDir(): string { const configured = process.env.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR; if (configured && configured.trim()) return path.resolve(configured.trim()); @@ -3326,37 +3344,14 @@ function getDockerDriverGatewayEnv( versionOutput: string | null = null, platform: NodeJS.Platform = process.platform, ): Record { - const stateDir = getDockerDriverGatewayStateDir(); - const env: Record = { - OPENSHELL_DRIVERS: platform === "darwin" ? "vm" : "docker", - OPENSHELL_BIND_ADDRESS: "127.0.0.1", - OPENSHELL_SERVER_PORT: String(GATEWAY_PORT), - OPENSHELL_DISABLE_TLS: "true", - OPENSHELL_DISABLE_GATEWAY_AUTH: "true", - OPENSHELL_DB_URL: `sqlite:${path.join(stateDir, "openshell.db")}`, - OPENSHELL_GRPC_ENDPOINT: - platform === "darwin" - ? `http://host.containers.internal:${GATEWAY_PORT}` - : getDockerDriverGatewayEndpoint(), - OPENSHELL_SSH_GATEWAY_HOST: "127.0.0.1", - OPENSHELL_SSH_GATEWAY_PORT: String(GATEWAY_PORT), - }; - if (platform === "darwin") { - env.OPENSHELL_VM_DRIVER_STATE_DIR = path.join(stateDir, "vm-driver"); - const vmDriverBin = resolveOpenShellVmDriverBinary(); - if (vmDriverBin) { - env.OPENSHELL_DRIVER_DIR = path.dirname(vmDriverBin); - } - } else { - env.OPENSHELL_DOCKER_NETWORK_NAME = - process.env.OPENSHELL_DOCKER_NETWORK_NAME || "openshell-docker"; - env.OPENSHELL_DOCKER_SUPERVISOR_IMAGE = getOpenShellDockerSupervisorImage(versionOutput); - const sandboxBin = resolveOpenShellSandboxBinary(); - if (sandboxBin) { - env.OPENSHELL_DOCKER_SUPERVISOR_BIN = sandboxBin; - } - } - return env; + return dockerDriverGatewayEnv.buildDockerDriverGatewayEnv({ + platform, + stateDir: getDockerDriverGatewayStateDir(), + dockerNetworkName: process.env.OPENSHELL_DOCKER_NETWORK_NAME || "openshell-docker", + getDockerSupervisorImage: () => getOpenShellDockerSupervisorImage(versionOutput), + resolveVmDriverBin: resolveOpenShellVmDriverBinary, + resolveSandboxBin: resolveOpenShellSandboxBinary, + }); } function isPidAlive(pid: number): boolean { @@ -3432,21 +3427,6 @@ function shouldRequireDockerDriverEnv(platform: NodeJS.Platform = process.platfo return platform === "linux"; } -const DOCKER_DRIVER_GATEWAY_RUNTIME_ENV_KEYS = [ - "OPENSHELL_DRIVERS", - "OPENSHELL_BIND_ADDRESS", - "OPENSHELL_SERVER_PORT", - "OPENSHELL_DISABLE_TLS", - "OPENSHELL_DISABLE_GATEWAY_AUTH", - "OPENSHELL_DB_URL", - "OPENSHELL_GRPC_ENDPOINT", - "OPENSHELL_SSH_GATEWAY_HOST", - "OPENSHELL_SSH_GATEWAY_PORT", - "OPENSHELL_DOCKER_NETWORK_NAME", - "OPENSHELL_DOCKER_SUPERVISOR_IMAGE", - "OPENSHELL_DOCKER_SUPERVISOR_BIN", -] as const; - function getDockerDriverGatewayRuntimeDriftFromSnapshot({ processEnv, processExe, @@ -3461,7 +3441,7 @@ function getDockerDriverGatewayRuntimeDriftFromSnapshot({ if (!processEnv) { return { reason: "could not verify process environment" }; } - for (const key of DOCKER_DRIVER_GATEWAY_RUNTIME_ENV_KEYS) { + for (const key of dockerDriverGatewayEnv.DOCKER_DRIVER_GATEWAY_RUNTIME_ENV_KEYS) { const desired = desiredEnv[key]; if (typeof desired !== "string") continue; const actual = processEnv[key]; @@ -3588,38 +3568,6 @@ function isDockerDriverGatewayPortListener( return getDockerDriverGatewayPortListenerPid(portCheck, opts) !== null; } -function writeDockerGatewayDebEnvOverride(): void { - const servicePath = "/usr/lib/systemd/user/openshell-gateway.service"; - const legacyServicePath = "/lib/systemd/user/openshell-gateway.service"; - if ( - !fs.existsSync("/usr/bin/openshell-gateway") && - !fs.existsSync(servicePath) && - !fs.existsSync(legacyServicePath) - ) { - return; - } - const envFile = path.join(os.homedir(), ".config", "openshell", "gateway.env"); - fs.mkdirSync(path.dirname(envFile), { recursive: true, mode: 0o700 }); - const existing = fs.existsSync(envFile) ? fs.readFileSync(envFile, "utf-8") : ""; - const preserved = existing - .split("\n") - .filter( - (line: string) => - line.trim() && - !/^OPENSHELL_(DRIVERS|DOCKER_SUPERVISOR_IMAGE|DOCKER_SUPERVISOR_BIN)=/.test(line), - ); - const override = getDockerDriverGatewayEnv(); - const next = [ - ...preserved, - `OPENSHELL_DRIVERS=${override.OPENSHELL_DRIVERS}`, - `OPENSHELL_DOCKER_SUPERVISOR_IMAGE=${override.OPENSHELL_DOCKER_SUPERVISOR_IMAGE}`, - ...(override.OPENSHELL_DOCKER_SUPERVISOR_BIN - ? [`OPENSHELL_DOCKER_SUPERVISOR_BIN=${override.OPENSHELL_DOCKER_SUPERVISOR_BIN}`] - : []), - ].join("\n"); - fs.writeFileSync(envFile, `${next}\n`, { encoding: "utf-8", mode: 0o600 }); -} - function registerDockerDriverGatewayEndpoint(): boolean { const selectExisting = runQuietOpenshell(["gateway", "select", GATEWAY_NAME]); if (selectExisting.status === 0) { @@ -3635,13 +3583,13 @@ function registerDockerDriverGatewayEndpoint(): boolean { } let addResult = runOpenshell( - ["gateway", "add", getDockerDriverGatewayEndpoint(), "--local", "--name", GATEWAY_NAME], + ["gateway", "add", getDockerDriverGatewayEndpointArg(), "--local", "--name", GATEWAY_NAME], { ignoreError: true, suppressOutput: true }, ); if (addResult.status !== 0) { removeDockerDriverGatewayRegistration(); addResult = runOpenshell( - ["gateway", "add", getDockerDriverGatewayEndpoint(), "--local", "--name", GATEWAY_NAME], + ["gateway", "add", getDockerDriverGatewayEndpointArg(), "--local", "--name", GATEWAY_NAME], { ignoreError: true, suppressOutput: true }, ); } @@ -4192,7 +4140,7 @@ async function preflight( // back before the OpenShell gateway upstream finishes warming up. Safe to // recreate because Docker is functional. See #3258. console.log( - ` Gateway container is running but http://127.0.0.1:${GATEWAY_PORT}/ is not responding. Recreating...`, + ` Gateway container is running but ${getGatewayLocalEndpoint()}/ is not responding. Recreating...`, ); runOpenshell(["forward", "stop", String(DASHBOARD_PORT)], { ignoreError: true }); gatewayReuseState = destroyGatewayForReuse( @@ -4278,7 +4226,11 @@ async function preflight( // find a free port. const dashboardPortToCheck = _preflightDashboardPort ?? null; const requiredPorts = [ - { port: GATEWAY_PORT, label: "OpenShell gateway", envVar: "NEMOCLAW_GATEWAY_PORT" }, + { + port: GATEWAY_PORT, + label: "OpenShell gateway", + envVar: "NEMOCLAW_GATEWAY_PORT", + }, ...(dashboardPortToCheck !== null ? [ { @@ -4290,7 +4242,9 @@ async function preflight( : []), ]; for (const { port, label, envVar } of requiredPorts) { - let portCheck = await checkPortAvailable(port); + const portCheckOptions = + port === GATEWAY_PORT ? dockerDriverGatewayEnv.getGatewayPortCheckOptions() : undefined; + let portCheck = await checkPortAvailable(port, portCheckOptions); if (!portCheck.ok) { if ((port === GATEWAY_PORT || port === DASHBOARD_PORT) && gatewayReuseState === "healthy") { console.log( @@ -4321,7 +4275,7 @@ async function preflight( ); run(["kill", String(portCheck.pid)], { ignoreError: true }); sleep(1); - portCheck = await checkPortAvailable(port); + portCheck = await checkPortAvailable(port, portCheckOptions); if (portCheck.ok) { console.log(` ✓ Port ${port} available after orphaned forward cleanup (${label})`); continue; @@ -4360,6 +4314,7 @@ async function preflight( } console.log(` ✓ Port ${port} available (${label})`); } + dockerDriverGatewayEnv.warnIfGatewayWildcardBindAddress(); // GPU const gpu = nim.detectGpu(); @@ -4475,7 +4430,7 @@ async function startGatewayWithOptions( return; } console.log( - ` Gateway metadata reports healthy but http://127.0.0.1:${GATEWAY_PORT}/ is not responding. Starting a fresh gateway...`, + ` Gateway metadata reports healthy but ${getGatewayLocalEndpoint()}/ is not responding. Starting a fresh gateway...`, ); } @@ -4506,7 +4461,7 @@ async function startGatewayWithOptions( } } - const gwArgs = ["--name", GATEWAY_NAME, "--port", String(GATEWAY_PORT)]; + const gwArgs = ["--name", GATEWAY_NAME, "--port", getGatewayPortArg()]; // On NVIDIA hosts, pass --gpu unless the user explicitly opted out. This // makes direct CUDA tools available in the sandbox by default while still // supporting host-side inference providers. @@ -4647,7 +4602,7 @@ async function startGatewayWithOptions( async function startDockerDriverGateway({ exitOnFailure = true, }: { exitOnFailure?: boolean } = {}): Promise { - writeDockerGatewayDebEnvOverride(); + dockerDriverGatewayEnv.writeDockerGatewayDebEnvOverride(() => getDockerDriverGatewayEnv()); const gatewayBin = resolveOpenShellGatewayBinary(); const openshellVersionOutput = runCaptureOpenshell(["--version"], { ignoreError: true, @@ -4678,7 +4633,7 @@ async function startDockerDriverGateway({ } } - const portCheck = await checkPortAvailable(GATEWAY_PORT); + const portCheck = await checkGatewayPortAvailable(); const portListenerPid = getDockerDriverGatewayPortListenerPid(portCheck, { gatewayBin }); if (portListenerPid !== null) { const drift = getDockerDriverGatewayRuntimeDrift(portListenerPid, gatewayEnv, gatewayBin); @@ -4793,7 +4748,7 @@ async function startGatewayForRecovery(_gpu: ReturnType): } function getGatewayStartEnv(): Record { - const gatewayEnv: Record = {}; + const gatewayEnv = dockerDriverGatewayEnv.getGatewayStartNetworkEnv(); const openshellVersion = getInstalledOpenshellVersion(); const stableGatewayImage = openshellVersion ? `ghcr.io/nvidia/openshell/cluster:${openshellVersion}` @@ -4919,7 +4874,7 @@ async function recoverGatewayRuntime() { } const startResult = runOpenshell( - ["gateway", "start", "--name", GATEWAY_NAME, "--port", String(GATEWAY_PORT)], + ["gateway", "start", "--name", GATEWAY_NAME, "--port", getGatewayPortArg()], { ignoreError: true, env: getGatewayStartEnv(), @@ -10682,7 +10637,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { // `destroyGateway()` between attempts — which would tear down a // possibly-live gateway. Bail with an actionable error instead. console.log( - ` Error: could not verify gateway container state and http://127.0.0.1:${GATEWAY_PORT}/ is not responding.`, + ` Error: could not verify gateway container state and ${getGatewayLocalEndpoint()}/ is not responding.`, ); console.log( " Refusing to proceed without a clear Docker signal — restarting Docker and re-running onboard is the safe path. See #3258 / #2020.", @@ -10695,7 +10650,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { // back before the OpenShell gateway upstream finishes warming up. Safe to // recreate because Docker is functional. See #3258. console.log( - ` Gateway container is running but http://127.0.0.1:${GATEWAY_PORT}/ is not responding. Recreating...`, + ` Gateway container is running but ${getGatewayLocalEndpoint()}/ is not responding. Recreating...`, ); runOpenshell(["forward", "stop", String(DASHBOARD_PORT)], { ignoreError: true }); gatewayReuseState = destroyGatewayForReuse( diff --git a/src/lib/onboard/docker-driver-gateway-env.test.ts b/src/lib/onboard/docker-driver-gateway-env.test.ts new file mode 100644 index 00000000000..4c1c10a384f --- /dev/null +++ b/src/lib/onboard/docker-driver-gateway-env.test.ts @@ -0,0 +1,108 @@ +// 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 { describe, expect, it, vi } from "vitest"; + +import { + buildDockerDriverGatewayEnv, + buildDockerGatewayDebEnvFile, + writeDockerGatewayDebEnvOverride, +} from "./docker-driver-gateway-env"; + +describe("buildDockerDriverGatewayEnv", () => { + it("sets Docker-driver gateway networking from NemoClaw configuration", () => { + expect( + buildDockerDriverGatewayEnv({ + platform: "linux", + stateDir: "/tmp/nemoclaw-gateway", + getDockerSupervisorImage: () => "ghcr.io/nvidia/openshell/supervisor:0.0.37", + resolveVmDriverBin: () => null, + resolveSandboxBin: () => "/usr/bin/openshell-sandbox", + }), + ).toMatchObject({ + OPENSHELL_BIND_ADDRESS: "127.0.0.1", + OPENSHELL_SERVER_PORT: "8080", + OPENSHELL_GRPC_ENDPOINT: "http://127.0.0.1:8080", + OPENSHELL_SSH_GATEWAY_HOST: "127.0.0.1", + OPENSHELL_SSH_GATEWAY_PORT: "8080", + }); + }); +}); + +describe("buildDockerGatewayDebEnvFile", () => { + it("replaces all managed gateway env keys and preserves unrelated values", () => { + const next = buildDockerGatewayDebEnvFile( + [ + "KEEP_ME=1", + "OPENSHELL_BIND_ADDRESS=127.0.0.1", + "OPENSHELL_SERVER_PORT=8080", + "OPENSHELL_DOCKER_SUPERVISOR_IMAGE=old", + ].join("\n"), + { + OPENSHELL_DRIVERS: "docker", + OPENSHELL_BIND_ADDRESS: "0.0.0.0", + OPENSHELL_SERVER_PORT: "8990", + OPENSHELL_DISABLE_TLS: "true", + OPENSHELL_DISABLE_GATEWAY_AUTH: "true", + OPENSHELL_DB_URL: "sqlite:/tmp/openshell.db", + OPENSHELL_GRPC_ENDPOINT: "http://127.0.0.1:8990", + OPENSHELL_SSH_GATEWAY_HOST: "127.0.0.1", + OPENSHELL_SSH_GATEWAY_PORT: "8990", + OPENSHELL_DOCKER_NETWORK_NAME: "openshell-docker", + OPENSHELL_DOCKER_SUPERVISOR_IMAGE: "new", + }, + ); + + expect(next).toContain("KEEP_ME=1\n"); + expect(next).toContain("OPENSHELL_BIND_ADDRESS=0.0.0.0\n"); + expect(next).toContain("OPENSHELL_SERVER_PORT=8990\n"); + expect(next).toContain("OPENSHELL_DOCKER_SUPERVISOR_IMAGE=new\n"); + expect(next).not.toContain("OPENSHELL_BIND_ADDRESS=127.0.0.1"); + expect(next).not.toContain("OPENSHELL_DOCKER_SUPERVISOR_IMAGE=old"); + }); + + it("rejects multiline managed values", () => { + expect(() => + buildDockerGatewayDebEnvFile("", { + OPENSHELL_BIND_ADDRESS: "127.0.0.1\nINJECTED=1", + }), + ).toThrow("line break"); + }); +}); + +describe("writeDockerGatewayDebEnvOverride", () => { + it("enforces restrictive permissions on an existing env directory and file", () => { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-env-")); + const envDir = path.join(tempHome, ".config", "openshell"); + const envFile = path.join(envDir, "gateway.env"); + fs.mkdirSync(envDir, { recursive: true, mode: 0o755 }); + fs.chmodSync(envDir, 0o755); + fs.writeFileSync(envFile, "KEEP_ME=1\n", { mode: 0o644 }); + fs.chmodSync(envFile, 0o644); + + const existsSpy = vi + .spyOn(fs, "existsSync") + .mockImplementation((candidate) => candidate === "/usr/bin/openshell-gateway"); + const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(tempHome); + + try { + writeDockerGatewayDebEnvOverride(() => ({ + OPENSHELL_BIND_ADDRESS: "127.0.0.1", + })); + + const envFileContent = fs.readFileSync(envFile, "utf-8"); + expect(fs.statSync(envDir).mode & 0o777).toBe(0o700); + expect(fs.statSync(envFile).mode & 0o777).toBe(0o600); + expect(envFileContent).toContain("KEEP_ME=1\n"); + expect(envFileContent).toContain("OPENSHELL_BIND_ADDRESS=127.0.0.1\n"); + } finally { + existsSpy.mockRestore(); + homedirSpy.mockRestore(); + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); +}); diff --git a/src/lib/onboard/docker-driver-gateway-env.ts b/src/lib/onboard/docker-driver-gateway-env.ts new file mode 100644 index 00000000000..757625bac06 --- /dev/null +++ b/src/lib/onboard/docker-driver-gateway-env.ts @@ -0,0 +1,163 @@ +// 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 { + GATEWAY_BIND_ADDRESS, + WILDCARD_GATEWAY_BIND_ADDRESS, + getGatewayConnectHost, + getGatewayHttpEndpoint, + getGatewayHttpsEndpoint, +} from "../core/gateway-address"; +import { GATEWAY_PORT } from "../core/ports"; + +export { getGatewayHttpsEndpoint }; + +export const DOCKER_DRIVER_GATEWAY_RUNTIME_ENV_KEYS = [ + "OPENSHELL_DRIVERS", + "OPENSHELL_BIND_ADDRESS", + "OPENSHELL_SERVER_PORT", + "OPENSHELL_DISABLE_TLS", + "OPENSHELL_DISABLE_GATEWAY_AUTH", + "OPENSHELL_DB_URL", + "OPENSHELL_GRPC_ENDPOINT", + "OPENSHELL_SSH_GATEWAY_HOST", + "OPENSHELL_SSH_GATEWAY_PORT", + "OPENSHELL_DOCKER_NETWORK_NAME", + "OPENSHELL_DOCKER_SUPERVISOR_IMAGE", + "OPENSHELL_DOCKER_SUPERVISOR_BIN", +] as const; + +export interface BuildDockerDriverGatewayEnvOptions { + platform?: NodeJS.Platform; + stateDir: string; + dockerNetworkName?: string; + getDockerSupervisorImage: () => string; + resolveVmDriverBin: () => string | null; + resolveSandboxBin: () => string | null; +} + +export function getGatewayPortCheckOptions(): { host: string } { + return { host: GATEWAY_BIND_ADDRESS }; +} + +export function getGatewayStartNetworkEnv(): Record { + return { + OPENSHELL_BIND_ADDRESS: GATEWAY_BIND_ADDRESS, + OPENSHELL_SERVER_PORT: String(GATEWAY_PORT), + OPENSHELL_SSH_GATEWAY_HOST: getGatewayConnectHost(), + OPENSHELL_SSH_GATEWAY_PORT: String(GATEWAY_PORT), + }; +} + +export function getDockerDriverGatewayEndpoint(): string { + return getGatewayHttpEndpoint(); +} + +export function warnIfGatewayWildcardBindAddress(): void { + if (GATEWAY_BIND_ADDRESS !== WILDCARD_GATEWAY_BIND_ADDRESS) return; + console.log( + " ! OpenShell gateway bind address set to 0.0.0.0; the gateway may be reachable from other hosts on this network.", + ); +} + +export function buildDockerDriverGatewayEnv({ + platform = process.platform, + stateDir, + dockerNetworkName = "openshell-docker", + getDockerSupervisorImage, + resolveVmDriverBin, + resolveSandboxBin, +}: BuildDockerDriverGatewayEnvOptions): Record { + const env: Record = { + OPENSHELL_DRIVERS: platform === "darwin" ? "vm" : "docker", + ...getGatewayStartNetworkEnv(), + OPENSHELL_DISABLE_TLS: "true", + OPENSHELL_DISABLE_GATEWAY_AUTH: "true", + OPENSHELL_DB_URL: `sqlite:${path.join(stateDir, "openshell.db")}`, + OPENSHELL_GRPC_ENDPOINT: + platform === "darwin" + ? `http://host.containers.internal:${GATEWAY_PORT}` + : getDockerDriverGatewayEndpoint(), + }; + if (platform === "darwin") { + env.OPENSHELL_VM_DRIVER_STATE_DIR = path.join(stateDir, "vm-driver"); + const vmDriverBin = resolveVmDriverBin(); + if (vmDriverBin) { + env.OPENSHELL_DRIVER_DIR = path.dirname(vmDriverBin); + } + } else { + env.OPENSHELL_DOCKER_NETWORK_NAME = dockerNetworkName; + env.OPENSHELL_DOCKER_SUPERVISOR_IMAGE = getDockerSupervisorImage(); + const sandboxBin = resolveSandboxBin(); + if (sandboxBin) { + env.OPENSHELL_DOCKER_SUPERVISOR_BIN = sandboxBin; + } + } + return env; +} + +export function buildDockerGatewayDebEnvFile( + existing: string, + override: Record, +): string { + const managedKeyPattern = new RegExp( + `^(${DOCKER_DRIVER_GATEWAY_RUNTIME_ENV_KEYS.join("|")})=`, + ); + const preserved = existing + .split("\n") + .filter((line) => line.trim() && !managedKeyPattern.test(line)); + const managed = DOCKER_DRIVER_GATEWAY_RUNTIME_ENV_KEYS.flatMap((key) => + typeof override[key] === "string" + ? [formatEnvironmentFileAssignment(key, override[key])] + : [], + ); + return `${[...preserved, ...managed].join("\n")}\n`; +} + +function formatEnvironmentFileAssignment(key: string, value: string): string { + if (/[\0\r\n]/.test(value)) { + throw new Error(`Invalid OpenShell gateway env value for ${key}: contains a line break`); + } + return `${key}=${value}`; +} + +function readTextFileIfPresent(filePath: string): string { + try { + return fs.readFileSync(filePath, "utf-8"); + } catch (error) { + if ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ) { + return ""; + } + throw error; + } +} + +export function writeDockerGatewayDebEnvOverride( + getOverride: () => Record, +): void { + const servicePaths = [ + "/usr/bin/openshell-gateway", + "/usr/lib/systemd/user/openshell-gateway.service", + "/lib/systemd/user/openshell-gateway.service", + ]; + if (!servicePaths.some((candidate) => fs.existsSync(candidate))) return; + const override = getOverride(); + const envDir = path.join(os.homedir(), ".config", "openshell"); + const envFile = path.join(envDir, "gateway.env"); + fs.mkdirSync(envDir, { recursive: true, mode: 0o700 }); + fs.chmodSync(envDir, 0o700); + const existing = readTextFileIfPresent(envFile); + fs.writeFileSync(envFile, buildDockerGatewayDebEnvFile(existing, override), { + encoding: "utf-8", + mode: 0o600, + }); + fs.chmodSync(envFile, 0o600); +} diff --git a/src/lib/onboard/gateway-http-readiness.ts b/src/lib/onboard/gateway-http-readiness.ts index 7c19668026c..4647e9cb7ca 100644 --- a/src/lib/onboard/gateway-http-readiness.ts +++ b/src/lib/onboard/gateway-http-readiness.ts @@ -4,7 +4,7 @@ /** * Host-level HTTP readiness probe for the OpenShell gateway. * - * Hits `http://127.0.0.1:${GATEWAY_PORT}/` directly (no Docker dependency), + * Hits the local gateway HTTP endpoint directly (no Docker dependency), * which lets the reuse path verify the gateway is genuinely serving even when * the Docker daemon is flaky and openshell CLI metadata is stale. See #3258 * (regression of #2020) for the original motivation. @@ -13,6 +13,7 @@ import http from "node:http"; import http2 from "node:http2"; +import { getGatewayHttpEndpoint } from "../core/gateway-address"; import { GATEWAY_PORT } from "../core/ports"; import { sleepSeconds } from "../core/wait"; import { envInt } from "./env"; @@ -56,7 +57,7 @@ export function getGatewayReuseHealthWaitConfig(): { count: number; interval: nu } /** - * Probe the host-level gateway HTTP endpoint at `http://127.0.0.1:${GATEWAY_PORT}/`. + * Probe the host-level gateway HTTP endpoint. * * Returns true when the gateway responds with a known-alive status code, * false on any other status (notably 5xx from a warming upstream) or any @@ -70,7 +71,7 @@ export function getGatewayReuseHealthWaitConfig(): { count: number; interval: nu */ export function isGatewayHttpReady( timeoutMs = ISGATEWAY_HTTP_READY_DEFAULT_TIMEOUT_MS, - url = `http://127.0.0.1:${GATEWAY_PORT}/`, + url = `${getGatewayHttpEndpoint(GATEWAY_PORT)}/`, method: "GET" | "POST" = "GET", ): Promise { const effectiveTimeout = @@ -101,7 +102,7 @@ export function isGatewayHttpReady( export function isDockerDriverGatewayHttpReady( timeoutMs = ISGATEWAY_HTTP_READY_DEFAULT_TIMEOUT_MS, - url = `http://127.0.0.1:${GATEWAY_PORT}/openshell.v1.OpenShell/Health`, + url = `${getGatewayHttpEndpoint(GATEWAY_PORT)}/openshell.v1.OpenShell/Health`, ): Promise { const effectiveTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 diff --git a/src/lib/onboard/gateway-tcp-readiness.test.ts b/src/lib/onboard/gateway-tcp-readiness.test.ts index 43774ea2383..4d6a62beb6f 100644 --- a/src/lib/onboard/gateway-tcp-readiness.test.ts +++ b/src/lib/onboard/gateway-tcp-readiness.test.ts @@ -9,7 +9,7 @@ // See: https://github.com/NVIDIA/NemoClaw/issues/3111 import net from "node:net"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { GATEWAY_PORT } from "../core/ports"; import { isGatewayTcpReady } from "./gateway-tcp-readiness"; @@ -53,6 +53,7 @@ describe("isGatewayTcpReady (#3111)", () => { await teardown(); teardown = null; } + vi.restoreAllMocks(); }); it("resolves true when something is accepting connections", async () => { @@ -95,9 +96,12 @@ describe("isGatewayTcpReady (#3111)", () => { }); it("defaults to GATEWAY_PORT when no port is supplied", async () => { - // The development host may already have a gateway on GATEWAY_PORT. Compare - // the implicit and explicit calls instead of assuming the port is closed. - const implicit = await isGatewayTcpReady(undefined, 200); - await expect(isGatewayTcpReady(GATEWAY_PORT, 200)).resolves.toBe(implicit); + // The host running this test may already have something listening on the + // default gateway port, so assert the probed argument instead of the result. + const createConnection = vi.spyOn(net, "createConnection"); + await expect(isGatewayTcpReady(undefined, 200)).resolves.toBeTypeOf("boolean"); + expect(createConnection).toHaveBeenCalledWith( + expect.objectContaining({ port: GATEWAY_PORT }), + ); }); }); diff --git a/src/lib/onboard/gateway-tcp-readiness.ts b/src/lib/onboard/gateway-tcp-readiness.ts index 7de691885a0..f0a01e3ec1c 100644 --- a/src/lib/onboard/gateway-tcp-readiness.ts +++ b/src/lib/onboard/gateway-tcp-readiness.ts @@ -4,7 +4,7 @@ /** * Host-level TCP readiness probe for the OpenShell Docker-driver gateway. * - * Plain TCP connect to `127.0.0.1:${GATEWAY_PORT}` — semantic-free, just asks + * Plain TCP connect to the local gateway endpoint — semantic-free, just asks * "is anyone listening?". Used by `startDockerDriverGateway` in `onboard.ts` * to gate the "✓ Docker-driver gateway is healthy" log against the class of * bug reported in #3111, where the openshell-gateway binary crashed on @@ -50,6 +50,7 @@ import net from "node:net"; +import { getGatewayConnectHost } from "../core/gateway-address"; import { GATEWAY_PORT } from "../core/ports"; const ISGATEWAY_TCP_READY_DEFAULT_TIMEOUT_MS = 500; @@ -63,7 +64,7 @@ const ISGATEWAY_TCP_READY_DEFAULT_TIMEOUT_MS = 500; const ISGATEWAY_TCP_READY_MIN_TIMEOUT_MS = 50; /** - * Probe a TCP endpoint on localhost to verify something is actually + * Probe a TCP endpoint on the local gateway host to verify something is actually * listening and accepting connections on the gateway port. * * Resolves true on a successful TCP connect. Resolves false on connection @@ -74,14 +75,14 @@ const ISGATEWAY_TCP_READY_MIN_TIMEOUT_MS = 50; * @param timeoutMs Per-connect timeout. Clamped to a 50 ms minimum so the * probe can't spin or return instantly when a caller * passes 0. - * @param host Target host. Defaults to `127.0.0.1`; overridable only - * for unit testing (exercising the timeout path against - * a non-routable address). + * @param host Target host. Defaults to the local connect host derived + * from the configured bind address; overridable for unit + * testing. */ export function isGatewayTcpReady( port: number = GATEWAY_PORT, timeoutMs: number = ISGATEWAY_TCP_READY_DEFAULT_TIMEOUT_MS, - host = "127.0.0.1", + host = getGatewayConnectHost(), ): Promise { const effectiveTimeout = Number.isFinite(timeoutMs) && timeoutMs > ISGATEWAY_TCP_READY_MIN_TIMEOUT_MS diff --git a/src/lib/onboard/preflight.test.ts b/src/lib/onboard/preflight.test.ts index c12370f75bc..1ddbce418f3 100644 --- a/src/lib/onboard/preflight.test.ts +++ b/src/lib/onboard/preflight.test.ts @@ -46,6 +46,21 @@ describe("checkPortAvailable", () => { expect(result).toEqual({ ok: true }); }); + it("passes the requested host to the fallback probe", async () => { + let probedHost: string | null = null; + const result = await checkPortAvailable(8990, { + lsofOutput: "", + host: "0.0.0.0", + probeImpl: async (_port, host) => { + probedHost = host; + return { ok: true }; + }, + }); + + expect(probedHost).toBe("0.0.0.0"); + expect(result).toEqual({ ok: true }); + }); + it("probe catches occupied port even when lsof returns empty", async () => { const result = await checkPortAvailable(18789, { lsofOutput: "", @@ -167,6 +182,20 @@ describe("probePortAvailability", () => { } }); + it("uses the requested host for real net probes", async () => { + const net = require("node:net"); + const srv = net.createServer(); + await new Promise((resolve) => srv.listen(0, "0.0.0.0", resolve)); + const port = srv.address().port; + try { + const result = await probePortAvailability(port, { host: "0.0.0.0" }); + expect(result.ok).toBe(false); + expect(result.reason).toContain("EADDRINUSE"); + } finally { + await new Promise((resolve) => srv.close(resolve)); + } + }); + it("delegates to probeImpl when provided", async () => { let called = false; const result = await probePortAvailability(9999, { diff --git a/src/lib/onboard/preflight.ts b/src/lib/onboard/preflight.ts index c707786ae7c..4127fdfe6e6 100644 --- a/src/lib/onboard/preflight.ts +++ b/src/lib/onboard/preflight.ts @@ -41,8 +41,10 @@ export interface CheckPortOpts { lsofOutput?: string; /** Force the net-probe fallback path. */ skipLsof?: boolean; + /** Host address to use for the fallback net probe. */ + host?: string; /** Async probe implementation for testing. */ - probeImpl?: (port: number) => Promise; + probeImpl?: (port: number, host: string) => Promise; } export interface MemoryInfo { @@ -717,10 +719,11 @@ export function planHostRemediation(assessment: HostAssessment): RemediationActi export async function probePortAvailability( port: number, - opts: Pick = {}, + opts: Pick = {}, ): Promise { + const host = opts.host || "127.0.0.1"; if (typeof opts.probeImpl === "function") { - return opts.probeImpl(port); + return opts.probeImpl(port, host); } return new Promise((resolve) => { @@ -750,7 +753,7 @@ export async function probePortAvailability( warning: `port probe inconclusive: ${err.message}`, }); }); - srv.listen(port, "127.0.0.1", () => { + srv.listen(port, host, () => { srv.close(() => resolve({ ok: true })); }); }); diff --git a/src/lib/runner.ts b/src/lib/runner.ts index b4cdca517cc..0bedf4777d7 100644 --- a/src/lib/runner.ts +++ b/src/lib/runner.ts @@ -11,6 +11,7 @@ import { NAME_ALLOWED_FORMAT } from "./name-validation"; const { spawnSync } = require("child_process"); const path = require("path"); const { detectDockerHost } = require("./platform"); +const { buildSubprocessEnv } = require("./subprocess-env") as typeof import("./subprocess-env"); const ROOT = path.resolve(__dirname, "..", ".."); const SCRIPTS = path.join(ROOT, "scripts"); @@ -33,6 +34,16 @@ if (dockerHost) { process.env.DOCKER_HOST = dockerHost.dockerHost; } +function buildRunnerEnv(extraEnv?: NodeJS.ProcessEnv): Record { + const normalizedExtra: Record = {}; + if (extraEnv) { + for (const [key, value] of Object.entries(extraEnv)) { + if (value !== undefined) normalizedExtra[key] = value; + } + } + return buildSubprocessEnv(normalizedExtra); +} + function logOpenshellRuntimeHint(file: string, renderedCommand = ""): void { if ( file === "openshell" || @@ -59,7 +70,7 @@ function spawnAndHandle( ...opts, stdio, cwd: ROOT, - env: { ...process.env, ...opts.env }, + env: buildRunnerEnv(opts.env), }); if (!opts.suppressOutput) { writeRedactedResult(result, stdio); @@ -133,7 +144,7 @@ function runArrayCmd( ...spawnOpts, stdio, cwd: ROOT, - env: { ...process.env, ...extraEnv }, + env: buildRunnerEnv(extraEnv), }); if (!suppressOutput) { writeRedactedResult(result, stdio); @@ -221,7 +232,7 @@ function runCapture(cmd: readonly string[], opts: CaptureOptions = {}): string { const result = spawnSync(exe, args, { ...spawnOpts, cwd: ROOT, - env: { ...process.env, ...extraEnv }, + env: buildRunnerEnv(extraEnv), stdio: ["pipe", "pipe", "pipe"], encoding: "utf-8", }); diff --git a/test/e2e/test-openshell-gateway-upgrade.sh b/test/e2e/test-openshell-gateway-upgrade.sh index 63a0916f6f6..db78b8e900c 100755 --- a/test/e2e/test-openshell-gateway-upgrade.sh +++ b/test/e2e/test-openshell-gateway-upgrade.sh @@ -149,6 +149,7 @@ if [ "${1:-}" = "--version" ]; then exit 0 fi exit 99 +# request-body-credential-rewrite websocket-credential-rewrite EOF cat >"$fake_bin/gh" <<'EOF' @@ -238,6 +239,7 @@ if [ "${1:-}" = "--version" ]; then exit 0 fi exit 99 +# request-body-credential-rewrite websocket-credential-rewrite EOF cat >"$fake_bin/openshell-gateway" <<'EOF' diff --git a/test/gateway-start-wait.test.ts b/test/gateway-start-wait.test.ts index c83203411c6..655e4620f35 100644 --- a/test/gateway-start-wait.test.ts +++ b/test/gateway-start-wait.test.ts @@ -8,17 +8,23 @@ const require = createRequire(import.meta.url); const ORIGINAL_ENV = { ...process.env }; const ONBOARD_MODULE = require.resolve("../dist/lib/onboard.js"); const PORTS_MODULE = require.resolve("../dist/lib/core/ports.js"); +const GATEWAY_ADDRESS_MODULE = require.resolve("../dist/lib/core/gateway-address.js"); +const GATEWAY_ENV_MODULE = require.resolve("../dist/lib/onboard/docker-driver-gateway-env.js"); function loadOnboard() { delete require.cache[ONBOARD_MODULE]; + delete require.cache[GATEWAY_ENV_MODULE]; delete require.cache[PORTS_MODULE]; + delete require.cache[GATEWAY_ADDRESS_MODULE]; return require("../dist/lib/onboard"); } afterEach(() => { process.env = { ...ORIGINAL_ENV }; delete require.cache[ONBOARD_MODULE]; + delete require.cache[GATEWAY_ENV_MODULE]; delete require.cache[PORTS_MODULE]; + delete require.cache[GATEWAY_ADDRESS_MODULE]; }); describe("gateway startup wait config", () => { @@ -119,6 +125,28 @@ describe("gateway bootstrap secret repair", () => { expect(getGatewayLocalEndpoint()).toBe("https://127.0.0.1:9443"); }); + it("uses wildcard only as the gateway bind address, not the local endpoint", () => { + process.env.NEMOCLAW_GATEWAY_PORT = "9443"; + process.env.NEMOCLAW_GATEWAY_BIND_ADDRESS = "0.0.0.0"; + process.env.NEMOCLAW_DISABLE_OVERLAY_FIX = "1"; + const { getDockerDriverGatewayEnv, getGatewayLocalEndpoint, getGatewayStartEnv } = + loadOnboard(); + + expect(getGatewayLocalEndpoint()).toBe("https://127.0.0.1:9443"); + expect(getDockerDriverGatewayEnv("openshell 0.0.37", "linux")).toMatchObject({ + OPENSHELL_BIND_ADDRESS: "0.0.0.0", + OPENSHELL_GRPC_ENDPOINT: "http://127.0.0.1:9443", + OPENSHELL_SSH_GATEWAY_HOST: "127.0.0.1", + OPENSHELL_SSH_GATEWAY_PORT: "9443", + }); + expect(getGatewayStartEnv()).toMatchObject({ + OPENSHELL_BIND_ADDRESS: "0.0.0.0", + OPENSHELL_SERVER_PORT: "9443", + OPENSHELL_SSH_GATEWAY_HOST: "127.0.0.1", + OPENSHELL_SSH_GATEWAY_PORT: "9443", + }); + }); + it("repairs the client CA and client TLS secrets together", () => { const { getGatewayBootstrapRepairPlan } = loadOnboard(); expect( diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index 99ca398f86e..236cc3a0dce 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -8,12 +8,15 @@ import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; +import { testTimeout } from "./helpers/timeouts"; + const CREDENTIAL_RETRY_PROMPT = " Options: retry (re-enter key), back (change provider), exit [retry]: "; const CREDENTIAL_RETRY_PROMPT_RE = /Options: retry \(re-enter key\), back \(change provider\), exit \[retry\]: /; const OLLAMA_CHAT_COMPLETIONS_TOOL_CALL_RESPONSE = '{"choices":[{"message":{"role":"assistant","content":"","tool_calls":[{"type":"function","function":{"name":"emit_ok","arguments":"{\\"ok\\":true}"}}]}}]}'; +const PROVIDER_SELECTION_TEST_TIMEOUT_MS = testTimeout(60_000); function writeOpenAiStyleAuthRetryCurl(fakeBin: string, goodToken: string, models = ["gpt-5.4"]) { fs.writeFileSync( @@ -99,7 +102,7 @@ printf '%s' "$status" ); } -describe("onboard provider selection UX", () => { +describe("onboard provider selection UX", { timeout: PROVIDER_SELECTION_TEST_TIMEOUT_MS }, () => { it("prompts explicitly instead of silently auto-selecting detected Ollama", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-selection-")); @@ -971,7 +974,7 @@ const { setupNim } = require(${onboardPath}); ); }); - it("starts managed Ollama on loopback before exposing the auth proxy", { timeout: 10_000 }, () => { + it("starts managed Ollama on loopback before exposing the auth proxy", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-ollama-loopback-")); const fakeBin = path.join(tmpDir, "bin"); @@ -1013,6 +1016,9 @@ const child_process = require("child_process"); child_process.spawn = () => ({ pid: 99999, unref() {}, on() {} }); const originalSpawnSync = child_process.spawnSync; child_process.spawnSync = (cmd, args, opts) => { + if (cmd === "nc" && args?.includes("11435")) { + return { status: 0, stdout: "", stderr: "", signal: null }; + } if (cmd === "ps") { return { status: 0, stdout: "node ollama-auth-proxy.js", stderr: "", signal: null }; } @@ -3864,7 +3870,7 @@ const { setupNim } = require(${onboardPath}); assert.ok(payload.lines.some((line: string) => line.includes("tool-call-parser requires"))); }); - it("offers install-ollama option on Linux when Ollama is not installed", { timeout: 10_000 }, () => { + it("offers install-ollama option on Linux when Ollama is not installed", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-install-ollama-")); const fakeBin = path.join(tmpDir, "bin"); @@ -3922,6 +3928,9 @@ child_process.spawn = (...args) => { const originalSpawnSync = child_process.spawnSync; child_process.spawnSync = (cmd, args, opts) => { const cmdStr = [cmd, ...(args || [])].join(" "); + if (cmd === "nc" && args?.includes("11435")) { + return { status: 0, stdout: "", stderr: "", signal: null }; + } // ollama pull — pretend it succeeds if (cmd === "ollama" && args && args[0] === "pull") { return { status: 0, stdout: "", stderr: "", signal: null }; @@ -4161,7 +4170,7 @@ const { setupNim } = require(${onboardPath}); assert.match(result.stderr, /Refusing to continue/); }); - it("uses install-ollama for non-interactive NEMOCLAW_PROVIDER=ollama on fresh Linux", { timeout: 10_000 }, () => { + it("uses install-ollama for non-interactive NEMOCLAW_PROVIDER=ollama on fresh Linux", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync( path.join(os.tmpdir(), "nemoclaw-onboard-noninteractive-install-ollama-"), @@ -4209,6 +4218,9 @@ child_process.spawn = () => ({ pid: 99999, unref() {}, on() {} }); const originalSpawnSync = child_process.spawnSync; child_process.spawnSync = (cmd, args, opts) => { const command = [cmd, ...(args || [])].join(" "); + if (cmd === "nc" && args?.includes("11435")) { + return { status: 0, stdout: "", stderr: "", signal: null }; + } if (command.includes("ollama pull")) { return { status: 0, stdout: "", stderr: "", signal: null }; } diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 24d2874e5a1..e5552303ad0 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -15,6 +15,7 @@ import { loadAgent } from "../dist/lib/agent/defs.js"; import { buildChain, buildControlUiUrls } from "../dist/lib/dashboard/contract.js"; import { NAME_ALLOWED_FORMAT } from "../dist/lib/name-validation.js"; import { stageOptimizedSandboxBuildContext } from "../dist/lib/sandbox/build-context.js"; +import { testTimeoutOptions } from "./helpers/timeouts"; type ShimScalar = string | number | boolean | null | undefined; type ShimCallable = (...args: readonly string[]) => ShimValue; @@ -97,6 +98,7 @@ type OnboardTestInternals = { versionOutput?: string | null, platform?: NodeJS.Platform, ) => Record; + getGatewayStartEnv: () => Record; shouldRequireDockerDriverEnv: (platform?: NodeJS.Platform) => boolean; getDockerDriverGatewayRuntimeDriftFromSnapshot: (snapshot: { processEnv: Record | null; @@ -224,6 +226,7 @@ function isOnboardTestInternals( typeof value.buildDirectSandboxGpuProofCommands === "function" && typeof value.classifySandboxCreateFailure === "function" && typeof value.getDockerDriverGatewayEnv === "function" && + typeof value.getGatewayStartEnv === "function" && typeof value.shouldRequireDockerDriverEnv === "function" && typeof value.getDockerDriverGatewayRuntimeDriftFromSnapshot === "function" && typeof value.isLinuxDockerDriverGatewayEnabled === "function" && @@ -283,6 +286,7 @@ const { getBlueprintMinOpenshellVersion, getBlueprintMaxOpenshellVersion, getDockerDriverGatewayEnv, + getGatewayStartEnv, shouldRequireDockerDriverEnv, getDockerDriverGatewayRuntimeDriftFromSnapshot, isLinuxDockerDriverGatewayEnabled, @@ -436,15 +440,36 @@ network_policies: expect(isLinuxDockerDriverGatewayEnabled("win32")).toBe(false); const linuxEnv = getDockerDriverGatewayEnv("openshell 0.0.37", "linux"); expect(linuxEnv.OPENSHELL_DRIVERS).toBe("docker"); + expect(linuxEnv.OPENSHELL_BIND_ADDRESS).toBe("127.0.0.1"); expect(linuxEnv.OPENSHELL_GRPC_ENDPOINT).toBe("http://127.0.0.1:8080"); + expect(linuxEnv.OPENSHELL_SSH_GATEWAY_HOST).toBe("127.0.0.1"); expect(linuxEnv.OPENSHELL_CLUSTER_IMAGE).toBeUndefined(); expect(linuxEnv.OPENSHELL_DOCKER_SUPERVISOR_IMAGE).toContain(":0.0.37"); const darwinEnv = getDockerDriverGatewayEnv("openshell 0.0.37", "darwin"); expect(darwinEnv.OPENSHELL_DRIVERS).toBe("vm"); + expect(darwinEnv.OPENSHELL_BIND_ADDRESS).toBe("127.0.0.1"); expect(darwinEnv.OPENSHELL_GRPC_ENDPOINT).toBe("http://host.containers.internal:8080"); + expect(darwinEnv.OPENSHELL_SSH_GATEWAY_HOST).toBe("127.0.0.1"); expect(darwinEnv.OPENSHELL_VM_DRIVER_STATE_DIR).toContain("vm-driver"); expect(darwinEnv.OPENSHELL_DOCKER_SUPERVISOR_IMAGE).toBeUndefined(); + + const originalOverlayFix = process.env.NEMOCLAW_DISABLE_OVERLAY_FIX; + process.env.NEMOCLAW_DISABLE_OVERLAY_FIX = "1"; + try { + expect(getGatewayStartEnv()).toMatchObject({ + OPENSHELL_BIND_ADDRESS: "127.0.0.1", + OPENSHELL_SERVER_PORT: "8080", + OPENSHELL_SSH_GATEWAY_HOST: "127.0.0.1", + OPENSHELL_SSH_GATEWAY_PORT: "8080", + }); + } finally { + if (originalOverlayFix === undefined) { + delete process.env.NEMOCLAW_DISABLE_OVERLAY_FIX; + } else { + process.env.NEMOCLAW_DISABLE_OVERLAY_FIX = originalOverlayFix; + } + } }); it("requires platform-specific standalone gateway binaries", () => { @@ -539,6 +564,18 @@ network_policies: })?.reason, ).toContain("OPENSHELL_DOCKER_SUPERVISOR_IMAGE="); + expect( + getDockerDriverGatewayRuntimeDriftFromSnapshot({ + processEnv: { + ...desiredEnv, + OPENSHELL_BIND_ADDRESS: "0.0.0.0", + }, + processExe: gatewayBin, + desiredEnv, + gatewayBin, + })?.reason, + ).toContain("OPENSHELL_BIND_ADDRESS="); + expect( getDockerDriverGatewayRuntimeDriftFromSnapshot({ processEnv: desiredEnv, @@ -2514,13 +2551,13 @@ const { loadAgent } = require(${agentDefsPath}); // Primary start path (startGatewayWithOptions) builds gwArgs with --port. assert.match( source, - /const gwArgs = \["--name", GATEWAY_NAME, "--port", String\(GATEWAY_PORT\)\]/, + /const gwArgs = \["--name", GATEWAY_NAME, "--port", getGatewayPortArg\(\)\]/, ); // Recovery start path (recoverGatewayRuntime) also passes --port. assert.match( source, - /runOpenshell\(\s*\["gateway", "start", "--name", GATEWAY_NAME, "--port", String\(GATEWAY_PORT\)\]/, + /runOpenshell\(\s*\["gateway", "start", "--name", GATEWAY_NAME, "--port", getGatewayPortArg\(\)\]/, ); }); @@ -3677,7 +3714,7 @@ const { setupInference, getSandboxInferenceConfig } = require(${onboardPath}); }); }); - it("prepares managed Model Router dependencies instead of using PATH when managed command is absent", () => { + it("prepares managed Model Router dependencies instead of using PATH when managed command is absent", testTimeoutOptions(20_000), () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-router-venv-")); const fakeBin = path.join(tmpDir, "bin"); @@ -3699,7 +3736,7 @@ const { setupInference, getSandboxInferenceConfig } = require(${onboardPath}); path.join(fakeBin, "model-router"), [ "#!/usr/bin/env bash", - 'printf "path-router %s\\n" "$*" >> "$ROUTER_SETUP_LOG"', + `printf "path-router %s\\n" "$*" >> ${JSON.stringify(setupLog)}`, "exit 89", "", ].join("\n"), @@ -3710,17 +3747,17 @@ const { setupInference, getSandboxInferenceConfig } = require(${onboardPath}); [ "#!/usr/bin/env bash", "set -euo pipefail", - 'printf "python3 %s\\n" "$*" >> "$ROUTER_SETUP_LOG"', + `printf "python3 %s\\n" "$*" >> ${JSON.stringify(setupLog)}`, 'if [ "$1" = "-m" ] && [ "$2" = "venv" ]; then', ' venv_dir="$3"', ' mkdir -p "$venv_dir/bin"', ' cat > "$venv_dir/bin/python" <<\'PY\'', "#!/usr/bin/env bash", "set -euo pipefail", - 'printf "venv-python %s\\n" "$*" >> "$ROUTER_SETUP_LOG"', + `printf "venv-python %s\\n" "$*" >> ${JSON.stringify(setupLog)}`, 'if [ "$1" = "-m" ] && [ "$2" = "pip" ] && [ "$3" = "install" ]; then', ' venv_bin="$(cd "$(dirname "$0")" && pwd)"', - ' cp "$FAKE_ROUTER_SOURCE" "$venv_bin/model-router"', + ` cp ${JSON.stringify(fakeRouterSource)} "$venv_bin/model-router"`, ' chmod +x "$venv_bin/model-router"', " exit 0", "fi", @@ -3917,7 +3954,7 @@ const { setupInference } = require(${onboardPath}); path.join(fakeBin, "model-router"), [ "#!/usr/bin/env bash", - 'printf "path-router %s\\n" "$*" >> "$ROUTER_SETUP_LOG"', + `printf "path-router %s\\n" "$*" >> ${JSON.stringify(setupLog)}`, "exit 89", "", ].join("\n"), @@ -3931,7 +3968,7 @@ const { setupInference } = require(${onboardPath}); 'const http = require("http");', 'const path = require("path");', "const args = process.argv.slice(2);", - 'if (process.env.ROUTER_SETUP_LOG) fs.appendFileSync(process.env.ROUTER_SETUP_LOG, `managed ${args[0]}\\n`);', + `fs.appendFileSync(${JSON.stringify(setupLog)}, \`managed \${args[0]}\\n\`);`, 'if (args[0] === "proxy-config") {', ' const output = args[args.indexOf("--output") + 1];', " fs.mkdirSync(path.dirname(output), { recursive: true });", @@ -4099,7 +4136,7 @@ const { setupInference } = require(${onboardPath}); path.join(fakeBin, "model-router"), [ "#!/usr/bin/env bash", - 'printf "path-router %s\\n" "$*" >> "$ROUTER_SETUP_LOG"', + `printf "path-router %s\\n" "$*" >> ${JSON.stringify(setupLog)}`, "exit 89", "", ].join("\n"), @@ -4110,17 +4147,17 @@ const { setupInference } = require(${onboardPath}); [ "#!/usr/bin/env bash", "set -euo pipefail", - 'printf "python3 %s\\n" "$*" >> "$ROUTER_SETUP_LOG"', + `printf "python3 %s\\n" "$*" >> ${JSON.stringify(setupLog)}`, 'if [ "$1" = "-m" ] && [ "$2" = "venv" ]; then', ' venv_dir="$3"', ' mkdir -p "$venv_dir/bin"', ' cat > "$venv_dir/bin/python" <<\'PY\'', "#!/usr/bin/env bash", "set -euo pipefail", - 'printf "venv-python %s\\n" "$*" >> "$ROUTER_SETUP_LOG"', + `printf "venv-python %s\\n" "$*" >> ${JSON.stringify(setupLog)}`, 'if [ "$1" = "-m" ] && [ "$2" = "pip" ] && [ "$3" = "install" ]; then', ' venv_bin="$(cd "$(dirname "$0")" && pwd)"', - ' cp "$FAKE_ROUTER_SOURCE" "$venv_bin/model-router"', + ` cp ${JSON.stringify(fakeRouterSource)} "$venv_bin/model-router"`, ' chmod +x "$venv_bin/model-router"', " exit 0", "fi", @@ -4138,7 +4175,7 @@ const { setupInference } = require(${onboardPath}); path.join(venvBin, "model-router"), [ "#!/usr/bin/env bash", - 'printf "stale-managed %s\\n" "$*" >> "$ROUTER_SETUP_LOG"', + `printf "stale-managed %s\\n" "$*" >> ${JSON.stringify(setupLog)}`, "exit 89", "", ].join("\n"), @@ -4155,7 +4192,7 @@ const { setupInference } = require(${onboardPath}); 'const http = require("http");', 'const path = require("path");', "const args = process.argv.slice(2);", - 'if (process.env.ROUTER_SETUP_LOG) fs.appendFileSync(process.env.ROUTER_SETUP_LOG, `fresh ${args[0]}\\n`);', + `fs.appendFileSync(${JSON.stringify(setupLog)}, \`fresh \${args[0]}\\n\`);`, 'if (args[0] === "proxy-config") {', ' const output = args[args.indexOf("--output") + 1];', " fs.mkdirSync(path.dirname(output), { recursive: true });",