From 175761d20327d495599faa1abcc3ec9dfd79d7ec Mon Sep 17 00:00:00 2001 From: NianJiu <3235467914@qq.com> Date: Thu, 16 Jul 2026 16:11:33 +0800 Subject: [PATCH] fix(sandbox): handle missing stat paths across locales (#105263) * fix(sandbox): stabilize stat errors across locales * fix(sandbox): normalize remote stat locale --------- Co-authored-by: Peter Steinberger --- .../sandbox/fs-bridge-shell-command-plans.ts | 2 +- .../sandbox/fs-bridge.anchored-ops.test.ts | 71 +++++++++++++++++++ src/agents/sandbox/remote-fs-bridge.test.ts | 7 +- src/agents/sandbox/remote-fs-bridge.ts | 2 +- 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/agents/sandbox/fs-bridge-shell-command-plans.ts b/src/agents/sandbox/fs-bridge-shell-command-plans.ts index 07a0f0dc02e0..5ced3e5c1167 100644 --- a/src/agents/sandbox/fs-bridge-shell-command-plans.ts +++ b/src/agents/sandbox/fs-bridge-shell-command-plans.ts @@ -22,7 +22,7 @@ export function buildStatPlan( ): SandboxFsCommandPlan { return { checks: [{ target, options: { action: "stat files" } }], - script: 'set -eu\ncd -- "$1"\nstat -c "%F|%s|%y" -- "$2"', + script: 'set -eu\ncd -- "$1"\nLC_ALL=C stat -c "%F|%s|%y" -- "$2"', args: [anchoredTarget.canonicalParentPath, anchoredTarget.basename], allowFailure: true, }; diff --git a/src/agents/sandbox/fs-bridge.anchored-ops.test.ts b/src/agents/sandbox/fs-bridge.anchored-ops.test.ts index 9d97fb114aa5..eb5a91ed011a 100644 --- a/src/agents/sandbox/fs-bridge.anchored-ops.test.ts +++ b/src/agents/sandbox/fs-bridge.anchored-ops.test.ts @@ -223,6 +223,77 @@ describe("sandbox fs bridge anchored ops", () => { }); }); + it("runs stat under the C locale so missing-file errors return null", async () => { + await withTempDir("openclaw-fs-bridge-stat-missing-", async (stateDir) => { + const workspaceDir = path.join(stateDir, "workspace"); + await fs.mkdir(workspaceDir, { recursive: true }); + + mockedExecDockerRaw.mockImplementation(async (args) => { + const script = getDockerScript(args); + if (script.includes('readlink -f -- "$cursor"')) { + return dockerExecResult(`${getDockerArg(args, 1)}\n`); + } + if (script.includes('stat -c "%F|%s|%y"')) { + const stderr = script.includes('LC_ALL=C stat -c "%F|%s|%y"') + ? "stat: cannot stat 'note.txt': No such file or directory\n" + : "stat: der Aufruf von statx für 'note.txt' ist nicht möglich: Datei oder Verzeichnis nicht gefunden\n"; + return { + stdout: Buffer.alloc(0), + stderr: Buffer.from(stderr), + code: 1, + }; + } + return dockerExecResult(""); + }); + + const bridge = createSandboxFsBridge({ + sandbox: createSandbox({ + workspaceDir, + agentWorkspaceDir: workspaceDir, + }), + }); + + await expect(bridge.stat({ filePath: "note.txt" })).resolves.toBeNull(); + + const statCall = requireDockerCall( + findCallByScriptFragment('stat -c "%F|%s|%y" -- "$2"'), + "stat", + ); + expect(getDockerScript(statCall[0])).toContain('LC_ALL=C stat -c "%F|%s|%y" -- "$2"'); + }); + }); + + it("keeps non-missing stat failures as errors", async () => { + await withTempDir("openclaw-fs-bridge-stat-error-", async (stateDir) => { + const workspaceDir = path.join(stateDir, "workspace"); + await fs.mkdir(workspaceDir, { recursive: true }); + + mockedExecDockerRaw.mockImplementation(async (args) => { + const script = getDockerScript(args); + if (script.includes('readlink -f -- "$cursor"')) { + return dockerExecResult(`${getDockerArg(args, 1)}\n`); + } + if (script.includes('stat -c "%F|%s|%y"')) { + return { + stdout: Buffer.alloc(0), + stderr: Buffer.from("stat: cannot stat 'note.txt': Permission denied\n"), + code: 1, + }; + } + return dockerExecResult(""); + }); + + const bridge = createSandboxFsBridge({ + sandbox: createSandbox({ + workspaceDir, + agentWorkspaceDir: workspaceDir, + }), + }); + + await expect(bridge.stat({ filePath: "note.txt" })).rejects.toThrow("Permission denied"); + }); + }); + it("saturates unsafe stat size output", async () => { await withTempDir("openclaw-fs-bridge-stat-parse-", async (stateDir) => { const workspaceDir = path.join(stateDir, "workspace"); diff --git a/src/agents/sandbox/remote-fs-bridge.test.ts b/src/agents/sandbox/remote-fs-bridge.test.ts index dff37b573144..18c7784da951 100644 --- a/src/agents/sandbox/remote-fs-bridge.test.ts +++ b/src/agents/sandbox/remote-fs-bridge.test.ts @@ -188,7 +188,7 @@ describe("remote sandbox fs bridge", () => { }, ); - it("saturates unsafe stat size output without returning NaN", async () => { + it("normalizes stat output locale and saturates unsafe sizes", async () => { // Remote stat output is untrusted shell text; unsafe numeric fields should // clamp to deterministic values instead of leaking NaN into callers. await withTempDir("openclaw-remote-fs-bridge-stat-", async (stateDir) => { @@ -216,8 +216,11 @@ describe("remote sandbox fs bridge", () => { }; } if (command.script.includes('stat -c "%F|%s|%y"')) { + const kind = command.script.includes('LC_ALL=C stat -c "%F|%s|%y"') + ? "regular file" + : "reguläre Datei"; return { - stdout: Buffer.from("regular file|9007199254740992|8640000000001\n"), + stdout: Buffer.from(`${kind}|9007199254740992|8640000000001\n`), stderr: Buffer.alloc(0), code: 0, }; diff --git a/src/agents/sandbox/remote-fs-bridge.ts b/src/agents/sandbox/remote-fs-bridge.ts index 7edc2c8d526d..7c9204235e1b 100644 --- a/src/agents/sandbox/remote-fs-bridge.ts +++ b/src/agents/sandbox/remote-fs-bridge.ts @@ -255,7 +255,7 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge { signal: params.signal, }); const result = await this.runRemoteScript({ - script: 'set -eu\nstat -c "%F|%s|%y" -- "$1"', + script: 'set -eu\nLC_ALL=C stat -c "%F|%s|%y" -- "$1"', args: [canonical], signal: params.signal, });