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";