fix(workspaces): retain redacted archive capture causes (#18935)

## 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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-04 07:18:55 -06:00
1 parent b6352578c5
commit ecc253b73d
5 files changed
+161 -9

No files matched your search

@@ -5,7 +5,7 @@ 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, restoreRemoteWorkspaceArchive } from "./remote-workspace-snapshot-archive.js";
import { captureRemoteWorkspaceDirectory, remoteWorkspaceArchiveScript, remoteWorkspaceCaptureFailureMessage, restoreRemoteWorkspaceArchive } from "./remote-workspace-snapshot-archive.js";
const archiveIo = vi.hoisted(() => ({
afterChmod: undefined as ((target: string, mode: number) => void) | undefined,
@@ -53,6 +53,19 @@ describe("portable remote workspace archives", () => {
}
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"), "");
@@ -290,6 +303,30 @@ describe("portable remote workspace archives", () => {
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"]) {
@@ -352,8 +352,32 @@ export function restoreRemoteWorkspaceArchive(input: { archivePath: string; targ
return workspaceArchive({ ...input, kind: "restore" });
}
export function remoteWorkspaceCaptureFailureMessage(error: unknown): string | null {
if (!error || typeof error !== "object") return null;
const failure = error as { message?: unknown; code?: unknown; stderr?: unknown };
const diskFull = "remote_workspace_sandbox_disk_full: the sandbox disk is full; free space in the sandbox before saving the workspace";
const validation = [
"remote_workspace_archive_changed", "remote_workspace_archive_unsupported_filename",
"remote_workspace_archive_unsafe_git_link", "remote_workspace_archive_unsafe_symlink",
"remote_workspace_archive_unsupported_entry", "remote_workspace_archive_source_missing",
"remote_workspace_archive_source_invalid", "remote_workspace_archive_credential_in_git_remote",
"remote_workspace_archive_unsupported_git_setting", "remote_workspace_archive_manifest_too_large",
"remote_workspace_archive_git_operation_in_progress: finish or abort the Git operation before saving the workspace",
diskFull,
];
if (typeof failure.message === "string" && validation.includes(failure.message)) return failure.message;
const childText = (typeof failure.stderr === "string" ? failure.stderr : "")
+ (typeof failure.message === "string" ? failure.message : "");
if (failure.code === "ENOSPC" || failure.code === "EDQUOT" || /No space left on device|Disk quota exceeded/i.test(childText)) return diskFull;
for (const code of ["EACCES", "EPERM", "ENOENT", "ENOTDIR", "EISDIR", "ELOOP", "EMFILE", "ENFILE", "EIO", "EROFS", "EEXIST"]) {
const message = `remote_workspace_archive_fs_${code.toLowerCase()}`;
if (failure.code === code || failure.message === message) return message;
}
return null;
}
export function remoteWorkspaceArchiveScript(): string {
// tsx's function-name preservation helper may appear in Function#toString.
const source = workspaceArchive.toString().replaceAll("__vite_ssr_dynamic_import__", "import");
return `const __name = (value) => value;\nconst archive = ${source};\nconst [kind, first, second, digest] = process.argv.slice(2);\narchive(kind === 'capture' ? {kind,cwd:first,archivePath:second} : {kind,archivePath:first,targetDir:second,expectedManifestDigest:digest}).then(value=>process.stdout.write(JSON.stringify(value))).catch(error=>{process.stderr.write(error.message);process.exitCode=1;});\n`;
return `const __name = (value) => value;\nconst captureFailure = ${remoteWorkspaceCaptureFailureMessage.toString()};\nconst archive = ${source};\nconst [kind, first, second, digest] = process.argv.slice(2);\narchive(kind === 'capture' ? {kind,cwd:first,archivePath:second} : {kind,archivePath:first,targetDir:second,expectedManifestDigest:digest}).then(value=>process.stdout.write(JSON.stringify(value))).catch(error=>{process.stderr.write(kind === 'capture' ? captureFailure(error) ?? 'remote_workspace_archive_failed' : error.message);process.exitCode=1;});\n`;
}
@@ -339,6 +339,37 @@ describe("remote workspace transport safety with real sandbox commands", () => {
expect(await fs.readdir(outside)).toEqual(["workspace.pcws"]);
});
it.each(["split", "one-shot"] as const)("retains a closed capture validation failure with %s output", async outputMode => {
const privateName = "SYNTHETIC_PRIVATE_FILENAME", outside = path.join(scratch, privateName);
await fs.writeFile(outside, "outside source bytes\n");
await fs.writeFile(path.join(workspace, "saved.txt"), "retained source bytes\n");
await fs.symlink(`../${privateName}`, path.join(workspace, "link"));
const { remote, execute } = target({}, outputMode), operationId = randomUUID();
await expect(captureRemoteWorkspaceArchive({ target: remote, remoteCwd: workspace, operationId }))
.rejects.toThrow(/^remote_workspace_archive_unsafe_symlink$/);
expect(execute).toHaveBeenCalledTimes(2);
expect(await fs.readFile(outside, "utf8")).toBe("outside source bytes\n");
expect(await fs.readFile(path.join(workspace, "saved.txt"), "utf8")).toBe("retained source bytes\n");
expect(await fs.readlink(path.join(workspace, "link"))).toBe(`../${privateName}`);
const directory = path.join(workspace, ".paperclip-runtime", "workspace-snapshots", operationId);
expect(await fs.readdir(directory)).toEqual(["archive.cjs"]);
});
it.each(["split", "one-shot"] as const)("retains a closed filesystem failure for a missing canonical archive with %s output", async outputMode => {
const operationId = randomUUID(), canonical = path.join(workspace, ".paperclip-runtime", "workspace-snapshots", operationId, "captured");
await fs.mkdir(canonical, { recursive: true });
const receipt = '{"SYNTHETIC_PRIVATE_METADATA":"retained receipt"}';
await fs.writeFile(path.join(canonical, "receipt.json"), receipt);
await fs.writeFile(path.join(workspace, "saved.txt"), "retained unsaved source bytes\n");
const { remote, execute } = target({}, outputMode);
await expect(captureRemoteWorkspaceArchive({ target: remote, remoteCwd: workspace, operationId }))
.rejects.toThrow(/^remote_workspace_archive_fs_enoent$/);
expect(execute).toHaveBeenCalledTimes(2);
expect(await fs.readFile(path.join(canonical, "receipt.json"), "utf8")).toBe(receipt);
expect(await fs.readdir(canonical)).toEqual(["receipt.json"]);
expect(await fs.readFile(path.join(workspace, "saved.txt"), "utf8")).toBe("retained unsaved source bytes\n");
});
it.each(["split", "one-shot"] as const)("preserves only the fixed unfinished Git operation diagnostic with %s output", async outputMode => {
await git(workspace, "init", "--initial-branch=main");
await fs.writeFile(path.join(workspace, "work.txt"), "base\n"); await git(workspace, "add", "."); await git(workspace, "commit", "-m", "base");
@@ -406,6 +437,18 @@ describe("remote workspace transport safety with real sandbox commands", () => {
.rejects.toThrow(/^remote_workspace_operation_failed$/);
});
it.each(["split", "one-shot"] as const)("preserves a Git child disk failure with %s output without its private text", async outputMode => {
await git(workspace, "init", "--initial-branch=main");
await fs.writeFile(path.join(workspace, "saved.txt"), "retained source bytes\n");
const bin = path.join(scratch, "controlled-bin"); await fs.mkdir(bin);
const gitWrapper = path.join(bin, "git");
await fs.writeFile(gitWrapper, "#!/bin/sh\nprintf '%s' 'No space left on device: SYNTHETIC_PRIVATE_GIT_PATH' >&2\nexit 2\n", { mode: 0o700 });
const { remote } = target({ PATH: `${bin}${path.delimiter}${process.env.PATH}` }, outputMode);
await expect(captureRemoteWorkspaceArchive({ target: remote, remoteCwd: workspace, operationId: randomUUID() }))
.rejects.toThrow(/^remote_workspace_sandbox_disk_full: the sandbox disk is full; free space in the sandbox before saving the workspace$/);
expect(await fs.readFile(path.join(workspace, "saved.txt"), "utf8")).toBe("retained source bytes\n");
});
it.each([
{ label: "private stdout", stdout: "SYNTHETIC_PRIVATE_STDOUT", stderr: "" },
{ label: "private stderr", stdout: "", stderr: "SYNTHETIC_PRIVATE_STDERR" },
@@ -413,6 +456,11 @@ describe("remote workspace transport safety with real sandbox commands", () => {
{ label: "prefixed stdout diagnostic", stdout: "SYNTHETIC_PRIVATE_PATH\nremote_workspace_sandbox_disk_full: the sandbox disk is full; free space in the sandbox before saving the workspace", stderr: "" },
{ label: "unrelated helper diagnostic", stdout: "remote_workspace_capture_failed", stderr: "" },
{ label: "diagnostic split between streams", stdout: "remote_workspace_sandbox_", stderr: "disk_full: the sandbox disk is full; free space in the sandbox before saving the workspace" },
{ label: "suffixed capture validation", stdout: "", stderr: "remote_workspace_archive_unsafe_symlink\nSYNTHETIC_PRIVATE_FILENAME" },
{ label: "prefixed capture filesystem failure", stdout: "SYNTHETIC_PRIVATE_PATH\nremote_workspace_archive_fs_enoent", stderr: "" },
{ label: "unknown capture filesystem code", stdout: "remote_workspace_archive_fs_eintr", stderr: "" },
{ label: "unknown capture validation", stdout: "", stderr: "remote_workspace_archive_Synthetic_PRIVATE" },
{ label: "capture failure split between streams", stdout: "remote_workspace_archive_", stderr: "unsafe_symlink" },
])("keeps $label private", async ({ stdout, stderr }) => {
const { remote, execute } = target();
execute.mockResolvedValueOnce({ exitCode: 1, stdout, stderr, timedOut: false, signal: null,
@@ -422,6 +470,37 @@ describe("remote workspace transport safety with real sandbox commands", () => {
expect(execute).toHaveBeenCalledTimes(1);
});
it.each(["prepare", "repository", "restore", "cleanup"] as const)("keeps new capture diagnostics private during %s", async operation => {
const { remote, execute } = target();
const failed = { exitCode: 1, stdout: "remote_workspace_archive_fs_enoent", stderr: "", timedOut: false,
signal: null, pid: process.pid, startedAt: new Date().toISOString() };
if (operation === "restore") execute.mockResolvedValueOnce({ ...failed, exitCode: 0, stdout: "" });
execute.mockResolvedValueOnce(failed);
const request = operation === "prepare"
? prepareRemoteWorkspaceDirectory({ target: remote, remoteCwd: workspace, source: { kind: "empty" },
preparationIdentity: "a".repeat(64), resolveGitAuth: async () => null })
: operation === "repository"
? prepareRemoteRepository({ target: remote, remoteCwd: workspace, repoUrl: "https://example.invalid/fixture.git", ref: null, resolveGitAuth: async () => null })
: operation === "cleanup"
? cleanupRemoteWorkspaceArchive({ target: remote, remoteCwd: workspace, operationId: randomUUID() })
: (async () => {
const archivePath = path.join(scratch, "restore.pcws"); await fs.writeFile(archivePath, "synthetic archive");
return restoreRemoteWorkspaceDirectory({ target: remote, remoteCwd: path.join(scratch, "restored"), archivePath,
manifestDigest: "a".repeat(64), restorationIdentity: "b".repeat(64) });
})();
await expect(request).rejects.toThrow(/^remote_workspace_operation_failed$/);
expect(execute).toHaveBeenCalledTimes(operation === "restore" ? 2 : 1);
});
it("keeps the existing disk diagnostic ahead of a new capture diagnostic", async () => {
const { remote, execute } = target();
const diskFull = "remote_workspace_sandbox_disk_full: the sandbox disk is full; free space in the sandbox before saving the workspace";
execute.mockResolvedValueOnce({ exitCode: 1, stdout: diskFull, stderr: "remote_workspace_archive_fs_enoent",
timedOut: false, signal: null, pid: process.pid, startedAt: new Date().toISOString() });
await expect(captureRemoteWorkspaceArchive({ target: remote, remoteCwd: workspace, operationId: randomUUID() }))
.rejects.toThrow(diskFull);
});
it("keeps the existing safe stderr diagnostic when stdout reports another safe failure", async () => {
const { remote, execute } = target();
const diskFull = "remote_workspace_sandbox_disk_full: the sandbox disk is full; free space in the sandbox before saving the workspace";
@@ -5,7 +5,7 @@ import os from "node:os";
import path from "node:path";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import type { RemoteWorkspaceSource } from "@paperclipai/shared";
import { remoteWorkspaceArchiveScript } from "./remote-workspace-snapshot-archive.js";
import { remoteWorkspaceArchiveScript, remoteWorkspaceCaptureFailureMessage } from "./remote-workspace-snapshot-archive.js";
import type { GitRemoteAuthProvider } from "./git-credentials.js";
export type RemoteWorkspaceTarget = Extract<AdapterExecutionTarget, { transport: "sandbox" }>;
@@ -26,6 +26,7 @@ export const REMOTE_WORKSPACE_SANDBOX_DISK_FULL =
"remote_workspace_sandbox_disk_full: the sandbox disk is full; free space in the sandbox before saving the workspace";
// Node reports ENOSPC or EDQUOT. Git and tar child processes report the text.
const DISK_FULL_CHECK = String.raw`const diskFull=e=>['ENOSPC','EDQUOT'].includes(e?.code)||/No space left on device|Disk quota exceeded/i.test(String(e?.stderr??'')+String(e?.message??''));`;
const CAPTURE_FAILURE_CHECK = `const captureFailure=${remoteWorkspaceCaptureFailureMessage.toString()};`;
type RemoteWorkspaceOperation = "prepare" | "repository" | "archive_helper" | "capture" | "restore" | "cleanup";
@@ -57,8 +58,10 @@ async function command(target: RemoteWorkspaceTarget, operation: RemoteWorkspace
const gitOperation = "remote_workspace_archive_git_operation_in_progress: finish or abort the Git operation before saving the workspace";
// One-shot providers can return combined output in stdout. Keep the same
// exact safe diagnostics; never forward arbitrary output from either stream.
const reported = [result.stderr.trim(), result.stdout.trim()]
.find(value => [gitOperation, REMOTE_WORKSPACE_SANDBOX_DISK_FULL].includes(value));
const output = [result.stderr.trim(), result.stdout.trim()];
const reported = output.find(value => [gitOperation, REMOTE_WORKSPACE_SANDBOX_DISK_FULL].includes(value))
?? (operation === "capture" || operation === "archive_helper"
? output.find(value => remoteWorkspaceCaptureFailureMessage({ message: value }) === value) : undefined);
throw new Error(reported ?? "remote_workspace_operation_failed");
}
return result.stdout;
@@ -196,7 +199,7 @@ async function installArchiveHelper(target: RemoteWorkspaceTarget, remoteCwd: st
if (!/^[a-f0-9-]{36}$/.test(operationId)) throw new Error("remote_workspace_operation_identity_invalid");
const directory = path.posix.join(remoteCwd, ".paperclip-runtime", "workspace-snapshots", operationId);
const helper = path.posix.join(directory, "archive.cjs");
const install = String.raw`const fs=require('node:fs/promises'),path=require('node:path');${DISK_FULL_CHECK}(async()=>{
const install = String.raw`const fs=require('node:fs/promises'),path=require('node:path');${DISK_FULL_CHECK}${CAPTURE_FAILURE_CHECK}(async()=>{
const [root,dir,file,body]=process.argv.slice(1);let p='/';
for(const s of root.split('/').filter(Boolean)){p=path.join(p,s);await fs.mkdir(p,{mode:0o700}).catch(e=>{if(e.code!=='EEXIST')throw e});const st=await fs.lstat(p);if(st.isSymbolicLink()||!st.isDirectory())throw Error('unsafe');}
for(const s of path.relative(root,dir).split('/')){p=path.join(p,s);await fs.mkdir(p,{mode:0o700}).catch(e=>{if(e.code!=='EEXIST')throw e});const st=await fs.lstat(p);if(st.isSymbolicLink()||!st.isDirectory())throw Error('unsafe');}
@@ -205,7 +208,7 @@ async function installArchiveHelper(target: RemoteWorkspaceTarget, remoteCwd: st
// A failed write can leave partial bytes. Remove only the temporary file this run created.
try{await fs.writeFile(temp,Buffer.from(body,'base64'),{mode:0o600,flag:'wx'});await fs.rename(temp,file);}
catch(e){if(e?.code!=='EEXIST')await fs.rm(temp,{force:true}).catch(()=>{});throw e;}
})().catch(error=>{if(diskFull(error))process.stderr.write(${JSON.stringify(REMOTE_WORKSPACE_SANDBOX_DISK_FULL)});process.exitCode=1});`;
})().catch(error=>{const message=diskFull(error)?${JSON.stringify(REMOTE_WORKSPACE_SANDBOX_DISK_FULL)}:captureFailure(error);if(message)process.stderr.write(message);process.exitCode=1});`;
await command(target, "archive_helper", ["-e", install, remoteCwd, directory, helper, Buffer.from(remoteWorkspaceArchiveScript()).toString("base64")]);
return { directory, helper, archive: path.posix.join(directory, "workspace.pcws"), receipt: path.posix.join(directory, "receipt.json") };
}
@@ -232,7 +235,7 @@ export async function captureRemoteWorkspaceArchive(input: {
const staged = await installArchiveHelper(input.target, input.remoteCwd, input.operationId);
// Publish the archive and receipt as one immutable directory. Concurrent
// invocations own separate attempts; no invocation unlinks another's archive.
const capture = String.raw`const fs=require('node:fs/promises'),path=require('node:path'),cp=require('node:child_process');const {promisify}=require('node:util');${DISK_FULL_CHECK}(async()=>{
const capture = String.raw`const fs=require('node:fs/promises'),path=require('node:path'),cp=require('node:child_process');const {promisify}=require('node:util');${DISK_FULL_CHECK}${CAPTURE_FAILURE_CHECK}(async()=>{
const [helper,root,directory]=process.argv.slice(1),canonical=path.join(directory,'captured');
const readPair=async(dir,layout,required)=>{
const st=await fs.lstat(dir).catch(e=>{if(e.code==='ENOENT')return null;throw e});if(!st)return null;
@@ -258,7 +261,8 @@ export async function captureRemoteWorkspaceArchive(input: {
})().catch(error=>{
const gitOperation='remote_workspace_archive_git_operation_in_progress: finish or abort the Git operation before saving the workspace';
process.stderr.write(typeof error?.stderr==='string'&&error.stderr.trim()===gitOperation?gitOperation
:diskFull(error)?${JSON.stringify(REMOTE_WORKSPACE_SANDBOX_DISK_FULL)}:'remote_workspace_capture_failed');process.exitCode=1;
:diskFull(error)?${JSON.stringify(REMOTE_WORKSPACE_SANDBOX_DISK_FULL)}
:captureFailure({message:typeof error?.stderr==='string'?error.stderr.trim():null})??captureFailure(error)??'remote_workspace_capture_failed');process.exitCode=1;
});`;
const { layout, ...receipt } = JSON.parse(await command(input.target, "capture", ["-e", capture, staged.helper, input.remoteCwd, staged.directory])) as {
manifestDigest: string; sha256: string; byteSize: number; entries: number; layout: "legacy" | "captured";