Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 6 additions & 1 deletion docs/manage-sandboxes/backup-restore.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,12 @@ It preserves OpenShell credential placeholders so rebuild can reattach the host-
If NemoClaw cannot sanitize a copied configuration or environment file, it omits that file from the snapshot.
If it cannot remove the unsafe file, snapshot creation returns an error.
It deletes the incomplete backup when cleanup succeeds and reports when the backup remains.
This sanitization uses an isolated `python3` helper on POSIX hosts to keep reads, replacements, and removals anchored to opened directory descriptors.
Snapshot sanitization on POSIX hosts requires Python 3 from a trusted absolute path.
NemoClaw checks fixed installation paths and the directory that contains the Node.js executable.
It does not resolve the helper through `PATH`, which can contain user-controlled entries.
If no trusted interpreter is available, snapshot creation removes the incomplete backup when possible and tells you to install Python 3 before rerunning the command.
Install Python 3 through your operating-system package manager or Homebrew, then rerun `$$nemoclaw <name> snapshot create`.
NemoClaw runs the helper in isolated mode to keep reads, replacements, and removals anchored to opened directory descriptors.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
If a copied file or parent directory changes identity during the operation, snapshot creation fails closed instead of following the changed path.

<AgentOnly variant="hermes">
Expand Down
14 changes: 14 additions & 0 deletions docs/reference/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,20 @@ Upgrade NemoClaw to a version that supports your OpenShell release, or install a
For fresh installs, NemoClaw passes the blueprint range to `install-openshell.sh` and resolves a compatible published OpenShell release before downloading.
If GitHub release metadata is unavailable, the script uses its bundled fallback pin and the post-install gate still enforces the configured range.

### Review Sandbox Creation Diagnostics

When onboarding fails during `openshell sandbox create`, NemoClaw prints the redacted failure and exits with a nonzero status.
Expected CLI failures show the actionable message without internal Node.js stack frames.
The `Diagnostics saved:` line identifies the exact bundle path under the active NemoClaw state root.
The default path is `~/.nemoclaw/onboard-failures/`; a custom gateway port uses `~/.nemoclaw/gateways/<port>/onboard-failures/`.

Open `summary.txt` in the printed directory first.
It records the retained output path under `create_output` and shows the final error lines under `failure_excerpt`.
The `sandbox-create-output.log` file retains a bounded tail of the sandbox creation output.
The bundle also retains any available OpenShell gateway or virtual machine console logs.
This keeps the failing gateway or image-build step available on Linux and macOS, even when the gateway log or console output path is unavailable.
Correct the failure reported in the bundle before you rerun `$$nemoclaw onboard --resume`.

<AgentOnly variant="openclaw">

### Sandbox build fails during OpenClaw plugin install
Expand Down
8 changes: 4 additions & 4 deletions nemoclaw/src/shared/snapshot-sanitizer-boundary.cts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function setSnapshotSanitizerPythonPathForTest(
snapshotSanitizerPythonPathForTest = pythonPath;
}

function snapshotSanitizerPythonPath(): string | null {
export function resolveSnapshotSanitizerPythonPath(): string | null {
if (process.env.VITEST === "true" && snapshotSanitizerPythonPathForTest !== undefined) {
return snapshotSanitizerPythonPathForTest;
}
Expand Down Expand Up @@ -719,7 +719,7 @@ export function scanDescriptorSnapshot(
targetName?: string,
): DescriptorSnapshotScan | null {
const mode = targetName === undefined ? "scan-tree" : "scan-file";
const pythonPath = snapshotSanitizerPythonPath();
const pythonPath = resolveSnapshotSanitizerPythonPath();
if (pythonPath === null) return null;
const result = spawnSync(
pythonPath,
Expand Down Expand Up @@ -751,7 +751,7 @@ export function applyDescriptorSnapshotActions(
actions: readonly SnapshotSanitizationAction[],
): boolean {
if (actions.length === 0) return true;
const pythonPath = snapshotSanitizerPythonPath();
const pythonPath = resolveSnapshotSanitizerPythonPath();
if (pythonPath === null) return false;
const result = spawnSync(
pythonPath,
Expand All @@ -774,7 +774,7 @@ export function installDescriptorSnapshotFile(
content: string,
): boolean {
if (!isSafeRelativePath(targetName) || targetName.includes("/")) return false;
const pythonPath = snapshotSanitizerPythonPath();
const pythonPath = resolveSnapshotSanitizerPythonPath();
if (pythonPath === null) return false;
const result = spawnSync(
pythonPath,
Expand Down
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 @@ -12,6 +12,7 @@ const EXPORTED_DIAGNOSTIC_FILES = new Set([
"openshell-gateway-relevant.log",
"openshell-gateway-tail.log",
"rootfs-console.log",
"sandbox-create-output.log",
"summary.txt",
]);

Expand Down
10 changes: 10 additions & 0 deletions src/lib/onboard/created-sandbox-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ describe("reportSandboxCreateFailure", () => {
).toThrow(ExitSignal);
expect(deps.printCreateFailureDiagnostics).toHaveBeenCalledWith("alpha", {
backupPath: "/tmp/backup",
createOutput: "boom",
});
expect(deps.printRecoveryHints).toHaveBeenCalledWith("boom", {
createArgs: ["sandbox", "create", "alpha"],
Expand Down Expand Up @@ -139,6 +140,15 @@ describe("reportSandboxCreateFailure", () => {
expect(hinted).not.toContain("ghp_abcdefghijklmnopqrstuvwxyz1234567890");
expect(hinted).not.toContain("sk-abcdefghijklmnopqrstuvwxyz1234567890");
expect(hinted).not.toContain("AKIAABCDEFGHIJKLMNOP"); // gitleaks:allow
const diagnosticOutput = String(
(deps.printCreateFailureDiagnostics as ReturnType<typeof vi.fn>).mock.calls[0]?.[1]
?.createOutput,
);
expect(diagnosticOutput).toContain("<REDACTED>");
expect(diagnosticOutput).not.toContain("secret-token");
expect(diagnosticOutput).not.toContain("ghp_abcdefghijklmnopqrstuvwxyz1234567890");
expect(diagnosticOutput).not.toContain("sk-abcdefghijklmnopqrstuvwxyz1234567890");
expect(diagnosticOutput).not.toContain("AKIAABCDEFGHIJKLMNOP"); // gitleaks:allow
});

it("falls back to exit code 1 when the create status is zero", () => {
Expand Down
13 changes: 11 additions & 2 deletions src/lib/onboard/created-sandbox-failure.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { redact } from "../security/redact";
import { redact, redactFull } from "../security/redact";
import type { CreatedSandboxReadinessResult } from "./sandbox-readiness-tracing";

/** Remove credential material before sandbox create failures reach a diagnostic sink. */
export function redactSandboxCreateFailureOutput(output: string): string {
return redact(redactFull(output));
}

export type SandboxCreateFailureReportOptions = {
sandboxName: string;
/** Non-zero exit status from the create stream. */
Expand All @@ -18,7 +23,10 @@ export type SandboxCreateFailureReportOptions = {

export type SandboxCreateFailureReportDeps = {
classifyCreateFailure(output: string): { kind: string };
printCreateFailureDiagnostics(sandboxName: string, options: { backupPath: string | null }): void;
printCreateFailureDiagnostics(
sandboxName: string,
options: { backupPath: string | null; createOutput: string },
): void;
printRecoveryHints(output: string, options: { createArgs: readonly string[] }): void;
warn(message: string): void;
error(message: string): void;
Expand Down Expand Up @@ -56,6 +64,7 @@ export function reportSandboxCreateFailure(
}
deps.printCreateFailureDiagnostics(options.sandboxName, {
backupPath: options.restoreBackupPath,
createOutput: redactSandboxCreateFailureOutput(options.createOutput),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});
deps.error(" Try: openshell sandbox list # check gateway state");
deps.printRecoveryHints(redactedCreateOutput, { createArgs: options.createArgs });
Expand Down
87 changes: 79 additions & 8 deletions src/lib/onboard/sandbox-create-failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,16 @@ import path from "node:path";
import { GATEWAY_PORT } from "../core/ports";
import { rejectSymlinksOnPath } from "../state/config-io";
import { nemoclawStateRoot } from "../state/state-root";
import { redactSandboxCreateFailureOutput } from "./created-sandbox-failure";

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_CREATE_OUTPUT_LINES = 240;
const MAX_CREATE_OUTPUT_CHARS = 32_000;
const MAX_FAILURE_EXCERPT_LINES = 8;
const TRUNCATED_OUTPUT_MARKER = "[diagnostic truncated; showing final output]";

export type SandboxCreateFailureDiagnostics = {
dir: string;
Expand All @@ -22,13 +27,16 @@ export type SandboxCreateFailureDiagnostics = {
consoleOutput: string | null;
copiedConsoleOutput: string | null;
gatewayTailPath: string | null;
createOutputPath: string | null;
backupPath: string | null;
summaryLines: string[];
};

export type SandboxCreateFailureDiagnosticOptions = {
homeDir?: string;
gatewayLogPath?: string | null;
homebrewPrefix?: string | null;
createOutput?: string | null;
backupPath?: string | null;
now?: Date;
};
Expand All @@ -45,8 +53,8 @@ function timestampForPath(now: Date): string {
return now.toISOString().replace(/[:.]/g, "-");
}

function gatewayLogCandidates(homeDir: string): string[] {
return [
function gatewayLogCandidates(homeDir: string, homebrewPrefix?: string | null): string[] {
const candidates = [
path.join(
homeDir,
".local",
Expand All @@ -57,17 +65,59 @@ function gatewayLogCandidates(homeDir: string): string[] {
),
path.join(homeDir, ".local", "state", "openshell", "openshell-gateway.log"),
];
const homebrewPrefixes = [
homebrewPrefix,
process.env.HOMEBREW_PREFIX,
"/opt/homebrew",
"/usr/local",
].filter((prefix): prefix is string => Boolean(prefix) && path.isAbsolute(prefix as string));
for (const prefix of new Set(homebrewPrefixes)) {
candidates.push(
path.join(prefix, "var", "log", "openshell", "openshell-gateway.err.log"),
path.join(prefix, "var", "log", "openshell", "openshell-gateway.out.log"),
);
}
return candidates;
}

function latestGatewayLogPath(candidates: string[]): string | null {
let latest: { path: string; modified: number } | null = null;
for (const candidate of candidates) {
try {
const stat = fs.statSync(candidate);
if (!stat.isFile()) continue;
if (latest === null || stat.mtimeMs > latest.modified) {
latest = { path: candidate, modified: stat.mtimeMs };
}
} catch {
// Continue to the next known log location.
}
}
return latest?.path ?? null;
}

function readLogLines(filePath: string): string[] | null {
try {
if (!fs.existsSync(filePath)) return null;
return stripAnsi(fs.readFileSync(filePath, "utf-8")).split(/\r?\n/);
return redactSandboxCreateFailureOutput(stripAnsi(fs.readFileSync(filePath, "utf-8"))).split(
/\r?\n/,
);
} catch {
return null;
}
}

function createOutputTail(value: string | null | undefined): string[] {
const redacted = redactSandboxCreateFailureOutput(stripAnsi(value ?? "")).trim();
if (!redacted) return [];
const wasTruncated = redacted.length > MAX_CREATE_OUTPUT_CHARS;
const lines = (wasTruncated ? redacted.slice(-MAX_CREATE_OUTPUT_CHARS) : redacted)
.split(/\r?\n/)
.filter((line) => line.trim());
if (!wasTruncated) return lines.slice(-MAX_CREATE_OUTPUT_LINES);
return [TRUNCATED_OUTPUT_MARKER, ...lines.slice(-(MAX_CREATE_OUTPUT_LINES - 1))];
}

function extractField(line: string, field: string): string | null {
const match = line.match(new RegExp(`${field}=([^\\s]+)`));
return match?.[1] ?? null;
Expand Down Expand Up @@ -113,7 +163,9 @@ function filterRelevantLines(
if (!line.trim()) return false;
if (line.includes(`sandbox_name=${sandboxName}`)) return true;
if (sandboxId && line.includes(`sandbox_id=${sandboxId}`)) return true;
return /ERROR krun|VmCreate|ProcessExited|console_output=|state_dir=/.test(line);
return /\bERROR\b|failed to (?:build|solve)|VmCreate|ProcessExited|console_output=|state_dir=/i.test(
line,
);
});
return relevant.slice(-MAX_RELEVANT_LOG_LINES);
}
Expand Down Expand Up @@ -172,8 +224,7 @@ export function collectSandboxCreateFailureDiagnostics(

const gatewayLogPath =
options.gatewayLogPath ??
gatewayLogCandidates(homeDir).find((candidate) => fs.existsSync(candidate)) ??
null;
latestGatewayLogPath(gatewayLogCandidates(homeDir, options.homebrewPrefix));
const rawLines = gatewayLogPath ? readLogLines(gatewayLogPath) : null;
const block = rawLines ? findLatestSandboxBlock(rawLines, sandboxName) : [];
const sandboxId = getLatestSandboxId(block, sandboxName);
Expand All @@ -192,6 +243,13 @@ export function collectSandboxCreateFailureDiagnostics(
);
const stateEntries = listStateDir(stateDir);
const backupPath = options.backupPath ?? null;
const createOutputLines = createOutputTail(options.createOutput);
const createOutputPath =
createOutputLines.length > 0 ? path.join(dir, "sandbox-create-output.log") : null;

if (createOutputPath) {
fs.writeFileSync(createOutputPath, `${createOutputLines.join("\n")}\n`, { mode: 0o600 });
}

if (relevantLines.length > 0) {
fs.writeFileSync(
Expand All @@ -213,11 +271,23 @@ export function collectSandboxCreateFailureDiagnostics(
`sandbox_id=${sandboxId ?? "unknown"}`,
`gateway_log=${gatewayLogPath ?? "not-found"}`,
`gateway_tail=${gatewayTailPath ?? "not-written"}`,
`create_output=${createOutputPath ?? "not-written"}`,
`state_dir=${stateDir ?? "unknown"}`,
`console_output=${consoleOutput ?? "unknown"}`,
`copied_console_output=${copiedConsoleOutput ?? "not-copied"}`,
`backup_path=${backupPath ?? "none"}`,
];
const failureExcerpt = (
createOutputLines.length > 0
? createOutputLines
: relevantLines.length > 0
? relevantLines
: gatewayTailLines
).slice(-MAX_FAILURE_EXCERPT_LINES);
if (failureExcerpt.length > 0) {
summaryLines.push("failure_excerpt:");
summaryLines.push(...failureExcerpt.map((line) => ` ${line}`));
}
if (stateEntries.length > 0) {
summaryLines.push("state_dir_entries:");
summaryLines.push(...stateEntries.map((entry) => ` ${entry}`));
Expand All @@ -234,8 +304,9 @@ export function collectSandboxCreateFailureDiagnostics(
consoleOutput,
copiedConsoleOutput,
gatewayTailPath,
createOutputPath,
backupPath,
summaryLines: relevantLines.length > 0 ? relevantLines.slice(-8) : gatewayTailLines.slice(-8),
summaryLines: failureExcerpt,
};
}

Expand All @@ -248,7 +319,7 @@ export function printSandboxCreateFailureDiagnostics(

console.error(` Diagnostics saved: ${diagnostics.dir}`);
if (diagnostics.summaryLines.length > 0) {
console.error(" Recent OpenShell gateway failure:");
console.error(" Recent sandbox creation failure:");
for (const line of diagnostics.summaryLines) {
console.error(` ${line}`);
}
Expand Down
1 change: 1 addition & 0 deletions src/lib/onboard/sandbox-gpu-create-run-attempt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ export function createSandboxGpuCreateAttemptRunner(
await runtimePatch.rollbackManagedStartupAfterCreateFailure();
printSandboxCreateFailureDiagnostics(input.sandboxName, {
backupPath: input.restoreBackupPath,
createOutput: createResult.output,
});
if (compatibility) runtimePatch.printReadinessFailureIfEnabled();
else {
Expand Down
10 changes: 7 additions & 3 deletions src/lib/sandbox-base-image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,15 @@ describe("sandbox base-image build diagnostics", () => {
expect(output).toContain("****");
});

it("bounds captured build diagnostics before returning them", () => {
const output = formatBuildFailureDiagnostics({ stderr: "x".repeat(10_000) });
it("bounds captured build diagnostics while preserving the final failure", () => {
const output = formatBuildFailureDiagnostics({
stderr: `initial Dockerfile output\n${"x".repeat(10_000)}\nERROR: final build step failed`,
});

expect(output.length).toBeLessThan(8_100);
expect(output.endsWith("[diagnostic truncated]")).toBe(true);
expect(output.startsWith("[diagnostic truncated; showing final output]")).toBe(true);
expect(output).toContain("ERROR: final build step failed");
expect(output).not.toContain("initial Dockerfile output");
});

it("surfaces a redacted spawn failure cause", () => {
Expand Down
4 changes: 2 additions & 2 deletions src/lib/sandbox-base-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export * from "./sandbox-base-image/source-identity";
export * from "./sandbox-base-image/types";

const BUILD_FAILURE_DIAGNOSTIC_LIMIT = 8_000;
const BUILD_FAILURE_TRUNCATED_SUFFIX = "\n[diagnostic truncated]";
const BUILD_FAILURE_TRUNCATED_PREFIX = "[diagnostic truncated; showing final output]\n";

/**
* Combine stderr + stdout from a captured `dockerBuild` failure and pass them
Expand Down Expand Up @@ -82,7 +82,7 @@ export function formatBuildFailureDiagnostics(buildResult: {
diagnostics = diagnostics.replaceAll(prefix, replacement);
}
return diagnostics.length > BUILD_FAILURE_DIAGNOSTIC_LIMIT
? `${diagnostics.slice(0, BUILD_FAILURE_DIAGNOSTIC_LIMIT)}${BUILD_FAILURE_TRUNCATED_SUFFIX}`
? `${BUILD_FAILURE_TRUNCATED_PREFIX}${diagnostics.slice(-BUILD_FAILURE_DIAGNOSTIC_LIMIT)}`
: diagnostics;
}

Expand Down
Loading
Loading