Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cf8b72e
fix(snapshot): name the python3 prerequisite when sanitization cannot…
harjothkhara Aug 17, 2026
ae90932
fix(snapshot): cover the remaining prerequisite branches and drop the…
harjothkhara Aug 17, 2026
823f2e7
docs(snapshot): clarify verified interpreter recovery
harjothkhara Aug 17, 2026
6cc7a01
fix(snapshot): report retained sanitization path
cv Aug 17, 2026
eaea9f6
fix(snapshot): preserve sanitizer failure evidence
harjothkhara Aug 17, 2026
b4030d6
test(snapshot): cover helper failure classification
harjothkhara Aug 17, 2026
d4158b1
chore(snapshot): format sanitizer boundary
harjothkhara Aug 17, 2026
cd7c7f6
test(snapshot): pass the retained path to failure mapping
harjothkhara Aug 17, 2026
c0bfc13
test(snapshot): assert the canonical retained backup path
harjothkhara Aug 17, 2026
efc5f5d
test(snapshot): keep prerequisite case linear
cv Aug 17, 2026
4e0fc94
fix(logger): preserve aggregate error details
harjothkhara Aug 17, 2026
dc92863
docs(snapshot): scope interpreter guidance to live commands
harjothkhara Aug 17, 2026
ba59f8d
test(snapshot): harden sanitizer failure coverage
harjothkhara Aug 17, 2026
50f09bf
test(snapshot): check python before sanitizer scan
apurvvkumaria Aug 17, 2026
df34207
test(snapshot): distinguish helper execution failures
harjothkhara Aug 17, 2026
5ed9f0e
Merge branch 'main' into oss-find/nemoclaw-2026-08-16
prekshivyas Aug 18, 2026
cdb3e71
Merge branch 'main' into oss-find/nemoclaw-2026-08-16
prekshivyas Aug 18, 2026
edb39f2
test(logger): cover aggregate error redaction
apurvvkumaria Aug 18, 2026
937e9f7
refactor(security): simplify sanitizer prerequisite errors
apurvvkumaria Aug 18, 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
43 changes: 43 additions & 0 deletions docs/reference/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1631,6 +1631,49 @@ $$nemoclaw <name> snapshot create
$$nemoclaw <name> rebuild
```

### Snapshot Sanitization Requires a Verified Python Interpreter

The CLI operations listed below stop when NemoClaw cannot resolve a verified interpreter.
The error begins with this text:

```text
python3 is required for snapshot sanitization; install python3 and rerun
```

NemoClaw removes credentials from copied state with an isolated `python3` helper.
The operation fails closed when that helper cannot run.
These operations can report the error:

- `$$nemoclaw <name> snapshot create`
- `$$nemoclaw <name> rebuild`, including rebuilds started by `$$nemoclaw upgrade-sandboxes`
- `$$nemoclaw backup-all`, including the installer's pre-upgrade backup and an eligible stopped Docker-driver sandbox

A host that already has `python3` can still report this message.
NemoClaw does not search `PATH` for this credential-bearing helper.
NemoClaw accepts `python3` only at these locations:

- `/usr/bin/python3`
- `/usr/local/bin/python3`
- `/opt/homebrew/bin/python3`
- `/opt/local/bin/python3`
- A `python3` executable beside the canonical Node.js executable

NemoClaw rejects a candidate that fails its ownership, permission, or executable checks.
For more information about the interpreter requirement, refer to [Prerequisites](../get-started/prerequisites).

If NemoClaw reports that it removed the incomplete snapshot, install or repair `python3` at an accepted location.
Then rerun the complete command.

If cleanup fails, treat the exact reported directory as retained until you confirm that it is absent.

<Warning title="Retained Incomplete Snapshot">
A retained incomplete snapshot can contain unsanitized credentials.
The directory must remain owner-only.
You must not restore, copy, or share the directory.
You must remove the directory only by the exact path that NemoClaw reports.
You must confirm that the exact path no longer exists before you rerun the complete command.
</Warning>

### Sandbox shows as stopped

When status reports `sandbox_container_stopped`, Docker still has a container for the sandbox, but the container is not running.
Expand Down
13 changes: 8 additions & 5 deletions nemoclaw/src/security/snapshot-sanitizer-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
inspectDescriptorSnapshotRoot,
installDescriptorSnapshotFile,
resolveTrustedSnapshotSanitizerPythonPath,
SnapshotSanitizerPrerequisiteError,
type SnapshotFileIdentity,
scanDescriptorSnapshot,
setSnapshotSanitizerPythonPathForTest,
Expand Down Expand Up @@ -128,25 +129,27 @@ describe("migration snapshot sanitizer fallbacks", () => {
},
];

it("fails closed when the descriptor helper is unavailable", () => {
it("reports when the descriptor helper has no trusted interpreter (#8202)", () => {
const configPath = path.join(makeRoot(), "openclaw.json");
const original = JSON.stringify({ apiKey: "sk-secret-value" });
writeFileSync(configPath, original);
setSnapshotSanitizerPythonPathForTest(null);

expect(sanitizeOpenClawConfigFile(configPath)).toBe(false);
expect(() => sanitizeOpenClawConfigFile(configPath)).toThrow(
SnapshotSanitizerPrerequisiteError,
);
expect(readFileSync(configPath, "utf-8")).toBe(original);
});

it("fails closed when the descriptor apply helper is unavailable", () => {
it("reports the validated root when the apply helper has no trusted interpreter (#8202)", () => {
const root = { canonicalPath: makeRoot(), identity };
setSnapshotSanitizerPythonPathForTest(null);

expect(
expect(() =>
applyDescriptorSnapshotActions(root, { root: identity, directories: {}, files: [] }, [
{ kind: "remove", path: "config.json", metadata: identity },
]),
).toBe(false);
).toThrow(expect.objectContaining({ snapshotPath: root.canonicalPath }));
});

it("fails closed when the descriptor install helper is unavailable", () => {
Expand Down
20 changes: 15 additions & 5 deletions nemoclaw/src/shared/snapshot-sanitizer-boundary.cts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,7 @@ export function resolveTrustedSnapshotSanitizerPythonPath(): string | null {
}

/** @visibleForTesting Install an explicit helper substitute without weakening production lookup. */
export function setSnapshotSanitizerPythonPathForTest(
pythonPath: string | null | undefined,
): void {
export function setSnapshotSanitizerPythonPathForTest(pythonPath: string | null | undefined): void {
if (process.env.VITEST !== "true") {
throw new Error("Snapshot sanitizer Python substitution is only available under Vitest");
}
Expand All @@ -67,13 +65,25 @@ export function setSnapshotSanitizerPythonPathForTest(
snapshotSanitizerPythonPathForTest = pythonPath;
}

/** Resolve the interpreter the helpers will actually run, including the test substitute. */
function snapshotSanitizerPythonPath(): string | null {
if (process.env.VITEST === "true" && snapshotSanitizerPythonPathForTest !== undefined) {
return snapshotSanitizerPythonPathForTest;
}
return resolveTrustedSnapshotSanitizerPythonPath();
}

/** No trusted python3 interpreter resolved, so no descriptor-relative helper can run. (#8202) */
export class SnapshotSanitizerPrerequisiteError extends Error {
readonly snapshotPath: string;

constructor(snapshotPath: string) {
super("python3 is required for snapshot sanitization; install python3 and rerun");
this.name = "SnapshotSanitizerPrerequisiteError";
this.snapshotPath = snapshotPath;
}
}

export interface SnapshotFileIdentity {
readonly dev: string;
readonly ino: string;
Expand Down Expand Up @@ -720,7 +730,7 @@ export function scanDescriptorSnapshot(
): DescriptorSnapshotScan | null {
const mode = targetName === undefined ? "scan-tree" : "scan-file";
const pythonPath = snapshotSanitizerPythonPath();
if (pythonPath === null) return null;
if (pythonPath === null) throw new SnapshotSanitizerPrerequisiteError(root.canonicalPath);
const result = spawnSync(
pythonPath,
[
Expand Down Expand Up @@ -752,7 +762,7 @@ export function applyDescriptorSnapshotActions(
): boolean {
if (actions.length === 0) return true;
const pythonPath = snapshotSanitizerPythonPath();
if (pythonPath === null) return false;
if (pythonPath === null) throw new SnapshotSanitizerPrerequisiteError(root.canonicalPath);
const result = spawnSync(
pythonPath,
["-I", "-c", SNAPSHOT_SANITIZER_PYTHON, "apply", root.canonicalPath],
Expand Down
46 changes: 35 additions & 11 deletions src/lib/cli/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,14 @@ describe("Logger", () => {
expect(log.level).toBe("debug");
});

it.each([
"1",
"true",
"y",
"yes",
"TRUE",
])("enables debug for the supported NEMOCLAW_DEBUG value %s", async (value) => {
vi.stubEnv("NEMOCLAW_DEBUG", value);
const { log } = await freshLogger();
expect(log.level).toBe("debug");
});
it.each(["1", "true", "y", "yes", "TRUE"])(
"enables debug for the supported NEMOCLAW_DEBUG value %s",
async (value) => {
vi.stubEnv("NEMOCLAW_DEBUG", value);
const { log } = await freshLogger();
expect(log.level).toBe("debug");
},
);

it("does not treat the framework DEBUG variable as a NemoClaw logging control", async () => {
vi.stubEnv("DEBUG", "*");
Expand Down Expand Up @@ -224,6 +221,33 @@ describe("Logger", () => {
expect(output()).toContain('"name": "Error"');
});

it("serializes nested AggregateError entries without exposing credentials", async () => {
const apiKey = `nvapi-${"a".repeat(40)}`;
const authorizationToken = "opaque-aggregate-authorization-token";
const urlToken = "opaque-aggregate-url-token";
const { log } = await freshLogger();
log.setDebug(true);
const sanitizerError = new Error(`sanitizer failed with ${apiKey}`);
sanitizerError.stack = `Error: sanitizer failed\nAuthorization: Bearer ${authorizationToken}`;
Object.assign(sanitizerError, {
endpoint: `https://example.test/path?access_token=${urlToken}`,
});
const cause = new AggregateError(
[sanitizerError, new Error("cleanup failed")],
"both failed",
);

log.debugObject("context", new Error("outer failure", { cause }));

expect(output()).toContain('"errors"');
expect(output()).toContain("sanitizer failed");
expect(output()).toContain("cleanup failed");
expect(output()).not.toContain(apiKey);
expect(output()).not.toContain(authorizationToken);
expect(output()).not.toContain(urlToken);
expect(output()).toContain("<REDACTED>");
});

it("does not let a synchronous stderr failure escape", async () => {
const { log } = await freshLogger();
stderrSpy.mockImplementation(() => {
Expand Down
3 changes: 3 additions & 0 deletions src/lib/cli/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ function normalizeForSerialization(value: unknown, seen = new WeakSet<object>())
if (value.cause !== undefined) {
normalized.cause = normalizeForSerialization(value.cause, seen);
}
if (value instanceof AggregateError) {
normalized.errors = normalizeForSerialization(value.errors, seen);
}
for (const [key, entry] of Object.entries(value)) {
normalized[key] = normalizeForSerialization(entry, seen);
}
Expand Down
3 changes: 3 additions & 0 deletions src/lib/security/snapshot-sanitizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import {
valueLooksLikeSecret,
} from "./credential-filter";

/** Re-exported so CLI callers identify the prerequisite failure without importing the plugin boundary module. (#8202) */
export { SnapshotSanitizerPrerequisiteError } from "../../../nemoclaw/dist/shared/snapshot-sanitizer-boundary.cjs";

const MAX_SANITIZATION_PASSES = 3;

const VENDORED_DEPENDENCY_DIRECTORY = "node_modules";
Expand Down
118 changes: 118 additions & 0 deletions src/lib/state/sandbox-backup-sanitization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
statSync,
writeFileSync,
Expand All @@ -18,6 +19,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
resolveTrustedSnapshotSanitizerPythonPath,
setSnapshotSanitizerPythonPathForTest,
SnapshotSanitizerPrerequisiteError,
} from "../../../nemoclaw/dist/shared/snapshot-sanitizer-boundary.cjs";
import { sanitizeBackupDirectory } from "./sandbox.js";

Expand Down Expand Up @@ -310,6 +312,122 @@ describe("rebuild backup credential sanitization", () => {
expect(existsSync(backupPath)).toBe(false);
});

it("permits a rerun after removing a snapshot that the sanitizer could not inspect (#8202)", () => {
const backupPath = createBackup();
writeFileSync(join(backupPath, "state", "config.json"), '{"apiKey":"sk-secret-value"}');
setSnapshotSanitizerPythonPathForTest(null);

expect(() => sanitizeBackupDirectory(backupPath)).toThrow(
"python3 is required for snapshot sanitization; install python3 and rerun. Credential sanitization failed; removed the incomplete backup",
);
expect(existsSync(backupPath)).toBe(false);
});

it("keeps a helper failure distinct from a missing interpreter (#8202)", () => {
const backupPath = createBackup();
writeFileSync(join(backupPath, "state", "config.json"), '{"apiKey":"sk-secret-value"}');
const wrapperRoot = mkdtempSync(join(tmpdir(), "nemoclaw-failing-python-"));
testDirectories.push(wrapperRoot);
const pythonWrapper = join(wrapperRoot, "python3");
writeFileSync(pythonWrapper, "#!/bin/sh\nexit 1\n");
chmodSync(pythonWrapper, 0o755);
setSnapshotSanitizerPythonPathForTest(pythonWrapper);

expect(() => sanitizeBackupDirectory(backupPath)).toThrow(
"Credential sanitization failed; removed the incomplete backup",
);
expect(existsSync(backupPath)).toBe(false);
});

it("reports the validated directory when backup cleanup throws (#8202)", () => {
const backupPath = createBackup();
const validatedPath = realpathSync(backupPath);
writeFileSync(join(backupPath, "state", "config.json"), '{"apiKey":"sk-secret-value"}');
setSnapshotSanitizerPythonPathForTest(null);

const cleanupError = new Error("injected cleanup failure");
let received: unknown;
try {
sanitizeBackupDirectory(backupPath, {
removeBackup: () => {
throw cleanupError;
},
});
} catch (error) {
received = error;
}

expect(received).toBeInstanceOf(Error);
expect((received as Error).message).toBe(
`python3 is required for snapshot sanitization; install python3 and rerun. Credential sanitization failed and backup cleanup failed; the incomplete backup may remain at ${validatedPath}`,
);
expect((received as Error).cause).toBeInstanceOf(AggregateError);
expect(((received as Error).cause as AggregateError).errors).toEqual([
expect.any(SnapshotSanitizerPrerequisiteError),
cleanupError,
]);
expect(existsSync(backupPath)).toBe(true);
});

it("preserves a generic sanitization error when backup cleanup also fails (#8202)", () => {
const backupPath = createBackup();
const sanitizeError = new Error("injected sanitization failure");
const cleanupError = new Error("injected cleanup failure");
let received: unknown;

try {
sanitizeBackupDirectory(backupPath, {
sanitizeDirectory: () => {
throw sanitizeError;
},
removeBackup: () => {
throw cleanupError;
},
});
} catch (error) {
received = error;
}

expect(received).toBeInstanceOf(Error);
expect((received as Error).message).toBe(
"Credential sanitization failed and backup cleanup failed",
);
expect((received as Error).cause).toBeInstanceOf(AggregateError);
expect(((received as Error).cause as AggregateError).errors).toEqual([
sanitizeError,
cleanupError,
]);
});

it("reports only the validated directory when failed cleanup retains a snapshot (#8202)", () => {
const backupPath = createBackup();
const validatedPath = realpathSync(backupPath);
const unvalidatedPath = `${backupPath}/.`;
writeFileSync(join(backupPath, "state", "config.json"), '{"apiKey":"sk-secret-value"}');
setSnapshotSanitizerPythonPathForTest(null);
const removeBackup = vi.fn();
const backupExists = vi.fn(() => true);

let thrown: unknown;
try {
sanitizeBackupDirectory(unvalidatedPath, {
removeBackup,
backupExists,
});
} catch (error) {
thrown = error;
}

expect(thrown).toBeInstanceOf(Error);
expect((thrown as Error).message).toBe(
`python3 is required for snapshot sanitization; install python3 and rerun. Credential sanitization failed and the incomplete backup remains at ${validatedPath}`,
);
expect((thrown as Error).message).not.toContain(unvalidatedPath);
expect(removeBackup).toHaveBeenCalledWith(unvalidatedPath);
expect(backupExists).toHaveBeenCalledWith(unvalidatedPath);
expect(existsSync(backupPath)).toBe(true);
});

it("reports when cleanup leaves an incomplete backup behind", () => {
const backupPath = createBackup();
const yamlPath = join(backupPath, "state", "config.yaml");
Expand Down
Loading
Loading