Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/lib/core/gateway-address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
63 changes: 63 additions & 0 deletions src/lib/onboard/docker-driver-gateway-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(
Expand Down
93 changes: 81 additions & 12 deletions src/lib/onboard/docker-driver-gateway-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -54,15 +60,78 @@ export type PackageManagedDockerDriverGatewayWithEnvOverrideOptions = Omit<
gatewayEnv: Record<string, string>;
};

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<string, string> {
export function getGatewayStartNetworkEnv(
deps: GatewayBindAddressDeps = {},
): Record<string, string> {
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),
};
}
Expand All @@ -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.",
);
Expand Down
11 changes: 10 additions & 1 deletion src/lib/onboard/wsl-docker-desktop-gpu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>): 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
Expand Down