Files
PaperClipAI/packages/plugins/sandbox-providers/daytona/src/file-sync-diagnostics.test.ts
T
Devin FoleyandPaperclip 1c4ce44e13 fix: retain bounded workspace transfer failure evidence (#15637)
## Thinking Path

> - Paperclip manages AI agents and the work they produce.
> - Sandbox work must return to the host when an agent run ends.
> - A failed transfer must keep its source available for recovery.
> - Some transfer errors lose their useful fields before restore
diagnostics reach Sentry.
> - This change records fixed transfer stages, failure kinds, and
numeric RPC codes.
> - The next failure can identify the operation that failed without
exposing workspace contents.

## Linked Issues or Issue Description

**What happened?**

A workspace transfer can fail after model execution succeeds. Several
command, archive validation, and download errors become plain errors.
The existing RPC envelope then reports `unknown`, with no transfer stage
or command exit code. A host RPC timeout also loses its code from
restore diagnostics.

**Expected behavior**

Keep bounded evidence through the provider, worker RPC, restore result,
and Sentry context. Keep the same exception, error message, retry rules,
and source retention policy. A transfer timeout must remain separate
from the model execution timeout flag.

**Steps to reproduce**

Return a nonzero exit code from the sandbox archive command, return a
per-file download error, exceed a tar listing limit, or time out the
sync-out RPC. Observe the missing fields in the saved restore
diagnostic. The added tests use local fixtures for these cases.

Related public work: #15479 preserves the source after restore failure.
#15481 carries bounded sync-out diagnostics through RPC. This change
supplies missing producer evidence and extends that same envelope. I
checked the roadmap and searched open PRs for duplicate transfer
diagnostic work.

## What Changed

- Add optional transfer stage and failure kind fields to the existing
diagnostic envelope. Capture command exits and listing deadlines at
their producers.
- Record only known codes from typed RPC errors at the sync-out
boundary. Revalidate every field before persistence and Sentry
projection.
- Keep annotations private to each outbound request and restore
settlement. Preserve frozen error identity and prevent evidence from
leaking across concurrent or later calls.
- Document the fields. Cover producer failures, quota limits, old
workers, concurrent error reuse, and redaction through the real Sentry
SDK.

## Verification

- Focused SDK, provider, host, persistence, and real Sentry tests: 449
passed, no skips. Independent review also ran the focused contracts and
all provider tests. The optional Sentry SDK is required for this check,
so the contract tests cannot skip.
- A compiled Daytona transfer and compiled SDK worker pass a local RPC
round trip. This uses the development TypeScript loader for workspace
dependency exports. The transfer and capture modules are compiled
JavaScript.
- `pnpm -r typecheck` and `pnpm build` passed. The excluded Daytona
package also passed `tsc --noEmit`.
- The initial local `pnpm test:run` stopped in the general-server group:
60 suites could not initialize embedded PostgreSQL because the isolated
install skipped its Darwin library-link setup. The existing package
postinstall repairs this; `initdb --version` now passes. All 60 affected
suites then passed: 1,342 tests, no skips. No local full aggregate pass
is claimed.
- Independent source review covers privacy, concurrency, packaging, and
recovery behavior.

## Risks

- These diagnostics help identify future failures. They do not establish
or repair the cause of a past transfer failure.
- New fields are optional. Older workers remain compatible. Unknown
values are omitted.
- The change adds a small request-local diagnostic scope. It keeps the
original thrown errors, archive confinement, cleanup order, timeout
values, retry count, and source retention policy.
- No schema or deployment changes are required. No live provider actions
are part of validation.

## Model Used

OpenAI Codex, based on GPT-6, with repository inspection, code
execution, and independent agent review. The exact deployed model ID and
context-window size are not exposed in this session.

## 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
- [ ] 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>
2026-10-08 19:27:39 -07:00

114 lines
5.7 KiB
TypeScript

import { promises as fs } from "node:fs";
import { execFileSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { environmentSyncErrorData, withEnvironmentSyncErrorCapture, readEnvironmentSyncErrorDiagnostic } from "@paperclipai/plugin-sdk";
vi.mock("./plugin.js", async () => {
const { NOOP_PLUGIN_TRACER } = await import("@paperclipai/plugin-sdk");
return { getPluginTracer: () => NOOP_PLUGIN_TRACER };
});
import { performSyncOut } from "./file-sync.js";
const dirs: string[] = [];
afterEach(async () => { await Promise.all(dirs.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true }))); });
it.each([
["guard", "sandbox_guard", "command_failed", 42],
["create", "archive_create", "command_failed", 2],
["download", "archive_download", "download_failed", undefined],
["missing", "archive_download", "download_missing", undefined],
["file", "file_download", "download_failed", undefined],
["file-missing", "file_download", "download_missing", undefined],
] as const)("records producer evidence for %s without copying output or paths", async (failure, step, kind, exitCode) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "daytona-diagnostic-"));
dirs.push(dir);
const sandbox = {
process: { executeCommand: async (command: string) => ({
exitCode: failure === "guard" ? 42 : failure === "create" && command.includes("tar -c") ? 2 : 0,
result: "private-command-output",
}) },
fs: {
downloadFiles: async (requests: Array<{ source: string }>) => failure.endsWith("missing") ? []
: requests.map(request => ({ source: request.source, error: "private-download-error" })),
deleteFile: async () => undefined,
},
};
await withEnvironmentSyncErrorCapture(async () => {
let caught: unknown;
try {
await performSyncOut({ sandbox: sandbox as never, remoteDir: "/workspace", timeoutSeconds: 30,
operations: [{ operationId: "fixture", files: [{
sourcePath: "/workspace/private-path", targetPath: path.join(dir, "target"), kind: failure.startsWith("file") ? "file" : "directory",
}] }],
});
} catch (error) { caught = error; }
expect(caught).toBeInstanceOf(Error);
const diagnostic = readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(caught) });
expect(diagnostic).toEqual({ errorCode: "unknown", transferStep: step, transferFailureKind: kind,
...(exitCode !== undefined ? { exitCode } : {}),
});
expect(JSON.stringify(diagnostic)).not.toContain("private-");
expect(caught).not.toHaveProperty("transferStep");
expect(caught).not.toHaveProperty("exitCode");
});
});
it("keeps a thrown download exception identical and its nested safe code", async () => {
const original = Object.freeze(new Error("private-download-error", { cause: { code: "ECONNRESET", token: "private-token" } }));
const sandbox = {
process: { executeCommand: async () => ({ exitCode: 0 }) },
fs: { downloadFiles: async () => { throw original; }, deleteFile: async () => undefined },
};
await withEnvironmentSyncErrorCapture(async () => {
await expect(performSyncOut({ sandbox: sandbox as never, remoteDir: "/workspace", timeoutSeconds: 30,
operations: [{ operationId: "fixture", files: [{ sourcePath: "/workspace/private-path", targetPath: "/unused", kind: "directory" }] }],
})).rejects.toBe(original);
expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(original) })).toEqual({
errorCode: "ECONNRESET", transferStep: "archive_download",
});
});
});
it.each(["file_finalize", "archive_validate", "archive_extract"] as const)("identifies the host-side %s failure without relaxing confinement", async (step) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "daytona-host-diagnostic-"));
dirs.push(dir);
const target = path.join(dir, "target");
const archive = path.join(dir, "safe.tar");
if (step === "archive_extract") {
await fs.writeFile(path.join(dir, "safe.txt"), "fixture");
execFileSync("tar", ["-cf", archive, "-C", dir, "safe.txt"]);
await fs.writeFile(target, "preserve target");
}
const sandbox = {
process: { executeCommand: async () => ({ exitCode: 0 }) },
fs: {
downloadFiles: async (requests: Array<{ source: string; destination: string }>) => {
for (const request of requests) {
if (step === "archive_validate") await fs.writeFile(request.destination, "invalid private archive");
if (step === "archive_extract") await fs.copyFile(archive, request.destination);
}
return requests.map(request => ({ source: request.source }));
},
deleteFile: async () => undefined,
},
};
await withEnvironmentSyncErrorCapture(async () => {
let caught: unknown;
try {
await performSyncOut({ sandbox: sandbox as never, remoteDir: "/workspace", timeoutSeconds: 30,
operations: [{ operationId: "fixture", files: [{ sourcePath: "/workspace/private-path", targetPath: target,
kind: step === "file_finalize" ? "file" : "directory" }] }],
});
} catch (error) { caught = error; }
expect(caught).toBeInstanceOf(Error);
const diagnostic = readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(caught) });
expect(diagnostic?.transferStep).toBe(step);
expect(JSON.stringify(diagnostic)).not.toContain("private");
if (step === "file_finalize") expect(diagnostic?.errorCode).toBe("ENOENT");
if (step === "archive_validate") expect(diagnostic?.transferFailureKind).toBe("command_failed");
if (step === "archive_extract") expect(await fs.readFile(target, "utf8")).toBe("preserve target");
});
});