mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-20 04:20:38 +09:00
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Heartbeats run inside managed sandboxes (E2B, Cloudflare Sandbox), and each run begins by uploading the agent's workspace as a tar archive > - PAPA-381's E2B runs were failing at 5 and 11 minutes — two distinct failure modes were entangled: workspace tar extraction errors on Linux, and sandbox idle/lease timeouts during normal heartbeat gaps > - Workspace tar extraction failed because macOS bsdtar embeds `LIBARCHIVE.xattr.*` PAX headers that GNU tar on Linux rejects with "This does not look like a tar archive"; the existing `COPYFILE_DISABLE=1` only suppresses AppleDouble `._*` sidecars, not inline PAX xattr entries > - E2B sandboxes also expired between heartbeats because `timeoutMs` defaulted to a short window and was never refreshed per execute, and Cloudflare sandboxes idled out because `sleepAfter` defaulted to 10 minutes > - This pull request adds `--no-xattrs` to the workspace tar invocation, refreshes the E2B sandbox lifetime on each execute and bumps the default `timeoutMs` to 1h, and raises the Cloudflare `sleepAfter` default to 1h > - The benefit is that long-running heartbeat-driven runs (Claude, Codex, etc.) survive across both their initial workspace upload and the natural idle gaps between executes on both E2B and Cloudflare ## What Changed - `packages/adapter-utils/src/sandbox-managed-runtime.ts`: added `--no-xattrs` to `createTarballFromDirectory` so macOS bsdtar produces a clean POSIX tar that GNU tar on Linux can extract, with an inline comment explaining why `COPYFILE_DISABLE=1` alone is insufficient. - `packages/plugins/sandbox-providers/e2b/src/plugin.ts`: refresh the sandbox lifetime on every execute (so long runs don't expire mid-job) and raised the default `timeoutMs` to 1h. - `packages/plugins/sandbox-providers/e2b/src/manifest.ts` and `plugin.test.ts`: updated manifest defaults and added regression coverage for the new behavior. - `packages/plugins/sandbox-providers/cloudflare/src/config.ts`, `manifest.ts`, `plugin.test.ts`: raised default `sleepAfter` from 10m to 1h, mirroring the E2B 1h default, and added a regression test asserting the acquire-lease request body sends `sleepAfter: "1h"` when not overridden. ## Verification - `pnpm --filter @paperclipai/plugin-e2b test` - `pnpm --filter @paperclipai/plugin-cloudflare-sandbox test` - Locally cherry-picked the `--no-xattrs` fix onto master and confirmed end-to-end via a real PAPA-381-style heartbeat-driven E2B run that the workspace upload now extracts cleanly on Linux. The user (board operator) tested this on master and reported "Ok, that worked." - Manual reviewer steps: trigger an E2B heartbeat from a macOS host (this is where the bsdtar xattr headers come from), confirm the workspace tar extracts on the Linux sandbox side; run a long (>15 min) Cloudflare sandbox flow and confirm no lost-lease/idle errors between executes. ## Risks - Low risk overall. - `--no-xattrs` is widely supported by both macOS bsdtar and GNU tar (Linux). Worst case it silently no-ops on a future host that doesn't support it; in that case the existing failure mode reappears, it doesn't introduce a new one. - Raising default `timeoutMs` (E2B) and `sleepAfter` (Cloudflare) from short values to 1h means sandboxes stay alive longer between executes by default. This is the intended behavior — operators that want a tighter idle window can still override via plugin config. - E2B per-execute sandbox lifetime refresh adds a small API call per execute; it is bounded by the same client that already handles execute traffic, so no new dependencies or retry semantics. ## Model Used - Claude (Anthropic), `claude-opus-4-7`, extended thinking enabled, tool use enabled (file/grep/git tools and Paperclip control-plane API). Used to diagnose the dual failure mode (workspace tar PAX xattr headers + sandbox lifetime), write the fixes and tests, and drive the verification loop with the board operator. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots (N/A — no UI changes) - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
84 lines
3.1 KiB
TypeScript
84 lines
3.1 KiB
TypeScript
import type { CloudflareDriverConfig } from "./types.js";
|
|
|
|
const DEFAULT_REQUESTED_CWD = "/workspace/paperclip";
|
|
const DEFAULT_SLEEP_AFTER = "1h";
|
|
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
const DEFAULT_BRIDGE_REQUEST_TIMEOUT_MS = 300_000;
|
|
const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
|
|
function readTrimmedString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
function readBoolean(value: unknown, fallback: boolean): boolean {
|
|
return value === undefined ? fallback : value === true;
|
|
}
|
|
|
|
function readInteger(value: unknown, fallback: number): number {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
|
|
}
|
|
|
|
function isLocalBridgeHost(url: URL): boolean {
|
|
return LOCALHOST_HOSTNAMES.has(url.hostname);
|
|
}
|
|
|
|
export function parseCloudflareDriverConfig(raw: Record<string, unknown>): CloudflareDriverConfig {
|
|
return {
|
|
bridgeBaseUrl: readTrimmedString(raw.bridgeBaseUrl) ?? "",
|
|
bridgeAuthToken: readTrimmedString(raw.bridgeAuthToken) ?? "",
|
|
reuseLease: readBoolean(raw.reuseLease, false),
|
|
keepAlive: readBoolean(raw.keepAlive, false),
|
|
sleepAfter: readTrimmedString(raw.sleepAfter) ?? DEFAULT_SLEEP_AFTER,
|
|
normalizeId: readBoolean(raw.normalizeId, true),
|
|
requestedCwd: readTrimmedString(raw.requestedCwd) ?? DEFAULT_REQUESTED_CWD,
|
|
sessionStrategy: raw.sessionStrategy === "default" ? "default" : "named",
|
|
sessionId: readTrimmedString(raw.sessionId) ?? "paperclip",
|
|
timeoutMs: readInteger(raw.timeoutMs, DEFAULT_TIMEOUT_MS),
|
|
bridgeRequestTimeoutMs: readInteger(raw.bridgeRequestTimeoutMs, DEFAULT_BRIDGE_REQUEST_TIMEOUT_MS),
|
|
previewHostname: readTrimmedString(raw.previewHostname),
|
|
};
|
|
}
|
|
|
|
export function validateCloudflareDriverConfig(config: CloudflareDriverConfig): string[] {
|
|
const errors: string[] = [];
|
|
|
|
if (!config.bridgeBaseUrl) {
|
|
errors.push("Cloudflare sandbox environments require bridgeBaseUrl.");
|
|
} else {
|
|
try {
|
|
const url = new URL(config.bridgeBaseUrl);
|
|
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocalBridgeHost(url))) {
|
|
errors.push("bridgeBaseUrl must use HTTPS unless it points at localhost.");
|
|
}
|
|
} catch {
|
|
errors.push("bridgeBaseUrl must be a valid URL.");
|
|
}
|
|
}
|
|
|
|
if (!config.bridgeAuthToken) {
|
|
errors.push("Cloudflare sandbox environments require bridgeAuthToken.");
|
|
}
|
|
|
|
if (config.reuseLease && !config.keepAlive) {
|
|
errors.push("reuseLease requires keepAlive for Cloudflare sandboxes.");
|
|
}
|
|
|
|
if (config.timeoutMs < 1 || config.timeoutMs > 86_400_000) {
|
|
errors.push("timeoutMs must be between 1 and 86400000.");
|
|
}
|
|
|
|
if (config.bridgeRequestTimeoutMs < 1 || config.bridgeRequestTimeoutMs > 86_400_000) {
|
|
errors.push("bridgeRequestTimeoutMs must be between 1 and 86400000.");
|
|
}
|
|
|
|
if (!config.requestedCwd.startsWith("/")) {
|
|
errors.push("requestedCwd must be an absolute POSIX path.");
|
|
}
|
|
|
|
if (config.sessionStrategy === "named" && config.sessionId.trim().length === 0) {
|
|
errors.push("sessionId is required when sessionStrategy is named.");
|
|
}
|
|
|
|
return errors;
|
|
}
|