From 804609fa08efb986c8676bbc2615fff5b561d14f Mon Sep 17 00:00:00 2001 From: Deepak Jain Date: Tue, 1 Sep 2026 13:43:41 -0700 Subject: [PATCH 1/3] fix(onboard): bound preflight docker info probe Apply the same 3-second timeout used by Docker host detection to onboarding preflight docker info, surface a timeout-specific advisory, and keep docker-group/start-docker guidance from misdiagnosing a hang. Fixes #10645 Signed-off-by: Deepak Jain --- src/lib/advisories/checks/host/docker.test.ts | 11 ++++ src/lib/advisories/checks/host/docker.ts | 33 +++++++++++ src/lib/advisories/checks/host/index.test.ts | 1 + .../preflight-docker-info-timeout.test.ts | 55 +++++++++++++++++++ src/lib/onboard/preflight.ts | 19 +++++-- src/lib/platform.ts | 2 +- 6 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 src/lib/onboard/preflight-docker-info-timeout.test.ts diff --git a/src/lib/advisories/checks/host/docker.test.ts b/src/lib/advisories/checks/host/docker.test.ts index 31a5a9f4cf5..e436147491b 100644 --- a/src/lib/advisories/checks/host/docker.test.ts +++ b/src/lib/advisories/checks/host/docker.test.ts @@ -49,6 +49,16 @@ describe("Docker host advisories (#3213)", () => { expect(result.advisories.map((advisory) => advisory.id)).toEqual([expectedId]); }); + it("reports a docker info timeout instead of a docker-group remediation (#10645)", () => { + const result = runAdvisories( + DOCKER_HOST_ADVISORY_CHECKS, + host({ dockerServiceActive: true, dockerInfoTimedOut: true }), + { phase: "preflight.host" }, + ); + + expect(result.advisories.map((advisory) => advisory.id)).toEqual(["docker_info_timeout"]); + }); + it("reports an invalid DOCKER_HOST instead of a docker-group remediation (#7731)", () => { const result = runAdvisories( DOCKER_HOST_ADVISORY_CHECKS, @@ -163,6 +173,7 @@ describe("Docker host advisories (#3213)", () => { "enable_docker_desktop_wsl_integration", "install_docker", "invalid_docker_host", + "docker_info_timeout", "docker_group_permission", "start_docker", "docker_desktop_credential_store_headless", diff --git a/src/lib/advisories/checks/host/docker.ts b/src/lib/advisories/checks/host/docker.ts index 024e3e5926e..91a470685c9 100644 --- a/src/lib/advisories/checks/host/docker.ts +++ b/src/lib/advisories/checks/host/docker.ts @@ -95,6 +95,36 @@ export const invalidDockerHost: AdvisoryCheck = { }, }; +export const dockerInfoTimeout: AdvisoryCheck = { + id: "docker_info_timeout", + phase: "preflight.host", + severity: "blocking", + resumeSafe: false, + check(host) { + if ( + host.dockerHostInvalid || + !host.dockerInstalled || + host.dockerReachable || + host.dockerInfoTimedOut !== true + ) { + return null; + } + return hostAdvisory(dockerInfoTimeout, { + title: "Docker did not answer the preflight probe in time", + kind: "manual", + reason: + "Docker is installed, but `docker info` did not finish within the bounded preflight timeout. " + + "This usually means the configured Docker authority accepted the connection but never returned a response. " + + "Retry after confirming Docker is healthy, or correct DOCKER_HOST if it points at a stalled socket or proxy.", + commands: [ + "echo \"DOCKER_HOST=${DOCKER_HOST:-}\"", + "docker info", + "nemoclaw onboard", + ], + }); + }, +}; + export const addUserToDockerGroup: AdvisoryCheck = { id: "docker_group_permission", phase: "preflight.host", @@ -105,6 +135,7 @@ export const addUserToDockerGroup: AdvisoryCheck = { host.dockerHostInvalid || !host.dockerInstalled || host.dockerReachable || + host.dockerInfoTimedOut === true || host.isWsl || host.platform !== "linux" || host.dockerServiceActive !== true @@ -141,6 +172,7 @@ export const startDocker: AdvisoryCheck = { host.dockerHostInvalid || !host.dockerInstalled || host.dockerReachable || + host.dockerInfoTimedOut === true || host.isWsl || likelyGroupIssue ) @@ -199,6 +231,7 @@ export const DOCKER_HOST_ADVISORY_CHECKS = Object.freeze([ enableDockerDesktopWslIntegration, installDocker, invalidDockerHost, + dockerInfoTimeout, addUserToDockerGroup, startDocker, dockerDesktopCredentialStoreHeadless, diff --git a/src/lib/advisories/checks/host/index.test.ts b/src/lib/advisories/checks/host/index.test.ts index 5d5263dc21a..d0deea582c5 100644 --- a/src/lib/advisories/checks/host/index.test.ts +++ b/src/lib/advisories/checks/host/index.test.ts @@ -45,6 +45,7 @@ describe("host advisory registry (#3213)", () => { "enable_docker_desktop_wsl_integration", "install_docker", "invalid_docker_host", + "docker_info_timeout", "docker_group_permission", "start_docker", "docker_desktop_credential_store_headless", diff --git a/src/lib/onboard/preflight-docker-info-timeout.test.ts b/src/lib/onboard/preflight-docker-info-timeout.test.ts new file mode 100644 index 00000000000..a72e6e58a52 --- /dev/null +++ b/src/lib/onboard/preflight-docker-info-timeout.test.ts @@ -0,0 +1,55 @@ +// 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"; +import { printRemediationActions } from "./remediation"; + +describe("assessHost docker info timeout (#10645)", () => { + it("flags a bounded docker info timeout instead of a docker-group remediation", () => { + const assessment = assessHost({ + platform: "linux", + env: { DOCKER_HOST: "unix:///var/run/docker.sock" }, + dockerInfoTimedOut: true, + dockerInfoOutput: "", + commandExistsImpl: (name: string) => name === "docker" || name === "systemctl", + runCaptureImpl: (command: readonly string[]) => + command.includes("is-active") ? "active" : "", + }); + + expect(assessment.dockerInfoTimedOut).toBe(true); + expect(assessment.dockerReachable).toBe(false); + + const ids = planHostAdvisories(assessment).map((action) => action.id); + expect(ids).toContain("docker_info_timeout"); + expect(ids).not.toContain("docker_group_permission"); + expect(ids).not.toContain("start_docker"); + }); + + it("names the configured Docker authority in the timeout remediation", () => { + const assessment = assessHost({ + platform: "linux", + env: { DOCKER_HOST: "unix:///var/run/docker.sock" }, + dockerInfoTimedOut: true, + dockerInfoOutput: "", + commandExistsImpl: (name: string) => name === "docker" || name === "systemctl", + runCaptureImpl: (command: readonly string[]) => + command.includes("is-active") ? "active" : "", + }); + + const lines: string[] = []; + const err = console.error; + console.error = (line: string) => { + lines.push(line); + }; + try { + printRemediationActions(planHostAdvisories(assessment)); + } finally { + console.error = err; + } + + expect(lines.join("\n")).toContain("docker_info_timeout"); + expect(lines.join("\n")).toContain("DOCKER_HOST"); + }); +}); diff --git a/src/lib/onboard/preflight.ts b/src/lib/onboard/preflight.ts index e8e401557ad..f37d43813a5 100644 --- a/src/lib/onboard/preflight.ts +++ b/src/lib/onboard/preflight.ts @@ -28,7 +28,7 @@ import { isDockerDaemonReachable, isSupportedGatewayDockerHost, } from "../domain/docker-host"; -import { classifyDockerVersionIdentity } from "../platform"; +import { classifyDockerVersionIdentity, DOCKER_PROBE_TIMEOUT_MS } from "../platform"; import { resolveOpenshell } from "../readiness/openshell-resolver"; import { MIN_RECOMMENDED_DOCKER_CPUS, @@ -48,7 +48,7 @@ export { getNvidiaCdiSpecPath, parseDockerCdiSpecDirs } from "./docker-cdi"; export { isWslDockerDesktopRuntime } from "./wsl-docker-desktop-gpu"; // runner.ts still uses CommonJS-style exports — use require here. -const { run, runCapture } = require("../runner"); +const { run, runCapture, runCaptureEx } = require("../runner"); const DOCKER_HOST_ADVISORY_IDS = new Set(DOCKER_HOST_ADVISORY_CHECKS.map(({ id }) => id)); type RunCaptureFn = typeof import("../runner").runCapture; @@ -132,6 +132,8 @@ export interface HostAssessment { dockerInstalled: boolean; dockerRunning: boolean; dockerReachable: boolean; + /** True when the bounded preflight `docker info` probe timed out (#10645). */ + dockerInfoTimedOut?: boolean; nodeInstalled: boolean; openshellInstalled: boolean; dockerInfoSummary?: string; @@ -182,6 +184,7 @@ export interface AssessHostOpts { release?: string; procVersion?: string; dockerInfoOutput?: string; + dockerInfoTimedOut?: boolean; dockerInfoError?: string; dockerVersionOutput?: string; readFileImpl?: (filePath: string, encoding: BufferEncoding) => string; @@ -566,12 +569,19 @@ export function assessHost(opts: AssessHostOpts = {}): HostAssessment { const dockerHostInvalid = !isSupportedGatewayDockerHost(env.DOCKER_HOST); let dockerInfoOutput = opts.dockerInfoOutput; + let dockerInfoTimedOut = opts.dockerInfoTimedOut === true; let dockerReachable = false; let dockerRunning = false; if (dockerInstalled && !dockerHostInvalid && dockerInfoOutput === undefined) { - dockerInfoOutput = runCaptureImpl(["docker", "info", "--format", "{{json .}}"], { - ignoreError: true, + const dockerInfoCapture = runCaptureEx(["docker", "info", "--format", "{{json .}}"], { + timeout: DOCKER_PROBE_TIMEOUT_MS, }); + if (dockerInfoCapture.timedOut) { + dockerInfoTimedOut = true; + dockerInfoOutput = ""; + } else { + dockerInfoOutput = dockerInfoCapture.stdout; + } } if (dockerInstalled && isDockerDaemonReachable(dockerInfoOutput)) { dockerReachable = true; @@ -704,6 +714,7 @@ export function assessHost(opts: AssessHostOpts = {}): HostAssessment { dockerInstalled, dockerRunning, dockerReachable, + dockerInfoTimedOut, nodeInstalled, openshellInstalled, dockerInfoSummary: parseDockerInfoSummary(dockerInfoOutput), diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 54d9e7b0d70..f77a59c9afa 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -85,7 +85,7 @@ export function windowsProcessListensOnlyOnLoopback( ); } -const DOCKER_PROBE_TIMEOUT_MS = 3_000; +export const DOCKER_PROBE_TIMEOUT_MS = 3_000; const DOCKER_PROBE_MAX_BUFFER_BYTES = 1024 * 1024; const DOCKER_PROBE_ENV_NAMES = ["HOME", "USER", "LOGNAME", "PATH"] as const; From 055b0358c99183594dae750e73d8fbb99423485c Mon Sep 17 00:00:00 2001 From: Deepak Jain Date: Thu, 3 Sep 2026 03:24:22 -0700 Subject: [PATCH 2/3] fix(onboard): render assessed Docker authority Preserve the probed Docker authority so timeout remediation reports the exact endpoint safely. Signed-off-by: Deepak Jain --- src/lib/advisories/checks/host/docker.ts | 3 ++- src/lib/onboard/preflight-docker-info-timeout.test.ts | 4 +++- src/lib/onboard/preflight.ts | 3 +++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/lib/advisories/checks/host/docker.ts b/src/lib/advisories/checks/host/docker.ts index 91a470685c9..7bef24b6a99 100644 --- a/src/lib/advisories/checks/host/docker.ts +++ b/src/lib/advisories/checks/host/docker.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { shellQuote } from "../../../core/shell-quote"; import { DOCKER_DESKTOP_CREDENTIAL_STORE_NAMES } from "../../../domain/docker-host"; import type { HostAssessment, PackageManager } from "../../../onboard/preflight"; import type { AdvisoryCheck } from "../../types"; @@ -117,7 +118,7 @@ export const dockerInfoTimeout: AdvisoryCheck = { "This usually means the configured Docker authority accepted the connection but never returned a response. " + "Retry after confirming Docker is healthy, or correct DOCKER_HOST if it points at a stalled socket or proxy.", commands: [ - "echo \"DOCKER_HOST=${DOCKER_HOST:-}\"", + `printf 'DOCKER_HOST=%s\\n' ${shellQuote(host.dockerHostAuthority ?? "")}`, "docker info", "nemoclaw onboard", ], diff --git a/src/lib/onboard/preflight-docker-info-timeout.test.ts b/src/lib/onboard/preflight-docker-info-timeout.test.ts index a72e6e58a52..3bac9b0d521 100644 --- a/src/lib/onboard/preflight-docker-info-timeout.test.ts +++ b/src/lib/onboard/preflight-docker-info-timeout.test.ts @@ -50,6 +50,8 @@ describe("assessHost docker info timeout (#10645)", () => { } expect(lines.join("\n")).toContain("docker_info_timeout"); - expect(lines.join("\n")).toContain("DOCKER_HOST"); + expect(lines.join("\n")).toContain( + "printf 'DOCKER_HOST=%s\\n' 'unix:///var/run/docker.sock'", + ); }); }); diff --git a/src/lib/onboard/preflight.ts b/src/lib/onboard/preflight.ts index f37d43813a5..bbf99e34722 100644 --- a/src/lib/onboard/preflight.ts +++ b/src/lib/onboard/preflight.ts @@ -129,6 +129,8 @@ export interface HostAssessment { dockerServiceActive?: boolean | null; dockerServiceEnabled?: boolean | null; dockerHostInvalid?: boolean; + /** Docker authority assessed by the bounded preflight probe. */ + dockerHostAuthority?: string; dockerInstalled: boolean; dockerRunning: boolean; dockerReachable: boolean; @@ -711,6 +713,7 @@ export function assessHost(opts: AssessHostOpts = {}): HostAssessment { dockerServiceActive, dockerServiceEnabled, dockerHostInvalid, + dockerHostAuthority: env.DOCKER_HOST ?? "", dockerInstalled, dockerRunning, dockerReachable, From 990f1af292b055cd9058a4e7fd71181d390b96fd Mon Sep 17 00:00:00 2001 From: Deepak Jain Date: Thu, 3 Sep 2026 14:23:16 -0700 Subject: [PATCH 3/3] fix(onboard): inject Docker info probe Signed-off-by: Deepak Jain --- ci/source-architecture-budget.json | 2 +- .../preflight-docker-info-timeout.test.ts | 20 ++++++++++++++----- src/lib/onboard/preflight.ts | 14 ++++++++++--- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index ab902284213..f0d5bcc10bd 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -18,7 +18,7 @@ "src/lib/cli/terminal-style.ts": 42, "src/lib/core/json-types.ts": 34, "src/lib/core/ports.ts": 89, - "src/lib/core/shell-quote.ts": 27, + "src/lib/core/shell-quote.ts": 28, "src/lib/core/url-utils.ts": 25, "src/lib/core/wait.ts": 30, "src/lib/credentials/store.ts": 45, diff --git a/src/lib/onboard/preflight-docker-info-timeout.test.ts b/src/lib/onboard/preflight-docker-info-timeout.test.ts index 3bac9b0d521..57afa543ac1 100644 --- a/src/lib/onboard/preflight-docker-info-timeout.test.ts +++ b/src/lib/onboard/preflight-docker-info-timeout.test.ts @@ -8,16 +8,28 @@ import { printRemediationActions } from "./remediation"; describe("assessHost docker info timeout (#10645)", () => { it("flags a bounded docker info timeout instead of a docker-group remediation", () => { + const probeCalls: Array<{ + command: readonly string[]; + options?: { timeout?: number }; + }> = []; const assessment = assessHost({ platform: "linux", env: { DOCKER_HOST: "unix:///var/run/docker.sock" }, - dockerInfoTimedOut: true, - dockerInfoOutput: "", commandExistsImpl: (name: string) => name === "docker" || name === "systemctl", + runCaptureExImpl: (command, options) => { + probeCalls.push({ command, options }); + return { stdout: "", exitCode: null, timedOut: true }; + }, runCaptureImpl: (command: readonly string[]) => command.includes("is-active") ? "active" : "", }); + expect(probeCalls).toEqual([ + { + command: ["docker", "info", "--format", "{{json .}}"], + options: { timeout: 3_000 }, + }, + ]); expect(assessment.dockerInfoTimedOut).toBe(true); expect(assessment.dockerReachable).toBe(false); @@ -50,8 +62,6 @@ describe("assessHost docker info timeout (#10645)", () => { } expect(lines.join("\n")).toContain("docker_info_timeout"); - expect(lines.join("\n")).toContain( - "printf 'DOCKER_HOST=%s\\n' 'unix:///var/run/docker.sock'", - ); + expect(lines.join("\n")).toContain("printf 'DOCKER_HOST=%s\\n' 'unix:///var/run/docker.sock'"); }); }); diff --git a/src/lib/onboard/preflight.ts b/src/lib/onboard/preflight.ts index bbf99e34722..ba4b55c2dbd 100644 --- a/src/lib/onboard/preflight.ts +++ b/src/lib/onboard/preflight.ts @@ -54,6 +54,10 @@ const DOCKER_HOST_ADVISORY_IDS = new Set(DOCKER_HOST_ADVISORY_CHECKS.map(({ id } type RunCaptureFn = typeof import("../runner").runCapture; type RunFn = typeof import("../runner").run; type RunCaptureOpts = Parameters[1]; +type RunCaptureExFn = ( + command: readonly string[], + options?: { timeout?: number }, +) => import("../runner").CaptureResult; type NullableRunCaptureFn = ( command: Parameters[0], options?: RunCaptureOpts, @@ -192,6 +196,7 @@ export interface AssessHostOpts { readFileImpl?: (filePath: string, encoding: BufferEncoding) => string; readdirImpl?: (dir: string) => string[]; runCaptureImpl?: RunCaptureFn; + runCaptureExImpl?: RunCaptureExFn; resolveOpenshellImpl?: () => string | null; commandExistsImpl?: (commandName: string) => boolean; gpuProbeImpl?: () => boolean; @@ -575,9 +580,12 @@ export function assessHost(opts: AssessHostOpts = {}): HostAssessment { let dockerReachable = false; let dockerRunning = false; if (dockerInstalled && !dockerHostInvalid && dockerInfoOutput === undefined) { - const dockerInfoCapture = runCaptureEx(["docker", "info", "--format", "{{json .}}"], { - timeout: DOCKER_PROBE_TIMEOUT_MS, - }); + const dockerInfoCapture = (opts.runCaptureExImpl ?? runCaptureEx)( + ["docker", "info", "--format", "{{json .}}"], + { + timeout: DOCKER_PROBE_TIMEOUT_MS, + }, + ); if (dockerInfoCapture.timedOut) { dockerInfoTimedOut = true; dockerInfoOutput = "";