diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index eaa6e14e0a8..ea73364ec3c 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -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 diff --git a/server/src/services/remote-workspace-snapshot-archive.test.ts b/server/src/services/remote-workspace-snapshot-archive.test.ts index c3caf11b72a..e194f6624eb 100644 --- a/server/src/services/remote-workspace-snapshot-archive.test.ts +++ b/server/src/services/remote-workspace-snapshot-archive.test.ts @@ -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((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>; + 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((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((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((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((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); diff --git a/server/src/services/remote-workspace-snapshot-archive.ts b/server/src/services/remote-workspace-snapshot-archive.ts index f04f1b2558a..a3cdc4d584a 100644 --- a/server/src/services/remote-workspace-snapshot-archive.ts +++ b/server/src/services/remote-workspace-snapshot-archive.ts @@ -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);