Skip to content

Commit 29feb03

Browse files
Dongni-Yangclaude
andauthored
fix(inference): report a probe image-pull failure as its own diagnostic (#9311)
<!-- markdownlint-disable MD041 --> ## Summary When the container reachability probe image was absent from the local Docker cache and Docker could not pull it, onboarding reported the failure as a provider networking problem: "the Docker container reachability check failed for http://host.openshell.internal:11434" with the lumped diagnostic "Docker command failed (image pull error or runtime failure)". On WSL2 reached over a remote Windows OpenSSH session, Docker Desktop's `desktop.exe` credential helper has no logon session, blocks the pull of the public probe image, and hits exactly this path. After this change that case is reported as a Docker image-pull failure with the reporter-verified recovery, and networking is not blamed for a check that never ran. ## Related Issue Closes #9308 ## Changes - `src/lib/inference/local.ts`: when all reachability retries and both diagnostic re-probes produce empty output, classify before reporting. `classifyContainerRunFailure` uses data the existing stdout-only capture seam already provides: `docker version` answers (daemon is up) while `docker image inspect` finds no local copy of `curlimages/curl:8.10.1` (the probe image was never available). In that case: - The message states the check could not run and names the failure class: "This is a Docker image-pull failure, not an Ollama networking failure." (vLLM wording for `vllm-local`.) - The diagnostic names the credential-helper cause (`credsStore` in `~/.docker/config.json` failing in a remote login session) and prints the recovery the reporter verified: `DOCKER_CONFIG=$(mktemp -d) docker pull curlimages/curl:8.10.1` followed by `nemoclaw onboard --resume`. - Every other failure keeps the existing messages: daemon not answering, probe image present, partial diagnostic output, and the invalid-command guard all report exactly as before. - `src/lib/inference/local.test.ts`: three cases — image-pull classification for `ollama-local` and `vllm-local` (message, remediation, and absence of the networking misreport), and a present-image case pinning the unchanged runtime-failure report. ## Design notes for reviewers - **Classification never parses Docker error text.** Docker writes pull progress to stderr on success and its error wording varies across versions and platforms, so matching stderr signatures is fragile — the daemon-answers + image-absent probe is stable across Docker Desktop, rootless, and remote contexts, and it reuses the same injected `RunCaptureFn` seam as every other probe in this file. No new dependency-injection parameter, no stderr capture, and `src/lib/inference/local.ts` gains no new imports (its fan-out is pinned at exactly 21 in `ci/source-architecture-budget.json`). - **The two extra `docker` invocations run only on the already-failed path**, after three failed probe attempts and two empty diagnostic re-probes. The success path is untouched. - **The hard stop is retained deliberately.** The same broken credential helper would block later sandbox image pulls, so warn-and-continue would only defer the failure to a worse place. The printed pre-pull repairs both. If maintainers prefer the warn-and-continue carveout that `vllm-local.ts` and the auth-proxy path already use for host-responding cases, that is a small follow-up. - **Out of scope:** a pre-pull or image-presence gate before the probe runs (an onboarding flow change), and capturing stderr in the retry loop (any non-empty output would false-positive the health acceptor). ## Type of Change - [x] Code change (feature, bug fix, or refactor) ## Quality Gates - [x] Tests added or updated for changed behavior - [x] Docs not applicable — justification: no documentation quotes the previous message or diagnostic (verified by repo-wide grep); the changed text is a CLI error surface only. - [x] Sensitive paths changed (security, policy, credentials, preflight, onboarding, inference, runner, sandbox, or messaging) - [ ] Sensitive-path review completed or maintainer-approved waiver recorded — reviewer/approval link/justification: Validation on `7d4f96ae63`: - `npx vitest run src/lib/inference/local.test.ts` — 86 passed (3 new). - `npx vitest run src/lib/actions/inference-set.test.ts src/lib/onboard/inference-providers` — 46 passed (consumers of `validateLocalProvider`). - `npm run typecheck:cli` — clean. - `npx oxlint` on both changed files — clean. - `npm run test-size:check` — passed (`local.test.ts` is 1429 lines against the 1500 budget). - `npm run source-shape:check` — `source_shape_cases=0`. - Changed-test-file `if` count unchanged (21 at head, 21 at base) — new mocks dispatch on command content with ternaries. | Case | Asserts | |---|---| | Daemon up, probe image absent (`ollama-local`) | image-pull message, no networking misreport, `DOCKER_CONFIG` pre-pull + `onboard --resume` remediation | | Daemon up, probe image absent (`vllm-local`) | image-pull message with vLLM wording | | Daemon up, probe image present | unchanged "reachability check failed" + runtime-failure diagnostic | | All probes empty, daemon not answering | covered by the existing tests, unchanged | ## Documentation Writer Review - [ ] Documentation writer subagent reviewed the completed changes - Result: `no-docs-needed` - Evidence: repo-wide grep shows no documentation references the changed message or diagnostic text; `docs/inference/set-up-ollama.mdx` and the WSL pages describe onboarding behavior at a level this change does not alter. - Agent: Claude Code Signed-off-by: Dongni Yang <dongniy@nvidia.com> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved local provider diagnostics when Docker probe images are unavailable. * Added clear image-pull guidance for missing Ollama and vLLM probe images. * Preserved existing container-runtime error details when the required image is available but the runtime cannot be reached. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Dongni Yang <dongniy@nvidia.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 3606cb4 commit 29feb03

3 files changed

Lines changed: 139 additions & 9 deletions

File tree

src/lib/inference/local-vllm-auth.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,9 @@ describe("managed vLLM authentication", () => {
373373
.filter((argv) => argv[0] === "docker");
374374

375375
expect(result.ok).toBe(false);
376-
expect(dockerCommands).toHaveLength(5);
376+
// 3 reachability probes, 2 diagnostic re-probes, and the daemon probe of
377+
// the image-pull classifier (#9308) — every one context-pinned.
378+
expect(dockerCommands).toHaveLength(6);
377379
expect(
378380
dockerCommands.every((argv) => argv.slice(0, 3).join(" ") === "docker --context default"),
379381
).toBe(true);

src/lib/inference/local.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,60 @@ describe("local inference helpers", () => {
299299
expect(result.diagnostic).toMatch(/Docker command failed/);
300300
});
301301

302+
it("reports an image-pull failure instead of an Ollama networking failure when Docker cannot provide the probe image (#9308)", () => {
303+
const mockCapture = (cmd: readonly string[]) =>
304+
cmd.includes("version")
305+
? "29.6.2"
306+
: cmd.includes("inspect")
307+
? ""
308+
: cmd.includes("run")
309+
? ""
310+
: '{"models":[]}';
311+
const noopSleep = () => {};
312+
const result = validateLocalProvider("ollama-local", mockCapture, noopSleep);
313+
expect(result.ok).toBe(false);
314+
expect(result.message).toMatch(/Docker image-pull failure/);
315+
expect(result.message).toMatch(/not an Ollama networking failure/);
316+
expect(result.message).not.toMatch(/Docker container reachability check failed/);
317+
expect(result.message).not.toMatch(/sandbox uses a different network path/);
318+
expect(result.diagnostic).toMatch(/DOCKER_CONFIG=\$\(mktemp -d\) docker pull curlimages\/curl/);
319+
expect(result.diagnostic).toMatch(/credential helper/);
320+
expect(result.diagnostic).toMatch(/onboard --resume/);
321+
});
322+
323+
it("reports an image-pull failure instead of a vLLM networking failure when Docker cannot provide the probe image (#9308)", () => {
324+
const mockCapture = (cmd: readonly string[]) =>
325+
cmd.includes("version")
326+
? "29.6.2"
327+
: cmd.includes("inspect")
328+
? ""
329+
: cmd.includes("run")
330+
? ""
331+
: '{"data":[]}';
332+
const noopSleep = () => {};
333+
const result = validateLocalProvider("vllm-local", mockCapture, noopSleep);
334+
expect(result.ok).toBe(false);
335+
expect(result.message).toMatch(/Docker image-pull failure/);
336+
expect(result.message).toMatch(/not a vLLM networking failure/);
337+
expect(result.diagnostic).toMatch(/docker pull curlimages\/curl/);
338+
});
339+
340+
it("keeps the runtime-failure report when the probe image is present locally (#9308)", () => {
341+
const mockCapture = (cmd: readonly string[]) =>
342+
cmd.includes("version")
343+
? "29.6.2"
344+
: cmd.includes("inspect")
345+
? "sha256:0d9b7ef1"
346+
: cmd.includes("run")
347+
? ""
348+
: '{"models":[]}';
349+
const noopSleep = () => {};
350+
const result = validateLocalProvider("ollama-local", mockCapture, noopSleep);
351+
expect(result.ok).toBe(false);
352+
expect(result.message).toMatch(/Docker container reachability check failed/);
353+
expect(result.diagnostic).toMatch(/image pull error or runtime failure/);
354+
});
355+
302356
it("succeeds after container check retry", () => {
303357
let callCount = 0;
304358
const mockCapture = () => {

src/lib/inference/local.ts

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,28 +1165,51 @@ export function validateLocalProvider(
11651165
// All retries exhausted — collect diagnostics
11661166
const diagnostic = collectContainerDiagnostic(containerCommand, capture);
11671167

1168+
if (diagnostic.probeImageUnavailable) {
1169+
return probeImageUnavailableResult(provider, diagnostic.text);
1170+
}
1171+
11681172
switch (provider) {
11691173
case "vllm-local":
11701174
return {
11711175
ok: false,
11721176
message: `Local vLLM is responding on the host, but the Docker container reachability check failed for ${getContainerCheckUrl(provider)}. This may be a Docker networking issue — the sandbox uses a different network path and may still work.`,
1173-
diagnostic,
1177+
diagnostic: diagnostic.text,
11741178
};
11751179
case "ollama-local":
11761180
return {
11771181
ok: false,
11781182
message: `Local Ollama is responding on ${getResolvedOllamaHost()}, but the Docker container reachability check failed for http://host.openshell.internal:${getOllamaContainerPort()}. This may be a Docker networking issue — the sandbox uses a different network path and may still work.`,
1179-
diagnostic,
1183+
diagnostic: diagnostic.text,
11801184
};
11811185
default:
11821186
return {
11831187
ok: false,
11841188
message: "The selected local inference provider is unavailable from containers.",
1185-
diagnostic,
1189+
diagnostic: diagnostic.text,
11861190
};
11871191
}
11881192
}
11891193

1194+
/**
1195+
* Report a reachability check that never ran because Docker could not
1196+
* provide the probe image (#9308). Blaming the provider's network path here
1197+
* is a misreport: the reporter's environment had a working path once the
1198+
* image existed.
1199+
*/
1200+
function probeImageUnavailableResult(provider: string, diagnostic: string): ValidationResult {
1201+
const responding =
1202+
provider === "vllm-local"
1203+
? "Local vLLM is responding on the host"
1204+
: `Local Ollama is responding on ${getResolvedOllamaHost()}`;
1205+
const providerLabel = provider === "vllm-local" ? "a vLLM" : "an Ollama";
1206+
return {
1207+
ok: false,
1208+
message: `${responding}, but the container reachability check could not run because Docker could not provide its probe image. This is a Docker image-pull failure, not ${providerLabel} networking failure.`,
1209+
diagnostic,
1210+
};
1211+
}
1212+
11901213
function getContainerCheckUrl(provider: string): string | null {
11911214
switch (provider) {
11921215
case "vllm-local": {
@@ -1204,13 +1227,62 @@ function getContainerCheckUrl(provider: string): string | null {
12041227
}
12051228
}
12061229

1207-
function collectContainerDiagnostic(containerCommand: string[], capture: RunCaptureFn): string {
1230+
type ContainerDiagnostic = { text: string; probeImageUnavailable: boolean };
1231+
1232+
function containerRuntimeFailureDiagnostic(text: string): ContainerDiagnostic {
1233+
return { text, probeImageUnavailable: false };
1234+
}
1235+
1236+
/**
1237+
* Distinguish "Docker could not provide the probe image" from a general
1238+
* runtime failure using the stdout-only capture seam: the daemon answers
1239+
* `docker version` while `docker image inspect` finds no local copy of the
1240+
* probe image. Credential-helper failures land here (#9308) — a remote login
1241+
* session can lose access to Docker Desktop's credential store, so the pull
1242+
* fails and every probe run produces empty stdout.
1243+
*
1244+
* Image-absent-after-run-attempts proves the pull failed: `docker run` pulls
1245+
* an absent image before it creates the container, and `--add-host`, policy,
1246+
* and seccomp failures all happen after that pull. Five runs precede this
1247+
* check (three probes, two diagnostics), so a pullable image would be in the
1248+
* cache by now and a run that failed for any post-pull reason keeps the
1249+
* generic runtime diagnostic.
1250+
*/
1251+
function classifyContainerRunFailure(
1252+
dockerCommand: string[],
1253+
capture: RunCaptureFn,
1254+
): ContainerDiagnostic {
1255+
const runtimeFailure = containerRuntimeFailureDiagnostic(
1256+
`Docker command failed (image pull error or runtime failure). Retried ${CONTAINER_CHECK_MAX_ATTEMPTS} times.`,
1257+
);
1258+
const daemonVersion = capture(
1259+
[...dockerCommand, "version", "--format", "{{.Server.Version}}"],
1260+
{ ignoreError: true },
1261+
);
1262+
if (!daemonVersion) return runtimeFailure;
1263+
const probeImageId = capture(
1264+
[...dockerCommand, "image", "inspect", "--format", "{{.Id}}", CONTAINER_REACHABILITY_IMAGE],
1265+
{ ignoreError: true },
1266+
);
1267+
if (probeImageId) return runtimeFailure;
1268+
return {
1269+
text: `The probe image ${CONTAINER_REACHABILITY_IMAGE} is not in the local Docker image cache, and Docker could not pull it. The image is public and needs no credentials, but a Docker credential helper (credsStore in ~/.docker/config.json) can fail in a remote login session and block every pull. Pre-pull the image with an isolated Docker config, then resume: DOCKER_CONFIG=$(mktemp -d) docker pull ${CONTAINER_REACHABILITY_IMAGE} && nemoclaw onboard --resume`,
1270+
probeImageUnavailable: true,
1271+
};
1272+
}
1273+
1274+
function collectContainerDiagnostic(
1275+
containerCommand: string[],
1276+
capture: RunCaptureFn,
1277+
): ContainerDiagnostic {
12081278
const url = containerCommand.at(-1);
12091279
const dockerRunIndex = containerCommand.indexOf("run");
12101280
const addHostIndex = containerCommand.indexOf("--add-host");
12111281
const hostAlias = containerCommand[addHostIndex + 1];
12121282
if (!url || dockerRunIndex < 1 || addHostIndex < 0 || !hostAlias) {
1213-
return `Docker command failed (invalid reachability command). Retried ${CONTAINER_CHECK_MAX_ATTEMPTS} times.`;
1283+
return containerRuntimeFailureDiagnostic(
1284+
`Docker command failed (invalid reachability command). Retried ${CONTAINER_CHECK_MAX_ATTEMPTS} times.`,
1285+
);
12141286
}
12151287
const dockerCommand = containerCommand.slice(0, dockerRunIndex);
12161288
try {
@@ -1253,7 +1325,7 @@ function collectContainerDiagnostic(containerCommand: string[], capture: RunCapt
12531325
);
12541326

12551327
if (!httpStatus && !hostsOutput) {
1256-
return `Docker command failed (image pull error or runtime failure). Retried ${CONTAINER_CHECK_MAX_ATTEMPTS} times.`;
1328+
return classifyContainerRunFailure(dockerCommand, capture);
12571329
}
12581330

12591331
const parts: string[] = [];
@@ -1271,9 +1343,11 @@ function collectContainerDiagnostic(containerCommand: string[], capture: RunCapt
12711343
parts.push(
12721344
`Retried ${CONTAINER_CHECK_MAX_ATTEMPTS} times over ~${(CONTAINER_CHECK_MAX_ATTEMPTS - 1) * CONTAINER_CHECK_RETRY_DELAY_SECS}s`,
12731345
);
1274-
return parts.join(". ") + ".";
1346+
return containerRuntimeFailureDiagnostic(parts.join(". ") + ".");
12751347
} catch {
1276-
return `Docker command failed (image pull error or runtime failure). Retried ${CONTAINER_CHECK_MAX_ATTEMPTS} times.`;
1348+
return containerRuntimeFailureDiagnostic(
1349+
`Docker command failed (image pull error or runtime failure). Retried ${CONTAINER_CHECK_MAX_ATTEMPTS} times.`,
1350+
);
12771351
}
12781352
}
12791353

0 commit comments

Comments
 (0)