diff --git a/Dockerfile.base b/Dockerfile.base index 4d920cb614..08876f33cb 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -68,7 +68,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ jq=1.7.1-6+deb13u2 \ vim-tiny=2:9.1.1230-2 \ openssh-sftp-server=1:10.0p1-7+deb13u4 \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* \ + && ln -s /usr/bin/python3 /usr/local/bin/python # gosu for privilege separation (gateway vs sandbox user). # Install from GitHub release with checksum verification instead of diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index dcdef43c5d..6251e5f83f 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -192,12 +192,20 @@ describe("sandbox provisioning: base runtime tools", () => { const dockerfile = fs.readFileSync(DOCKERFILE_BASE, "utf-8"); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-base-apt-")); const lists = path.join(tmp, "apt-lists"); + const fakePy3 = path.join(tmp, "usr-bin", "python3"); + const fakePyLink = path.join(tmp, "usr-local-bin", "python"); fs.mkdirSync(lists); + fs.mkdirSync(path.dirname(fakePy3), { recursive: true }); + fs.mkdirSync(path.dirname(fakePyLink), { recursive: true }); + fs.writeFileSync(fakePy3, "#!/bin/sh\n", { mode: 0o755 }); const command = dockerRunCommandBetween( dockerfile, "RUN apt-get update", "# gosu for privilege separation", - ).replaceAll("/var/lib/apt/lists", lists); + ) + .replaceAll("/var/lib/apt/lists", lists) + .replaceAll("/usr/local/bin/python", fakePyLink) + .replaceAll("/usr/bin/python3", fakePy3); try { const { result, calls } = runLoggedDockerShell(command, tmp, [ @@ -213,6 +221,38 @@ describe("sandbox provisioning: base runtime tools", () => { } }); + it("symlinks bare `python` to python3 so agent tool calls don't fail with command-not-found (#1452)", () => { + const dockerfile = fs.readFileSync(DOCKERFILE_BASE, "utf-8"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-base-pysymlink-")); + const lists = path.join(tmp, "apt-lists"); + const fakePy3 = path.join(tmp, "usr-bin", "python3"); + const fakePyLink = path.join(tmp, "usr-local-bin", "python"); + fs.mkdirSync(lists, { recursive: true }); + fs.mkdirSync(path.dirname(fakePy3), { recursive: true }); + fs.mkdirSync(path.dirname(fakePyLink), { recursive: true }); + fs.writeFileSync(fakePy3, "#!/bin/sh\necho 3.13\n", { mode: 0o755 }); + + const command = dockerRunCommandBetween( + dockerfile, + "RUN apt-get update", + "# gosu for privilege separation", + ) + .replaceAll("/var/lib/apt/lists", lists) + .replaceAll("/usr/local/bin/python", fakePyLink) + .replaceAll("/usr/bin/python3", fakePy3); + + try { + const { result } = runLoggedDockerShell(command, tmp, [ + 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; }', + ]); + expect(result.status).toBe(0); + expect(fs.lstatSync(fakePyLink).isSymbolicLink()).toBe(true); + expect(fs.readlinkSync(fakePyLink)).toBe(fakePy3); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("runtime hardening installs procps and e2fsprogs when a stale base lacks ps and chattr", () => { const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8"); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-procps-"));