From ecc253b73d6c344e0145d59a9ca850ed01dcfd84 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:18:55 -0600 Subject: [PATCH] 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 --- doc/DEVELOPING.md | 8 ++ .../remote-workspace-snapshot-archive.test.ts | 39 ++++++++- .../remote-workspace-snapshot-archive.ts | 26 +++++- .../remote-workspace-transport.test.ts | 79 +++++++++++++++++++ .../services/remote-workspace-transport.ts | 18 +++-- 5 files changed, 161 insertions(+), 9 deletions(-) diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index ec278667695..071b75ae895 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1965,6 +1965,14 @@ failure notice. They retain the normal Retry permission and admission gates. Retained-folder access and trust denials use this explanation; low-trust project boundary denials retain their dedicated project-access action. +Remote archive capture retains fixed validation failures and a closed set of +Node filesystem codes as `remote_workspace_archive_fs_`. The transport +accepts exact diagnostic messages from separate or combined command output. +Paths, filenames, file contents, and unknown errors remain redacted. The existing +full-disk and unfinished-Git messages keep their priority. These diagnostics +describe a failed capture attempt; saving and recovery still require their +ordinary verified receipts. + A child or follow-up task can use a remote workspace inherited through its persisted task binding, including an isolated workspace or operator branch. Reservation checks current task/run access and all retained workspace sources. diff --git a/server/src/services/remote-workspace-snapshot-archive.test.ts b/server/src/services/remote-workspace-snapshot-archive.test.ts index ef9664d05df..c3caf11b72a 100644 --- a/server/src/services/remote-workspace-snapshot-archive.test.ts +++ b/server/src/services/remote-workspace-snapshot-archive.test.ts @@ -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"]) { diff --git a/server/src/services/remote-workspace-snapshot-archive.ts b/server/src/services/remote-workspace-snapshot-archive.ts index 6c910a83ed9..8d6254d84ac 100644 --- a/server/src/services/remote-workspace-snapshot-archive.ts +++ b/server/src/services/remote-workspace-snapshot-archive.ts @@ -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`; } diff --git a/server/src/services/remote-workspace-transport.test.ts b/server/src/services/remote-workspace-transport.test.ts index 642272c4d22..53b05138959 100644 --- a/server/src/services/remote-workspace-transport.test.ts +++ b/server/src/services/remote-workspace-transport.test.ts @@ -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"; diff --git a/server/src/services/remote-workspace-transport.ts b/server/src/services/remote-workspace-transport.ts index 96cc69f0b65..3b89333275b 100644 --- a/server/src/services/remote-workspace-transport.ts +++ b/server/src/services/remote-workspace-transport.ts @@ -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; @@ -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";