Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
26e3d01
fix(onboard): capture sandbox logs before rollback
ericksoa Sep 7, 2026
984d84f
fix(onboard): harden failure diagnostics
ericksoa Sep 7, 2026
90aea50
fix(onboard): cover terminal diagnostic states
ericksoa Sep 7, 2026
e36e5cf
fix(onboard): preserve diagnostic failure outcomes
ericksoa Sep 7, 2026
e1c1f07
Merge remote-tracking branch 'origin/main' into fix/wsl-arm-hermes-sa…
ericksoa Sep 7, 2026
56a51b6
fix(onboard): contain diagnostic edge cases
ericksoa Sep 7, 2026
e95a943
fix(onboard): bind logs to verified sandbox
ericksoa Sep 7, 2026
f173522
fix(onboard): preserve diagnostics before rollback
ericksoa Sep 7, 2026
5ee75ae
fix(onboard): preserve diagnostics before rollback
ericksoa Sep 7, 2026
b93374e
Merge remote-tracking branch 'origin/main' into fix/wsl-arm-hermes-sa…
ericksoa Sep 7, 2026
460a2a7
Merge published repair head after advisor remediation
ericksoa Sep 7, 2026
00b1e0d
test(onboard): verify diagnostic fallback notice
ericksoa Sep 7, 2026
70a3ae2
fix(onboard): report unavailable diagnostics by phase
ericksoa Sep 7, 2026
0f77b83
fix(onboard): bound readiness failure evidence
ericksoa Sep 7, 2026
802cf79
Merge remote-tracking branch 'origin/main' into fix/wsl-arm-hermes-sa…
ericksoa Sep 7, 2026
aa9fd52
fix(onboard): bind diagnostics to sandbox identity
ericksoa Sep 7, 2026
25a77b4
fix(onboard): retain bounded identity evidence
ericksoa Sep 7, 2026
62998c5
fix(onboard): preserve anchored gateway failures
ericksoa Sep 7, 2026
cc3fa10
fix(onboard): exclude unattributed gateway evidence
ericksoa Sep 7, 2026
eb587ad
fix(onboard): redact scoped failure bundles
ericksoa Sep 7, 2026
a18ad63
fix(onboard): confine and retain failure bundles
ericksoa Sep 7, 2026
a7663a5
docs: clarify failure bundle retention trigger
ericksoa Sep 7, 2026
e851ab4
fix(onboard): preserve verified gateway evidence
ericksoa Sep 7, 2026
f51e6ea
fix(onboard): preserve portable failure guidance
ericksoa Sep 7, 2026
cb3ac78
fix(onboard): preserve readiness failure outcomes
ericksoa Sep 7, 2026
925375b
fix(onboard): preserve UTF-8 diagnostic bounds
ericksoa Sep 7, 2026
1fb2ca2
fix(onboard): require create-bound diagnostic identity
ericksoa Sep 7, 2026
ee86d7c
fix(onboard): retain structural failure evidence
ericksoa Sep 8, 2026
3b5ab86
merge: update issue 10412 from main
ericksoa Sep 8, 2026
1b28764
test(onboard): focus issue 10412 on Hermes handoff
ericksoa Sep 8, 2026
00067ba
test(onboard): clarify standard Hermes handoff coverage
ericksoa Sep 8, 2026
e1c3598
test(onboard): keep create handoff title behavior-based
ericksoa Sep 8, 2026
46d2c60
test(onboard): model post-ready Hermes failure
ericksoa Sep 8, 2026
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
1 change: 1 addition & 0 deletions scripts/checks/export-managed-image-failure-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { isCredentialField } from "../../src/lib/security/credential-filter.ts";
const EXPORTED_DIAGNOSTIC_FILES = new Set([
"openshell-gateway-relevant.log",
"openshell-gateway-tail.log",
"openshell-logs.txt",
"rootfs-console.log",
"summary.txt",
]);
Expand Down
19 changes: 19 additions & 0 deletions src/lib/onboard/docker-gpu-diagnostic-redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,25 @@ export function discoverDockerGpuDiagnosticSensitiveValues(
.map(([, value]) => value);
}

/** Return staged credential values that a failed sandbox can echo into its logs. */
export function discoverDockerGpuDiagnosticSensitiveValuesFromEnv(
env: NodeJS.ProcessEnv,
): string[] {
const extraPlaceholderKeys = new Set(
String(env[EXTRA_PLACEHOLDER_KEYS_ENV] ?? "")
.split(/[\s,]+/u)
.filter(Boolean),
);
return Object.entries(env)
.filter(
([key, value]) =>
typeof value === "string" &&
Buffer.byteLength(value, "utf8") >= 8 &&
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
(SENSITIVE_ENV_KEY.test(key) || extraPlaceholderKeys.has(key)),
)
.map(([, value]) => value as string);
}

/**
* SOURCE_OF_TRUTH_REVIEW (shared Docker GPU diagnostic redaction; #6110):
* invalidState: inspect, network, log, or startup-command credentials reach an artifact sink.
Expand Down
82 changes: 80 additions & 2 deletions src/lib/onboard/sandbox-create-failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,27 @@ import os from "node:os";
import path from "node:path";

import { GATEWAY_PORT } from "../core/ports";
import { buildSandboxLogsArgs } from "../domain/sandbox/logs";
import { rejectSymlinksOnPath } from "../state/config-io";
import { nemoclawStateRoot } from "../state/state-root";
import {
createDockerGpuDiagnosticRedactor,
discoverDockerGpuDiagnosticSensitiveValuesFromEnv,
} from "./docker-gpu-diagnostic-redaction";
import { resolveGatewayLogPathForPort } from "./gateway/state-dir";

const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g;
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
const MAX_RELEVANT_LOG_LINES = 120;
const MAX_GATEWAY_TAIL_LINES = 240;
const MAX_OPENSHELL_CAPTURE_BYTES = 64 * 1024;
const MAX_OPENSHELL_CAPTURE_LINES = 120;
const OPENSHELL_CAPTURE_TIMEOUT_MS = 10_000;

type RunCaptureOpenshell = (
args: string[],
options?: { ignoreError?: boolean; killProcessTreeOnTimeout?: boolean; timeout?: number },
) => string;

export type SandboxCreateFailureDiagnostics = {
dir: string;
Expand All @@ -23,6 +36,7 @@ export type SandboxCreateFailureDiagnostics = {
consoleOutput: string | null;
copiedConsoleOutput: string | null;
gatewayTailPath: string | null;
openshellLogsPath: string | null;
backupPath: string | null;
summaryLines: string[];
};
Expand All @@ -32,6 +46,9 @@ export type SandboxCreateFailureDiagnosticOptions = {
gatewayPort?: number;
gatewayLogPath?: string | null;
gatewayStateDir?: string;
gatewayName?: string;
runCaptureOpenshell?: RunCaptureOpenshell;
env?: NodeJS.ProcessEnv;
backupPath?: string | null;
now?: Date;
};
Expand Down Expand Up @@ -154,6 +171,56 @@ function listStateDir(stateDir: string | null): string[] {
}
}

function boundedRedactedCapture(output: string, env: NodeJS.ProcessEnv): string[] {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const bytes = Buffer.from(stripAnsi(output), "utf8");
const offset = Math.max(0, bytes.length - MAX_OPENSHELL_CAPTURE_BYTES);
let bounded = bytes.subarray(offset).toString("utf8");
if (offset > 0) {
const firstCompleteLine = bounded.indexOf("\n");
bounded = firstCompleteLine === -1 ? "" : bounded.slice(firstCompleteLine + 1);
}
return createDockerGpuDiagnosticRedactor(discoverDockerGpuDiagnosticSensitiveValuesFromEnv(env))
.redactText(bounded)
.split(/\r?\n/u)
.filter((line) => line.trim().length > 0)
.slice(-MAX_OPENSHELL_CAPTURE_LINES);
}

function captureOpenShellFailureLogs(
dir: string,
sandboxName: string,
options: SandboxCreateFailureDiagnosticOptions,
): { path: string | null; summaryLines: string[] } {
if (!options.runCaptureOpenshell) return { path: null, summaryLines: [] };
try {
const lines = boundedRedactedCapture(
options.runCaptureOpenshell(
buildSandboxLogsArgs(
sandboxName,
{ follow: false, lines: String(MAX_OPENSHELL_CAPTURE_LINES), since: null },
options.gatewayName,
),
{
ignoreError: true,
killProcessTreeOnTimeout: true,
timeout: OPENSHELL_CAPTURE_TIMEOUT_MS,
},
),
options.env ?? process.env,
);
if (lines.length === 0) return { path: null, summaryLines: [] };
const filePath = path.join(dir, "openshell-logs.txt");
fs.writeFileSync(filePath, `${lines.join("\n")}\n`, { mode: 0o600 });
return {
path: filePath,
summaryLines: lines.slice(-2).map((line) => `sandbox logs: ${line}`),
};
} catch {
// Diagnostics must not replace the original sandbox-create failure.
return { path: null, summaryLines: [] };
}
}

export function collectSandboxCreateFailureDiagnostics(
sandboxName: string,
options: SandboxCreateFailureDiagnosticOptions = {},
Expand Down Expand Up @@ -215,6 +282,7 @@ export function collectSandboxCreateFailureDiagnostics(
if (gatewayTailPath) {
fs.writeFileSync(gatewayTailPath, `${gatewayTailLines.join("\n")}\n`, { mode: 0o600 });
}
const openshellLogs = captureOpenShellFailureLogs(dir, sandboxName, options);
const summaryLines = [
`created_at=${now.toISOString()}`,
`sandbox_name=${sandboxName}`,
Expand All @@ -224,6 +292,7 @@ export function collectSandboxCreateFailureDiagnostics(
`state_dir=${stateDir ?? "unknown"}`,
`console_output=${consoleOutput ?? "unknown"}`,
`copied_console_output=${copiedConsoleOutput ?? "not-copied"}`,
`openshell_logs=${openshellLogs.path ?? "not-written"}`,
`backup_path=${backupPath ?? "none"}`,
];
if (stateEntries.length > 0) {
Expand All @@ -242,8 +311,17 @@ export function collectSandboxCreateFailureDiagnostics(
consoleOutput,
copiedConsoleOutput,
gatewayTailPath,
openshellLogsPath: openshellLogs.path,
backupPath,
summaryLines: relevantLines.length > 0 ? relevantLines.slice(-8) : gatewayTailLines.slice(-8),
summaryLines:
openshellLogs.summaryLines.length > 0
? [
...(relevantLines.length > 0 ? relevantLines : gatewayTailLines).slice(-2),
...openshellLogs.summaryLines,
].slice(-8)
: relevantLines.length > 0
? relevantLines.slice(-8)
: gatewayTailLines.slice(-8),
};
}

Expand All @@ -256,7 +334,7 @@ export function printSandboxCreateFailureDiagnostics(

console.error(` Diagnostics saved: ${diagnostics.dir}`);
if (diagnostics.summaryLines.length > 0) {
console.error(" Recent OpenShell gateway failure:");
console.error(" Recent OpenShell failure diagnostics:");
for (const line of diagnostics.summaryLines) {
console.error(` ${line}`);
}
Expand Down
6 changes: 6 additions & 0 deletions src/lib/onboard/sandbox-fresh-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ describe("fresh sandbox executable readiness", () => {
);
expect(mocks.printSandboxCreateFailureDiagnostics).toHaveBeenCalledWith("alpha", {
backupPath: null,
gatewayName: "nemoclaw",
runCaptureOpenshell: deps.runCaptureOpenshell,
});
});

Expand Down Expand Up @@ -183,6 +185,8 @@ describe("fresh sandbox executable readiness", () => {
);
expect(mocks.printSandboxCreateFailureDiagnostics).toHaveBeenCalledWith("alpha", {
backupPath: null,
gatewayName: "nemoclaw",
runCaptureOpenshell: deps.runCaptureOpenshell,
});
});

Expand Down Expand Up @@ -244,6 +248,8 @@ describe("fresh sandbox executable readiness", () => {
);
expect(mocks.printSandboxCreateFailureDiagnostics).toHaveBeenCalledWith("alpha", {
backupPath: null,
gatewayName: "nemoclaw",
runCaptureOpenshell: deps.runCaptureOpenshell,
});
});
});
8 changes: 5 additions & 3 deletions src/lib/onboard/sandbox-gpu-create-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,17 +727,19 @@ describe("runSandboxGpuCreateFlow native failure and readiness", () => {
};
});
mockExit();

await expect(runSandboxGpuCreateFlow(input, deps)).rejects.toThrow("process.exit:1");

expect(patch.rollbackManagedStartupAfterCreateFailure).toHaveBeenCalledOnce();
expect(deps.runOpenshell).not.toHaveBeenCalledWith(
["sandbox", "delete", "alpha"],
expect.anything(),
);
expect(mocks.printSandboxCreateFailureDiagnostics).toHaveBeenCalledWith("alpha", {
backupPath: null,
gatewayName: "nemoclaw",
runCaptureOpenshell: deps.runCaptureOpenshell,
});
expect(mocks.printSandboxCreateFailureDiagnostics.mock.invocationCallOrder[0]).toBeLessThan(
patch.rollbackManagedStartupAfterCreateFailure.mock.invocationCallOrder[0]!,
);
expect(errorOutput()).toContain(
"NemoClaw left the sandbox in place for inspection and recovery",
);
Expand Down
9 changes: 7 additions & 2 deletions src/lib/onboard/sandbox-gpu-create-run-attempt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,12 @@ export function createSandboxGpuCreateAttemptRunner(
console.error(
` Hermes portable sandbox '${sandboxName}' did not complete receipt-owned creation. Preserve its lifecycle receipt and resume onboarding after correcting the reported failure.`,
)
: printSandboxCreateFailureDiagnostics);
: (sandboxName, options) =>
printSandboxCreateFailureDiagnostics(sandboxName, {
...options,
gatewayName: input.gatewayName,
runCaptureOpenshell: deps.runCaptureOpenshell,
}));
if (
portableLifecycle &&
(input.gpuRoutePlan === "compatibility-only" ||
Expand Down Expand Up @@ -1141,10 +1146,10 @@ export function createSandboxGpuCreateAttemptRunner(
...nativeCleanup,
} as const;
}
await runtimePatch.rollbackManagedStartupAfterCreateFailure();
printCreateFailureDiagnostics(input.sandboxName, {
backupPath: input.restoreBackupPath,
});
await runtimePatch.rollbackManagedStartupAfterCreateFailure();
if (compatibility) runtimePatch.printReadinessFailureIfEnabled();
else if (expectedRecreatedSandboxId) {
console.error(
Expand Down
18 changes: 10 additions & 8 deletions test/inference/managed/managed-image-failure-diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ describe("managed-image failure diagnostic export", () => {
path.join(diagnosticBundle, "openshell-gateway-relevant.log"),
`gateway reconnect failed for ${opaqueCanary}\n`,
);
fs.writeFileSync(
path.join(diagnosticBundle, "openshell-logs.txt"),
`sandbox exited after printing ${opaqueCanary}\n`,
);
fs.writeFileSync(
path.join(diagnosticBundle, "rootfs-console.log"),
"managed startup exited before the supervisor reconnected\n",
Expand All @@ -93,7 +97,7 @@ describe("managed-image failure diagnostic export", () => {
sourceRoot,
});

expect(result).toMatchObject({ bundles: 1, files: 3 });
expect(result).toMatchObject({ bundles: 1, files: 4 });
const exported = outputText(outputRoot);
expect(exported).toContain("<REDACTED>");
expect(exported).toContain("managed startup exited before the supervisor reconnected");
Expand Down Expand Up @@ -165,13 +169,11 @@ describe("managed-image failure diagnostic export", () => {
sourceRoot,
`2026-07-29T01-02-${String(index).padStart(2, "0")}-000Z-agent`,
);
[
"openshell-gateway-relevant.log",
"openshell-gateway-tail.log",
"summary.txt",
].forEach((name) => {
fs.writeFileSync(path.join(diagnosticBundle, name), largeButReadable);
});
["openshell-gateway-relevant.log", "openshell-gateway-tail.log", "summary.txt"].forEach(
(name) => {
fs.writeFileSync(path.join(diagnosticBundle, name), largeButReadable);
},
);
fs.writeFileSync(path.join(diagnosticBundle, "rootfs-console.log"), tooLarge);
}

Expand Down
68 changes: 67 additions & 1 deletion test/onboarding/onboard-sandbox-create-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";

import {
collectSandboxCreateFailureDiagnostics,
Expand Down Expand Up @@ -104,10 +104,12 @@ describe("sandbox create failure diagnostics", () => {
homeDir,
backupPath: "/tmp/pre-upgrade-backup",
now: new Date("2026-05-12T20:35:00.000Z"),
runCaptureOpenshell: () => "sandbox startup failed\n",
});

expect(diagnostics?.dir).toContain(path.join(homeDir, ".nemoclaw", "onboard-failures"));
expect(messages).toContain(` Diagnostics saved: ${diagnostics!.dir}`);
expect(messages).toContain(" Recent OpenShell failure diagnostics:");
expect(messages).toContain(" State backup retained: /tmp/pre-upgrade-backup");
} finally {
console.error = originalError;
Expand Down Expand Up @@ -146,4 +148,68 @@ describe("sandbox create failure diagnostics", () => {
"gateway_tail=",
);
});

it("captures bounded redacted OpenShell failure evidence before WSL ARM cleanup (#10412)", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-create-failure-openshell-"));
const homeDir = path.join(tmp, "home");
const token = "opaque-credential-value-10412";
const logLines = [
"x".repeat(64 * 1024),
...Array.from({ length: 119 }, (_, index) => `earlier log ${String(index)}`),
`custom provider output ${token}`,
"[sandbox] /usr/local/bin/hermes: Exec format error",
];
const runCaptureOpenshell = vi.fn(
(
_args: string[],
_options?: {
ignoreError?: boolean;
killProcessTreeOnTimeout?: boolean;
timeout?: number;
},
) => `${logLines.join("\n")}\n`,
);

const diagnostics = collectSandboxCreateFailureDiagnostics("my-assistant", {
gatewayName: "nemoclaw",
homeDir,
now: new Date("2026-08-26T16:02:46.000Z"),
runCaptureOpenshell,
env: {
CUSTOM_PROVIDER_CREDENTIAL: token,
NEMOCLAW_EXTRA_PLACEHOLDER_KEYS: "CUSTOM_PROVIDER_CREDENTIAL",
},
});
const captured = fs.readFileSync(diagnostics!.openshellLogsPath!, "utf8");

expect({
calls: runCaptureOpenshell.mock.calls.map(([args, options]) => ({ args, options })),
firstLine: captured.trim().split("\n").at(0),
lineCount: captured.trim().split("\n").length,
lastLine: captured.trim().split("\n").at(-1),
summaryLines: diagnostics?.summaryLines,
tokenPresent: captured.includes(token),
withinByteLimit: Buffer.byteLength(captured, "utf8") <= 64 * 1024,
}).toEqual({
calls: [
{
args: ["logs", "-g", "nemoclaw", "my-assistant", "-n", "120", "--source", "all"],
options: {
ignoreError: true,
killProcessTreeOnTimeout: true,
timeout: 10_000,
},
},
],
firstLine: "earlier log 1",
lineCount: 120,
lastLine: "[sandbox] /usr/local/bin/hermes: Exec format error",
summaryLines: [
"sandbox logs: custom provider output <REDACTED>",
"sandbox logs: [sandbox] /usr/local/bin/hermes: Exec format error",
],
tokenPresent: false,
withinByteLimit: true,
});
});
});
Loading