mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 21:01:54 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The Daytona sandbox provider copies the agent's workspace back to
the host after each turn (syncOut)
> - When the sandbox `tar` fails in the `archive_create` step, the
restore diagnostic records only `exitCode: 2` and `errorCode: "unknown"`
> - The command output is not sent across the plugin RPC boundary, so an
operator cannot tell a full disk from a permission error or a path that
is too long
> - This pull request maps the system error text at the end of a failed
sandbox command's output line to the existing allowlisted `errorCode`
> - The benefit is that the next failure records the real errno, and the
output text stays private
## Linked Issues or Issue Description
Refs #18172
Refs #18212
**What happened?**
A hosted Daytona run failed its workspace restore. The `archive_create`
step exited 2 on all three attempts. The run log recorded
`{"step":"workspace_transfer","transferStep":"archive_create","transferFailureKind":"command_failed","exitCode":2,"errorCode":"unknown"}`.
Two causes are possible. The sandbox disk was full, or a path was longer
than PATH_MAX, for example from deeply nested GitHub helper config
directories before #18212. The diagnostic cannot tell these causes
apart.
**Expected behavior**
A failed sandbox command records a bounded errno such as `ENOSPC`,
`EACCES` or `ENAMETOOLONG` when its output shows one. The output text
itself must not be recorded.
**Steps to reproduce**
1. Run `performSyncOut` against a sandbox whose `tar -c` exits 2 with
`tar: ./x: Cannot open: Permission denied`.
2. Read the environment sync diagnostic from the thrown error.
3. See that `errorCode` is `unknown`.
**Paperclip version or commit**
`master` at 3fca8a1.
## What Changed
- `packages/plugins/sandbox-providers/daytona/src/file-sync.ts`:
`assertSandboxCommandOkWithOutput` maps the strerror suffix of a failed
command's output to an allowlisted errno. It reads only the last 16 KiB
in memory. A line must end with `: <message>`. Fatal causes are checked
first (ENOSPC, EIO, ENAMETOOLONG, EACCES, EPERM, ENOTDIR, EISDIR,
ENOENT), so a warning about a vanished file does not hide the error that
stopped `tar`. The error message, capacity fallback (#18172), restore
and retain-on-failure behavior do not change.
- `packages/plugins/sdk/src/environment-sync-error.ts` and
`packages/adapter-utils/src/workspace-restore-diagnostics.ts`: add
`ENAMETOOLONG` to both allowlists. Both sides still validate
independently.
- Tests: 7 new Daytona cases, plus one allowlist case each in the SDK
and adapter-utils.
## Verification
- `vitest run --config
packages/plugins/sandbox-providers/daytona/vitest.config.ts
packages/plugins/sandbox-providers/daytona/src/file-sync-diagnostics.test.ts`:
18 passed. Without the `file-sync.ts` change, the 7 new cases fail with
`errorCode: "unknown"`.
- `packages/plugins/sdk`: `vitest run
tests/environment-sync-error.test.ts`: 20 passed.
- `packages/adapter-utils`: `vitest run
src/workspace-restore-diagnostics.test.ts`: 50 passed.
- `tsc --noEmit` passes for the SDK and adapter-utils. `git diff
--check` is clean.
- I did not run the Daytona package typecheck or its full suite locally
because the sandbox disk was full. CI covers them.
## Risks
- Low risk. The change adds a diagnostic field only. Control flow, error
messages and the capacity fallback regex do not change.
- Localized `tar` output does not match, so it stays `unknown`. That is
the current behavior.
- `ENAMETOOLONG` is a new allowlisted code on both sides. It is a fixed
string and carries no path or output text.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code
execution in an agent harness.
## 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
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
117 lines
7.1 KiB
TypeScript
117 lines
7.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { preserveEnvironmentSyncErrorDiagnostic, environmentSyncErrorData, readEnvironmentSyncErrorDiagnostic, withEnvironmentSyncErrorCapture, withEnvironmentSyncTransferStep, recordEnvironmentSyncError } from "../src/environment-sync-error.js";
|
|
|
|
const schema = "paperclip/environment-sync-error/v1";
|
|
|
|
describe("environment sync diagnostic envelope", () => {
|
|
it("keeps only known codes and bounded numbers from nested provider errors", () => {
|
|
const error = Object.assign(new Error("private-message"), {
|
|
name: "private-name", code: "private-code", path: "/private-path",
|
|
response: { status: 503, data: { token: "private-token" } },
|
|
data: { credentials: "private-credentials" },
|
|
cause: { code: "ECONNRESET", exitCode: 7, stderr: "private-output" },
|
|
});
|
|
const data = environmentSyncErrorData(error);
|
|
expect(data).toEqual({ schema, diagnostic: { errorCode: "ECONNRESET", httpStatus: 503, exitCode: 7 } });
|
|
expect(JSON.stringify(data)).not.toContain("private-");
|
|
expect(readEnvironmentSyncErrorDiagnostic({ data })).toEqual({ errorCode: "ECONNRESET", httpStatus: 503, exitCode: 7 });
|
|
});
|
|
|
|
it.each([undefined, null, "private-error", { code: "PRIVATE_TOKEN" }, { status: 600 }, { status: 399 },
|
|
{ statusCode: "503" }, { status: 500.5 }, { exitCode: 0 }, { exitCode: 256 }, { code: -32001 },
|
|
{ exitCode: Infinity }, { data: { schema, diagnostic: { errorCode: "EACCES" } } },
|
|
])("omits unrecognized or unbounded provider values: %j", (error) => {
|
|
expect(environmentSyncErrorData(error)).toBeUndefined();
|
|
});
|
|
|
|
it("bounds cyclic causes and ignores throwing getters without changing the error", () => {
|
|
const error = Object.defineProperties({ statusCode: 429 } as Record<string, unknown>, {
|
|
code: { get() { throw new Error("private-code"); } },
|
|
response: { get() { throw new Error("private-response"); } },
|
|
});
|
|
error.cause = error;
|
|
expect(environmentSyncErrorData(error)).toEqual({ schema, diagnostic: { errorCode: "unknown", httpStatus: 429 } });
|
|
const wrap = (cause: unknown) => ({ cause });
|
|
expect(environmentSyncErrorData(wrap(wrap(wrap({ code: "EIO" }))))).toEqual({ schema, diagnostic: { errorCode: "EIO" } });
|
|
expect(environmentSyncErrorData(wrap(wrap(wrap(wrap({ code: "EIO" })))))).toBeUndefined();
|
|
expect(environmentSyncErrorData({ code: "ENAMETOOLONG" })).toEqual({ schema, diagnostic: { errorCode: "ENAMETOOLONG" } });
|
|
});
|
|
|
|
it("revalidates worker fields and ignores arbitrary payload and protocol versions", () => {
|
|
expect(readEnvironmentSyncErrorDiagnostic({ data: { schema, diagnostic: {
|
|
errorCode: "EACCES", httpStatus: 403, exitCode: 1.5,
|
|
message: "private-message", path: "/private-path", credentials: "private-token",
|
|
} } })).toEqual({ errorCode: "EACCES", httpStatus: 403 });
|
|
expect(readEnvironmentSyncErrorDiagnostic({ data: { schema: "other", diagnostic: { errorCode: "EACCES" } } })).toBeUndefined();
|
|
expect(readEnvironmentSyncErrorDiagnostic({ data: { schema, diagnostic: { errorCode: "private-token", httpStatus: 900 } } })).toBeUndefined();
|
|
expect(readEnvironmentSyncErrorDiagnostic(Object.defineProperty({}, "data", { get() { throw new Error("private"); } }))).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
|
|
describe("request-local transfer evidence", () => {
|
|
it("preserves frozen error identity, nested attribution, and later-attempt attribution", async () => {
|
|
const error = Object.freeze(new Error("private-message"));
|
|
const before = Object.getOwnPropertyDescriptors(error);
|
|
await withEnvironmentSyncErrorCapture(async () => {
|
|
await expect(withEnvironmentSyncTransferStep("archive_download", () =>
|
|
withEnvironmentSyncTransferStep("sandbox_access", async () => { throw error; }))).rejects.toBe(error);
|
|
expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(error) })?.transferStep).toBe("sandbox_access");
|
|
await expect(withEnvironmentSyncTransferStep("archive_create", async () => { throw error; })).rejects.toBe(error);
|
|
expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(error) })?.transferStep).toBe("archive_create");
|
|
});
|
|
expect(environmentSyncErrorData(error)).toBeUndefined();
|
|
expect(Object.getOwnPropertyDescriptors(error)).toEqual(before);
|
|
await withEnvironmentSyncErrorCapture(async () => expect(environmentSyncErrorData(error)).toBeUndefined());
|
|
});
|
|
|
|
it("does not trust labels on raw errors, their causes, or unknown producer values", async () => {
|
|
const error = { transferStep: "archive_create", transferFailureKind: "command_failed",
|
|
cause: { transferStep: "archive_validate", message: "private-message" } };
|
|
await withEnvironmentSyncErrorCapture(async () => {
|
|
expect(environmentSyncErrorData(error)).toBeUndefined();
|
|
recordEnvironmentSyncError(error, { transferStep: "private-stage", transferFailureKind: "private-kind", exitCode: 900 } as never);
|
|
expect(environmentSyncErrorData(error)).toBeUndefined();
|
|
});
|
|
expect(readEnvironmentSyncErrorDiagnostic({ data: { schema, diagnostic: {
|
|
errorCode: "unknown", transferStep: "private-stage", transferFailureKind: "private-kind", rpcCode: -32003,
|
|
} } })).toBeUndefined();
|
|
});
|
|
|
|
it("omits evidence from detached work after the RPC scope closes", async () => {
|
|
const error = new Error("private-message");
|
|
let resume!: () => void;
|
|
const gate = new Promise<void>(resolve => { resume = resolve; });
|
|
let detached!: Promise<unknown>;
|
|
await withEnvironmentSyncErrorCapture(async () => {
|
|
detached = withEnvironmentSyncTransferStep("archive_create", async () => { await gate; throw error; })
|
|
.catch(failure => environmentSyncErrorData(failure));
|
|
});
|
|
resume();
|
|
expect(await detached).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
|
|
it("copies safe evidence into an existing wrapper only within its request", async () => {
|
|
const source = Object.freeze(Object.assign(new Error("private-message"), { status: 404, cause: { code: "EIO", token: "private-token" } }));
|
|
const wrapper = Object.freeze(new Error("stable-policy-message"));
|
|
await withEnvironmentSyncErrorCapture(async () => {
|
|
await expect(withEnvironmentSyncTransferStep("file_download", async () => { throw source; })).rejects.toBe(source);
|
|
expect(preserveEnvironmentSyncErrorDiagnostic(wrapper, source)).toBe(wrapper);
|
|
expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(wrapper) })).toEqual({
|
|
errorCode: "EIO", httpStatus: 404, transferStep: "file_download",
|
|
});
|
|
expect(wrapper).not.toHaveProperty("cause");
|
|
expect(wrapper.message).toBe("stable-policy-message");
|
|
// Reusing a wrapper for a later untyped failure must clear the earlier evidence.
|
|
preserveEnvironmentSyncErrorDiagnostic(wrapper, new Error("private-untyped-message"));
|
|
expect(environmentSyncErrorData(wrapper)).toBeUndefined();
|
|
});
|
|
expect(environmentSyncErrorData(wrapper)).toBeUndefined();
|
|
await withEnvironmentSyncErrorCapture(async () => {
|
|
preserveEnvironmentSyncErrorDiagnostic(wrapper, source);
|
|
expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(wrapper) })).toEqual({ errorCode: "EIO", httpStatus: 404 });
|
|
});
|
|
});
|