From c20d266489b867b941277b58962f0544b0db4ff2 Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Thu, 10 Sep 2026 13:56:55 +0800 Subject: [PATCH 1/3] fix(preflight): name the Docker authority conflict instead of a docker-group fix (#10622) When DOCKER_HOST is unset, the default Docker authority does not answer and two discovered sockets answer as different engines, detectDockerHost deliberately selects neither (#8816, #10253). Nothing named that conflict, so preflight printed docker_group_permission and a `usermod -aG docker` remedy that does not address the cause. The socket-candidate loop moves into selectDockerAuthority, which returns the selection and the conflict; detectDockerHost keeps returning the selection unchanged and observeDockerAuthorityConflict exposes the two candidates. assessHost records the conflict only on the unreachable path with DOCKER_HOST unset and a probe verdict, and only when it probes the local host itself: injected Docker evidence or a command transport gets no default observer. A new blocking advisory, docker_authority_conflict, names both sockets and engines, says NemoClaw did not choose and did not diagnose why the default is unreachable, and prints a single-quoted `export DOCKER_HOST` line for each socket. The docker-group, start-Docker and WSL-integration advisories stay silent while a conflict is observed. Signed-off-by: Dongni Yang Co-Authored-By: Claude Fable 5.1 --- docs/reference/system-readiness.mdx | 2 + src/lib/advisories/checks/host/docker.test.ts | 106 +++++++++++ src/lib/advisories/checks/host/docker.ts | 50 ++++- src/lib/advisories/checks/host/index.test.ts | 1 + ...reflight-docker-authority-conflict.test.ts | 172 ++++++++++++++++++ src/lib/onboard/preflight.ts | 39 +++- src/lib/platform.ts | 62 ++++++- test/e2e-runtime/platform.test.ts | 154 ++++++++++++++++ 8 files changed, 575 insertions(+), 11 deletions(-) create mode 100644 src/lib/onboard/preflight-docker-authority-conflict.test.ts diff --git a/docs/reference/system-readiness.mdx b/docs/reference/system-readiness.mdx index 8cf04ab5455..9b3b99720cf 100644 --- a/docs/reference/system-readiness.mdx +++ b/docs/reference/system-readiness.mdx @@ -105,6 +105,8 @@ If that authority completes with an unreachable result, NemoClaw probes a bounde 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. +When two reachable fallbacks identify as different engines, onboarding reports the blocking `docker_authority_conflict` advisory. +The advisory names both sockets and their engines and asks you to set `DOCKER_HOST` to the intended socket. A reachable Podman endpoint remains classified as Podman and does not satisfy the standard Docker runtime requirement. The Docker storage capabilities separate the current host configuration from NemoClaw's supported remediation path. diff --git a/src/lib/advisories/checks/host/docker.test.ts b/src/lib/advisories/checks/host/docker.test.ts index 0ed048d31ad..fe85830be26 100644 --- a/src/lib/advisories/checks/host/docker.test.ts +++ b/src/lib/advisories/checks/host/docker.test.ts @@ -79,6 +79,111 @@ describe("Docker host advisories (#3213)", () => { expect(result.advisories.map((advisory) => advisory.id)).toEqual(["invalid_docker_host"]); }); + const CONFLICT: HostAssessment["dockerAuthorityConflict"] = { + candidates: [ + { socketPath: "/run/user/1000/podman/podman.sock", identity: "podman" }, + { socketPath: "/var/run/docker.sock", identity: "docker" }, + ], + }; + + it("names the authority conflict instead of a docker-group or start-docker fix (#10622)", () => { + const result = runAdvisories( + DOCKER_HOST_ADVISORY_CHECKS, + host({ dockerServiceActive: true, dockerAuthorityConflict: CONFLICT }), + { phase: "preflight.host" }, + ); + + expect(result.advisories.map((advisory) => advisory.id)).toEqual(["docker_authority_conflict"]); + const advisory = result.advisories[0]; + expect(advisory).toMatchObject({ + severity: "blocking", + kind: "manual", + title: "Choose the Docker authority", + }); + expect(advisory?.reason).toContain("podman at /run/user/1000/podman/podman.sock"); + expect(advisory?.reason).toContain("docker at /var/run/docker.sock"); + expect(advisory?.reason).toContain("did not choose between them"); + expect(advisory?.reason).toContain("did not diagnose why"); + expect(advisory?.commands).toEqual([ + "export DOCKER_HOST='unix:///run/user/1000/podman/podman.sock' # podman", + "# or: export DOCKER_HOST='unix:///var/run/docker.sock' # docker", + "nemoclaw onboard", + ]); + }); + + it("quotes a socket path that contains a space (#10622)", () => { + const result = runAdvisories( + DOCKER_HOST_ADVISORY_CHECKS, + host({ + platform: "darwin", + packageManager: "brew", + dockerServiceActive: null, + dockerAuthorityConflict: { + candidates: [ + { socketPath: "/Users/Jane Doe/.docker/run/docker.sock", identity: "docker" }, + { + socketPath: "/Users/Jane Doe/.local/share/containers/podman/machine/podman.sock", + identity: "podman", + }, + ], + }, + }), + { phase: "preflight.host" }, + ); + + expect(result.advisories[0]?.commands?.[0]).toBe( + "export DOCKER_HOST='unix:///Users/Jane Doe/.docker/run/docker.sock' # docker", + ); + }); + + it.each([ + ["an inactive Linux docker.service", host({ dockerAuthorityConflict: CONFLICT })], + [ + "a null service state on macOS", + host({ + platform: "darwin", + packageManager: "brew", + dockerServiceActive: null, + dockerAuthorityConflict: CONFLICT, + }), + ], + ["a WSL distro", host({ isWsl: true, dockerAuthorityConflict: CONFLICT })], + ])("keeps the conflict as the only Docker advisory with %s (#10622)", (_case, context) => { + const result = runAdvisories(DOCKER_HOST_ADVISORY_CHECKS, context, { + phase: "preflight.host", + }); + + expect(result.advisories.map((advisory) => advisory.id)).toEqual(["docker_authority_conflict"]); + }); + + it.each([ + [ + "the daemon is reachable", + host({ dockerReachable: true, dockerRunning: true, dockerAuthorityConflict: CONFLICT }), + [], + ], + [ + "Docker is not installed", + host({ dockerInstalled: false, dockerAuthorityConflict: CONFLICT }), + ["install_docker"], + ], + [ + "DOCKER_HOST is invalid", + host({ + dockerHostInvalid: true, + dockerServiceActive: true, + dockerAuthorityConflict: CONFLICT, + }), + ["invalid_docker_host"], + ], + ])("does not report a conflict when %s (#10622)", (_case, context, expectedIds) => { + const result = runAdvisories(DOCKER_HOST_ADVISORY_CHECKS, context, { + phase: "preflight.host", + }); + + expect(result.advisories.map((advisory) => advisory.id)).toEqual(expectedIds); + }); + it("warns about a Docker Desktop credential store in a headless session (#9457)", () => { const result = runAdvisories( DOCKER_HOST_ADVISORY_CHECKS, @@ -164,6 +269,7 @@ describe("Docker host advisories (#3213)", () => { "install_docker", "invalid_docker_host", "docker_probe_inconclusive", + "docker_authority_conflict", "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 d120a91a85b..e81aa0c29dd 100644 --- a/src/lib/advisories/checks/host/docker.ts +++ b/src/lib/advisories/checks/host/docker.ts @@ -29,7 +29,13 @@ export const enableDockerDesktopWslIntegration: AdvisoryCheck = severity: "blocking", resumeSafe: false, check(host) { - if (host.dockerHostInvalid || !wslDockerBlocksRemainingChecks(host)) return null; + if ( + host.dockerHostInvalid || + host.dockerAuthorityConflict !== undefined || + !wslDockerBlocksRemainingChecks(host) + ) { + return null; + } const dockerMissing = !host.dockerInstalled; return hostAdvisory(enableDockerDesktopWslIntegration, { title: "Enable Docker Desktop WSL integration", @@ -123,6 +129,45 @@ export const retryDockerProbe: AdvisoryCheck = { }, }; +/** Quote a value for a POSIX shell so a path with spaces survives `export`. */ +function shellSingleQuoted(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +export const chooseDockerAuthority: AdvisoryCheck = { + id: "docker_authority_conflict", + phase: "preflight.host", + severity: "blocking", + resumeSafe: false, + check(host) { + const conflict = host.dockerAuthorityConflict; + if ( + conflict === undefined || + !host.dockerInstalled || + host.dockerHostInvalid || + host.dockerReachable + ) { + return null; + } + const [first, second] = conflict.candidates; + return hostAdvisory(chooseDockerAuthority, { + title: "Choose the Docker authority", + kind: "manual", + reason: + "The default Docker authority did not answer, and two engines answered on discovered sockets: " + + `${first.identity} at ${first.socketPath} and ${second.identity} at ${second.socketPath}. ` + + "NemoClaw did not choose between them and kept the default authority. " + + "It did not diagnose why that authority is unreachable, and it withholds the docker-group and start-Docker remedies while two other engines answer. " + + "Set DOCKER_HOST to the socket you want; NemoClaw honours it before any probe. Or repair the default authority.", + commands: [ + `export DOCKER_HOST=${shellSingleQuoted(`unix://${first.socketPath}`)} # ${first.identity}`, + `# or: export DOCKER_HOST=${shellSingleQuoted(`unix://${second.socketPath}`)} # ${second.identity}`, + "nemoclaw onboard", + ], + }); + }, +}; + export const addUserToDockerGroup: AdvisoryCheck = { id: "docker_group_permission", phase: "preflight.host", @@ -132,6 +177,7 @@ export const addUserToDockerGroup: AdvisoryCheck = { if ( host.dockerHostInvalid || host.dockerProbeIssue !== undefined || + host.dockerAuthorityConflict !== undefined || !host.dockerInstalled || host.dockerReachable || host.isWsl || @@ -169,6 +215,7 @@ export const startDocker: AdvisoryCheck = { if ( host.dockerHostInvalid || host.dockerProbeIssue !== undefined || + host.dockerAuthorityConflict !== undefined || !host.dockerInstalled || host.dockerReachable || host.isWsl || @@ -230,6 +277,7 @@ export const DOCKER_HOST_ADVISORY_CHECKS = Object.freeze([ installDocker, invalidDockerHost, retryDockerProbe, + chooseDockerAuthority, addUserToDockerGroup, startDocker, dockerDesktopCredentialStoreHeadless, diff --git a/src/lib/advisories/checks/host/index.test.ts b/src/lib/advisories/checks/host/index.test.ts index 2634177e451..e83f78f0cb1 100644 --- a/src/lib/advisories/checks/host/index.test.ts +++ b/src/lib/advisories/checks/host/index.test.ts @@ -46,6 +46,7 @@ describe("host advisory registry (#3213)", () => { "install_docker", "invalid_docker_host", "docker_probe_inconclusive", + "docker_authority_conflict", "docker_group_permission", "start_docker", "docker_desktop_credential_store_headless", diff --git a/src/lib/onboard/preflight-docker-authority-conflict.test.ts b/src/lib/onboard/preflight-docker-authority-conflict.test.ts new file mode 100644 index 00000000000..bef99791014 --- /dev/null +++ b/src/lib/onboard/preflight-docker-authority-conflict.test.ts @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import * as platform from "../platform"; +import type { DockerAuthorityConflict } from "../platform"; +import { assessHost, planHostAdvisories } from "./preflight"; + +// Regression: NemoClaw #10622. The default Docker authority is unreachable +// and the socket fallback meets both a Podman and a Docker engine. Detection +// deliberately selects neither (#8816, #10253). Preflight used to emit only +// the docker-group remediation, whose fix is wrong for this cause. The host +// assessment now carries the conflict so onboarding names both engines. +const CONFLICT: DockerAuthorityConflict = { + candidates: [ + { socketPath: "/run/user/1000/podman/podman.sock", identity: "podman" }, + { socketPath: "/var/run/docker.sock", identity: "docker" }, + ], +}; + +const REACHABLE_DOCKER_INFO = JSON.stringify({ + ServerVersion: "29.6.2", + OperatingSystem: "Ubuntu 24.04.3 LTS", + OSType: "linux", + Architecture: "x86_64", + DefaultRuntime: "runc", + CgroupVersion: "2", + Driver: "overlay2", +}); + +function commandExistsImpl(name: string): boolean { + return name === "docker" || name === "systemctl"; +} + +function runCaptureImpl(command: readonly string[]): string { + return command.includes("is-active") ? "active" : ""; +} + +describe("assessHost Docker authority conflict (#10622)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("carries the conflict so onboarding names both engines, not a docker-group fix", () => { + const observe = vi.fn(() => CONFLICT); + const assessment = assessHost({ + platform: "linux", + env: {}, + dockerInfoOutput: "", + commandExistsImpl, + runCaptureImpl, + observeDockerAuthorityConflictImpl: observe, + }); + + expect(observe).toHaveBeenCalledTimes(1); + expect(observe).toHaveBeenCalledWith({ env: {}, platform: "linux" }); + expect(assessment.dockerReachable).toBe(false); + expect(assessment.dockerServiceActive).toBe(true); + expect(assessment.dockerAuthorityConflict).toEqual(CONFLICT); + + const ids = planHostAdvisories(assessment).map((action) => action.id); + expect(ids).toContain("docker_authority_conflict"); + expect(ids).not.toContain("docker_group_permission"); + expect(ids).not.toContain("start_docker"); + }); + + it("does not observe the conflict when DOCKER_HOST is set", () => { + const observe = vi.fn(() => CONFLICT); + const assessment = assessHost({ + platform: "linux", + env: { DOCKER_HOST: "unix:///var/run/docker.sock" }, + dockerInfoOutput: "", + commandExistsImpl, + runCaptureImpl, + observeDockerAuthorityConflictImpl: observe, + }); + + expect(observe).not.toHaveBeenCalled(); + expect(assessment.dockerAuthorityConflict).toBeUndefined(); + expect(planHostAdvisories(assessment).map((action) => action.id)).not.toContain( + "docker_authority_conflict", + ); + }); + + it("does not observe the conflict when the daemon is reachable", () => { + const observe = vi.fn(() => CONFLICT); + const assessment = assessHost({ + platform: "linux", + env: {}, + dockerInfoOutput: REACHABLE_DOCKER_INFO, + dockerVersionOutput: "", + commandExistsImpl, + runCaptureImpl, + observeDockerAuthorityConflictImpl: observe, + }); + + expect(assessment.dockerReachable).toBe(true); + expect(observe).not.toHaveBeenCalled(); + expect(assessment.dockerAuthorityConflict).toBeUndefined(); + }); + + it("leaves the field unset when the observer reports no conflict", () => { + const observe = vi.fn(() => null); + const assessment = assessHost({ + platform: "linux", + env: {}, + dockerInfoOutput: "", + commandExistsImpl, + runCaptureImpl, + observeDockerAuthorityConflictImpl: observe, + }); + + expect(observe).toHaveBeenCalledTimes(1); + expect(assessment.dockerAuthorityConflict).toBeUndefined(); + expect(planHostAdvisories(assessment).map((action) => action.id)).toContain( + "docker_group_permission", + ); + }); + + it("does not probe this host's sockets when the caller injects Docker evidence", () => { + const observe = vi.spyOn(platform, "observeDockerAuthorityConflict").mockReturnValue(CONFLICT); + const assessment = assessHost({ + platform: "linux", + env: {}, + dockerInfoOutput: "", + commandExistsImpl, + runCaptureImpl, + }); + + expect(observe).not.toHaveBeenCalled(); + expect(assessment.dockerAuthorityConflict).toBeUndefined(); + expect(planHostAdvisories(assessment).map((action) => action.id)).toContain( + "docker_group_permission", + ); + }); + + it("does not observe the conflict when the Docker probe times out", () => { + const observe = vi.fn(() => CONFLICT); + const assessment = assessHost({ + platform: "linux", + env: {}, + commandExistsImpl, + runCaptureImpl, + runCaptureExImpl: () => ({ stdout: "", stderr: "", exitCode: null, timedOut: true }), + observeDockerAuthorityConflictImpl: observe, + }); + + expect(assessment.dockerProbeIssue).toBe("info_timeout"); + expect(observe).not.toHaveBeenCalled(); + expect(assessment.dockerAuthorityConflict).toBeUndefined(); + expect(planHostAdvisories(assessment).map((action) => action.id)).not.toContain( + "docker_authority_conflict", + ); + }); + + it("does not observe the conflict when Docker is not installed", () => { + const observe = vi.fn(() => CONFLICT); + const assessment = assessHost({ + platform: "linux", + env: {}, + dockerInfoOutput: "", + commandExistsImpl: (name: string) => name === "systemctl", + runCaptureImpl, + observeDockerAuthorityConflictImpl: observe, + }); + + expect(observe).not.toHaveBeenCalled(); + expect(assessment.dockerAuthorityConflict).toBeUndefined(); + expect(planHostAdvisories(assessment).map((action) => action.id)).toContain("install_docker"); + }); +}); diff --git a/src/lib/onboard/preflight.ts b/src/lib/onboard/preflight.ts index e3870eebbd2..938134726b8 100644 --- a/src/lib/onboard/preflight.ts +++ b/src/lib/onboard/preflight.ts @@ -28,7 +28,8 @@ import { isDockerDaemonReachable, isSupportedGatewayDockerHost, } from "../domain/docker-host"; -import { classifyDockerVersionIdentity } from "../platform"; +import type { DockerAuthorityConflict } from "../platform"; +import { classifyDockerVersionIdentity, observeDockerAuthorityConflict } from "../platform"; import { resolveOpenshell } from "../readiness/openshell-resolver"; import { MIN_RECOMMENDED_DOCKER_CPUS, @@ -140,6 +141,13 @@ export interface HostAssessment { | "info_unavailable" | "version_timeout" | "version_unavailable"; + /** + * Set when DOCKER_HOST is unset, the default Docker authority is + * unreachable, and the socket fallback met two reachable engines of + * different known identities. Detection deliberately selects neither + * engine and keeps the unreachable default (#8816, #10253, #10622). + */ + dockerAuthorityConflict?: DockerAuthorityConflict; nodeInstalled: boolean; openshellInstalled: boolean; dockerInfoSummary?: string; @@ -199,6 +207,10 @@ export interface AssessHostOpts { resolveOpenshellImpl?: () => string | null; commandExistsImpl?: (commandName: string) => boolean; gpuProbeImpl?: () => boolean; + observeDockerAuthorityConflictImpl?: (opts: { + env: NodeJS.ProcessEnv; + platform: NodeJS.Platform | string; + }) => DockerAuthorityConflict | null; } function buildCommandVArgv(commandName: string): readonly string[] { @@ -604,6 +616,30 @@ export function assessHost(opts: AssessHostOpts = {}): HostAssessment { dockerRunning = true; } + // An unreachable default authority with two reachable engines of different + // identities is an authority conflict (#10622). It is observed only when + // DOCKER_HOST is unset, because a set DOCKER_HOST is honoured before any + // socket probe. The default observer probes this host's own sockets, so it + // applies only when this assessment probes the local host itself: injected + // Docker evidence or a command transport (tests, a remote host) gets no + // observer unless the caller injects one. + const observeConflict = + opts.observeDockerAuthorityConflictImpl ?? + (opts.dockerInfoOutput === undefined && + opts.runCaptureImpl === undefined && + opts.runCaptureExImpl === undefined + ? observeDockerAuthorityConflict + : undefined); + const dockerAuthorityConflict = + observeConflict !== undefined && + dockerInstalled && + !dockerHostInvalid && + !dockerReachable && + dockerProbeIssue === undefined && + !env.DOCKER_HOST + ? (observeConflict({ env, platform }) ?? undefined) + : undefined; + // Capture the docker-compat engine banner so Podman fronting the Docker CLI // socket is reclassified below. Only probed when the daemon is reachable so a // down/absent Docker never pays for the extra call (#7320). @@ -745,6 +781,7 @@ export function assessHost(opts: AssessHostOpts = {}): HostAssessment { dockerRunning, dockerReachable, dockerProbeIssue, + dockerAuthorityConflict, nodeInstalled, openshellInstalled, dockerInfoSummary: parseDockerInfoSummary(dockerInfoOutput), diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 61e0a0c9b8a..7a625b69e29 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -307,13 +307,37 @@ function getDockerSocketCandidates(opts: PlatformLookupOptions = {}): string[] { return []; } -function detectDockerHost(opts: DockerHostDetectionOptions = {}): DockerHostDetection | null { +/** One reachable fallback socket that identified itself as a known engine. */ +export interface DockerAuthorityCandidate { + readonly socketPath: string; + readonly identity: "docker" | "podman"; +} + +/** + * Two reachable fallback sockets that identify as different engines while the + * host default authority refuses to answer. NemoClaw does not choose between + * them (#8816, #10253); this record lets preflight explain why (#10622). + */ +export interface DockerAuthorityConflict { + /** [the candidate selected first, the differing candidate], in probe order. */ + readonly candidates: readonly [DockerAuthorityCandidate, DockerAuthorityCandidate]; +} + +interface DockerAuthoritySelection { + selection: DockerHostDetection | null; + conflict: DockerAuthorityConflict | null; +} + +function selectDockerAuthority(opts: DockerHostDetectionOptions = {}): DockerAuthoritySelection { const env = opts.env ?? process.env; if (env.DOCKER_HOST) { return { - dockerHost: env.DOCKER_HOST, - source: "env", - socketPath: null, + selection: { + dockerHost: env.DOCKER_HOST, + source: "env", + socketPath: null, + }, + conflict: null, }; } @@ -322,24 +346,43 @@ function detectDockerHost(opts: DockerHostDetectionOptions = {}): DockerHostDete // Redirect the CLI away from the host's own default authority only after // observing that the default refuses to answer. A probe that timed out or // never ran is no evidence at all (#10367). - if (ambient.reachable || ambient.inconclusive) return null; + if (ambient.reachable || ambient.inconclusive) return { selection: null, conflict: null }; const fileExists = opts.existsSync ?? defaultExistsSync; let selection: DockerHostDetection | null = null; - let selectedIdentity: Exclude | null = null; + let selected: DockerAuthorityCandidate | null = null; for (const socketPath of getDockerSocketCandidates(opts)) { if (!fileExists(socketPath)) continue; const dockerHost = `unix://${socketPath}`; const observation = probe(dockerHost); if (!observation.reachable) continue; if (observation.identity === "unknown") continue; - if (selectedIdentity && observation.identity !== selectedIdentity) return null; + if (selected && observation.identity !== selected.identity) { + return { + selection: null, + conflict: { candidates: [selected, { socketPath, identity: observation.identity }] }, + }; + } if (selection) continue; + selected = { socketPath, identity: observation.identity }; selection = { dockerHost, source: "socket", socketPath }; - selectedIdentity = observation.identity; } - return selection; + return { selection, conflict: null }; +} + +function detectDockerHost(opts: DockerHostDetectionOptions = {}): DockerHostDetection | null { + return selectDockerAuthority(opts).selection; +} + +/** + * Report the engine conflict that made detectDockerHost decline to select a + * fallback socket, or null when no such conflict was observed. + */ +function observeDockerAuthorityConflict( + opts: DockerHostDetectionOptions = {}, +): DockerAuthorityConflict | null { + return selectDockerAuthority(opts).conflict; } export { @@ -352,5 +395,6 @@ export { getPodmanSocketCandidates, inferContainerRuntime, isWsl, + observeDockerAuthorityConflict, shouldPatchCoredns, }; diff --git a/test/e2e-runtime/platform.test.ts b/test/e2e-runtime/platform.test.ts index ae581ac69f1..1eec1acb66c 100644 --- a/test/e2e-runtime/platform.test.ts +++ b/test/e2e-runtime/platform.test.ts @@ -15,6 +15,7 @@ import { getPodmanSocketCandidates, inferContainerRuntime, isWsl, + observeDockerAuthorityConflict, shouldPatchCoredns, } from "../../src/lib/platform"; @@ -454,6 +455,159 @@ describe("platform helpers", () => { }); }); + describe("observeDockerAuthorityConflict (#10622)", () => { + const unreachableDefault = { reachable: false, identity: "unknown" as const }; + + it("reports both engines in probe order when mixed fallbacks answer and the default is unreachable", () => { + const dockerSocket = "/var/run/docker.sock"; + const podmanSocket = "/run/user/1000/podman/podman.sock"; + const sockets = new Set([dockerSocket, podmanSocket]); + const answers = new Map([ + [`unix://${dockerSocket}`, { reachable: true, identity: "docker" as const }], + [`unix://${podmanSocket}`, { reachable: true, identity: "podman" as const }], + ]); + const opts = { + env: {}, + platform: "linux" as const, + uid: 1000, + existsSync: (candidate: string) => sockets.has(candidate), + probeDockerHost: (dockerHost: string | undefined) => + answers.get(dockerHost ?? "") ?? unreachableDefault, + }; + + expect(observeDockerAuthorityConflict(opts)).toEqual({ + candidates: [ + { socketPath: dockerSocket, identity: "docker" }, + { socketPath: podmanSocket, identity: "podman" }, + ], + }); + expect(detectDockerHost(opts)).toBe(null); + }); + + it("lists the Podman candidate first when it is probed before the Docker candidate", () => { + const home = "/tmp/test-home"; + const podmanSocket = path.join(home, ".local/share/containers/podman/machine/podman.sock"); + const dockerDesktopSocket = path.join(home, ".docker/run/docker.sock"); + const sockets = new Set([podmanSocket, dockerDesktopSocket]); + const answers = new Map([ + [`unix://${podmanSocket}`, { reachable: true, identity: "podman" as const }], + [`unix://${dockerDesktopSocket}`, { reachable: true, identity: "docker" as const }], + ]); + + expect( + observeDockerAuthorityConflict({ + env: {}, + platform: "darwin", + home, + existsSync: (candidate) => sockets.has(candidate), + probeDockerHost: (dockerHost) => answers.get(dockerHost ?? "") ?? unreachableDefault, + }), + ).toEqual({ + candidates: [ + { socketPath: podmanSocket, identity: "podman" }, + { socketPath: dockerDesktopSocket, identity: "docker" }, + ], + }); + }); + + it("returns null when two reachable candidates identify as the same engine", () => { + const sockets = new Set(["/run/docker.sock", "/var/run/docker.sock"]); + + expect( + observeDockerAuthorityConflict({ + env: {}, + platform: "linux", + uid: 1000, + existsSync: (candidate) => sockets.has(candidate), + probeDockerHost: reachableDockerFallback, + }), + ).toBe(null); + }); + + it("returns null and probes no candidate when the default authority is reachable", () => { + const sockets = new Set(["/var/run/docker.sock", "/run/user/1000/podman/podman.sock"]); + const probes: Array = []; + + expect( + observeDockerAuthorityConflict({ + env: {}, + platform: "linux", + uid: 1000, + existsSync: (candidate) => sockets.has(candidate), + probeDockerHost: (dockerHost) => { + probes.push(dockerHost); + return { reachable: true, identity: "docker" }; + }, + }), + ).toBe(null); + expect(probes).toEqual([undefined]); + }); + + it("returns null when the default authority probe is inconclusive", () => { + const sockets = new Set(["/var/run/docker.sock", "/run/user/1000/podman/podman.sock"]); + + expect( + observeDockerAuthorityConflict({ + env: {}, + platform: "linux", + uid: 1000, + existsSync: (candidate) => sockets.has(candidate), + probeDockerHost: (dockerHost) => + dockerHost + ? { reachable: true, identity: dockerHost.includes("podman") ? "podman" : "docker" } + : { reachable: false, identity: "unknown", inconclusive: true }, + }), + ).toBe(null); + }); + + it("returns null when the differing candidate has an unknown engine identity", () => { + const dockerSocket = "/var/run/docker.sock"; + const podmanSocket = "/run/user/1000/podman/podman.sock"; + const sockets = new Set([dockerSocket, podmanSocket]); + const answers = new Map([ + [`unix://${dockerSocket}`, { reachable: true, identity: "docker" as const }], + [`unix://${podmanSocket}`, { reachable: true, identity: "unknown" as const }], + ]); + const opts = { + env: {}, + platform: "linux" as const, + uid: 1000, + existsSync: (candidate: string) => sockets.has(candidate), + probeDockerHost: (dockerHost: string | undefined) => + answers.get(dockerHost ?? "") ?? unreachableDefault, + }; + + expect(observeDockerAuthorityConflict(opts)).toBe(null); + expect(detectDockerHost(opts)).toEqual({ + dockerHost: `unix://${dockerSocket}`, + source: "socket", + socketPath: dockerSocket, + }); + }); + + it("returns null and probes nothing when DOCKER_HOST is set", () => { + const sockets = new Set(["/var/run/docker.sock", "/run/user/1000/podman/podman.sock"]); + const probes: Array = []; + + expect( + observeDockerAuthorityConflict({ + env: { DOCKER_HOST: "unix:///custom/docker.sock" }, + platform: "linux", + uid: 1000, + existsSync: (candidate) => sockets.has(candidate), + probeDockerHost: (dockerHost) => { + probes.push(dockerHost); + return { + reachable: true, + identity: dockerHost?.includes("podman") ? "podman" : "docker", + }; + }, + }), + ).toBe(null); + expect(probes).toEqual([]); + }); + }); + describe("classifyDockerVersionIdentity", () => { it("identifies Docker from the server platform", () => { expect( From 22ec9c37375c55fc6d86b2e7bc2f22b1d27aa509 Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Thu, 10 Sep 2026 14:17:09 +0800 Subject: [PATCH 2/3] test(preflight): pin the default conflict observer wiring and document the touched checks (#10622) CodeRabbit asked for coverage of the default observer wiring, which the existing cases could not reach because they inject Docker evidence. The selection moves into resolveDockerAuthorityConflictObserver, tested directly: the real observer for a local assessment, none when evidence or a transport is injected, the injected one otherwise. The touched functions gain docstrings. Signed-off-by: Dongni Yang Co-Authored-By: Claude Fable 5.1 --- src/lib/advisories/checks/host/docker.ts | 4 +++ ...reflight-docker-authority-conflict.test.ts | 33 ++++++++++++++++- src/lib/onboard/preflight.ts | 36 ++++++++++++------- src/lib/platform.ts | 6 ++++ 4 files changed, 66 insertions(+), 13 deletions(-) diff --git a/src/lib/advisories/checks/host/docker.ts b/src/lib/advisories/checks/host/docker.ts index e81aa0c29dd..98dd26870e0 100644 --- a/src/lib/advisories/checks/host/docker.ts +++ b/src/lib/advisories/checks/host/docker.ts @@ -28,6 +28,7 @@ export const enableDockerDesktopWslIntegration: AdvisoryCheck = phase: "preflight.host", severity: "blocking", resumeSafe: false, + /** WSL guidance yields to a diagnosed authority conflict, which names the real remedy (#10622). */ check(host) { if ( host.dockerHostInvalid || @@ -139,6 +140,7 @@ export const chooseDockerAuthority: AdvisoryCheck = { phase: "preflight.host", severity: "blocking", resumeSafe: false, + /** Name both engines and the DOCKER_HOST remedy when detection declined to choose (#10622). */ check(host) { const conflict = host.dockerAuthorityConflict; if ( @@ -173,6 +175,7 @@ export const addUserToDockerGroup: AdvisoryCheck = { phase: "preflight.host", severity: "blocking", resumeSafe: false, + /** Silent while an authority conflict is observed: group membership is not the diagnosed cause (#10622). */ check(host) { if ( host.dockerHostInvalid || @@ -210,6 +213,7 @@ export const startDocker: AdvisoryCheck = { phase: "preflight.host", severity: "blocking", resumeSafe: false, + /** Silent while an authority conflict is observed: two engines already answer (#10622). */ check(host) { const likelyGroupIssue = host.platform === "linux" && host.dockerServiceActive === true; if ( diff --git a/src/lib/onboard/preflight-docker-authority-conflict.test.ts b/src/lib/onboard/preflight-docker-authority-conflict.test.ts index bef99791014..529e6ec049c 100644 --- a/src/lib/onboard/preflight-docker-authority-conflict.test.ts +++ b/src/lib/onboard/preflight-docker-authority-conflict.test.ts @@ -5,7 +5,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import * as platform from "../platform"; import type { DockerAuthorityConflict } from "../platform"; -import { assessHost, planHostAdvisories } from "./preflight"; +import { + assessHost, + planHostAdvisories, + resolveDockerAuthorityConflictObserver, +} from "./preflight"; // Regression: NemoClaw #10622. The default Docker authority is unreachable // and the socket fallback meets both a Podman and a Docker engine. Detection @@ -170,3 +174,30 @@ describe("assessHost Docker authority conflict (#10622)", () => { expect(planHostAdvisories(assessment).map((action) => action.id)).toContain("install_docker"); }); }); + +describe("resolveDockerAuthorityConflictObserver (#10622)", () => { + it("wires the real observer when the assessment probes the local host itself", () => { + expect(resolveDockerAuthorityConflictObserver({})).toBe( + platform.observeDockerAuthorityConflict, + ); + }); + + it.each([ + ["Docker evidence", { dockerInfoOutput: "" }], + ["a command transport", { runCaptureImpl: () => "" }], + [ + "an extended command transport", + { runCaptureExImpl: () => ({ stdout: "", stderr: "", exitCode: 1, timedOut: false }) }, + ], + ])("wires no observer when the caller injects %s", (_case, opts) => { + expect(resolveDockerAuthorityConflictObserver(opts)).toBeUndefined(); + }); + + it("prefers an injected observer over the default", () => { + const observe = vi.fn(() => CONFLICT); + + expect( + resolveDockerAuthorityConflictObserver({ observeDockerAuthorityConflictImpl: observe }), + ).toBe(observe); + }); +}); diff --git a/src/lib/onboard/preflight.ts b/src/lib/onboard/preflight.ts index 938134726b8..38e574bbecd 100644 --- a/src/lib/onboard/preflight.ts +++ b/src/lib/onboard/preflight.ts @@ -209,7 +209,7 @@ export interface AssessHostOpts { gpuProbeImpl?: () => boolean; observeDockerAuthorityConflictImpl?: (opts: { env: NodeJS.ProcessEnv; - platform: NodeJS.Platform | string; + platform: NodeJS.Platform; }) => DockerAuthorityConflict | null; } @@ -562,6 +562,27 @@ function parseSystemctlState(value = ""): boolean | null { return null; } +/** + * Pick the Docker authority conflict observer for one assessment. The default + * observer probes this host's own sockets, so it applies only when the + * assessment probes the local host itself: injected Docker evidence or a + * command transport (tests, a remote host) gets no observer unless the caller + * injects one (#10622). + */ +export function resolveDockerAuthorityConflictObserver( + opts: AssessHostOpts, +): AssessHostOpts["observeDockerAuthorityConflictImpl"] { + return ( + opts.observeDockerAuthorityConflictImpl ?? + (opts.dockerInfoOutput === undefined && + opts.runCaptureImpl === undefined && + opts.runCaptureExImpl === undefined + ? observeDockerAuthorityConflict + : undefined) + ); +} + +/** Assess the host: Docker, GPU, OpenShell, and the advisories they imply. */ export function assessHost(opts: AssessHostOpts = {}): HostAssessment { const platform = opts.platform ?? process.platform; const env = opts.env ?? process.env; @@ -619,17 +640,8 @@ export function assessHost(opts: AssessHostOpts = {}): HostAssessment { // An unreachable default authority with two reachable engines of different // identities is an authority conflict (#10622). It is observed only when // DOCKER_HOST is unset, because a set DOCKER_HOST is honoured before any - // socket probe. The default observer probes this host's own sockets, so it - // applies only when this assessment probes the local host itself: injected - // Docker evidence or a command transport (tests, a remote host) gets no - // observer unless the caller injects one. - const observeConflict = - opts.observeDockerAuthorityConflictImpl ?? - (opts.dockerInfoOutput === undefined && - opts.runCaptureImpl === undefined && - opts.runCaptureExImpl === undefined - ? observeDockerAuthorityConflict - : undefined); + // socket probe. + const observeConflict = resolveDockerAuthorityConflictObserver(opts); const dockerAuthorityConflict = observeConflict !== undefined && dockerInstalled && diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 7a625b69e29..be5d70ecab2 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -328,6 +328,11 @@ interface DockerAuthoritySelection { conflict: DockerAuthorityConflict | null; } +/** + * Probe the ambient Docker authority and the fallback sockets once, returning + * the selection detectDockerHost makes and the engine conflict, if any, that + * made it decline. The policy stays fail-closed (#8816, #10253). + */ function selectDockerAuthority(opts: DockerHostDetectionOptions = {}): DockerAuthoritySelection { const env = opts.env ?? process.env; if (env.DOCKER_HOST) { @@ -371,6 +376,7 @@ function selectDockerAuthority(opts: DockerHostDetectionOptions = {}): DockerAut return { selection, conflict: null }; } +/** Select the Docker authority the CLI should use, or null to keep the host default. */ function detectDockerHost(opts: DockerHostDetectionOptions = {}): DockerHostDetection | null { return selectDockerAuthority(opts).selection; } From 2fb87831b734b97f7ff9871bad15c01dbfa725ff Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Thu, 10 Sep 2026 14:28:28 +0800 Subject: [PATCH 3/3] test(preflight): assert the selected conflict observer by its result (#10622) CodeRabbit: the path rule prefers observable outcomes over callback identity. The resolver tests now invoke the selected observer with fixed options and compare its result; a spied real observer returns the conflict for a local assessment, and an injected observer wins over a spied real one that returns null. Signed-off-by: Dongni Yang Co-Authored-By: Claude Fable 5.1 --- ...preflight-docker-authority-conflict.test.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/lib/onboard/preflight-docker-authority-conflict.test.ts b/src/lib/onboard/preflight-docker-authority-conflict.test.ts index 529e6ec049c..28e4f4bff35 100644 --- a/src/lib/onboard/preflight-docker-authority-conflict.test.ts +++ b/src/lib/onboard/preflight-docker-authority-conflict.test.ts @@ -176,9 +176,15 @@ describe("assessHost Docker authority conflict (#10622)", () => { }); describe("resolveDockerAuthorityConflictObserver (#10622)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it("wires the real observer when the assessment probes the local host itself", () => { - expect(resolveDockerAuthorityConflictObserver({})).toBe( - platform.observeDockerAuthorityConflict, + vi.spyOn(platform, "observeDockerAuthorityConflict").mockReturnValue(CONFLICT); + + expect(resolveDockerAuthorityConflictObserver({})?.({ env: {}, platform: "linux" })).toEqual( + CONFLICT, ); }); @@ -194,10 +200,12 @@ describe("resolveDockerAuthorityConflictObserver (#10622)", () => { }); it("prefers an injected observer over the default", () => { - const observe = vi.fn(() => CONFLICT); + vi.spyOn(platform, "observeDockerAuthorityConflict").mockReturnValue(null); expect( - resolveDockerAuthorityConflictObserver({ observeDockerAuthorityConflictImpl: observe }), - ).toBe(observe); + resolveDockerAuthorityConflictObserver({ + observeDockerAuthorityConflictImpl: () => CONFLICT, + })?.({ env: {}, platform: "linux" }), + ).toEqual(CONFLICT); }); });