From b86f5d5ea4a1abbe78e88556db1f4612ff26ee90 Mon Sep 17 00:00:00 2001 From: Reed <129141816+reed1898@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:10:17 +0800 Subject: [PATCH] fix(sandbox): resolve pinned fs helper python without PATH (#58573) --- .../sandbox/fs-bridge-mutation-helper.test.ts | 34 +++++++++++++++++-- .../sandbox/fs-bridge-mutation-helper.ts | 24 +++++++++++-- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/agents/sandbox/fs-bridge-mutation-helper.test.ts b/src/agents/sandbox/fs-bridge-mutation-helper.test.ts index 653a213acd7..c2a4e9d8e78 100644 --- a/src/agents/sandbox/fs-bridge-mutation-helper.test.ts +++ b/src/agents/sandbox/fs-bridge-mutation-helper.test.ts @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -6,6 +7,7 @@ import { withTempDir } from "../../test-helpers/temp-dir.js"; import { buildPinnedWritePlan, SANDBOX_PINNED_MUTATION_PYTHON, + SANDBOX_PINNED_MUTATION_PYTHON_CANDIDATES, } from "./fs-bridge-mutation-helper.js"; function runMutation(args: string[], input?: string) { @@ -16,7 +18,7 @@ function runMutation(args: string[], input?: string) { }); } -function runWritePlan(args: string[], input?: string) { +function runWritePlan(args: string[], input?: string, env?: NodeJS.ProcessEnv) { const plan = buildPinnedWritePlan({ check: { target: { @@ -38,13 +40,18 @@ function runWritePlan(args: string[], input?: string) { mkdir: args[4] === "1", }); - return spawnSync("sh", ["-c", plan.script, "openclaw-sandbox-fs", ...(plan.args ?? [])], { + return spawnSync("/bin/sh", ["-c", plan.script, "openclaw-sandbox-fs", ...(plan.args ?? [])], { input, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], + env, }); } +const hasAbsolutePythonCandidate = SANDBOX_PINNED_MUTATION_PYTHON_CANDIDATES.some((candidate) => + existsSync(candidate), +); + describe("sandbox pinned mutation helper", () => { it("writes through a pinned directory fd", async () => { await withTempDir({ prefix: "openclaw-mutation-helper-" }, async (root) => { @@ -116,6 +123,29 @@ describe("sandbox pinned mutation helper", () => { }, ); + it.runIf(process.platform !== "win32" && hasAbsolutePythonCandidate)( + "finds an absolute python when the write plan runs with an empty PATH", + async () => { + await withTempDir({ prefix: "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", + { + PATH: "", + }, + ); + + 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 02767d0c496..4de5a6fd8d6 100644 --- a/src/agents/sandbox/fs-bridge-mutation-helper.ts +++ b/src/agents/sandbox/fs-bridge-mutation-helper.ts @@ -6,6 +6,13 @@ import type { } from "./fs-bridge-path-safety.js"; import type { SandboxFsCommandPlan } from "./fs-bridge-shell-command-plans.js"; +export const SANDBOX_PINNED_MUTATION_PYTHON_CANDIDATES = [ + "/usr/bin/python3", + "/usr/local/bin/python3", + "/opt/homebrew/bin/python3", + "/bin/python3", +] as const; + export const SANDBOX_PINNED_MUTATION_PYTHON = [ "import errno", "import os", @@ -276,6 +283,8 @@ export const SANDBOX_PINNED_MUTATION_PYTHON = [ " raise RuntimeError('unknown sandbox mutation operation: ' + operation)", ].join("\n"); +const SANDBOX_PINNED_MUTATION_PYTHON_SHELL_LITERAL = `'${SANDBOX_PINNED_MUTATION_PYTHON.replaceAll("'", `'\\''`)}'`; + function buildPinnedMutationPlan(params: { args: string[]; checks: PathSafetyCheck[]; @@ -286,9 +295,18 @@ function buildPinnedMutationPlan(params: { // 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", + "python_cmd=''", + ...SANDBOX_PINNED_MUTATION_PYTHON_CANDIDATES.map( + (candidate) => + `if [ -z "$python_cmd" ] && [ -x '${candidate}' ]; then python_cmd='${candidate}'; fi`, + ), + 'if [ -z "$python_cmd" ]; then python_cmd=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true); fi', + 'if [ -z "$python_cmd" ]; then', + " echo >&2 'sandbox pinned mutation helper requires python3 or python'", + " exit 127", + "fi", + `python_script=${SANDBOX_PINNED_MUTATION_PYTHON_SHELL_LITERAL}`, + 'exec "$python_cmd" -c "$python_script" "$@"', ].join("\n"), args: params.args, };