From 15114bb9a02c0057d202fc16ef999df9e85bf2d5 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 4 Nov 2026 15:29:55 -0800 Subject: [PATCH] fix(workspaces): keep durable seed copies above the free-space reserve (#18999) persistDurableSeedArchive now checks the source file and applies the shared workspace free-space reserve before copying, so a large seed cannot fill the instance volume. Co-Authored-By: Claude Opus 5.5 --- .../src/sandbox-managed-runtime.ts | 8 +++- .../src/sandbox-managed-seed.test.ts | 37 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index ffef06f4cd3..b7f4270292b 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -9,7 +9,7 @@ import { promises as fs, } from "node:fs"; import os from "node:os"; -import { createWorkspaceManifest, WorkspaceManifestMap, workspacePaths, workspacePathMatcher, writeWorkspacePaths, isPathManifest, type WorkspacePaths } from "./workspace-manifest.js"; +import { assertWorkspaceManifestDiskSpace, createWorkspaceManifest, WorkspaceManifestMap, workspacePaths, workspacePathMatcher, writeWorkspacePaths, isPathManifest, type WorkspacePaths } from "./workspace-manifest.js"; import path from "node:path"; import { promisify } from "node:util"; import { @@ -887,6 +887,12 @@ async function persistDurableSeedArchive(input: { if (parentStat.isSymbolicLink() || !parentStat.isDirectory()) { throw new Error("workspace_durable_seed_root_invalid"); } + const source = await fs.lstat(input.sourcePath); + if (source.isSymbolicLink() || !source.isFile()) { + throw new Error("workspace_durable_seed_invalid"); + } + // A seed is a full archive copy; refuse it before it consumes the host reserve. + assertWorkspaceManifestDiskSpace(parent, source.size); const temporary = path.join( parent, `.${path.basename(input.targetPath)}.${randomUUID()}.tmp`, diff --git a/packages/adapter-utils/src/sandbox-managed-seed.test.ts b/packages/adapter-utils/src/sandbox-managed-seed.test.ts index 4e563f5debb..c4c36776770 100644 --- a/packages/adapter-utils/src/sandbox-managed-seed.test.ts +++ b/packages/adapter-utils/src/sandbox-managed-seed.test.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import nodeFs from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFile } from "node:child_process"; @@ -16,6 +17,7 @@ function deferred() { const roots: string[] = []; afterEach(async () => { + vi.restoreAllMocks(); vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); }); @@ -156,3 +158,38 @@ it.each([false, true])("overlaps seed verification and upload and drains both be if ("error" in outcome) { expect(failUpload).toBe(true); expect(outcome.error.message).toBe("upload failed"); } else { expect(failUpload).toBe(false); await outcome.runtime.cleanupWorkspaceSnapshot(); } }); + +it("refuses a durable seed copy that would cross the shared free-space reserve", async () => { + const temp = await fs.mkdtemp(path.join(os.tmpdir(), "managed-seed-reserve-")); roots.push(temp); + vi.stubEnv("PAPERCLIP_HOME", path.join(temp, "home")); + const reserve = 64 * 1024 * 1024; + vi.stubEnv("PAPERCLIP_WORKSPACE_MANIFEST_MIN_FREE_BYTES", String(reserve)); + const host = path.join(temp, "host"), seedDirectory = path.join(temp, "durable"); + await fs.mkdir(host); await fs.mkdir(seedDirectory); + await fs.writeFile(path.join(host, "work.txt"), "x".repeat(64 * 1024)); + const statfsSync = nodeFs.statfsSync; + vi.spyOn(nodeFs, "statfsSync").mockImplementation(((directory: nodeFs.PathLike, options?: nodeFs.StatFsOptions) => { + const real = statfsSync(directory, { bigint: true }); + // The seed volume has room for the reserve plus a few KiB, less than the archive. + if (path.resolve(String(directory)) !== seedDirectory) return statfsSync(directory, options as never); + return { ...real, bsize: 1n, bavail: BigInt(reserve + 4 * 1024) }; + }) as typeof nodeFs.statfsSync); + const copied: string[] = []; + const copyFile = fs.copyFile; + vi.spyOn(fs, "copyFile").mockImplementation(async (source, target, mode) => { + if (path.dirname(path.resolve(String(target))) === seedDirectory) copied.push(String(target)); + return copyFile(source, target, mode); + }); + await expect(prepareSandboxManagedRuntime({ + spec: { provider: "test", sandboxId: "test", remoteCwd: path.join(temp, "remote"), timeoutMs: 30000, apiKey: null, transport: "sandbox" }, + adapterKey: "test", workspaceLocalDir: host, workspaceFileMode: "all", + workspaceDurableSeed: { workspaceArchivePath: path.join(seedDirectory, "workspace-seed.tar") }, + client: { + makeDir: async () => {}, run: async () => {}, writeFile: async () => {}, + readFile: async () => Buffer.alloc(0), listFiles: async () => [], remove: async () => {}, + syncIn: async () => ({ operations: [] }), + }, + })).rejects.toThrow("free-space reserve"); + expect(copied).toEqual([]); + expect(await fs.readdir(seedDirectory)).toEqual([]); +});