diff --git a/docs/reference/system-readiness.mdx b/docs/reference/system-readiness.mdx index 027576cd7df..199425512a5 100644 --- a/docs/reference/system-readiness.mdx +++ b/docs/reference/system-readiness.mdx @@ -106,7 +106,11 @@ On Linux, the set includes the rootless Docker socket at `/run/user//docker 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 provides a `DOCKER_HOST` command for the Docker socket. A reachable Podman endpoint remains classified as Podman and does not satisfy the standard Docker runtime requirement. +For native rootless Podman on a qualified Linux host, set `NEMOCLAW_GATEWAY_RUNTIME=podman` before onboarding. +Review the [native Podman requirements](./platform-support#deployment-paths) before selecting that path. 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..e1cde6b57cd 100644 --- a/src/lib/advisories/checks/host/docker.test.ts +++ b/src/lib/advisories/checks/host/docker.test.ts @@ -79,6 +79,125 @@ 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:///var/run/docker.sock'", + "nemoclaw onboard", + ]); + }); + + it.each(["linux", "darwin", "win32"] as const)( + "offers native Podman guidance only for Linux when the platform is %s (#10622)", + (platform) => { + const result = runAdvisories( + DOCKER_HOST_ADVISORY_CHECKS, + host({ platform, dockerAuthorityConflict: CONFLICT }), + { phase: "preflight.host" }, + ); + const reason = result.advisories[0]?.reason ?? ""; + expect(reason.includes("NEMOCLAW_GATEWAY_RUNTIME=podman")).toBe(platform === "linux"); + expect(reason.includes("qualified Linux host")).toBe(platform === "linux"); + expect(reason.includes("platform-support#deployment-paths")).toBe(platform === "linux"); + }, + ); + + 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'", + ); + }); + + 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 +283,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..2c445f82e79 100644 --- a/src/lib/advisories/checks/host/docker.ts +++ b/src/lib/advisories/checks/host/docker.ts @@ -28,8 +28,15 @@ 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 || !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,15 +130,62 @@ export const retryDockerProbe: AdvisoryCheck = { }, }; +/** Quote a socket value for the manual POSIX-shell recovery command. */ +function shellSingleQuoted(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +export const chooseDockerAuthority: AdvisoryCheck = { + id: "docker_authority_conflict", + 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 ( + conflict === undefined || + !host.dockerInstalled || + host.dockerHostInvalid || + host.dockerReachable + ) { + return null; + } + const [first, second] = conflict.candidates; + const docker = first.identity === "docker" ? first : second; + 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 Docker socket below, or repair the default authority. " + + "A Podman compatibility socket cannot satisfy the Docker runtime requirement." + + (host.platform === "linux" + ? " To use native rootless Podman on a qualified Linux host, set NEMOCLAW_GATEWAY_RUNTIME=podman before onboarding. " + + "Review the requirements at https://docs.nvidia.com/nemoclaw/latest/user-guide/openclaw/reference/platform-support#deployment-paths." + : ""), + commands: [ + `export DOCKER_HOST=${shellSingleQuoted(`unix://${docker.socketPath}`)}`, + "nemoclaw onboard", + ], + }); + }, +}; + export const addUserToDockerGroup: AdvisoryCheck = { id: "docker_group_permission", 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 || host.dockerProbeIssue !== undefined || + host.dockerAuthorityConflict !== undefined || !host.dockerInstalled || host.dockerReachable || host.isWsl || @@ -164,11 +218,13 @@ 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 ( host.dockerHostInvalid || host.dockerProbeIssue !== undefined || + host.dockerAuthorityConflict !== undefined || !host.dockerInstalled || host.dockerReachable || host.isWsl || @@ -230,6 +286,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..4dad90501c3 --- /dev/null +++ b/src/lib/onboard/preflight-docker-authority-conflict.test.ts @@ -0,0 +1,235 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, onTestFinished, 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)", () => { + 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"); + }); +}); + +describe("local Docker authority observation (#10622)", () => { + function localHostOptions() { + // Exercise the default process transport without contacting the host's Docker service. + const bin = mkdtempSync(join(tmpdir(), "nemoclaw-authority-")); + onTestFinished(() => rmSync(bin, { recursive: true, force: true })); + writeFileSync(join(bin, "docker"), "#!/bin/sh\nexit 1\n", { mode: 0o700 }); + writeFileSync(join(bin, "systemctl"), "#!/bin/sh\necho active\n", { mode: 0o700 }); + symlinkSync("/bin/sh", join(bin, "sh")); + vi.stubEnv("PATH", bin); + vi.stubEnv("DOCKER_HOST", undefined); + return { + platform: "linux" as const, + env: {}, + commandExistsImpl, + resolveOpenshellImpl: () => null, + gpuProbeImpl: () => false, + readFileImpl: () => "", + readdirImpl: () => [], + release: "", + procVersion: "", + }; + } + + it("uses the default observer and refreshes conflict evidence on the next assessment", () => { + const opts = localHostOptions(); + const observe = vi + .spyOn(platform, "observeDockerAuthorityConflict") + .mockReturnValueOnce(CONFLICT) + .mockReturnValueOnce(null); + const first = assessHost(opts); + expect(first.dockerProbeIssue).toBeUndefined(); + expect(first.dockerAuthorityConflict).toEqual(CONFLICT); + expect(observe).toHaveBeenNthCalledWith(1, { env: opts.env, platform: opts.platform }); + const firstIds = planHostAdvisories(first).map((action) => action.id); + expect(firstIds).toContain("docker_authority_conflict"); + expect(firstIds).not.toContain("docker_group_permission"); + expect(firstIds).not.toContain("start_docker"); + + const second = assessHost(opts); + expect(second.dockerAuthorityConflict).toBeUndefined(); + expect(planHostAdvisories(second).map((action) => action.id)).not.toContain( + "docker_authority_conflict", + ); + expect(observe).toHaveBeenCalledTimes(2); + }); + + it.each([ + ["Docker evidence", { dockerInfoOutput: "" }], + ["a command transport", { runCaptureImpl: () => "" }], + [ + "an extended command transport", + { + runCaptureExImpl: () => ({ stdout: "", stderr: "", exitCode: 1, timedOut: false }), + }, + ], + ])("does not observe local sockets when the caller injects %s", (_case, injected) => { + const opts = localHostOptions(); + const observe = vi.spyOn(platform, "observeDockerAuthorityConflict").mockReturnValue(CONFLICT); + const assessment = assessHost({ ...opts, ...injected }); + expect(assessment.dockerAuthorityConflict).toBeUndefined(); + expect(observe).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/onboard/preflight.ts b/src/lib/onboard/preflight.ts index e3870eebbd2..4d599eb5350 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; + }) => DockerAuthorityConflict | null; } function buildCommandVArgv(commandName: string): readonly string[] { @@ -550,6 +562,7 @@ function parseSystemctlState(value = ""): boolean | null { return null; } +/** 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; @@ -604,6 +617,29 @@ 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. + // Injected evidence or transports can describe a remote host. Only the local + // assessment defaults to local socket probes; repeat them after remediation. + 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..be5d70ecab2 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -307,13 +307,42 @@ 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; +} + +/** + * 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) { return { - dockerHost: env.DOCKER_HOST, - source: "env", - socketPath: null, + selection: { + dockerHost: env.DOCKER_HOST, + source: "env", + socketPath: null, + }, + conflict: null, }; } @@ -322,24 +351,44 @@ 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 }; +} + +/** 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; +} + +/** + * 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 +401,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(