mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## Thinking Path > - Paperclip helps people manage AI agents at work. > - Remote workspaces need a verified saved archive. > - A failed capture must retain a safe cause for diagnosis. > - The codec and capture wrapper erased known validation and filesystem failures. > - The one-shot output fix in #18888 preserves only the existing disk and Git messages. > - This PR retains a closed set of capture failures without exposing workspace data. ## Linked Issues or Issue Description Refs #18888. **What happened?** Remote archive capture can still report only `remote_workspace_operation_failed`. This was observed after the public revision `f211d44c00fbc31d22e7ff687e22dfa05643da2a` included #18888. That observation identifies the capture stage. It does not identify the underlying failure. The real local codec gives a specific validation error for an escaping symlink. Node also gives a specific errno when an archive is missing. The capture wrapper erased both causes. The codec sent raw filesystem paths to its parent process before that wrapper discarded them. **Expected behavior** Retain exact, fixed capture validation and filesystem classes. Keep arbitrary messages, paths, filenames, file contents, and command output private. **Steps to reproduce** Call the real capture transport with an escaping symlink. Separately, create a canonical capture receipt whose archive is missing. Both fail with the generic transport message on unchanged master. The new tests use real Node processes and files. ## What Changed - Add a self-contained classifier for fixed capture validation messages and a finite Node errno set. - Redact unknown codec capture errors. Preserve recognized classes through helper installation, capture, and split or combined command output. - Keep the existing disk and unfinished-Git messages first. Keep other command behavior and all save, retry, ownership, Stop, and permission policy unchanged. - Document the diagnostic contract. ## Verification Node 24.22.2, pnpm 9.15.4, macOS arm64, isolated source checkout. - Six new real-process cases failed before production edits. The same six passed after the repair. - The complete archive and transport family passed **93/93**, with no skips. A new contract test covers every capture-side reason in the actual serialized codec; an unlisted-reason mutation fails that test. - These tests cover real symlink rejection, missing canonical archive, serialized-helper ENOENT/EEXIST redaction, unchanged source bytes and canonical receipt, unknown errno, malformed output, other command stages, and the successful capture/restore path. - A safety test caught a regression in the first patch: early redaction hid a Git child's existing disk-full signal. Unchanged master passed both split/combined cases; the first patch failed both. The final family passes both after the same closed disk recognition was restored. The earlier failure remains recorded. - Full recursive typecheck and `pnpm build` passed on the current head with Node 24.22.2. - A fresh probe of the current head's compiled `server/dist` artifact passed. It ran nine real Node commands with combined one-shot output. It checked both fixed failure classes, unchanged source/receipt bytes, and a successful capture, hash check, cold restore, and scoped cleanup. - The first head's full local `pnpm test:run` failed one unchanged GitHub fast-response streaming-quarantine acknowledgement assertion: an acknowledgement appeared where the test expected none. The first chat shard passed 319 cases; the second passed 323 and failed one. One bounded focused comparison passed on both that head and untouched master. It did not explain the original failure. No chat source, assertion, or timeout changed. No full local-suite pass is claimed. - All **57 current-head contexts are terminal and clean**: 53 success, two skipped, two neutral. Required [CI](https://github.com/paperclipai/paperclip/actions/runs/41781977986) gates passed. Fresh Greptile and Paperclip assessments both report **5/5** on `d7b2fcc22c75e3157e0c53bf4725e665810f2d19`, with no open review threads. The optional security scan is neutral, not an independent security assessment pass. ```sh pnpm exec vitest run server/src/services/remote-workspace-transport.test.ts server/src/services/remote-workspace-snapshot-archive.test.ts ``` This is a **diagnostic repair**. It does not establish the cause of the observed hosted failures, qualify a live provider capture, or recover the original files. No hosted resources were executed, started, changed, or retried for this PR. ## Risks The classifier can expose only a fixed message or a fixed class from its finite errno set. Unknown and altered messages keep the generic failure. Existing disk/Git priority and command-stage restrictions have explicit tests. The new classes may improve later diagnosis; they do not authorize another operation. ## Model Used OpenAI Codex, GPT-6 family. The exact serving model build and context window are not exposed in this agent session. The agent used reasoning, terminal tools, code edits, and real Node/filesystem tests. A separate Codex agent reviewed the production diff. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
448 lines
33 KiB
TypeScript
448 lines
33 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { captureRemoteWorkspaceDirectory, remoteWorkspaceArchiveScript, remoteWorkspaceCaptureFailureMessage, restoreRemoteWorkspaceArchive } from "./remote-workspace-snapshot-archive.js";
|
|
|
|
const archiveIo = vi.hoisted(() => ({
|
|
afterChmod: undefined as ((target: string, mode: number) => void) | undefined,
|
|
beforeOpen: undefined as ((target: string) => Promise<void>) | undefined,
|
|
captures: [] as string[],
|
|
}));
|
|
vi.mock("node:fs/promises", async importOriginal => {
|
|
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
|
return { ...actual, async open(...args: Parameters<typeof actual.open>) {
|
|
await archiveIo.beforeOpen?.(String(args[0]));
|
|
return actual.open(...args);
|
|
}, async mkdtemp(...args: Parameters<typeof actual.mkdtemp>) {
|
|
const directory = await actual.mkdtemp(...args);
|
|
if (String(args[0]).includes("pc-workspace-capture-")) archiveIo.captures.push(String(directory));
|
|
return directory;
|
|
}, async chmod(target: string, mode: number) {
|
|
await actual.chmod(target, mode); archiveIo.afterChmod?.(target, mode);
|
|
} };
|
|
});
|
|
|
|
const execute = promisify(execFile);
|
|
const env = { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: os.devNull,
|
|
GIT_AUTHOR_NAME: "Snapshot", GIT_COMMITTER_NAME: "Snapshot", GIT_AUTHOR_EMAIL: "snapshot@example.test", GIT_COMMITTER_EMAIL: "snapshot@example.test" };
|
|
const git = async (cwd: string, ...args: string[]) => (await execute("git", ["-C", cwd, ...args], { env })).stdout.trim();
|
|
describe("portable remote workspace archives", () => {
|
|
let root: string;
|
|
beforeEach(async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), "pc-workspace-archive-test-")); });
|
|
afterEach(async () => {
|
|
archiveIo.afterChmod = undefined; archiveIo.beforeOpen = undefined; archiveIo.captures = [];
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
});
|
|
async function source() { const cwd = path.join(root, "source"); await fs.mkdir(cwd); return cwd; }
|
|
async function capture(cwd: string) { const archivePath = path.join(root, "snapshot.pcws"); return { archivePath, ...await captureRemoteWorkspaceDirectory({ cwd, archivePath }) }; }
|
|
async function restore(snapshot: Awaited<ReturnType<typeof capture>>) {
|
|
const targetDir = path.join(root, "restored");
|
|
await restoreRemoteWorkspaceArchive({ archivePath: snapshot.archivePath, targetDir, expectedManifestDigest: snapshot.manifestDigest });
|
|
return targetDir;
|
|
}
|
|
async function sourceBytes(directory: string): Promise<string> {
|
|
const entries: unknown[] = [];
|
|
for (const name of (await fs.readdir(directory)).sort()) {
|
|
const target = path.join(directory, name), stat = await fs.lstat(target);
|
|
entries.push([name, stat.mode & 0o777, stat.isDirectory() ? await sourceBytes(target)
|
|
: stat.isSymbolicLink() ? await fs.readlink(target) : createHash("sha256").update(await fs.readFile(target)).digest("hex")]);
|
|
}
|
|
return JSON.stringify(entries);
|
|
}
|
|
it("keeps every serialized capture validation reason in the closed diagnostic contract", () => {
|
|
const script = remoteWorkspaceArchiveScript(), start = script.indexOf("const archive = ");
|
|
const restore = script.indexOf("if (!input.targetDir", start);
|
|
expect(start).toBeGreaterThan(-1); expect(restore).toBeGreaterThan(start);
|
|
const reasons = new Set([...script.slice(start, restore).matchAll(/\bfail\(([^)]*)\)/gu)]
|
|
.map(call => JSON.parse(call[1]!) as string));
|
|
expect(reasons.size).toBeGreaterThan(0);
|
|
for (const reason of reasons) {
|
|
expect(typeof reason).toBe("string");
|
|
const message = `remote_workspace_archive_${reason}`;
|
|
expect(remoteWorkspaceCaptureFailureMessage({ message }), reason).toBe(message);
|
|
}
|
|
});
|
|
it("restores binary files, empty entries, executable modes and confined symlinks without its source", async () => {
|
|
const cwd = await source();
|
|
await fs.mkdir(path.join(cwd, "empty")); await fs.writeFile(path.join(cwd, "empty-file"), "");
|
|
const bytes = Buffer.from([0, 255, 1, 128]); await fs.writeFile(path.join(cwd, "data.bin"), bytes);
|
|
await fs.writeFile(path.join(cwd, "run"), "#!/bin/sh\n", { mode: 0o755 });
|
|
await fs.symlink("data.bin", path.join(cwd, "link"));
|
|
const snapshot = await capture(cwd); await fs.rm(cwd, { recursive: true });
|
|
const restored = await restore(snapshot);
|
|
expect(await fs.readFile(path.join(restored, "data.bin"))).toEqual(bytes);
|
|
expect(await fs.readFile(path.join(restored, "empty-file"))).toHaveLength(0);
|
|
expect(await fs.readdir(path.join(restored, "empty"))).toEqual([]);
|
|
expect((await fs.stat(path.join(restored, "run"))).mode & 0o777).toBe(0o755);
|
|
expect(await fs.readlink(path.join(restored, "link"))).toBe("data.bin");
|
|
});
|
|
it("round trips an empty workspace", async () => {
|
|
const snapshot = await capture(await source()); const restored = await restore(snapshot);
|
|
expect(await fs.readdir(restored)).toEqual([]);
|
|
});
|
|
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);
|
|
const archivePath = path.join(root, "snapshot.pcws");
|
|
async function bytes(directory: string): Promise<number> {
|
|
let total = 0;
|
|
for (const name of await fs.readdir(directory)) {
|
|
const file = path.join(directory, name), stat = await fs.lstat(file);
|
|
total += stat.isDirectory() ? await bytes(file) : stat.size;
|
|
}
|
|
return total;
|
|
}
|
|
archiveIo.beforeOpen = async target => {
|
|
if (target !== archivePath) return;
|
|
let staged = 0;
|
|
for (const directory of archiveIo.captures) staged += await bytes(directory);
|
|
if (staged + payload.length > payload.length + 128 * 1024) {
|
|
throw Object.assign(new Error("No space left for the archive"), { code: "ENOSPC" });
|
|
}
|
|
};
|
|
const snapshot = await capture(cwd);
|
|
archiveIo.beforeOpen = undefined;
|
|
const digest = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex");
|
|
expect(digest(await fs.readFile(path.join(await restore(snapshot), "work.bin")))).toBe(digest(payload));
|
|
expect(digest(await fs.readFile(path.join(cwd, "work.bin")))).toBe(digest(payload));
|
|
});
|
|
it.each(["content", "symlink", "added-file"])("rejects a concurrent %s change and removes its incomplete archive", async change => {
|
|
const cwd = await source(), file = path.join(cwd, "work.txt"), archivePath = path.join(root, "snapshot.pcws");
|
|
await fs.writeFile(file, "original\n");
|
|
const outside = path.join(root, "outside.txt"); await fs.writeFile(outside, "must stay outside\n");
|
|
archiveIo.beforeOpen = async target => {
|
|
if (target !== archivePath) return;
|
|
archiveIo.beforeOpen = undefined;
|
|
if (change === "content") await fs.writeFile(file, "changed\n");
|
|
if (change === "added-file") await fs.writeFile(path.join(cwd, "new.txt"), "new file\n");
|
|
if (change === "symlink") { await fs.unlink(file); await fs.symlink(outside, file); }
|
|
};
|
|
await expect(capture(cwd)).rejects.toThrow();
|
|
await expect(fs.access(archivePath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(await fs.readFile(outside, "utf8")).toBe("must stay outside\n");
|
|
for (const directory of archiveIo.captures) await expect(fs.access(directory)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
it("cleans a cancelled capture without deleting a pre-existing archive", async () => {
|
|
const cwd = await source(), archivePath = path.join(root, "snapshot.pcws");
|
|
await fs.writeFile(path.join(cwd, "file"), "retained\n");
|
|
const controller = new AbortController(), reason = new Error("capture cancelled");
|
|
archiveIo.beforeOpen = async target => { if (target === archivePath) controller.abort(reason); };
|
|
await expect(captureRemoteWorkspaceDirectory({ cwd, archivePath, signal: controller.signal })).rejects.toBe(reason);
|
|
await expect(fs.access(archivePath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
archiveIo.beforeOpen = undefined;
|
|
await fs.writeFile(archivePath, "existing archive\n");
|
|
await expect(capture(cwd)).rejects.toMatchObject({ code: "EEXIST" });
|
|
expect(await fs.readFile(archivePath, "utf8")).toBe("existing archive\n");
|
|
expect(await fs.readFile(path.join(cwd, "file"), "utf8")).toBe("retained\n");
|
|
});
|
|
it("rejects Git reference changes during archive output and keeps the new reference", async () => {
|
|
const cwd = await source(), archivePath = path.join(root, "snapshot.pcws");
|
|
await git(cwd, "init", "-q", "-b", "main");
|
|
await fs.writeFile(path.join(cwd, "file"), "retained\n");
|
|
await git(cwd, "add", "."); await git(cwd, "commit", "-qm", "base");
|
|
const head = await git(cwd, "rev-parse", "HEAD");
|
|
archiveIo.beforeOpen = async target => {
|
|
if (target !== archivePath) return;
|
|
archiveIo.beforeOpen = undefined;
|
|
await git(cwd, "update-ref", "refs/heads/later-work", head);
|
|
};
|
|
await expect(capture(cwd)).rejects.toThrow("remote_workspace_archive_changed");
|
|
await expect(fs.access(archivePath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(await git(cwd, "rev-parse", "refs/heads/later-work")).toBe(head);
|
|
});
|
|
it("rejects a Files entry limit before extraction while ordinary restores keep their full contents", async () => {
|
|
const cwd = await source(); await fs.writeFile(path.join(cwd, "first.txt"), "first\n"); await fs.writeFile(path.join(cwd, "second.txt"), "second\n");
|
|
const snapshot = await capture(cwd), targetDir = path.join(root, "limited");
|
|
await expect(restoreRemoteWorkspaceArchive({ archivePath: snapshot.archivePath, targetDir,
|
|
expectedManifestDigest: snapshot.manifestDigest, maxEntries: 1 })).rejects.toThrow("remote_workspace_archive_entry_limit");
|
|
await expect(fs.access(targetDir)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect((await fs.readdir(root)).filter(name => name.startsWith(".workspace-restore-"))).toEqual([]);
|
|
const restored = await restore(snapshot);
|
|
expect(await fs.readFile(path.join(restored, "first.txt"), "utf8")).toBe("first\n");
|
|
expect(await fs.readFile(path.join(restored, "second.txt"), "utf8")).toBe("second\n");
|
|
});
|
|
it("checks a Files manifest byte cap before allocating or reading its body", async () => {
|
|
const archivePath = path.join(root, "oversized-manifest.pcws"), targetDir = path.join(root, "limited-manifest");
|
|
const length = Buffer.alloc(4); length.writeUInt32BE(1024);
|
|
await fs.writeFile(archivePath, Buffer.concat([Buffer.from("PCWS1\n"), length]));
|
|
await expect(restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: "a".repeat(64), maxManifestBytes: 16 }))
|
|
.rejects.toThrow("remote_workspace_archive_manifest_too_large");
|
|
await expect(fs.access(targetDir)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect((await fs.readdir(root)).filter(name => name.startsWith(".workspace-restore-"))).toEqual([]);
|
|
await expect(restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: "a".repeat(64) }))
|
|
.rejects.toThrow("remote_workspace_archive_truncated");
|
|
});
|
|
it("does not publish an empty archive when restoration was already canceled", async () => {
|
|
const snapshot = await capture(await source()), targetDir = path.join(root, "canceled");
|
|
const controller = new AbortController(), reason = new Error("restore canceled"); controller.abort(reason);
|
|
await expect(restoreRemoteWorkspaceArchive({ archivePath: snapshot.archivePath, targetDir,
|
|
expectedManifestDigest: snapshot.manifestDigest, signal: controller.signal })).rejects.toBe(reason);
|
|
await expect(fs.access(targetDir)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect((await fs.readdir(root)).filter(name => name.startsWith(".workspace-restore-"))).toEqual([]);
|
|
});
|
|
it("removes private staging after cancellation during read-only directory mode restoration", async () => {
|
|
const cwd = await source(), directory = path.join(cwd, "readonly"); await fs.mkdir(directory);
|
|
await fs.writeFile(path.join(directory, "saved.txt"), "saved content"); await fs.chmod(directory, 0o555);
|
|
const snapshot = await capture(cwd), targetDir = path.join(root, "interrupted");
|
|
const controller = new AbortController(), reason = new Error("restore canceled while applying modes");
|
|
let appliedReadOnlyModes = 0;
|
|
archiveIo.afterChmod = (target, mode) => {
|
|
if (target.includes(".workspace-restore-") && mode === 0o555) { appliedReadOnlyModes++; controller.abort(reason); }
|
|
};
|
|
try {
|
|
await expect(restoreRemoteWorkspaceArchive({ archivePath: snapshot.archivePath, targetDir,
|
|
expectedManifestDigest: snapshot.manifestDigest, signal: controller.signal })).rejects.toBe(reason);
|
|
expect(appliedReadOnlyModes).toBe(1);
|
|
await expect(fs.access(targetDir)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect((await fs.readdir(root)).filter(name => name.startsWith(".workspace-restore-"))).toEqual([]);
|
|
} finally { archiveIo.afterChmod = undefined; await fs.chmod(directory, 0o700); }
|
|
});
|
|
it("preserves read-only directory modes without making capture scratch unremovable", async () => {
|
|
const cwd = await source(), directory = path.join(cwd, "readonly"); await fs.mkdir(directory);
|
|
await fs.writeFile(path.join(directory, "file"), "retained\n"); await fs.chmod(directory, 0o555);
|
|
let restored: string | undefined;
|
|
try {
|
|
restored = await restore(await capture(cwd));
|
|
expect((await fs.stat(path.join(restored, "readonly"))).mode & 0o777).toBe(0o555);
|
|
expect(await fs.readFile(path.join(restored, "readonly", "file"), "utf8")).toBe("retained\n");
|
|
} finally { await fs.chmod(directory, 0o700); if (restored) await fs.chmod(path.join(restored, "readonly"), 0o700); }
|
|
});
|
|
it.each(["ordinary", "linked", "borrowed"])("restores %s Git with staged/unstaged/unpushed data after all sources disappear", async kind => {
|
|
const base = await source(); await git(base, "init", "-q", "-b", "main");
|
|
await fs.writeFile(path.join(base, "work.txt"), "base\n"); await git(base, "add", "."); await git(base, "commit", "-qm", "base");
|
|
let cwd = base;
|
|
if (kind === "linked") { cwd = path.join(root, "linked"); await git(base, "worktree", "add", "-q", "-b", "feature", cwd); }
|
|
if (kind === "borrowed") { cwd = path.join(root, "borrowed"); await git(root, "clone", "-q", "--shared", base, cwd); }
|
|
await fs.writeFile(path.join(cwd, "work.txt"), "unpushed\n"); await git(cwd, "commit", "-qam", "unpushed");
|
|
const head = await git(cwd, "rev-parse", "HEAD");
|
|
await fs.writeFile(path.join(cwd, "work.txt"), "staged\n"); await git(cwd, "add", "work.txt");
|
|
await fs.writeFile(path.join(cwd, "work.txt"), "unstaged\n"); await fs.writeFile(path.join(cwd, "untracked"), "work\n");
|
|
const snapshot = await capture(cwd); await fs.rm(base, { recursive: true, force: true }); if (cwd !== base) await fs.rm(cwd, { recursive: true });
|
|
const restored = await restore(snapshot);
|
|
expect(await git(restored, "rev-parse", "HEAD")).toBe(head);
|
|
expect(await git(restored, "show", ":work.txt")).toBe("staged");
|
|
expect(await fs.readFile(path.join(restored, "work.txt"), "utf8")).toBe("unstaged\n");
|
|
expect(await fs.readFile(path.join(restored, "untracked"), "utf8")).toBe("work\n");
|
|
expect((await fs.lstat(path.join(restored, ".git"))).isDirectory()).toBe(true);
|
|
await expect(fs.access(path.join(restored, ".git", "objects", "info", "alternates"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
await git(restored, "fsck", "--full", "--no-dangling");
|
|
});
|
|
it.each(["rebase-merge", "rebase-apply", "linked-rebase", "sequencer", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD"])(
|
|
"rejects a real unfinished %s operation without changing its source or publishing an archive", async operation => {
|
|
let cwd = await source(); await git(cwd, "init", "-q", "-b", "main");
|
|
await fs.writeFile(path.join(cwd, "work.txt"), "base\n"); await git(cwd, "add", "."); await git(cwd, "commit", "-qm", "base");
|
|
await git(cwd, "checkout", "-qb", "feature");
|
|
await fs.writeFile(path.join(cwd, "work.txt"), "feature\n"); await git(cwd, "commit", "-qam", "feature change");
|
|
const first = await git(cwd, "rev-parse", "HEAD");
|
|
await fs.writeFile(path.join(cwd, "later.txt"), "remaining sequence step\n"); await git(cwd, "add", "."); await git(cwd, "commit", "-qm", "later change");
|
|
const second = await git(cwd, "rev-parse", "HEAD");
|
|
await git(cwd, "checkout", "-q", "main");
|
|
await fs.writeFile(path.join(cwd, "work.txt"), "main\n"); await git(cwd, "commit", "-qam", "main change");
|
|
let command: string[];
|
|
if (operation === "linked-rebase") {
|
|
const linked = path.join(root, "linked"); await git(cwd, "worktree", "add", "-q", linked, "feature"); cwd = linked;
|
|
}
|
|
if (operation.includes("rebase")) {
|
|
await git(cwd, "checkout", "-q", "feature");
|
|
command = ["rebase", operation === "rebase-apply" ? "--apply" : "--merge", "main"];
|
|
} else if (operation === "sequencer") command = ["cherry-pick", first, second];
|
|
else if (operation === "CHERRY_PICK_HEAD") command = ["cherry-pick", first];
|
|
else if (operation === "REVERT_HEAD") command = ["revert", "--no-edit", first];
|
|
else command = ["merge", "--no-edit", "feature"];
|
|
await expect(git(cwd, ...command)).rejects.toThrow();
|
|
const gitDir = await git(cwd, "rev-parse", "--absolute-git-dir");
|
|
const stateName = operation === "linked-rebase" ? "rebase-merge" : operation;
|
|
await fs.access(path.join(gitDir, stateName));
|
|
const before = await git(cwd, "status", "--porcelain=v2");
|
|
const index = await fs.readFile(path.join(gitDir, "index"));
|
|
const work = await fs.readFile(path.join(cwd, "work.txt"));
|
|
const state = (await fs.readdir(gitDir)).sort();
|
|
const bytes = await sourceBytes(cwd), administration = await sourceBytes(gitDir);
|
|
await expect(capture(cwd)).rejects.toThrow("remote_workspace_archive_git_operation_in_progress");
|
|
expect(await sourceBytes(cwd)).toBe(bytes);
|
|
expect(await sourceBytes(gitDir)).toBe(administration);
|
|
expect(await git(cwd, "status", "--porcelain=v2")).toBe(before);
|
|
expect(await fs.readFile(path.join(gitDir, "index"))).toEqual(index);
|
|
expect(await fs.readFile(path.join(cwd, "work.txt"))).toEqual(work);
|
|
expect((await fs.readdir(gitDir)).sort()).toEqual(state);
|
|
await fs.access(path.join(gitDir, stateName));
|
|
await expect(fs.access(path.join(root, "snapshot.pcws"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
if (operation === "sequencer") {
|
|
const helper = path.join(root, "helper.cjs"); await fs.writeFile(helper, remoteWorkspaceArchiveScript());
|
|
await expect(execute(process.execPath, [helper, "capture", cwd, path.join(root, "remote.pcws")], { env }))
|
|
.rejects.toMatchObject({ stderr: expect.stringContaining("remote_workspace_archive_git_operation_in_progress") });
|
|
await expect(fs.access(path.join(root, "remote.pcws"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(await sourceBytes(cwd)).toBe(bytes);
|
|
}
|
|
const abort = operation.includes("rebase") ? "rebase" : operation === "MERGE_HEAD" ? "merge"
|
|
: operation === "REVERT_HEAD" ? "revert" : "cherry-pick";
|
|
await git(cwd, abort, "--abort");
|
|
const head = await git(cwd, "rev-parse", "HEAD"), status = await git(cwd, "status", "--porcelain=v2");
|
|
const restored = await restore(await capture(cwd));
|
|
expect(await git(restored, "rev-parse", "HEAD")).toBe(head);
|
|
expect(await git(restored, "status", "--porcelain=v2")).toBe(status);
|
|
},
|
|
);
|
|
it("rejects an unfinished bisect without discarding its portable selection history", async () => {
|
|
const cwd = await source(); await git(cwd, "init", "-q", "-b", "main");
|
|
for (const value of ["base", "middle", "bad"]) {
|
|
await fs.writeFile(path.join(cwd, "work.txt"), `${value}\n`); await git(cwd, "add", "."); await git(cwd, "commit", "-qm", value);
|
|
}
|
|
await git(cwd, "bisect", "start", "HEAD", "HEAD~2");
|
|
const bytes = await sourceBytes(cwd);
|
|
await expect(capture(cwd)).rejects.toThrow("remote_workspace_archive_git_operation_in_progress");
|
|
expect(await sourceBytes(cwd)).toBe(bytes);
|
|
await expect(fs.access(path.join(root, "snapshot.pcws"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
it("rejects an external symlink before creating an archive", async () => {
|
|
const cwd = await source(); await fs.symlink("../outside", path.join(cwd, "unsafe"));
|
|
await expect(capture(cwd)).rejects.toThrow("unsafe_symlink");
|
|
await expect(fs.access(path.join(root, "snapshot.pcws"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
it("redacts a real missing-source error in the serialized capture helper", async () => {
|
|
const helper = path.join(root, "archive.cjs"), archivePath = path.join(root, "snapshot.pcws");
|
|
await fs.writeFile(helper, remoteWorkspaceArchiveScript());
|
|
await expect(execute(process.execPath, [helper, "capture", path.join(root, "SYNTHETIC_PRIVATE_MISSING_SOURCE"), archivePath], { env }))
|
|
.rejects.toMatchObject({ code: 1, stdout: "", stderr: "remote_workspace_archive_fs_enoent" });
|
|
await expect(fs.access(archivePath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
it("redacts a real exclusive-write error without replacing a preexisting archive", async () => {
|
|
const cwd = await source(), helper = path.join(root, "archive.cjs"), archivePath = path.join(root, "SYNTHETIC_PRIVATE_ARCHIVE.pcws");
|
|
await fs.writeFile(path.join(cwd, "saved.txt"), "retained source bytes\n");
|
|
await fs.writeFile(archivePath, "preexisting archive bytes\n");
|
|
await fs.writeFile(helper, remoteWorkspaceArchiveScript());
|
|
await expect(execute(process.execPath, [helper, "capture", cwd, archivePath], { env }))
|
|
.rejects.toMatchObject({ code: 1, stdout: "", stderr: "remote_workspace_archive_fs_eexist" });
|
|
expect(await fs.readFile(archivePath, "utf8")).toBe("preexisting archive bytes\n");
|
|
expect(await fs.readFile(path.join(cwd, "saved.txt"), "utf8")).toBe("retained source bytes\n");
|
|
});
|
|
it("keeps an unrecognized real filesystem error private in the serialized capture helper", async () => {
|
|
const helper = path.join(root, "archive.cjs"), archivePath = path.join(root, "snapshot.pcws");
|
|
await fs.writeFile(helper, remoteWorkspaceArchiveScript());
|
|
await expect(execute(process.execPath, [helper, "capture", path.join(root, "SYNTHETIC_PRIVATE_NAME".repeat(20)), archivePath], { env }))
|
|
.rejects.toMatchObject({ code: 1, stdout: "", stderr: "remote_workspace_archive_failed" });
|
|
await expect(fs.access(archivePath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
it("preserves shallow repository boundaries and a nested managed checkout", async () => {
|
|
const base = await source(); await git(base, "init", "-q", "-b", "main");
|
|
for (const value of ["one", "two"]) {
|
|
await fs.writeFile(path.join(base, "file"), value); await git(base, "add", "."); await git(base, "commit", "-qm", value);
|
|
}
|
|
const cwd = path.join(root, "task"), checkouts = path.join(cwd, ".paperclip-repositories"); await fs.mkdir(checkouts, { recursive: true });
|
|
const repository = path.join(checkouts, "repo");
|
|
await git(root, "clone", "-q", "--depth=1", `file://${base}`, repository);
|
|
const snapshot = await capture(cwd); await fs.rm(base, { recursive: true }); await fs.rm(cwd, { recursive: true });
|
|
const restored = path.join(await restore(snapshot), ".paperclip-repositories", "repo");
|
|
expect(await git(restored, "rev-parse", "--is-shallow-repository")).toBe("true");
|
|
expect(await git(restored, "rev-list", "--count", "HEAD")).toBe("1");
|
|
expect(await fs.readFile(path.join(restored, "file"), "utf8")).toBe("two");
|
|
});
|
|
it("produces the same saved contents for repeated captures of an unchanged Git clone", async () => {
|
|
const base = await source(); await git(base, "init", "-q", "-b", "main");
|
|
await fs.writeFile(path.join(base, "file"), "stable\n"); await git(base, "add", "."); await git(base, "commit", "-qm", "base");
|
|
const cwd = path.join(root, "clone"); await git(root, "clone", "-q", base, cwd);
|
|
const first = await captureRemoteWorkspaceDirectory({ cwd, archivePath: path.join(root, "first.pcws") });
|
|
await new Promise(resolve => setTimeout(resolve, 1100));
|
|
const second = await captureRemoteWorkspaceDirectory({ cwd, archivePath: path.join(root, "second.pcws") });
|
|
expect(second.manifestDigest).toBe(first.manifestDigest); expect(second.sha256).toBe(first.sha256);
|
|
});
|
|
it("preserves portable secondary remotes and tracking settings without local paths or executable configuration", async () => {
|
|
const cwd = await source(); await git(cwd, "init", "-q", "-b", "main");
|
|
await fs.writeFile(path.join(cwd, "file"), "portable tracking\n"); await git(cwd, "add", "."); await git(cwd, "commit", "-qm", "base");
|
|
const head = await git(cwd, "rev-parse", "HEAD");
|
|
await git(cwd, "remote", "add", "origin", "https://github.com/synthetic/work.git");
|
|
await git(cwd, "remote", "add", "upstream", "ssh://git@example.invalid/team/base.git");
|
|
await git(cwd, "config", "--add", "remote.upstream.url", "https://backup.example.invalid/team/base.git");
|
|
await git(cwd, "config", "remote.upstream.pushurl", "git@example.invalid:team/publish.git");
|
|
await git(cwd, "update-ref", "refs/remotes/upstream/main", head);
|
|
await git(cwd, "config", "branch.main.remote", "upstream");
|
|
await git(cwd, "config", "branch.main.merge", "refs/heads/main");
|
|
await git(cwd, "config", "branch.main.rebase", "true");
|
|
await git(cwd, "config", "user.name", "Synthetic repository author");
|
|
await git(cwd, "config", "user.email", "repository-author@example.invalid");
|
|
await git(cwd, "remote", "add", "local-cache", path.join(root, "old-host-cache.git"));
|
|
await git(cwd, "config", "branch.cache.remote", "local-cache");
|
|
await git(cwd, "config", "branch.cache.merge", "refs/heads/main");
|
|
for (const [key, value] of [["core.hooksPath", path.join(root, "old-host-hooks")], ["core.worktree", cwd],
|
|
["filter.synthetic.smudge", "echo SYNTHETIC_SENSITIVE"], ["credential.helper", "echo SYNTHETIC_SENSITIVE"],
|
|
["include.path", path.join(root, "old-host-gitconfig")]]) await git(cwd, "config", key!, value!);
|
|
const snapshot = await capture(cwd); await fs.rm(cwd, { recursive: true });
|
|
const restored = await restore(snapshot);
|
|
expect(await git(restored, "remote")).toBe("origin\nupstream");
|
|
expect(await git(restored, "config", "--get-all", "remote.upstream.url")).toBe("ssh://git@example.invalid/team/base.git\nhttps://backup.example.invalid/team/base.git");
|
|
expect(await git(restored, "remote", "get-url", "--push", "upstream")).toBe("git@example.invalid:team/publish.git");
|
|
expect(await git(restored, "config", "remote.upstream.fetch")).toBe("+refs/heads/*:refs/remotes/upstream/*");
|
|
expect(await git(restored, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")).toBe("upstream/main");
|
|
expect(await git(restored, "config", "branch.main.rebase")).toBe("true");
|
|
const configuration = await fs.readFile(path.join(restored, ".git", "config"), "utf8");
|
|
expect(configuration).not.toContain(root); expect(configuration).not.toContain("SYNTHETIC_SENSITIVE");
|
|
expect(configuration).not.toContain("local-cache"); expect(configuration).not.toContain('branch "cache"');
|
|
await fs.writeFile(path.join(restored, "next-file"), "later work\n"); await git(restored, "add", "next-file");
|
|
await execute("git", ["-C", restored, "-c", `core.hooksPath=${os.devNull}`, "-c", "commit.gpgsign=false", "commit", "-qm", "restored workspace continues"], {
|
|
env: { PATH: process.env.PATH, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: os.devNull, GIT_CONFIG_SYSTEM: os.devNull, GIT_CONFIG_COUNT: "0" },
|
|
});
|
|
expect(await git(restored, "log", "-1", "--format=%an <%ae>")).toBe("Synthetic repository author <repository-author@example.invalid>");
|
|
await git(restored, "fsck", "--full", "--no-dangling");
|
|
});
|
|
it.each(["https://synthetic-token@github.com/team/repo.git", "https://github.com/team/repo.git?access_token=SYNTHETIC_ONLY"])(
|
|
"rejects credentials in any saved remote URL: %s", async remoteUrl => {
|
|
const cwd = await source(); await git(cwd, "init", "-q", "-b", "main");
|
|
await fs.writeFile(path.join(cwd, "file"), "source\n"); await git(cwd, "add", "."); await git(cwd, "commit", "-qm", "base");
|
|
await git(cwd, "remote", "add", "upstream", remoteUrl);
|
|
await expect(capture(cwd)).rejects.toThrow("credential_in_git_remote");
|
|
await expect(fs.access(path.join(root, "snapshot.pcws"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
},
|
|
);
|
|
it("omits reinstallable dependencies while preserving deliberately tracked files", async () => {
|
|
const cwd = await source(); await fs.writeFile(path.join(cwd, "package.json"), "{}\n");
|
|
await fs.mkdir(path.join(cwd, "node_modules")); await fs.writeFile(path.join(cwd, "node_modules", "dependency"), "reinstall\n");
|
|
const first = await captureRemoteWorkspaceDirectory({ cwd, archivePath: path.join(root, "dependencies.pcws") });
|
|
const targetDir = path.join(root, "without-dependencies");
|
|
await restoreRemoteWorkspaceArchive({ archivePath: path.join(root, "dependencies.pcws"), targetDir, expectedManifestDigest: first.manifestDigest });
|
|
await expect(fs.access(path.join(targetDir, "node_modules"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
await git(cwd, "init", "-q", "-b", "main"); await git(cwd, "add", "."); await git(cwd, "commit", "-qm", "deliberately tracked dependency");
|
|
const second = await captureRemoteWorkspaceDirectory({ cwd, archivePath: path.join(root, "tracked.pcws") });
|
|
const trackedTarget = path.join(root, "with-tracked-dependencies");
|
|
await restoreRemoteWorkspaceArchive({ archivePath: path.join(root, "tracked.pcws"), targetDir: trackedTarget, expectedManifestDigest: second.manifestDigest });
|
|
expect(await fs.readFile(path.join(trackedTarget, "node_modules", "dependency"), "utf8")).toBe("reinstall\n");
|
|
});
|
|
it("rejects a symlink source root", async () => {
|
|
const cwd = await source(), linked = path.join(root, "linked-source"); await fs.symlink(cwd, linked);
|
|
await expect(capture(linked)).rejects.toThrow("source_invalid");
|
|
});
|
|
it("rejects path traversal before creating a restored directory", async () => {
|
|
const manifest = { schema: "paperclip.remote-workspace-archive.v1", entries: [{ path: "../escape", type: "file", mode: 0o600,
|
|
size: 0, sha256: createHash("sha256").digest("hex") }] };
|
|
const bytes = Buffer.from(JSON.stringify(manifest)), length = Buffer.alloc(4); length.writeUInt32BE(bytes.length);
|
|
const archivePath = path.join(root, "unsafe.pcws"), targetDir = path.join(root, "target");
|
|
await fs.writeFile(archivePath, Buffer.concat([Buffer.from("PCWS1\n"), length, bytes]));
|
|
await expect(restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: createHash("sha256").update(bytes).digest("hex") })).rejects.toThrow("invalid_entry");
|
|
await expect(fs.access(targetDir)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(fs.access(path.join(root, "escape"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
it("rejects a corrupt file without publishing a partial restored directory", async () => {
|
|
const cwd = await source(); await fs.writeFile(path.join(cwd, "file"), "saved"); const snapshot = await capture(cwd);
|
|
const file = await fs.open(snapshot.archivePath, "r+"); try { const size = (await file.stat()).size; await file.write(Buffer.from("X"), 0, 1, size - 1); } finally { await file.close(); }
|
|
await expect(restore(snapshot)).rejects.toThrow("file_hash_mismatch");
|
|
await expect(fs.access(path.join(root, "restored"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
it("executes the generated helper in a separate Node process", async () => {
|
|
const cwd = await source(); await fs.writeFile(path.join(cwd, "file"), "remote helper\n");
|
|
await git(cwd, "init", "-q", "-b", "main"); await git(cwd, "add", "."); await git(cwd, "commit", "-qm", "base");
|
|
await git(cwd, "remote", "add", "upstream", "https://example.invalid/team/base.git");
|
|
await git(cwd, "config", "branch.main.remote", "upstream"); await git(cwd, "config", "branch.main.merge", "refs/heads/main");
|
|
const script = path.join(root, "helper.cjs"), archivePath = path.join(root, "remote.pcws"), target = path.join(root, "remote-restored");
|
|
await fs.writeFile(script, remoteWorkspaceArchiveScript());
|
|
const receipt = JSON.parse((await execute(process.execPath, [script, "capture", cwd, archivePath])).stdout);
|
|
await fs.rm(cwd, { recursive: true });
|
|
await execute(process.execPath, [script, "restore", archivePath, target, receipt.manifestDigest]);
|
|
expect(await fs.readFile(path.join(target, "file"), "utf8")).toBe("remote helper\n");
|
|
expect(await git(target, "remote", "get-url", "upstream")).toBe("https://example.invalid/team/base.git");
|
|
expect(await git(target, "config", "branch.main.remote")).toBe("upstream");
|
|
});
|
|
});
|