mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
fix(workspaces): preserve snapshots beside runtime sockets (#19159)
## Thinking Path > - A workspace can contain saved source files alongside transient runtime IPC endpoints. > - The portable archive supports files, directories and confined symlinks; treating a Unix socket as a fatal entry prevents every file beside it from being saved. > - Keep the existing archive owner and validation. Record socket paths as exclusions, leaving the source endpoint and adjacent work intact. ## Linked Issues or Issue Description **What happened?** Retained workspace recovery repeatedly fails with `remote_workspace_archive_unsupported_entry` when the source tree contains a Unix socket, preventing adjacent source files from being saved. A real socket reproduces the error in local capture and the serialized sandbox helper. **Expected behavior** Save regular workspace files, record runtime socket paths as exclusions, and leave the source endpoints and new file edits intact. **Steps to reproduce** 1. Create a temporary workspace with an uncommitted text file. 2. Open a Unix domain socket in that directory with a Node server. 3. Capture the workspace through the local archive function or generated sandbox helper. Before this fix both captures fail; afterward the saved file restores and the socket exclusion is recorded. **Paperclip version or commit** Reproduced on `6fa54d55779295d90c455389cebdd3da96607d20` before this change. The socket fixture establishes this failure class; it does not establish the entry type behind every historical unsupported-entry occurrence. ## What Changed - Exclude Unix domain socket endpoints from portable workspace captures and include their paths in the existing exclusion manifest. - Keep source sockets in place. Preserve normal files and the existing rejection of named pipes, device entries and unsafe links. - Exercise local and serialized capture, an unchanged source endpoint, restoration of adjacent uncommitted work, FIFO rejection, and a socket becoming a real file during capture. A changed source still fails safely and retains that new file. - Document the portable snapshot behavior. ## Verification - Red regression: both local and serialized captures failed with `remote_workspace_archive_unsupported_entry` before the change. - `pnpm exec vitest run server/src/services/remote-workspace-snapshot-archive.test.ts`: **43 passed** using real filesystem sockets, pipes, Git repositories and archives. - `pnpm -r typecheck` and `pnpm build`: passed. - Module boundaries and `git diff --check`: passed. Added source and PR text were scanned for credentials, private incident identifiers and personal data before push. - Snapshot storage and save-concurrency consumer suites pass **23 tests**. Their first local runs exposed missing workspace SDK build links and unhydrated macOS embedded-Postgres library symlinks in the new checkout. Repairing those prerequisites made the unchanged consumer suites pass; the earlier failures remain recorded and no assertion was weakened. - Greptile reviewed `e313565a5088abce448f11b29982462aa338a692` at **5/5** with no unresolved threads. All current-head CI checks are green and the branch is conflict-free. Hosted recovery needs a qualified deployment and original-task verification. ## Risks Socket endpoints represent runtime IPC and are not recreated by archive restore. Their paths are visible in the existing exclusion manifest. This adds no archive entry type, subprocess, recurring work, provider call or dependency. It does not alter source authorization, stop receipts, ordinary file validation, restore safety or recovery budgets. Unix socket/FIFO fixtures are skipped on Windows; existing portable archive coverage still runs there. ## Model Used OpenAI GPT-6.1 Sol (`gpt-6.1-sol`) through Codex, xhigh reasoning. Used repository tools, code execution and real local filesystem/process fixtures. ## Checklist - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] Added focused behavioral and preservation regression coverage - [x] Updated the execution contract and documented limits - [x] Typecheck and build pass locally - [x] Scanned the diff and description before push - [x] Current-head CI is fully green - [x] Greptile is 5/5 with no unresolved comments Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7edbc1fc07
commit
94fccc3180
3 files changed
+55
No files matched your search
@@ -874,6 +874,12 @@ Warm sandbox reuse must match the current host Git tip and branch as well as the
|
||||
file snapshot and saved stamp, including managed nested repositories. A history
|
||||
or branch mismatch restages the host before the next run begins.
|
||||
|
||||
Portable workspace archives exclude Unix domain socket endpoints and record their
|
||||
paths in the exclusion manifest. Capture leaves the source endpoints in place;
|
||||
restore does not recreate runtime IPC. Named pipes and device entries remain
|
||||
unsupported, while regular files, directories and confined symlinks retain their
|
||||
existing validation.
|
||||
|
||||
Disk-backed native sandbox workspace snapshots run in at most two worker threads per
|
||||
controller process. Each capture retains the same file hashes, modes, symlinks,
|
||||
exclusions and ignored-path manifest as an inline capture. A worker seals its
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import net from "node:net";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -84,6 +85,49 @@ describe("portable remote workspace archives", () => {
|
||||
const snapshot = await capture(await source()); const restored = await restore(snapshot);
|
||||
expect(await fs.readdir(restored)).toEqual([]);
|
||||
});
|
||||
it.skipIf(process.platform === "win32").each(["local", "serialized"])("preserves files beside a transient socket in %s capture", async mode => {
|
||||
const cwd = await source(), socketPath = path.join(cwd, "s");
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(socketPath, resolve); });
|
||||
try {
|
||||
await fs.writeFile(path.join(cwd, "saved.txt"), "uncommitted work\n");
|
||||
let snapshot: Awaited<ReturnType<typeof capture>>;
|
||||
if (mode === "serialized") {
|
||||
const helper = path.join(root, "helper.cjs"), archivePath = path.join(root, "remote.pcws");
|
||||
await fs.writeFile(helper, remoteWorkspaceArchiveScript());
|
||||
snapshot = { archivePath, ...JSON.parse((await execute(process.execPath, [helper, "capture", cwd, archivePath])).stdout) };
|
||||
} else snapshot = await capture(cwd);
|
||||
expect((await fs.lstat(socketPath)).isSocket()).toBe(true);
|
||||
const restored = await restore(snapshot);
|
||||
expect(await fs.readFile(path.join(restored, "saved.txt"), "utf8")).toBe("uncommitted work\n");
|
||||
expect(await fs.readdir(restored)).toEqual(["saved.txt"]);
|
||||
const archive = await fs.readFile(snapshot.archivePath), start = Buffer.from("PCWS1\n").length;
|
||||
const length = archive.readUInt32BE(start);
|
||||
expect(JSON.parse(archive.subarray(start + 4, start + 4 + length).toString()).excluded).toContain("s");
|
||||
} finally { await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve())); }
|
||||
});
|
||||
it.skipIf(process.platform === "win32")("keeps named pipes outside the portable archive contract", async () => {
|
||||
const cwd = await source(), pipe = path.join(cwd, "pipe");
|
||||
await execute("mkfifo", [pipe]);
|
||||
await expect(capture(cwd)).rejects.toThrow("remote_workspace_archive_unsupported_entry");
|
||||
expect((await fs.lstat(pipe)).isFIFO()).toBe(true);
|
||||
});
|
||||
it.skipIf(process.platform === "win32")("retains a file that replaces a socket during capture", async () => {
|
||||
const cwd = await source(), socketPath = path.join(cwd, "s"), server = net.createServer();
|
||||
await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(socketPath, resolve); });
|
||||
try {
|
||||
archiveIo.beforeOpen = async target => {
|
||||
if (target !== path.join(root, "snapshot.pcws")) return;
|
||||
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
|
||||
await fs.writeFile(socketPath, "new work\n");
|
||||
};
|
||||
await expect(capture(cwd)).rejects.toThrow("remote_workspace_archive_changed");
|
||||
expect(await fs.readFile(socketPath, "utf8")).toBe("new work\n");
|
||||
await expect(fs.access(path.join(root, "snapshot.pcws"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
if (server.listening) await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
|
||||
}
|
||||
});
|
||||
it("captures with room for one archive and bounded metadata without another copy of working files", async () => {
|
||||
const cwd = await source(), payload = Buffer.alloc(4 * 1024 * 1024, 123);
|
||||
await fs.writeFile(path.join(cwd, "work.bin"), payload);
|
||||
|
||||
@@ -227,6 +227,11 @@ async function workspaceArchive(input: {
|
||||
if (!tracked) { excludedPaths.add(next); continue; }
|
||||
}
|
||||
}
|
||||
if (stat.isSocket()) {
|
||||
// Runtime IPC endpoints have no portable file contents. Record the
|
||||
// exclusion without removing the endpoint or dropping adjacent work.
|
||||
excludedPaths.add(next); continue;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
entries.push({ path: next, type: "directory", mode: stat.mode & 0o777 });
|
||||
await visit(next, inRepository);
|
||||
|
||||
Reference in new issue
Block a user