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:
Devin FoleyandPaperclip authored and GitHub committed 2026-11-04 18:06:59 -08:00
1 parent 7edbc1fc07
commit 94fccc3180
3 files changed
+55

No files matched your search

@@ -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);