Skip to content

Commit cc87d32

Browse files
committed
fix(snapshot): name the python3 prerequisite when sanitization cannot run
Snapshot sanitization runs through a trusted python3 helper. When no interpreter resolved, the helper reported that the same way it reports a helper that ran and failed, so `snapshot create` printed "Credential sanitization failed; removed the incomplete backup" and never named the prerequisite. The boundary that owns interpreter resolution now raises SnapshotSanitizerPrerequisiteError, both sanitizers raise through it, and the backup wrapper keeps that sentence in the message it rethrows. Refs: #8202 Signed-off-by: harjoth <harjoth.khara@gmail.com>
1 parent 9d94ca3 commit cc87d32

7 files changed

Lines changed: 82 additions & 10 deletions

File tree

docs/reference/troubleshooting.mdx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1631,6 +1631,22 @@ $$nemoclaw <name> snapshot create
16311631
$$nemoclaw <name> rebuild
16321632
```
16331633

1634+
### Snapshot creation reports that `python3` is required
1635+
1636+
Commands that write a host-side snapshot stop with this message when no verified interpreter resolves:
1637+
1638+
```text
1639+
python3 is required for snapshot sanitization; install python3 and retry
1640+
```
1641+
1642+
The message covers `$$nemoclaw <name> snapshot create`, `$$nemoclaw <name> rebuild`, and the automatic backups that NemoClaw takes before maintenance and before it backs up a stopped sandbox.
1643+
NemoClaw removes credentials from copied state with an isolated `python3` helper, and it fails closed when that helper cannot run.
1644+
1645+
A host that already has `python3` can still report this message.
1646+
NemoClaw does not resolve this credential-bearing helper through `PATH`, so an interpreter from a version manager or a virtual environment does not qualify.
1647+
Install `python3` at one of the accepted locations in [Prerequisites](../get-started/prerequisites).
1648+
Then run the command again.
1649+
16341650
### Sandbox shows as stopped
16351651

16361652
When status reports `sandbox_container_stopped`, Docker still has a container for the sandbox, but the container is not running.

nemoclaw/src/security/snapshot-sanitizer-failure.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,16 @@ describe("migration snapshot sanitizer fallbacks", () => {
9595
expect(readFileSync(configPath, "utf-8")).toBe(original);
9696
});
9797

98+
it("names the python3 prerequisite when the migration scan finds no interpreter (#8202)", () => {
99+
const root = makeRoot();
100+
writeFileSync(path.join(root, "openclaw.json"), JSON.stringify({ apiKey: "sk-secret-value" }));
101+
setSnapshotSanitizerPythonPathForTest(null);
102+
103+
expect(() => sanitizeMigrationDirectory(root)).toThrow(
104+
"python3 is required for snapshot sanitization; install python3 and retry",
105+
);
106+
});
107+
98108
it("fails closed when the descriptor apply helper is unavailable", () => {
99109
const root = { canonicalPath: makeRoot(), identity };
100110
setSnapshotSanitizerPythonPathForTest(null);

nemoclaw/src/security/snapshot-sanitizer.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
type SnapshotSanitizationAction,
1414
type SnapshotScannedFile,
1515
scanDescriptorSnapshot,
16+
snapshotSanitizerFailure,
1617
} from "../shared/snapshot-sanitizer-boundary.cjs";
1718
import {
1819
CREDENTIAL_PLACEHOLDER,
@@ -96,14 +97,14 @@ export function sanitizeMigrationDirectory(rootPath: string): void {
9697
}
9798
const scan = scanDescriptorSnapshot(root, CREDENTIAL_SENSITIVE_BASENAMES);
9899
if (scan === null) {
99-
throw new Error(`Failed to inspect migration artifacts safely: ${rootPath}`);
100+
throw snapshotSanitizerFailure(`Failed to inspect migration artifacts safely: ${rootPath}`);
100101
}
101102
const actions = scan.files
102103
.map((file) => actionForScannedFile(file))
103104
.filter((action): action is SnapshotSanitizationAction => action !== null);
104105
if (actions.length === 0) return;
105106
if (!applyDescriptorSnapshotActions(root, scan, actions)) {
106-
throw new Error(`Failed to sanitize migration artifacts safely: ${rootPath}`);
107+
throw snapshotSanitizerFailure(`Failed to sanitize migration artifacts safely: ${rootPath}`);
107108
}
108109
}
109110
throw new Error(`Migration artifacts did not reach a stable sanitized state: ${rootPath}`);

nemoclaw/src/shared/snapshot-sanitizer-boundary.cts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,35 @@ export function setSnapshotSanitizerPythonPathForTest(
6767
snapshotSanitizerPythonPathForTest = pythonPath;
6868
}
6969

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

78+
/** No trusted python3 interpreter resolved, so no descriptor-relative helper can run. (#8202) */
79+
export class SnapshotSanitizerPrerequisiteError extends Error {
80+
constructor() {
81+
super("python3 is required for snapshot sanitization; install python3 and retry");
82+
this.name = "SnapshotSanitizerPrerequisiteError";
83+
}
84+
}
85+
86+
/**
87+
* Return the prerequisite error when no interpreter resolved.
88+
*
89+
* Every helper reports an unresolved interpreter and a helper that ran and
90+
* failed the same way, so this separates them before the caller reports a
91+
* generic failure.
92+
*/
93+
export function snapshotSanitizerFailure(message: string): Error {
94+
return snapshotSanitizerPythonPath() === null
95+
? new SnapshotSanitizerPrerequisiteError()
96+
: new Error(message);
97+
}
98+
7799
export interface SnapshotFileIdentity {
78100
readonly dev: string;
79101
readonly ino: string;

src/lib/security/snapshot-sanitizer.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type SnapshotSanitizationAction,
1212
type SnapshotScannedFile,
1313
scanDescriptorSnapshot,
14+
snapshotSanitizerFailure,
1415
} from "../../../nemoclaw/dist/shared/snapshot-sanitizer-boundary.cjs";
1516

1617
import {
@@ -23,6 +24,9 @@ import {
2324
valueLooksLikeSecret,
2425
} from "./credential-filter";
2526

27+
/** Re-exported so CLI callers identify the prerequisite failure without importing the plugin boundary module. (#8202) */
28+
export { SnapshotSanitizerPrerequisiteError } from "../../../nemoclaw/dist/shared/snapshot-sanitizer-boundary.cjs";
29+
2630
const MAX_SANITIZATION_PASSES = 3;
2731

2832
const VENDORED_DEPENDENCY_DIRECTORY = "node_modules";
@@ -186,14 +190,14 @@ export function sanitizeSnapshotDirectory(rootPath: string): void {
186190

187191
const scan = scanDescriptorSnapshot(root, CREDENTIAL_SENSITIVE_BASENAMES);
188192
if (scan === null) {
189-
throw new Error(`Failed to inspect snapshot artifacts safely: ${rootPath}`);
193+
throw snapshotSanitizerFailure(`Failed to inspect snapshot artifacts safely: ${rootPath}`);
190194
}
191195
const actions = scan.files
192196
.map((file) => actionForScannedFile(file))
193197
.filter((action): action is SnapshotSanitizationAction => action !== null);
194198
if (actions.length === 0) return;
195199
if (!applyDescriptorSnapshotActions(root, scan, actions)) {
196-
throw new Error(`Failed to sanitize snapshot artifacts safely: ${rootPath}`);
200+
throw snapshotSanitizerFailure(`Failed to sanitize snapshot artifacts safely: ${rootPath}`);
197201
}
198202
}
199203
throw new Error(`Snapshot artifacts did not reach a stable sanitized state: ${rootPath}`);

src/lib/state/sandbox-backup-sanitization.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,17 @@ describe("rebuild backup credential sanitization", () => {
310310
expect(existsSync(backupPath)).toBe(false);
311311
});
312312

313+
it("names the python3 prerequisite when the backup sanitizer finds no interpreter (#8202)", () => {
314+
const backupPath = createBackup();
315+
writeFileSync(join(backupPath, "state", "config.json"), '{"apiKey":"sk-secret-value"}');
316+
setSnapshotSanitizerPythonPathForTest(null);
317+
318+
expect(() => sanitizeBackupDirectory(backupPath)).toThrow(
319+
"python3 is required for snapshot sanitization; install python3 and retry",
320+
);
321+
expect(existsSync(backupPath)).toBe(false);
322+
});
323+
313324
it("reports when cleanup leaves an incomplete backup behind", () => {
314325
const backupPath = createBackup();
315326
const yamlPath = join(backupPath, "state", "config.yaml");

src/lib/state/sandbox.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ import {
5050
} from "../domain/backup-failure.js";
5151
import { shellQuote } from "../runner.js";
5252
import { createTempSshConfig } from "../sandbox/temp-ssh-config.js";
53-
import { sanitizeSnapshotDirectory } from "../security/snapshot-sanitizer.js";
53+
import {
54+
SnapshotSanitizerPrerequisiteError,
55+
sanitizeSnapshotDirectory,
56+
} from "../security/snapshot-sanitizer.js";
5457
import {
5558
buildRestoreCleanupCommand,
5659
buildRestoreTarArgs,
@@ -752,19 +755,24 @@ export function sanitizeBackupDirectory(
752755
try {
753756
operations.sanitizeDirectory(dirPath);
754757
} catch (error) {
758+
// sanitizeBackupDirectory replaces the message, so an unmet prerequisite
759+
// would otherwise survive only as `cause` and never reach the operator. (#8202)
760+
const prerequisite =
761+
error instanceof SnapshotSanitizerPrerequisiteError ? `${error.message}. ` : "";
755762
try {
756763
operations.removeBackup(dirPath);
757764
} catch (cleanupError) {
758-
throw new Error("Credential sanitization failed and backup cleanup failed", {
765+
throw new Error(`${prerequisite}Credential sanitization failed and backup cleanup failed`, {
759766
cause: cleanupError,
760767
});
761768
}
762769
if (operations.backupExists(dirPath)) {
763-
throw new Error("Credential sanitization failed and the incomplete backup remains", {
764-
cause: error,
765-
});
770+
throw new Error(
771+
`${prerequisite}Credential sanitization failed and the incomplete backup remains`,
772+
{ cause: error },
773+
);
766774
}
767-
throw new Error("Credential sanitization failed; removed the incomplete backup", {
775+
throw new Error(`${prerequisite}Credential sanitization failed; removed the incomplete backup`, {
768776
cause: error,
769777
});
770778
}

0 commit comments

Comments
 (0)