Skip to content

Commit 581eea8

Browse files
prekshivyascv
andauthored
fix(portable): route receipt failures to valid recovery (#9194)
<!-- markdownlint-disable MD041 --> ## Summary Portable readiness failures without a recorded socket path no longer send users to repair a nonexistent endpoint. Invalid and legacy receipts now route to portable onboarding, current-user authority mismatches route to the recorded user or current-user onboarding, and socket service diagnostics appear only when a validated socket path is available. ## Related Issue Follow-up to #9186 for #9070. ## Changes - Record the required no-socket recovery in the portable readiness result so doctor and gateway consumers do not infer endpoint repair from error text. - Route invalid and legacy receipts to `nemoclaw onboard --experimental-profile portable`. - Route a current-user authority mismatch to the recorded user or onboarding as the current user. - Keep socket service and API diagnostics conditional on a reported socket path. - Add doctor and gateway regression tests for invalid and legacy receipt recovery. - Update troubleshooting guidance to match the runtime recovery contract. ## Type of Change - [ ] Code change (feature, bug fix, or refactor) - [x] Code change with doc updates - [ ] Doc only (prose changes, no code sample modifications) - [ ] Doc only (includes code sample changes) ## Quality Gates - [x] Tests added or updated for changed behavior - [ ] Existing tests cover changed behavior — justification: - [ ] Tests not applicable — justification: - [x] Docs updated for user-facing behavior changes - [ ] Docs not applicable — justification: - [x] Sensitive paths changed (security, policy, credentials, preflight, onboarding, inference, runner, sandbox, or messaging) - [x] Sensitive-path review completed or maintainer-approved waiver recorded — reviewer/approval link/justification: Independent Codex Desktop review passed all nine security categories for commit under review `5c6a000f5e6d036845cfbebee2a7fd4d0000c36d`. Invalid, legacy, and current-user mismatch paths fail closed without fabricated endpoint repair. The doctor and gateway tests cover each recovery branch. - [ ] Non-success, skipped, or missing CI check accepted by maintainer — check name, approval link, and follow-up issue: ## Documentation Writer Review - [x] Documentation writer subagent reviewed the completed changes - Result: `docs-updated` - Evidence: `docs/reference/troubleshooting.mdx` routes unsafe, invalid, and legacy receipts to portable onboarding; routes current-user mismatches to the recorded user or current-user onboarding; and limits socket service and API inspection to failures that report a socket path. Doctor and gateway guidance match these paths. The merge preserves the reviewed patch and adds current-base Google Gemini documentation and provider-model tests outside the nine PR-owned files. Focused recovery tests passed 59/59. `npm run docs` completed with 0 errors and 2 existing warnings. `git diff --check` passed. - Agent: `Codex Desktop` <!-- docs-review-head-sha: 5c6a000 --> <!-- docs-review-agents-blob-sha: e30afb2 --> ## DGX Station Hardware Evidence - [ ] Tested on DGX Station - Tested commit: - Station profile/scenario: - Result: - Supporting evidence: ## Verification - [x] PR description includes a `Signed-off-by:` line and every commit appears as `Verified` in GitHub - [x] Normal `pre-commit`, `commit-msg`, and `pre-push` hooks passed, or `npm run validate:pr` passed after refreshing `origin/main` when hooks were skipped or unavailable - [x] Targeted behavior tests pass for the current change set, or tests are marked not applicable above — `npx vitest run --project cli --project integration src/lib/actions/sandbox/doctor-lifecycle-registration.test.ts src/lib/onboard/experimental/portable-runtime-readiness.test.ts test/gateway-failure-classifier.test.ts`: 59 passed - [ ] Applicable broad gate passed — `npm test` for broad runtime/test-harness changes; `npm run check` for repo-wide validation/coverage changes — command/result: - [x] Quality Gates section completed with required justifications or waivers - [x] No secrets, API keys, or credentials committed - [ ] `npm run docs` builds without warnings (doc changes only) - [x] Doc pages follow the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md) (doc changes only) - [ ] New doc pages include SPDX header and frontmatter (new pages only) --- Signed-off-by: Prekshi Vyas <prekshiv@nvidia.com> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved Portable Podman troubleshooting for missing sockets, legacy receipts, and user-authority mismatches. * Recovery guidance now directs users to portable onboarding or the appropriate recorded/current user path. * Added clearer instructions for inspecting Podman units and validating the reported socket. * Prevented inappropriate endpoint-repair guidance and credential exposure in affected scenarios. * **Tests** * Expanded coverage for portable onboarding failures, legacy receipts, socket handling, and authority mismatches. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Signed-off-by: Prekshi Vyas <prekshiv@nvidia.com> Co-authored-by: Carlos Villela <cvillela@nvidia.com>
1 parent 43c77ae commit 581eea8

9 files changed

Lines changed: 146 additions & 12 deletions

docs/reference/troubleshooting.mdx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3353,7 +3353,10 @@ A portable readiness failure identifies the stage that did not complete:
33533353
| Startup API health | The service was activated, but the recorded endpoint did not return a real Podman API response within the startup period. | Inspect the user-unit logs and run the explicit API request below against the reported socket path. Raise `NEMOCLAW_PORTABLE_PODMAN_STARTUP_TIMEOUT_MS` only when valid cold activation needs more than 60,000 ms. |
33543354
| Steady-state API health | An endpoint that completed activation did not answer the later shorter health check. | Inspect host load and the user-unit logs, then rerun the NemoClaw command. |
33553355

3356-
Inspect the current user's units without changing them:
3356+
The remaining service and API inspection applies only when the failure reports a recorded socket path.
3357+
If the failure does not report one, follow the recovery in the table and do not try another endpoint.
3358+
3359+
When a recorded socket path is reported, inspect the current user's units without changing them:
33573360

33583361
```bash
33593362
systemctl --user status podman.socket podman.service --no-pager
@@ -3369,7 +3372,7 @@ systemctl --user start podman.socket
33693372

33703373
These commands do not enable the socket for later user sessions.
33713374

3372-
Use the exact socket path from the NemoClaw failure to require a real server response:
3375+
Then use the exact socket path from the NemoClaw failure to require a real server response:
33733376

33743377
```bash
33753378
podman --remote \

src/lib/actions/sandbox/doctor-lifecycle-registration.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,30 @@ const FAILED_PORTABLE_RUNTIME = {
4141
timing: { mode: "cold", activationMs: 21, apiMs: 9, totalMs: 30 },
4242
} satisfies PortablePodmanReadinessResult;
4343

44+
const PORTABLE_ONBOARDING_FAILURES = [
45+
{
46+
name: "invalid",
47+
result: {
48+
ok: false,
49+
stage: "socket authority",
50+
detail: "The portable lifecycle receipt is unsafe or invalid; rerun onboarding.",
51+
recovery: "portable-onboarding",
52+
timing: { mode: "warm", activationMs: 0, apiMs: 0, totalMs: 0 },
53+
} satisfies PortablePodmanReadinessResult,
54+
},
55+
{
56+
name: "legacy",
57+
result: {
58+
ok: false,
59+
stage: "socket authority",
60+
detail:
61+
"The lifecycle receipt predates recorded portable Podman authority; rerun onboarding.",
62+
recovery: "portable-onboarding",
63+
timing: { mode: "warm", activationMs: 0, apiMs: 0, totalMs: 0 },
64+
} satisfies PortablePodmanReadinessResult,
65+
},
66+
] as const;
67+
4468
function sandbox(overrides: Partial<SandboxEntry> = {}): SandboxEntry {
4569
return {
4670
name: "alpha",
@@ -90,6 +114,36 @@ describe("doctor lifecycle registration checks", () => {
90114
expect(receiptReadinessMocks.inspect).toHaveBeenCalledWith("alpha");
91115
});
92116

117+
it.each(PORTABLE_ONBOARDING_FAILURES)(
118+
"sends a $name receipt failure to portable onboarding without endpoint repair",
119+
({ result }) => {
120+
receiptReadinessMocks.inspect.mockReturnValue(result);
121+
122+
const check = buildPortableRuntimeCheck("alpha");
123+
124+
expect(check).toMatchObject({
125+
status: "fail",
126+
detail: expect.not.stringContaining("Recorded socket"),
127+
hint: "rerun portable onboarding with `nemoclaw onboard --experimental-profile portable`, then retry",
128+
});
129+
expect(check?.hint).not.toContain("endpoint");
130+
},
131+
);
132+
133+
it("routes a current-user authority mismatch to its recorded user or current-user onboarding", () => {
134+
receiptReadinessMocks.inspect.mockReturnValue({
135+
ok: false,
136+
stage: "socket authority",
137+
detail: "The recorded portable Podman authority does not match the current Linux user.",
138+
recovery: "current-user-authority",
139+
timing: { mode: "warm", activationMs: 0, apiMs: 0, totalMs: 0 },
140+
} satisfies PortablePodmanReadinessResult);
141+
142+
expect(buildPortableRuntimeCheck("alpha")).toMatchObject({
143+
hint: "run NemoClaw as the user who created the portable state, or rerun portable onboarding as the current user",
144+
});
145+
});
146+
93147
it("reports a complete managed sandbox registration as ok", () => {
94148
expect(buildLifecycleRegistrationCheck("alpha", sandbox(), "nemoclaw")).toMatchObject({
95149
group: "Sandbox",

src/lib/actions/sandbox/doctor-lifecycle-registration.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,14 @@ function formatFieldList(
2727
export function buildPortableRuntimeCheck(sandboxName: string): DoctorCheck | null {
2828
const portable = inspectPortableRuntimeReceiptReadiness(sandboxName);
2929
if (!portable) return null;
30-
const recordedSocket = !portable.ok && portable.socketPath
31-
? ` Recorded socket: ${portable.socketPath}.`
32-
: "";
30+
const recordedSocket =
31+
!portable.ok && portable.socketPath ? ` Recorded socket: ${portable.socketPath}.` : "";
32+
const recoveryHint =
33+
!portable.ok && !portable.socketPath
34+
? portable.recovery === "current-user-authority"
35+
? "run NemoClaw as the user who created the portable state, or rerun portable onboarding as the current user"
36+
: "rerun portable onboarding with `nemoclaw onboard --experimental-profile portable`, then retry"
37+
: "repair the recorded current-user Podman endpoint, then retry";
3338
return portable.ok
3439
? {
3540
group: "Host",
@@ -42,7 +47,7 @@ export function buildPortableRuntimeCheck(sandboxName: string): DoctorCheck | nu
4247
label: "Portable Podman API",
4348
status: "fail",
4449
detail: `${portable.stage}: ${portable.detail}${recordedSocket}`,
45-
hint: "repair the recorded current-user Podman endpoint, then retry",
50+
hint: recoveryHint,
4651
};
4752
}
4853

src/lib/actions/sandbox/gateway-failure-classifier.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,19 @@ export function printDockerRuntimeDownGuidance(
337337
` The receipt-owned Podman endpoint for sandbox '${sandboxName}' is not ready; no Docker or named-connection fallback was used.`,
338338
);
339339
writer(" Recovery:");
340+
if (!portable.socketPath) {
341+
if (portable.recovery === "current-user-authority") {
342+
writer(
343+
" 1. Run NemoClaw as the user who created the portable state, or rerun portable onboarding as the current user.",
344+
);
345+
} else {
346+
writer(
347+
` 1. Rerun portable onboarding: ${CLI_NAME} onboard --experimental-profile portable`,
348+
);
349+
}
350+
writer(` 2. Retry: ${CLI_NAME} ${sandboxName} ${retryCommand}`);
351+
return;
352+
}
340353
writer(
341354
" 1. Check the reported readiness stage and the current user's Podman socket service.",
342355
);

src/lib/onboard/experimental/portable-demo-lifecycle.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,7 @@ function inspectReceiptRuntimeReadiness(
528528
stage: "socket authority",
529529
detail:
530530
"The lifecycle receipt predates recorded portable Podman authority; rerun onboarding.",
531+
recovery: "portable-onboarding",
531532
timing: { mode: "warm", activationMs: 0, apiMs: 0, totalMs: 0 },
532533
};
533534
}

src/lib/onboard/experimental/portable-runtime-readiness.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,11 @@ describe("portable Podman activation readiness", () => {
309309
expect(validFailure.ok ? "" : portablePodmanReadinessError(validFailure).message).toContain(
310310
`Recorded socket: ${AUTHORITY.socketPath}.`,
311311
);
312-
expect(invalidFailure).toMatchObject({ ok: false, stage: "socket authority" });
312+
expect(invalidFailure).toMatchObject({
313+
ok: false,
314+
stage: "socket authority",
315+
recovery: "current-user-authority",
316+
});
313317
expect(invalidFailure).not.toHaveProperty("socketPath");
314318
});
315319

src/lib/onboard/experimental/portable-runtime-readiness.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export interface PortablePodmanReadinessTiming {
6666
readonly totalMs: number;
6767
}
6868

69+
export type PortablePodmanReadinessRecovery = "portable-onboarding" | "current-user-authority";
70+
6971
export type PortablePodmanReadinessResult =
7072
| {
7173
readonly ok: true;
@@ -79,6 +81,7 @@ export type PortablePodmanReadinessResult =
7981
readonly stage: PortablePodmanReadinessStage;
8082
readonly detail: string;
8183
readonly socketPath?: string;
84+
readonly recovery?: PortablePodmanReadinessRecovery;
8285
readonly timing: PortablePodmanReadinessTiming;
8386
};
8487

@@ -171,12 +174,14 @@ function failure(
171174
apiMs: number,
172175
totalMs: number,
173176
socketPath?: string,
177+
recovery?: PortablePodmanReadinessRecovery,
174178
): PortablePodmanReadinessResult {
175179
return {
176180
ok: false,
177181
stage,
178182
detail,
179183
...(socketPath ? { socketPath } : {}),
184+
...(recovery ? { recovery } : {}),
180185
timing: timing(mode, activationMs, apiMs, totalMs),
181186
};
182187
}
@@ -291,6 +296,8 @@ export function inspectPortablePodmanReadiness(
291296
0,
292297
0,
293298
elapsed(now, startedAt),
299+
undefined,
300+
"current-user-authority",
294301
);
295302
}
296303

src/lib/onboard/experimental/portable-runtime-receipt-readiness.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export function inspectPortableRuntimeReceiptReadiness(
175175
ok: false,
176176
stage: "socket authority",
177177
detail: "The portable lifecycle receipt is unsafe or invalid; rerun onboarding.",
178+
recovery: "portable-onboarding",
178179
timing: { mode: "warm", activationMs: 0, apiMs: 0, totalMs: 0 },
179180
};
180181
}
@@ -185,6 +186,7 @@ export function inspectPortableRuntimeReceiptReadiness(
185186
stage: "socket authority",
186187
detail:
187188
"The lifecycle receipt predates recorded portable Podman authority; rerun onboarding.",
189+
recovery: "portable-onboarding",
188190
timing: { mode: "warm", activationMs: 0, apiMs: 0, totalMs: 0 },
189191
};
190192
}

test/gateway-failure-classifier.test.ts

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,23 @@ function writePortableReceipt(stateDir: string): void {
5656
);
5757
}
5858

59+
function writeLegacyPortableReceipt(stateDir: string): void {
60+
const receiptPath = portableDemoLifecycleInternals.receiptPath("alpha", stateDir);
61+
fs.mkdirSync(path.dirname(receiptPath), { recursive: true });
62+
fs.writeFileSync(
63+
receiptPath,
64+
`${JSON.stringify({
65+
schemaVersion: 3,
66+
sandboxName: "alpha",
67+
sandboxId: "sandbox-id-alpha",
68+
containerId: "a".repeat(64),
69+
dashboardPort: 18789,
70+
registryGeneration: "a".repeat(64),
71+
})}\n`,
72+
{ mode: 0o600 },
73+
);
74+
}
75+
5976
function makeRunners(overrides: Partial<GatewayFailureRunners> = {}): GatewayFailureRunners {
6077
return {
6178
dockerInfo: () => true,
@@ -215,9 +232,7 @@ describe("isDockerRuntimeDown", () => {
215232
const out: string[] = [];
216233
printDockerRuntimeDownGuidance("alpha", { writer: (line) => out.push(line) });
217234
expect(out.join("\n")).toContain("steady-state API health");
218-
expect(out.join("\n")).toContain(
219-
`Recorded socket: ${PORTABLE_SOCKET_AUTHORITY.socketPath}`,
220-
);
235+
expect(out.join("\n")).toContain(`Recorded socket: ${PORTABLE_SOCKET_AUTHORITY.socketPath}`);
221236
expect(out.join("\n")).toContain("no Docker or named-connection fallback was used");
222237
} finally {
223238
fs.rmSync(stateDir, { recursive: true, force: true });
@@ -241,8 +256,38 @@ describe("isDockerRuntimeDown", () => {
241256
expect(dockerInfo).not.toHaveBeenCalled();
242257
const out: string[] = [];
243258
printDockerRuntimeDownGuidance("alpha", { writer: (line) => out.push(line) });
244-
expect(out.join("\n")).toContain("socket authority");
245-
expect(out.join("\n")).not.toContain("super-secret");
259+
const guidance = out.join("\n");
260+
expect(guidance).toContain("socket authority");
261+
expect(guidance).toContain("nemoclaw onboard --experimental-profile portable");
262+
expect(guidance).not.toContain("current user's Podman socket service");
263+
expect(guidance).not.toContain("Confirm the recorded endpoint");
264+
expect(guidance).not.toContain("super-secret");
265+
} finally {
266+
fs.rmSync(stateDir, { recursive: true, force: true });
267+
}
268+
});
269+
270+
it("routes a legacy portable receipt to onboarding without endpoint repair (#9070)", () => {
271+
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-readiness-"));
272+
writeLegacyPortableReceipt(stateDir);
273+
const dockerInfo = vi.fn(() => true);
274+
try {
275+
expect(
276+
isDockerRuntimeDown("alpha", {
277+
runners: { dockerInfo },
278+
getSandbox: dockerSandbox,
279+
portableLifecycle: { stateDir },
280+
}),
281+
).toBe(true);
282+
expect(dockerInfo).not.toHaveBeenCalled();
283+
284+
const out: string[] = [];
285+
printDockerRuntimeDownGuidance("alpha", { writer: (line) => out.push(line) });
286+
const guidance = out.join("\n");
287+
expect(guidance).toContain("predates recorded portable Podman authority");
288+
expect(guidance).toContain("nemoclaw onboard --experimental-profile portable");
289+
expect(guidance).not.toContain("current user's Podman socket service");
290+
expect(guidance).not.toContain("Confirm the recorded endpoint");
246291
} finally {
247292
fs.rmSync(stateDir, { recursive: true, force: true });
248293
}

0 commit comments

Comments
 (0)