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 <noreply@anthropic.com>
This commit is contained in:
Devin FoleyandClaude Opus 5.5 authored and GitHub committed 2026-11-04 15:29:55 -08:00
1 parent 2c39a2866b
commit 15114bb9a0
2 files changed
+44 -1

No files matched your search

@@ -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`,
@@ -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<T>() {
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([]);
});