diff --git a/src/lib/core/gateway-address.ts b/src/lib/core/gateway-address.ts index 147d12970c6..5459a5f7541 100644 --- a/src/lib/core/gateway-address.ts +++ b/src/lib/core/gateway-address.ts @@ -13,8 +13,9 @@ export type GatewayBindAddress = export function parseGatewayBindAddress( envVar = "NEMOCLAW_GATEWAY_BIND_ADDRESS", fallback: GatewayBindAddress = DEFAULT_GATEWAY_BIND_ADDRESS, + env: NodeJS.ProcessEnv = process.env, ): GatewayBindAddress { - const raw = process.env[envVar]; + const raw = env[envVar]; if (raw === undefined || raw === "") return fallback; const trimmed = String(raw).trim(); if (trimmed === DEFAULT_GATEWAY_BIND_ADDRESS) return DEFAULT_GATEWAY_BIND_ADDRESS; diff --git a/src/lib/onboard/docker-driver-gateway-env.test.ts b/src/lib/onboard/docker-driver-gateway-env.test.ts index 147079264c0..861332f56bd 100644 --- a/src/lib/onboard/docker-driver-gateway-env.test.ts +++ b/src/lib/onboard/docker-driver-gateway-env.test.ts @@ -10,7 +10,11 @@ import { describe, expect, it, vi } from "vitest"; import { buildDockerDriverGatewayEnv, buildDockerGatewayDebEnvFile, + getGatewayPortCheckOptions, + getGatewayStartNetworkEnv, + resolveGatewayBindAddress, startPackageManagedDockerDriverGatewayWithEnvOverride, + warnIfGatewayWildcardBindAddress, writeDockerGatewayDebEnvOverride, } from "./docker-driver-gateway-env"; @@ -58,6 +62,65 @@ describe("buildDockerDriverGatewayEnv", () => { }); }); +describe("resolveGatewayBindAddress on Docker Desktop WSL (#5513)", () => { + // The detector is injected so these unit tests stay deterministic and do not + // load the real Docker-adapter graph; detectWslDockerDesktopStatus itself is + // covered by wsl-docker-desktop-gpu's own tests. + const dockerDesktopWsl = { detectStatus: () => "docker-desktop" as const }; + const nativeLinux = { detectStatus: () => "not-docker-desktop" as const }; + + it("binds the wildcard address on Docker Desktop WSL so host-gateway reaches the gateway", () => { + expect(resolveGatewayBindAddress(dockerDesktopWsl)).toBe("0.0.0.0"); + }); + + it("keeps the loopback bind on native Linux Docker", () => { + expect(resolveGatewayBindAddress(nativeLinux)).toBe("127.0.0.1"); + }); + + it("honors an explicit NEMOCLAW_GATEWAY_BIND_ADDRESS override even on Docker Desktop WSL", () => { + expect( + resolveGatewayBindAddress({ + ...dockerDesktopWsl, + env: { NEMOCLAW_GATEWAY_BIND_ADDRESS: "127.0.0.1" }, + }), + ).toBe("127.0.0.1"); + }); + + it("accepts an explicit wildcard override on native Linux", () => { + expect( + resolveGatewayBindAddress({ + ...nativeLinux, + env: { NEMOCLAW_GATEWAY_BIND_ADDRESS: "0.0.0.0" }, + }), + ).toBe("0.0.0.0"); + }); + + it("threads the wildcard bind into the gateway start env while advertising loopback to clients", () => { + expect(getGatewayStartNetworkEnv(dockerDesktopWsl)).toMatchObject({ + OPENSHELL_BIND_ADDRESS: "0.0.0.0", + // Clients still connect over loopback; only the listen surface widens. + OPENSHELL_SSH_GATEWAY_HOST: "127.0.0.1", + }); + }); + + it("port-checks the wildcard interface on Docker Desktop WSL", () => { + expect(getGatewayPortCheckOptions(dockerDesktopWsl)).toEqual({ host: "0.0.0.0" }); + expect(getGatewayPortCheckOptions(nativeLinux)).toEqual({ host: "127.0.0.1" }); + }); + + it("warns about the widened bind surface on Docker Desktop WSL", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + try { + warnIfGatewayWildcardBindAddress(dockerDesktopWsl); + warnIfGatewayWildcardBindAddress(nativeLinux); + const warnings = logSpy.mock.calls.filter(([line]) => /0\.0\.0\.0/.test(String(line))); + expect(warnings).toHaveLength(1); + } finally { + logSpy.mockRestore(); + } + }); +}); + describe("buildDockerGatewayDebEnvFile", () => { it("replaces all managed gateway env keys and preserves unrelated values", () => { const next = buildDockerGatewayDebEnvFile( diff --git a/src/lib/onboard/docker-driver-gateway-env.ts b/src/lib/onboard/docker-driver-gateway-env.ts index 12118216b0e..5f898d382c3 100644 --- a/src/lib/onboard/docker-driver-gateway-env.ts +++ b/src/lib/onboard/docker-driver-gateway-env.ts @@ -6,21 +6,27 @@ import os from "node:os"; import path from "node:path"; import { - GATEWAY_BIND_ADDRESS, - WILDCARD_GATEWAY_BIND_ADDRESS, + DEFAULT_GATEWAY_BIND_ADDRESS, + type GatewayBindAddress, getGatewayConnectHost, getGatewayHttpEndpoint, getGatewayHttpsEndpoint, + parseGatewayBindAddress, + WILDCARD_GATEWAY_BIND_ADDRESS, } from "../core/gateway-address"; import { GATEWAY_PORT } from "../core/ports"; import { hasOpenShellGatewayUserService, - startPackageManagedDockerDriverGateway, type PackageManagedDockerDriverGatewayOptions, + startPackageManagedDockerDriverGateway, } from "./docker-driver-gateway-service"; +import { + detectWslDockerDesktopStatus, + type WslDockerDesktopDetectionDeps, + type WslDockerDesktopStatus, +} from "./wsl-docker-desktop-gpu"; -export { getGatewayHttpsEndpoint }; -export { startPackageManagedDockerDriverGateway }; +export { getGatewayHttpsEndpoint, startPackageManagedDockerDriverGateway }; export const DOCKER_DRIVER_GATEWAY_RUNTIME_ENV_KEYS = [ "OPENSHELL_DRIVERS", @@ -54,15 +60,78 @@ export type PackageManagedDockerDriverGatewayWithEnvOverrideOptions = Omit< gatewayEnv: Record; }; -export function getGatewayPortCheckOptions(): { host: string } { - return { host: GATEWAY_BIND_ADDRESS }; +export type GatewayBindAddressDeps = WslDockerDesktopDetectionDeps & { + /** + * Override the Docker Desktop WSL probe (defaults to the real detector). + * Tests inject a stub to stay deterministic without invoking `docker info`. + */ + detectStatus?: (deps: WslDockerDesktopDetectionDeps) => WslDockerDesktopStatus; +}; + +// Memoized real-host detection so a single onboard run performs at most one +// `docker info` for the gateway bind decision (the resolver is consulted by the +// preflight port check, the gateway env build, and the wildcard-bind warning). +let cachedWslDockerDesktopStatus: WslDockerDesktopStatus | null = null; + +function resolveWslDockerDesktopStatus(deps: GatewayBindAddressDeps): WslDockerDesktopStatus { + const { detectStatus, ...detectionDeps } = deps; + if (detectStatus) return detectStatus(detectionDeps); + // Real-host probe: memoize the argless production call so onboard runs + // `docker info` at most once; deps-bearing calls bypass the cache. A prior + // "unknown" (e.g. docker not yet reachable when the preflight port check ran) + // is not sticky — re-probe so a later definitive result still drives the bind. + if (Object.keys(detectionDeps).length > 0) return detectWslDockerDesktopStatus(detectionDeps); + if (cachedWslDockerDesktopStatus === null || cachedWslDockerDesktopStatus === "unknown") { + cachedWslDockerDesktopStatus = detectWslDockerDesktopStatus(detectionDeps); + } + return cachedWslDockerDesktopStatus; +} + +export function resetGatewayBindAddressDetectionCacheForTests(): void { + cachedWslDockerDesktopStatus = null; +} + +/** + * Resolve the effective OpenShell gateway bind address. + * + * An explicit `NEMOCLAW_GATEWAY_BIND_ADDRESS` always wins. With no override, + * Docker Desktop WSL must bind the wildcard address: sandbox containers reach + * the gateway via Docker's `host-gateway` route, which Docker Desktop maps to + * its own bridge IP rather than the WSL distro loopback, so a 127.0.0.1 bind is + * unreachable and the onboard [2/8] sandbox-bridge reachability probe fails + * 100% of the time (#5513). The container compatibility path already binds + * 0.0.0.0 for the same reason; this brings the host-mode gateway in line. + */ +export function resolveGatewayBindAddress(deps: GatewayBindAddressDeps = {}): GatewayBindAddress { + const env = deps.env ?? process.env; + const explicit = env.NEMOCLAW_GATEWAY_BIND_ADDRESS; + if (explicit !== undefined && String(explicit).trim() !== "") { + return parseGatewayBindAddress( + "NEMOCLAW_GATEWAY_BIND_ADDRESS", + DEFAULT_GATEWAY_BIND_ADDRESS, + env, + ); + } + if (resolveWslDockerDesktopStatus(deps) === "docker-desktop") { + return WILDCARD_GATEWAY_BIND_ADDRESS; + } + return DEFAULT_GATEWAY_BIND_ADDRESS; +} + +export function getGatewayPortCheckOptions(deps: GatewayBindAddressDeps = {}): { + host: string; +} { + return { host: resolveGatewayBindAddress(deps) }; } -export function getGatewayStartNetworkEnv(): Record { +export function getGatewayStartNetworkEnv( + deps: GatewayBindAddressDeps = {}, +): Record { + const bindAddress = resolveGatewayBindAddress(deps); return { - OPENSHELL_BIND_ADDRESS: GATEWAY_BIND_ADDRESS, + OPENSHELL_BIND_ADDRESS: bindAddress, OPENSHELL_SERVER_PORT: String(GATEWAY_PORT), - OPENSHELL_SSH_GATEWAY_HOST: getGatewayConnectHost(), + OPENSHELL_SSH_GATEWAY_HOST: getGatewayConnectHost(bindAddress), OPENSHELL_SSH_GATEWAY_PORT: String(GATEWAY_PORT), }; } @@ -71,8 +140,8 @@ export function getDockerDriverGatewayEndpoint(): string { return getGatewayHttpEndpoint(); } -export function warnIfGatewayWildcardBindAddress(): void { - if (GATEWAY_BIND_ADDRESS !== WILDCARD_GATEWAY_BIND_ADDRESS) return; +export function warnIfGatewayWildcardBindAddress(deps: GatewayBindAddressDeps = {}): void { + if (resolveGatewayBindAddress(deps) !== 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.", ); diff --git a/src/lib/onboard/wsl-docker-desktop-gpu.ts b/src/lib/onboard/wsl-docker-desktop-gpu.ts index c69415cbf96..7621453bb71 100644 --- a/src/lib/onboard/wsl-docker-desktop-gpu.ts +++ b/src/lib/onboard/wsl-docker-desktop-gpu.ts @@ -3,9 +3,18 @@ import fs from "node:fs"; import os from "node:os"; -import { dockerInfoFormat as defaultDockerInfoFormat } from "../adapters/docker"; import type { Arm64WslDockerDesktopGpuProver, DockerGpuProofResult } from "../inference/gpu-trust"; +// Lazy wrapper so importing this module does not statically pull the Docker +// adapter (and its `runner` graph) into importers that only need the cheap +// WSL / Docker-Desktop detection — e.g. the gateway bind-address resolver in +// docker-driver-gateway-env. The real adapter is required only on an actual +// Docker Desktop probe (production); tests inject `dockerInfoFormat` instead. +function defaultDockerInfoFormat(format: string, opts?: Record): string { + const { dockerInfoFormat } = require("../adapters/docker") as typeof import("../adapters/docker"); + return dockerInfoFormat(format, opts); +} + const WSL_DOCKER_DESKTOP_DETECTION_TIMEOUT_MS = 30_000; // This prover only ever runs on ARM64 (see `createArm64WslDockerDesktopGpuProver`), // so the proof image MUST ship a real aarch64 CUDA binary. The older