From 8ea79b64d0303e6d93a59d7faadff3b535e1a7ed Mon Sep 17 00:00:00 2001 From: glitch Date: Thu, 12 Mar 2026 12:42:57 +0300 Subject: [PATCH] fix: preserve sandbox write payload stdin (#43876) Merged via squash. Prepared head SHA: a10fd4b21c78ec57411e6a4f387f16b1441660c2 Co-authored-by: glitch418x <189487110+glitch418x@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + .../sandbox/fs-bridge-mutation-helper.test.ts | 54 ++++++++++++++++++- .../sandbox/fs-bridge-mutation-helper.ts | 8 ++- .../sandbox/fs-bridge.anchored-ops.test.ts | 2 +- src/agents/sandbox/fs-bridge.shell.test.ts | 4 ++ 5 files changed, 66 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b871b9760..0f8fff777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Mattermost/block streaming: fix duplicate message delivery (one threaded, one top-level) when block streaming is active by excluding `replyToId` from the block reply dedup key and adding an explicit `threading` dock to the Mattermost plugin. (#41362) Thanks @mathiasnagler and @vincentkoc. - BlueBubbles/self-chat echo dedupe: drop reflected duplicate webhook copies only when a matching `fromMe` event was just seen for the same chat, body, and timestamp, preventing self-chat loops without broad webhook suppression. Related to #32166. (#38442) Thanks @vincentkoc. - Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz. +- Sandbox/write: preserve pinned mutation-helper payload stdin so sandboxed `write` no longer reports success while creating empty files. (#43876) Thanks @glitch418x. ## 2026.3.11 diff --git a/src/agents/sandbox/fs-bridge-mutation-helper.test.ts b/src/agents/sandbox/fs-bridge-mutation-helper.test.ts index f2d3974f0..57f22cc84 100644 --- a/src/agents/sandbox/fs-bridge-mutation-helper.test.ts +++ b/src/agents/sandbox/fs-bridge-mutation-helper.test.ts @@ -3,7 +3,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { SANDBOX_PINNED_MUTATION_PYTHON } from "./fs-bridge-mutation-helper.js"; +import { + buildPinnedWritePlan, + SANDBOX_PINNED_MUTATION_PYTHON, +} from "./fs-bridge-mutation-helper.js"; async function withTempRoot(prefix: string, run: (root: string) => Promise): Promise { const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); @@ -22,6 +25,35 @@ function runMutation(args: string[], input?: string) { }); } +function runWritePlan(args: string[], input?: string) { + const plan = buildPinnedWritePlan({ + check: { + target: { + hostPath: args[1] ?? "", + containerPath: args[1] ?? "", + relativePath: path.posix.join(args[2] ?? "", args[3] ?? ""), + writable: true, + }, + options: { + action: "write files", + requireWritable: true, + }, + }, + pinned: { + mountRootPath: args[1] ?? "", + relativeParentPath: args[2] ?? "", + basename: args[3] ?? "", + }, + mkdir: args[4] === "1", + }); + + return spawnSync("sh", ["-c", plan.script, "moltbot-sandbox-fs", ...(plan.args ?? [])], { + input, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }); +} + describe("sandbox pinned mutation helper", () => { it("writes through a pinned directory fd", async () => { await withTempRoot("openclaw-mutation-helper-", async (root) => { @@ -37,6 +69,26 @@ describe("sandbox pinned mutation helper", () => { }); }); + it.runIf(process.platform !== "win32")( + "preserves stdin payload bytes when the pinned write plan runs through sh", + async () => { + await withTempRoot("openclaw-mutation-helper-", async (root) => { + const workspace = path.join(root, "workspace"); + await fs.mkdir(workspace, { recursive: true }); + + const result = runWritePlan( + ["write", workspace, "nested/deeper", "note.txt", "1"], + "hello", + ); + + expect(result.status).toBe(0); + await expect( + fs.readFile(path.join(workspace, "nested", "deeper", "note.txt"), "utf8"), + ).resolves.toBe("hello"); + }); + }, + ); + it.runIf(process.platform !== "win32")( "rejects symlink-parent writes instead of materializing a temp file outside the mount", async () => { diff --git a/src/agents/sandbox/fs-bridge-mutation-helper.ts b/src/agents/sandbox/fs-bridge-mutation-helper.ts index fc50c5ab7..3c6edb2c2 100644 --- a/src/agents/sandbox/fs-bridge-mutation-helper.ts +++ b/src/agents/sandbox/fs-bridge-mutation-helper.ts @@ -257,7 +257,13 @@ function buildPinnedMutationPlan(params: { return { checks: params.checks, recheckBeforeCommand: true, - script: ["set -eu", "python3 - \"$@\" <<'PY'", SANDBOX_PINNED_MUTATION_PYTHON, "PY"].join("\n"), + // Feed the helper source over fd 3 so stdin stays available for write payload bytes. + script: [ + "set -eu", + "python3 /dev/fd/3 \"$@\" 3<<'PY'", + SANDBOX_PINNED_MUTATION_PYTHON, + "PY", + ].join("\n"), args: params.args, }; } diff --git a/src/agents/sandbox/fs-bridge.anchored-ops.test.ts b/src/agents/sandbox/fs-bridge.anchored-ops.test.ts index a2a171943..48e7e9e23 100644 --- a/src/agents/sandbox/fs-bridge.anchored-ops.test.ts +++ b/src/agents/sandbox/fs-bridge.anchored-ops.test.ts @@ -120,7 +120,7 @@ describe("sandbox fs bridge anchored ops", () => { const opCall = mockedExecDockerRaw.mock.calls.find( ([args]) => typeof args[5] === "string" && - args[5].includes("python3 - \"$@\" <<'PY'") && + args[5].includes("python3 /dev/fd/3 \"$@\" 3<<'PY'") && getDockerArg(args, 1) === testCase.expectedArgs[0], ); expect(opCall).toBeDefined(); diff --git a/src/agents/sandbox/fs-bridge.shell.test.ts b/src/agents/sandbox/fs-bridge.shell.test.ts index 24b7d9fab..1685759ad 100644 --- a/src/agents/sandbox/fs-bridge.shell.test.ts +++ b/src/agents/sandbox/fs-bridge.shell.test.ts @@ -129,6 +129,10 @@ describe("sandbox fs bridge shell compatibility", () => { await bridge.writeFile({ filePath: "b.txt", data: "hello" }); const scripts = getScriptsFromCalls(); + expect(scripts.some((script) => script.includes("python3 - \"$@\" <<'PY'"))).toBe(false); + expect(scripts.some((script) => script.includes("python3 /dev/fd/3 \"$@\" 3<<'PY'"))).toBe( + true, + ); expect(scripts.some((script) => script.includes('cat >"$1"'))).toBe(false); expect(scripts.some((script) => script.includes('cat >"$tmp"'))).toBe(false); expect(scripts.some((script) => script.includes("os.replace("))).toBe(true);