mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
## 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>
114 lines
5.7 KiB
TypeScript
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");
|
|
});
|
|
});
|