Skip to content

Commit 66c6545

Browse files
committed
fix(snapshot): diagnose privileged config capture
Signed-off-by: Ho Lim <subhoya@gmail.com>
1 parent 535b00b commit 66c6545

4 files changed

Lines changed: 176 additions & 16 deletions

File tree

src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@ describe("rebuildSandbox flow: lifecycle", () => {
6363
).resolves.toBeUndefined();
6464

6565
expect(harness.backupSandboxStateSpy).toHaveBeenCalledOnce();
66-
expect(harness.backupSandboxStateSpy).toHaveBeenCalledWith("alpha");
66+
expect(harness.backupSandboxStateSpy).toHaveBeenCalledWith(
67+
"alpha",
68+
expect.objectContaining({ captureStateFile: expect.any(Function) }),
69+
);
6770
expect(harness.prepareMcpBridgesForRebuildSpy).toHaveBeenCalledWith("alpha");
6871
expect(harness.prepareMcpBridgesForRebuildSpy.mock.invocationCallOrder[0]).toBeLessThan(
6972
harness.warnUnpreservedUserManagedFilesSpy.mock.invocationCallOrder[0],

src/lib/actions/sandbox/snapshot/backup-authority.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,93 @@ describe("managed snapshot backup authority", () => {
229229
);
230230
});
231231

232+
it("recognizes only the fixed missing-file failure protocol", () => {
233+
privilegedCaptureMocks.dockerSpawnSync.mockReturnValue({
234+
status: 2,
235+
signal: null,
236+
error: undefined,
237+
stdout: Buffer.alloc(0),
238+
stderr: Buffer.from("nemoclaw-openclaw-config-capture:missing\n"),
239+
} as never);
240+
241+
const result = captureOpenClawStateFile("alpha", {
242+
sandboxName: "alpha",
243+
dir: "/sandbox/.openclaw",
244+
spec: { path: "openclaw.json", strategy: "copy" },
245+
});
246+
247+
expect(result).toEqual({ outcome: "missing" });
248+
});
249+
250+
it("fails closed with a fixed privileged safety reason", () => {
251+
privilegedCaptureMocks.dockerSpawnSync.mockReturnValue({
252+
status: 11,
253+
signal: null,
254+
error: undefined,
255+
stdout: Buffer.alloc(0),
256+
stderr: Buffer.from("nemoclaw-openclaw-config-capture:unsafe-file-metadata\n"),
257+
} as never);
258+
259+
const result = captureOpenClawStateFile("alpha", {
260+
sandboxName: "alpha",
261+
dir: "/sandbox/.openclaw",
262+
spec: { path: "openclaw.json", strategy: "copy" },
263+
});
264+
265+
expect(result).toEqual({
266+
outcome: "failed",
267+
error: "privileged config capture failed: exit 11; reason unsafe-file-metadata",
268+
});
269+
});
270+
271+
it("bounds and redacts untrusted privileged stderr", () => {
272+
privilegedCaptureMocks.dockerSpawnSync.mockReturnValue({
273+
status: 10,
274+
signal: null,
275+
error: undefined,
276+
stdout: Buffer.alloc(0),
277+
stderr: Buffer.from(`permission denied apiKey=secret-value\u0000${"x".repeat(2048)}`),
278+
} as never);
279+
280+
const result = captureOpenClawStateFile("alpha", {
281+
sandboxName: "alpha",
282+
dir: "/sandbox/.openclaw",
283+
spec: { path: "openclaw.json", strategy: "copy" },
284+
});
285+
286+
expect(result).toMatchObject({ outcome: "failed" });
287+
const failedResult = result as Extract<
288+
NonNullable<typeof result>,
289+
{ outcome: "failed" }
290+
>;
291+
const error = failedResult.error ?? "";
292+
expect(error).toContain("permission denied apiKey=<REDACTED>");
293+
expect(error).not.toContain("secret-value");
294+
expect(error).not.toContain("\u0000");
295+
expect(error.length).toBeLessThan(320);
296+
});
297+
298+
it("does not confuse an unrecognized exit 2 with a missing config", () => {
299+
privilegedCaptureMocks.dockerSpawnSync.mockReturnValue({
300+
status: 2,
301+
signal: null,
302+
error: undefined,
303+
stdout: Buffer.alloc(0),
304+
stderr: Buffer.from("docker exec usage error"),
305+
} as never);
306+
307+
const result = captureOpenClawStateFile("alpha", {
308+
sandboxName: "alpha",
309+
dir: "/sandbox/.openclaw",
310+
spec: { path: "openclaw.json", strategy: "copy" },
311+
});
312+
313+
expect(result).toEqual({
314+
outcome: "failed",
315+
error: "privileged config capture failed: exit 2; docker exec usage error",
316+
});
317+
});
318+
232319
it("does not grant privileged capture to undeclared paths or strategies", () => {
233320
const requests: StateFileCaptureRequest[] = [
234321
{

src/lib/actions/sandbox/snapshot/backup-authority.ts

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
privilegedSandboxExecArgv,
1818
withPrivilegedSandboxExecutionLease,
1919
} from "../../../sandbox/privileged-exec";
20+
import { sanitizeReadinessText } from "../../../readiness/sanitize";
2021
import { readManagedSnapshotProfileAuthority } from "./managed-profile";
2122
import { captureSandboxRuntimeSnapshot } from "./provider-lifecycle";
2223

@@ -42,27 +43,36 @@ interface SnapshotBackupAuthorityDependencies {
4243
const MAX_OPENCLAW_CONFIG_BYTES = 16 * 1024 * 1024;
4344
const OPENCLAW_CONFIG_CAPTURE_MAX_BUFFER = MAX_OPENCLAW_CONFIG_BYTES + 1024 * 1024;
4445
const OPENCLAW_CONFIG_CAPTURE_TIMEOUT_MS = 30_000;
46+
const OPENCLAW_CONFIG_CAPTURE_PROTOCOL_PREFIX = "nemoclaw-openclaw-config-capture:";
47+
const OPENCLAW_CONFIG_CAPTURE_PROTOCOL_MAX_BYTES = 128;
48+
const OPENCLAW_CONFIG_CAPTURE_DIAGNOSTIC_MAX_BYTES = 1024;
4549
const OPENCLAW_CONFIG_CAPTURE_SCRIPT = `import os, stat, sys
4650
maximum = ${MAX_OPENCLAW_CONFIG_BYTES}
4751
directory = "/sandbox/.openclaw"
4852
name = "openclaw.json"
53+
protocol = "${OPENCLAW_CONFIG_CAPTURE_PROTOCOL_PREFIX}"
54+
def fail(status, reason):
55+
print(protocol + reason, file=sys.stderr)
56+
raise SystemExit(status)
4957
directory_flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0)
5058
file_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
5159
try:
5260
directory_fd = os.open(directory, directory_flags)
5361
except OSError:
54-
raise SystemExit(10)
62+
fail(10, "directory-unavailable")
5563
try:
5664
try:
5765
file_fd = os.open(name, file_flags, dir_fd=directory_fd)
5866
except FileNotFoundError:
59-
raise SystemExit(2)
67+
fail(2, "missing")
6068
except OSError:
61-
raise SystemExit(10)
69+
fail(10, "file-unavailable")
6270
try:
6371
before = os.fstat(file_fd)
64-
if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1 or before.st_size > maximum:
65-
raise SystemExit(11)
72+
if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1:
73+
fail(11, "unsafe-file-metadata")
74+
if before.st_size > maximum:
75+
fail(12, "size-limit-exceeded")
6676
chunks = []
6777
total = 0
6878
while True:
@@ -72,19 +82,70 @@ try:
7282
chunks.append(chunk)
7383
total += len(chunk)
7484
if total > maximum:
75-
raise SystemExit(12)
85+
fail(12, "size-limit-exceeded")
7686
after = os.fstat(file_fd)
7787
current = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
7888
identity = lambda value: (value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns, value.st_nlink)
7989
if identity(before) != identity(after) or identity(before) != identity(current) or not stat.S_ISREG(current.st_mode):
80-
raise SystemExit(13)
90+
fail(13, "file-changed-during-read")
8191
sys.stdout.buffer.write(b"".join(chunks))
8292
finally:
8393
os.close(file_fd)
8494
finally:
8595
os.close(directory_fd)
8696
`;
8797

98+
type OpenClawConfigCaptureFailure =
99+
| "missing"
100+
| "directory-unavailable"
101+
| "file-unavailable"
102+
| "unsafe-file-metadata"
103+
| "size-limit-exceeded"
104+
| "file-changed-during-read";
105+
106+
function captureFailureProtocol(stderr: unknown): OpenClawConfigCaptureFailure | null {
107+
if (
108+
(Buffer.isBuffer(stderr) && stderr.length > OPENCLAW_CONFIG_CAPTURE_PROTOCOL_MAX_BYTES) ||
109+
(typeof stderr === "string" &&
110+
Buffer.byteLength(stderr) > OPENCLAW_CONFIG_CAPTURE_PROTOCOL_MAX_BYTES)
111+
) {
112+
return null;
113+
}
114+
const value = Buffer.isBuffer(stderr)
115+
? stderr.toString("utf8")
116+
: typeof stderr === "string"
117+
? stderr
118+
: "";
119+
const line = value.endsWith("\n") ? value.slice(0, -1) : value;
120+
if (!line.startsWith(OPENCLAW_CONFIG_CAPTURE_PROTOCOL_PREFIX) || /[\r\n]/.test(line)) {
121+
return null;
122+
}
123+
const reason = line.slice(OPENCLAW_CONFIG_CAPTURE_PROTOCOL_PREFIX.length);
124+
switch (reason) {
125+
case "missing":
126+
case "directory-unavailable":
127+
case "file-unavailable":
128+
case "unsafe-file-metadata":
129+
case "size-limit-exceeded":
130+
case "file-changed-during-read":
131+
return reason;
132+
default:
133+
return null;
134+
}
135+
}
136+
137+
function captureFailureDiagnostic(stderr: unknown): string | null {
138+
const value = Buffer.isBuffer(stderr)
139+
? stderr.subarray(0, OPENCLAW_CONFIG_CAPTURE_DIAGNOSTIC_MAX_BYTES).toString("utf8")
140+
: typeof stderr === "string"
141+
? Buffer.from(stderr)
142+
.subarray(0, OPENCLAW_CONFIG_CAPTURE_DIAGNOSTIC_MAX_BYTES)
143+
.toString("utf8")
144+
: "";
145+
const sanitized = sanitizeReadinessText(value, 240).replace(/\s+/g, " ").trim();
146+
return sanitized || null;
147+
}
148+
88149
export function captureOpenClawStateFile(
89150
sandboxName: string,
90151
request: sandboxState.StateFileCaptureRequest,
@@ -113,7 +174,13 @@ export function captureOpenClawStateFile(
113174
timeout: OPENCLAW_CONFIG_CAPTURE_TIMEOUT_MS,
114175
maxBuffer: OPENCLAW_CONFIG_CAPTURE_MAX_BUFFER,
115176
});
116-
if (result.status === 2 && result.signal === null && !result.error) {
177+
const protocolFailure = captureFailureProtocol(result.stderr);
178+
if (
179+
result.status === 2 &&
180+
result.signal === null &&
181+
!result.error &&
182+
protocolFailure === "missing"
183+
) {
117184
return { outcome: "missing" };
118185
}
119186
if (
@@ -122,9 +189,13 @@ export function captureOpenClawStateFile(
122189
result.error ||
123190
!Buffer.isBuffer(result.stdout)
124191
) {
125-
const detail =
192+
const primaryDetail =
126193
result.error?.message ??
127194
(result.signal ? `signal ${result.signal}` : `exit ${String(result.status)}`);
195+
const stderrDetail = protocolFailure
196+
? `reason ${protocolFailure}`
197+
: captureFailureDiagnostic(result.stderr);
198+
const detail = stderrDetail ? `${primaryDetail}; ${stderrDetail}` : primaryDetail;
128199
return { outcome: "failed", error: `privileged config capture failed: ${detail}` };
129200
}
130201
return { outcome: "backed_up", data: result.stdout };

test/openclaw-config-snapshot.test.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ function writeFakeSandboxBins(
4040
fakeRoot: string,
4141
options: { denyConfigSshRead?: boolean } = {},
4242
): void {
43+
const configReadDenial = options.denyConfigSshRead === true ? "process.exit(1);" : "";
4344
writeExecutable(
4445
path.join(binDir, "openshell"),
4546
`#!/bin/sh
@@ -75,7 +76,7 @@ function readStdin() {
7576
}
7677
if (cmd.includes("[ -d ")) { process.exit(0); }
7778
if (cmd.includes("openclaw.json") && cmd.includes("cat --")) {
78-
if (${JSON.stringify(options.denyConfigSshRead === true)}) process.exit(1);
79+
${configReadDenial}
7980
process.stdout.write(fs.readFileSync(path.join(dir, "openclaw.json")));
8081
process.exit(0);
8182
}
@@ -156,11 +157,9 @@ describe("OpenClaw durable config file (#5027)", () => {
156157
expect(stored.models.default).toBe("nvidia/test");
157158
expect(stored.apiKey).toBe("[STRIPPED_BY_MIGRATION]");
158159
} finally {
159-
if (oldOpenshell === undefined) {
160-
delete process.env.NEMOCLAW_OPENSHELL_BIN;
161-
} else {
162-
process.env.NEMOCLAW_OPENSHELL_BIN = oldOpenshell;
163-
}
160+
void (oldOpenshell === undefined
161+
? Reflect.deleteProperty(process.env, "NEMOCLAW_OPENSHELL_BIN")
162+
: Reflect.set(process.env, "NEMOCLAW_OPENSHELL_BIN", oldOpenshell));
164163
process.env.PATH = oldPath;
165164
fs.rmSync(fixture, { recursive: true, force: true });
166165
}

0 commit comments

Comments
 (0)