Skip to content
Merged
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
47 changes: 46 additions & 1 deletion src/lib/onboard/host-service-reachability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe("probeHostServiceSandboxReachability", () => {
expect(result.reason).toBe("probe_unavailable");
});

it("probes the requested port and host alias in the docker run args", async () => {
it("routes native Docker bridge probes through the inspected numeric gateway", async () => {
let capturedArgs: readonly string[] = [];
await probeHostServiceSandboxReachability({
port: 4000,
Expand All @@ -85,6 +85,51 @@ describe("probeHostServiceSandboxReachability", () => {
expect(capturedArgs).toContain("host.openshell.internal");
expect(capturedArgs).toContain("4000");
});

it("routes portable profile probes through the sandbox host gateway", async () => {
vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable");
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
let capturedArgs: readonly string[] = [];

const result = await probeHostServiceSandboxReachability({
port: 11435,
inspectNetworkImpl: () => ({ subnet: "10.89.0.0/24" }),
runImpl: (args) => {
capturedArgs = args;
return { status: 0 };
},
});

expect(result).toMatchObject({ ok: true, reason: "ok" });
expect(capturedArgs).toContain("host.openshell.internal:169.254.1.2");
expect(capturedArgs).not.toContain("host.openshell.internal:host-gateway");
expect(capturedArgs).not.toContain("host.openshell.internal:10.89.0.1");
});

it("keeps portable host-gateway failures credential-free and inconclusive", async () => {
vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable");
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
const credential = "nvapi-regression-secret";

const result = await probeHostServiceSandboxReachability({
port: 11435,
inspectNetworkImpl: () =>
makeNetwork({
subnet: "10.89.0.0/24",
gatewayIp: "10.89.0.1",
}),
runImpl: () => ({ status: 1, stderr: `nc failed with ${credential}` }),
});
const message = formatHostServiceUnreachableMessage(result, {
serviceLabel: "Ollama auth proxy",
});

expect(result).toMatchObject({ ok: false, reason: "probe_unavailable" });
expect(result.detail).toBe("portable host-gateway probe did not connect");
expect(result.detail).not.toContain(credential);
expect(message).toBe("");
expect(message).not.toContain(credential);
});
});

describe("formatHostServiceUnreachableMessage", () => {
Expand Down
32 changes: 22 additions & 10 deletions src/lib/onboard/host-service-reachability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@

import { dockerCapture, dockerRun } from "../adapters/docker/run";
import { cliName } from "./branding";
import {
isPortableExperimentalProfile,
PORTABLE_HOST_GATEWAY_IP,
} from "./experimental/portable-profile";

export const DEFAULT_PROBE_NETWORK = "openshell-docker";
const HOST_INTERNAL_NAME = "host.openshell.internal";
Expand Down Expand Up @@ -92,9 +96,9 @@ function defaultInspectNetwork(
return parseNetworkIpamConfig(raw);
}

// Docker Desktop and VM-backed Docker use a special host-gateway alias rather
// than a specific bridge IP. UFW is not relevant on those platforms, so we
// classify probes from those environments as probe_unavailable.
// Docker Desktop and VM-backed Docker use the runtime's host-gateway alias
// instead of the inspected bridge IP. These routes do not support native
// Docker bridge UFW remediation.
function defaultUsesHostGatewayRoute(): boolean {
if (process.platform !== "linux") return true;
const info = dockerCapture(
Expand Down Expand Up @@ -154,9 +158,11 @@ export async function probeHostServiceSandboxReachability(
};
}

const isHostGateway = usesHostGatewayRoute();
const portableProfile = isPortableExperimentalProfile();
const isHostGateway = portableProfile ? false : usesHostGatewayRoute();
const usesNonBridgeRoute = portableProfile || isHostGateway;

if (!isHostGateway && !network.gatewayIp) {
if (!usesNonBridgeRoute && !network.gatewayIp) {
return {
ok: false,
reason: "probe_unavailable",
Expand All @@ -167,7 +173,11 @@ export async function probeHostServiceSandboxReachability(
};
}

const hostInternalTarget = isHostGateway ? "host-gateway" : (network.gatewayIp as string);
const hostInternalTarget = portableProfile
? PORTABLE_HOST_GATEWAY_IP
: isHostGateway
? "host-gateway"
: (network.gatewayIp as string);

const probeArgs = [
"run",
Expand Down Expand Up @@ -206,17 +216,19 @@ export async function probeHostServiceSandboxReachability(
.filter((s): s is string => Boolean(s))
.join(" | ");

// Classify as probe_unavailable for: non-nc exit codes, DNS failures,
// or host-gateway mode (Docker Desktop / macOS — no UFW concern there).
if (result.status !== 1 || isNameResolutionFailure(detail) || isHostGateway) {
// Non-nc failures, DNS failures, and host-gateway routes do not prove that
// a native Docker bridge UFW rule blocked the connection.
if (result.status !== 1 || isNameResolutionFailure(detail) || usesNonBridgeRoute) {
return {
ok: false,
reason: "probe_unavailable",
port,
networkName,
subnet: network.subnet,
gatewayIp: network.gatewayIp,
detail: detail || "probe did not complete",
detail: portableProfile
? "portable host-gateway probe did not connect"
: detail || "probe did not complete",
};
}

Expand Down
Loading