Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/reference/system-readiness.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
106 changes: 106 additions & 0 deletions src/lib/advisories/checks/host/docker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
54 changes: 53 additions & 1 deletion src/lib/advisories/checks/host/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,15 @@ export const enableDockerDesktopWslIntegration: AdvisoryCheck<HostAssessment> =
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",
Expand Down Expand Up @@ -123,15 +130,57 @@ export const retryDockerProbe: AdvisoryCheck<HostAssessment> = {
},
};

/** 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<HostAssessment> = {
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;
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<HostAssessment> = {
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 ||
Expand Down Expand Up @@ -164,11 +213,13 @@ export const startDocker: AdvisoryCheck<HostAssessment> = {
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 ||
Expand Down Expand Up @@ -230,6 +281,7 @@ export const DOCKER_HOST_ADVISORY_CHECKS = Object.freeze([
installDocker,
invalidDockerHost,
retryDockerProbe,
chooseDockerAuthority,
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 @@ -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",
Expand Down
Loading
Loading