From e9b909015ad3d517f28c45f0c66c8f41d6112b4b Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 3 Nov 2026 17:05:19 -0600 Subject: [PATCH] Preserve cleanup of empty unreadable fake sandbox directories --- .../src/lease-cleanup.test.ts | 18 ++++++++++++++++++ .../src/lease-cleanup.ts | 14 ++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/plugins/paperclip-plugin-fake-sandbox/src/lease-cleanup.test.ts b/packages/plugins/paperclip-plugin-fake-sandbox/src/lease-cleanup.test.ts index 0f2bbabd333..c2c63cf3fd2 100644 --- a/packages/plugins/paperclip-plugin-fake-sandbox/src/lease-cleanup.test.ts +++ b/packages/plugins/paperclip-plugin-fake-sandbox/src/lease-cleanup.test.ts @@ -39,6 +39,24 @@ async function createLease() { } describe("fake sandbox lease cleanup", () => { + it.each(["destroy", "release"] as const)("%s removes an empty unreadable directory", async (operation) => { + const { harness, root, params } = await createLease(); + const unreadable = path.join(root, "workspace/unreadable"); + await mkdir(unreadable); + await chmod(unreadable, 0o000); + try { + const result = operation === "destroy" + ? await harness.destroyLease(params) + : await harness.releaseLease(params); + expect(result).toBeUndefined(); + await expect(access(root)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await harness.inspectLease(params)).toMatchObject({ state: "destroyed" }); + } finally { + await chmod(unreadable, 0o700).catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } + }); + it.each(["destroy", "release"] as const)("%s removes sealed native runtime context", async (operation) => { const { harness, root, params } = await createLease(); const instructions = path.join(root, "workspace/.paperclip-runtime/paperclip-runner/sessions/session/filesystem/context/instructions"); diff --git a/packages/plugins/paperclip-plugin-fake-sandbox/src/lease-cleanup.ts b/packages/plugins/paperclip-plugin-fake-sandbox/src/lease-cleanup.ts index f5129bc8e99..488de85ffb4 100644 --- a/packages/plugins/paperclip-plugin-fake-sandbox/src/lease-cleanup.ts +++ b/packages/plugins/paperclip-plugin-fake-sandbox/src/lease-cleanup.ts @@ -1,5 +1,5 @@ import { constants } from "node:fs"; -import { open, readdir, rm } from "node:fs/promises"; +import { lstat, open, readdir, rm, rmdir } from "node:fs/promises"; import path from "node:path"; export interface LeaseRootIdentity { @@ -11,8 +11,18 @@ async function makeDirectoriesWritable(directory: string, expected?: LeaseRootId const handle = await open( directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, - ).catch((error: NodeJS.ErrnoException) => { + ).catch(async (error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return null; + if (error.code === "EACCES") { + const identity = await lstat(directory); + if (!identity.isDirectory() || (expected && (identity.dev !== expected.dev || identity.ino !== expected.ino))) { + throw new Error("Refusing to remove a replaced fake sandbox lease root"); + } + // Removing an empty directory only needs access to its parent. Opening + // it first would regress cleanup of owned directories with mode 000. + await rmdir(directory); + return null; + } throw error; }); if (!handle) return false;