Files
PaperClipAI/packages/plugins/sdk/src/environment-sync-error.ts
T
Devin FoleyandClaude Opus 5.5 7f5d0793bd fix(daytona): record a bounded errno when a sandbox command fails (#19297)
## 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>
2026-11-05 11:34:41 -08:00

138 lines
6.8 KiB
TypeScript

import { AsyncLocalStorage } from "node:async_hooks";
/** Diagnostic evidence only. Never use this envelope to authorize cleanup or retry. */
export interface PluginEnvironmentSyncErrorDiagnostic {
errorCode: string;
httpStatus?: number;
exitCode?: number;
transferStep?: EnvironmentSyncTransferStep;
transferFailureKind?: EnvironmentSyncTransferFailureKind;
}
const TRANSFER_STEPS = new Set([
"sandbox_access", "sandbox_guard", "file_download", "file_finalize",
"archive_create", "archive_download", "archive_validate", "archive_extract",
] as const);
export type EnvironmentSyncTransferStep = typeof TRANSFER_STEPS extends Set<infer T> ? T : never;
const TRANSFER_FAILURE_KINDS = new Set([
"command_failed", "download_failed", "download_missing", "unsafe_archive",
"listing_timeout", "listing_entry_limit", "listing_byte_limit", "listing_line_limit", "listing_stderr_limit",
] as const);
export type EnvironmentSyncTransferFailureKind = typeof TRANSFER_FAILURE_KINDS extends Set<infer T> ? T : never;
interface Capture {
active: boolean;
sequence: number;
errors: WeakMap<object, { sequence: number; diagnostic: PluginEnvironmentSyncErrorDiagnostic }>;
}
const capture = new AsyncLocalStorage<Capture>();
/** Scope evidence to one outbound RPC, including its error serialization. */
export async function withEnvironmentSyncErrorCapture<T>(operation: () => Promise<T>): Promise<T> {
const scope: Capture = { active: true, sequence: 0, errors: new WeakMap() };
return capture.run(scope, async () => {
try { return await operation(); }
finally { scope.active = false; }
});
}
/** Producer-owned evidence only; never mutate, wrap, or retain a raw cause. */
export function recordEnvironmentSyncError<T>(error: T, diagnostic: Partial<PluginEnvironmentSyncErrorDiagnostic>): T {
const scope = capture.getStore();
const safe = sanitize(diagnostic);
if (scope?.active && error && typeof error === "object") {
if (safe) scope.errors.set(error, { sequence: ++scope.sequence, diagnostic: safe });
else scope.errors.delete(error);
}
return error;
}
/** A nested failing stage wins; a later attempt gets fresh attribution. */
export async function withEnvironmentSyncTransferStep<T>(step: EnvironmentSyncTransferStep, operation: () => Promise<T>): Promise<T> {
const scope = capture.getStore();
if (!scope?.active || !TRANSFER_STEPS.has(step)) return await operation();
const started = scope.sequence;
try { return await operation(); }
catch (error) {
if (scope.active && error && typeof error === "object") {
const saved = scope.errors.get(error);
const diagnostic = saved && saved.sequence > started ? saved.diagnostic : { errorCode: "unknown" };
scope.errors.set(error, {
sequence: ++scope.sequence, diagnostic: { ...diagnostic, transferStep: diagnostic.transferStep ?? step },
});
}
throw error;
}
}
const SCHEMA = "paperclip/environment-sync-error/v1";
// Match the host's workspace restore diagnostic allowlist. Both sides validate
// independently: plugin-supplied data must never widen the host's log surface.
const ERROR_CODES = new Set([
"ENOENT", "EACCES", "EPERM", "ENOSPC", "EIO", "EXDEV", "ENOTDIR", "EISDIR", "ENAMETOOLONG",
"ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EPIPE", "ENOTFOUND", "EAI_AGAIN",
"ABORT_ERR", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET",
]);
function field(value: unknown, key: string): unknown {
try { return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined; }
catch { return undefined; }
}
function integer(value: unknown, minimum: number, maximum: number): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value >= minimum && value <= maximum
? value : undefined;
}
function sanitize(value: unknown): PluginEnvironmentSyncErrorDiagnostic | undefined {
const code = field(value, "errorCode");
const errorCode = typeof code === "string" && ERROR_CODES.has(code) ? code : "unknown";
const httpStatus = integer(field(value, "httpStatus"), 400, 599);
const exitCode = integer(field(value, "exitCode"), 1, 255);
const step = field(value, "transferStep");
const kind = field(value, "transferFailureKind");
const transferStep = typeof step === "string" && TRANSFER_STEPS.has(step as EnvironmentSyncTransferStep) ? step as EnvironmentSyncTransferStep : undefined;
const transferFailureKind = typeof kind === "string" && TRANSFER_FAILURE_KINDS.has(kind as EnvironmentSyncTransferFailureKind) ? kind as EnvironmentSyncTransferFailureKind : undefined;
if (errorCode === "unknown" && httpStatus === undefined && exitCode === undefined && !transferStep && !transferFailureKind) return undefined;
return { errorCode, ...(httpStatus !== undefined ? { httpStatus } : {}), ...(exitCode !== undefined ? { exitCode } : {}),
...(transferStep ? { transferStep } : {}), ...(transferFailureKind ? { transferFailureKind } : {}),
};
}
/** Capture known codes and numbers, never arbitrary error data, text, or names. */
function captureDiagnostic(error: unknown): PluginEnvironmentSyncErrorDiagnostic | undefined {
const diagnostic: PluginEnvironmentSyncErrorDiagnostic = { errorCode: "unknown" };
const scope = capture.getStore();
if (scope?.active && error && typeof error === "object") {
Object.assign(diagnostic, scope.errors.get(error)?.diagnostic);
}
let current = error;
// Bound nested SDK causes, including cyclic errors and throwing getters.
for (let depth = 0; depth < 4 && current && typeof current === "object"; depth++) {
const code = field(current, "code");
if (diagnostic.errorCode === "unknown" && typeof code === "string" && ERROR_CODES.has(code)) diagnostic.errorCode = code;
diagnostic.httpStatus ??= integer(field(current, "status"), 400, 599)
?? integer(field(current, "statusCode"), 400, 599)
?? integer(field(field(current, "response"), "status"), 400, 599);
diagnostic.exitCode ??= integer(field(current, "exitCode"), 1, 255) ?? integer(code, 1, 255);
current = field(current, "cause");
}
return sanitize(diagnostic);
}
export function environmentSyncErrorData(error: unknown): unknown {
const diagnostic = captureDiagnostic(error);
return diagnostic ? { schema: SCHEMA, diagnostic } : undefined;
}
/** Retain bounded evidence across an existing wrapper, without adding a raw cause. */
export function preserveEnvironmentSyncErrorDiagnostic<T>(wrapper: T, source: unknown): T {
const diagnostic = captureDiagnostic(source);
return recordEnvironmentSyncError(wrapper, diagnostic ?? { errorCode: "unknown" });
}
/** Revalidate the worker envelope before adapting it into host-only diagnostics. */
export function readEnvironmentSyncErrorDiagnostic(error: unknown): PluginEnvironmentSyncErrorDiagnostic | undefined {
const data = field(error, "data");
return field(data, "schema") === SCHEMA ? sanitize(field(data, "diagnostic")) : undefined;
}