Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
da69672
fix(platform): probe Docker with the environment its commands run in
Dongni-Yang Aug 26, 2026
9e70b46
fix(platform): keep the Docker probe on the runner's proxy exclusions
Dongni-Yang Aug 26, 2026
491417c
test(platform): cover the probe's no-verdict classifier
Dongni-Yang Aug 26, 2026
33b8736
merge(main): refresh PR 10379 after advisor repair
apurvvkumaria Aug 30, 2026
786723d
test(platform): apply current formatting
apurvvkumaria Aug 30, 2026
c17a403
merge(main): adopt current policy authority
apurvvkumaria Aug 30, 2026
c1327a7
merge(main): refresh Docker authority probe
apurvvkumaria Aug 31, 2026
a24ae49
docs(cli): correct Docker probe comments
apurvvkumaria Aug 31, 2026
edf8408
merge(main): incorporate release documentation
apurvvkumaria Aug 31, 2026
d1fea67
docs(cli): correct Docker timeout comment
apurvvkumaria Aug 31, 2026
f813e8d
test(platform): cover rootless Docker selection
apurvvkumaria Aug 31, 2026
738d133
merge(main): incorporate current release updates
apurvvkumaria Aug 31, 2026
e678266
fix(onboard): bound Docker preflight probes
apurvvkumaria Aug 31, 2026
dc62ae4
merge: resolve conflicts with main
github-actions[bot] Sep 3, 2026
70ed2fe
fix(platform): preserve inconclusive Docker probes
prekshivyas Sep 3, 2026
fb9acca
test(plugin): cover Docker subprocess authority
prekshivyas Sep 3, 2026
6d4488d
test(plugin): use explicit subprocess env import
prekshivyas Sep 3, 2026
bcf31b5
fix(readiness): preserve inconclusive Docker probes
prekshivyas Sep 3, 2026
82efb29
merge: resolve conflicts with main
github-actions[bot] Sep 4, 2026
7e569aa
merge: resolve conflicts with main
github-actions[bot] Sep 4, 2026
4b81fd4
Merge branch 'main' into fix/10367-docker-host-detection
prekshivyas Sep 4, 2026
b20e7fc
chore(ci): ratchet the subprocess-env fan-in budget to its measured v…
Dongni-Yang Sep 7, 2026
7d1476a
Merge upstream/main into wt/10367-docker-host-detection
Dongni-Yang Sep 9, 2026
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
2 changes: 1 addition & 1 deletion ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"src/lib/state/onboard-session.ts": 35,
"src/lib/state/registry.ts": 96,
"src/lib/state/state-root.ts": 21,
"src/lib/subprocess-env.ts": 23,
"src/lib/subprocess-env.ts": 24,
"src/lib/validation.ts": 24
}
},
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/system-readiness.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ The report rejects an unsupported explicit `DOCKER_HOST` before it uses Docker d
`host.docker.host_invalid` records whether the configured endpoint is invalid, `host.docker.endpoint_supported` is absent when a configured endpoint is not an absolute local `unix://` socket, and the blocking `host.docker.host_invalid` finding identifies that condition.
TCP, SSH, relative Unix socket, and unsafe path values do not fall back to the default Docker socket.
When `DOCKER_HOST` is unset, NemoClaw first probes the Docker CLI's selected authority, including `DOCKER_CONTEXT` and its `DOCKER_CONFIG` directory when set, without a host override.
If that authority is unreachable, NemoClaw probes a bounded set of local Unix sockets and accepts only an endpoint with valid server-version evidence.
If that authority completes with an unreachable result, NemoClaw probes a bounded set of local Unix sockets and accepts only an endpoint with valid server-version evidence.
If the probe times out or cannot start, NemoClaw keeps the selected authority. `host probe` reports `host.docker.probe_inconclusive`; onboarding reports the `docker_probe_inconclusive` advisory with a command to verify that authority.
When it selects a fallback socket, NemoClaw clears the unreachable `DOCKER_CONTEXT` so later Docker commands use the selected `DOCKER_HOST`.
A mixed set of reachable Docker and Podman fallbacks, or a fallback with an unknown server identity, produces no automatic `DOCKER_HOST` override.
A reachable Podman endpoint remains classified as Podman and does not satisfy the standard Docker runtime requirement.
Expand Down
41 changes: 41 additions & 0 deletions nemoclaw/src/lib/subprocess-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";

import { buildDockerSubprocessEnv } from "./subprocess-env.js";

const source = {
PATH: "/usr/bin",
DOCKER_HOST: "unix:///ambient.sock",
DOCKER_CONFIG: "/source/docker",
DOCKER_CONTEXT: "source-context",
NVIDIA_INFERENCE_API_KEY: "must-not-leak",
};

describe("buildDockerSubprocessEnv", () => {
it("keeps the selected default authority without forwarding an ambient host", () => {
expect(
buildDockerSubprocessEnv(source, undefined, {
DOCKER_CONFIG: "/selected/docker",
DOCKER_CONTEXT: "selected-context",
}),
).toEqual({
PATH: "/usr/bin",
DOCKER_CONFIG: "/selected/docker",
DOCKER_CONTEXT: "selected-context",
});
});

it("pins an explicit host without a competing Docker context", () => {
expect(
buildDockerSubprocessEnv(source, "unix:///selected.sock", {
DOCKER_CONFIG: "/ignored/docker",
DOCKER_CONTEXT: "ignored-context",
}),
).toEqual({
PATH: "/usr/bin",
DOCKER_HOST: "unix:///selected.sock",
});
});
});
36 changes: 34 additions & 2 deletions nemoclaw/src/lib/subprocess-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,12 @@ export function withLocalNoProxy(env: Record<string, string>): void {
env.no_proxy = noProxy;
}

export function buildSubprocessEnv(extra?: Record<string, string>): Record<string, string> {
export function buildSubprocessEnvFrom(
source: NodeJS.ProcessEnv,
extra?: Record<string, string>,
): Record<string, string> {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
for (const [key, value] of Object.entries(source)) {
if (value === undefined) continue;
if (isSubprocessEnvNameAllowed(key)) {
env[key] = value;
Expand All @@ -127,3 +130,32 @@ export function buildSubprocessEnv(extra?: Record<string, string>): Record<strin
withLocalNoProxy(env);
return env;
}

/**
* Build the environment for a Docker CLI command against one explicit
* authority. The default authority keeps its context and config selection;
* an explicit socket removes both so they cannot override `DOCKER_HOST`.
*/
export function buildDockerSubprocessEnv(
source: NodeJS.ProcessEnv,
dockerHost: string | undefined,
extra?: Record<string, string>,
): Record<string, string> {
const env = buildSubprocessEnvFrom(source, extra);
delete env.DOCKER_HOST;
delete env.DOCKER_CONFIG;
delete env.DOCKER_CONTEXT;
if (dockerHost === undefined) {
const dockerConfig = extra?.DOCKER_CONFIG ?? source.DOCKER_CONFIG;
const dockerContext = extra?.DOCKER_CONTEXT ?? source.DOCKER_CONTEXT;
if (dockerConfig !== undefined) env.DOCKER_CONFIG = dockerConfig;
if (dockerContext !== undefined) env.DOCKER_CONTEXT = dockerContext;
} else {
env.DOCKER_HOST = dockerHost;
}
return env;
}

export function buildSubprocessEnv(extra?: Record<string, string>): Record<string, string> {
return buildSubprocessEnvFrom(process.env, extra);
}
1 change: 1 addition & 0 deletions src/lib/advisories/checks/host/docker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ describe("Docker host advisories (#3213)", () => {
"enable_docker_desktop_wsl_integration",
"install_docker",
"invalid_docker_host",
"docker_probe_inconclusive",
"docker_group_permission",
"start_docker",
"docker_desktop_credential_store_headless",
Expand Down
33 changes: 32 additions & 1 deletion src/lib/advisories/checks/host/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ const INSTALL_DOCKER_COMMANDS: Readonly<Record<PackageManager, string>> = {
};

export function wslDockerBlocksRemainingChecks(host: HostAssessment): boolean {
return host.isWsl && (!host.dockerInstalled || !host.dockerReachable);
return (
host.isWsl &&
host.dockerProbeIssue === undefined &&
(!host.dockerInstalled || !host.dockerReachable)
);
}

export const enableDockerDesktopWslIntegration: AdvisoryCheck<HostAssessment> = {
Expand Down Expand Up @@ -95,6 +99,30 @@ export const invalidDockerHost: AdvisoryCheck<HostAssessment> = {
},
};

export const retryDockerProbe: AdvisoryCheck<HostAssessment> = {
id: "docker_probe_inconclusive",
phase: "preflight.host",
severity: "blocking",
resumeSafe: false,
check(host) {
const issue = host.dockerProbeIssue;
if (!issue) return null;
const command = issue.startsWith("version_") ? "docker version" : "docker info";
const timedOut = issue.endsWith("_timeout");
return hostAdvisory(retryDockerProbe, {
title: "Retry the Docker readiness probe",
kind: "manual",
reason: timedOut
? `The selected Docker authority did not answer \`${command}\` within 15 seconds. NemoClaw kept that authority and did not classify the daemon as stopped.`
: `NemoClaw could not start the \`${command}\` readiness probe. It kept the selected Docker authority because no daemon verdict was available.`,
commands: [
`${command} # verify the selected Docker authority answers`,
"Restart the selected Docker runtime if the command remains unresponsive, then rerun `nemoclaw onboard`.",
],
});
},
};

export const addUserToDockerGroup: AdvisoryCheck<HostAssessment> = {
id: "docker_group_permission",
phase: "preflight.host",
Expand All @@ -103,6 +131,7 @@ export const addUserToDockerGroup: AdvisoryCheck<HostAssessment> = {
check(host) {
if (
host.dockerHostInvalid ||
host.dockerProbeIssue !== undefined ||
!host.dockerInstalled ||
host.dockerReachable ||
host.isWsl ||
Expand Down Expand Up @@ -139,6 +168,7 @@ export const startDocker: AdvisoryCheck<HostAssessment> = {
const likelyGroupIssue = host.platform === "linux" && host.dockerServiceActive === true;
if (
host.dockerHostInvalid ||
host.dockerProbeIssue !== undefined ||
!host.dockerInstalled ||
host.dockerReachable ||
host.isWsl ||
Expand Down Expand Up @@ -199,6 +229,7 @@ export const DOCKER_HOST_ADVISORY_CHECKS = Object.freeze([
enableDockerDesktopWslIntegration,
installDocker,
invalidDockerHost,
retryDockerProbe,
addUserToDockerGroup,
startDocker,
dockerDesktopCredentialStoreHeadless,
Expand Down
1 change: 1 addition & 0 deletions src/lib/advisories/checks/host/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ describe("host advisory registry (#3213)", () => {
"enable_docker_desktop_wsl_integration",
"install_docker",
"invalid_docker_host",
"docker_probe_inconclusive",
"docker_group_permission",
"start_docker",
"docker_desktop_credential_store_headless",
Expand Down
124 changes: 124 additions & 0 deletions src/lib/onboard/preflight-docker-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";

import { assessHost, planHostAdvisories } from "./preflight";

const dockerInfoCommand = ["docker", "info", "--format", "{{json .}}"] as const;
const dockerVersionCommand = ["docker", "version", "--format", "{{json .}}"] as const;
const reachableInfoOutput = JSON.stringify({
ServerVersion: "29.2.1",
OperatingSystem: "Docker Engine",
});

function commonAssessmentOptions() {
return {
platform: "linux" as const,
commandExistsImpl: (name: string) => name === "docker",
runCaptureImpl: () => "",
readFileImpl: () => "",
readdirImpl: () => [],
gpuProbeImpl: () => false,
};
}

describe("Docker preflight timeouts", () => {
it("bounds Docker info before reporting an unreachable daemon (#10367)", () => {
const calls: Array<{
command: readonly string[];
timeout: number | undefined;
}> = [];
const result = assessHost({
...commonAssessmentOptions(),
env: {},
runCaptureExImpl: (command, options) => {
calls.push({ command, timeout: options?.timeout });
return { stdout: "", stderr: "", exitCode: null, timedOut: true };
},
});

expect(calls.filter(({ command }) => command[0] === "docker")).toEqual([
{ command: dockerInfoCommand, timeout: 15_000 },
]);
expect(result.dockerReachable).toBe(false);
expect(result.dockerProbeIssue).toBe("info_timeout");
const advisories = planHostAdvisories(result);
expect(advisories.map(({ id }) => id)).toContain("docker_probe_inconclusive");
expect(advisories.find(({ id }) => id === "docker_probe_inconclusive")?.reason).toContain(
"did not answer `docker info` within 15 seconds",
);
expect(advisories.map(({ id }) => id)).not.toContain("start_docker");
expect(advisories.map(({ id }) => id)).not.toContain("docker_group_permission");
});

it("bounds Docker version after Docker info succeeds (#10367)", () => {
const calls: Array<{
command: readonly string[];
timeout: number | undefined;
}> = [];
const results = new Map([
[
dockerInfoCommand.join("\0"),
{ stdout: reachableInfoOutput, stderr: "", exitCode: 0, timedOut: false },
],
]);
const result = assessHost({
...commonAssessmentOptions(),
env: {},
runCaptureExImpl: (command, options) => {
calls.push({ command, timeout: options?.timeout });
return (
results.get(command.join("\0")) ?? {
stdout: "",
stderr: "",
exitCode: null,
timedOut: true,
}
);
},
});

expect(calls.filter(({ command }) => command[0] === "docker")).toEqual([
{ command: dockerInfoCommand, timeout: 15_000 },
{ command: dockerVersionCommand, timeout: 15_000 },
]);
expect(result.dockerReachable).toBe(true);
expect(result.dockerProbeIssue).toBe("version_timeout");
expect(
planHostAdvisories(result).find(({ id }) => id === "docker_probe_inconclusive")?.reason,
).toContain("did not answer `docker version` within 15 seconds");
});

it("reports an unavailable Docker info process as inconclusive", () => {
const result = assessHost({
...commonAssessmentOptions(),
env: {},
runCaptureExImpl: () => {
throw new Error("spawn failed");
},
});

expect(result.dockerReachable).toBe(false);
expect(result.dockerProbeIssue).toBe("info_unavailable");
const advisoryIds = planHostAdvisories(result).map(({ id }) => id);
expect(advisoryIds).toContain("docker_probe_inconclusive");
expect(advisoryIds).not.toContain("start_docker");
expect(advisoryIds).not.toContain("docker_group_permission");
});

it("reports an unavailable Docker version process as inconclusive", () => {
const result = assessHost({
...commonAssessmentOptions(),
env: {},
runCaptureExImpl: (command) =>
command[1] === "info"
? { stdout: reachableInfoOutput, stderr: "", exitCode: 0, timedOut: false }
: { stdout: "", stderr: "", exitCode: null, timedOut: false },
});

expect(result.dockerReachable).toBe(true);
expect(result.dockerProbeIssue).toBe("version_unavailable");
expect(planHostAdvisories(result).map(({ id }) => id)).toContain("docker_probe_inconclusive");
});
});
Loading
Loading