mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents can run in remote/sandboxed environments via the shared sandbox managed-runtime in `@paperclipai/adapter-utils` (used by SSH/E2B/Daytona and other sandbox providers), which syncs the workspace into the sandbox by tarring it up and extracting it inside the pod/host > - When the sandbox runs the harness as a non-root user whose home/workspace dir it does not own (for example a hardened, non-root, gVisor pod with an `emptyDir`-mounted workspace), the workspace upload aborts before the agent can start > - Root cause: `createTarballFromDirectory` archives `.`, embedding a `./` self-entry whose mode/mtime tar then tries to restore onto the **extraction target directory**; `chmod`/`utime` of `.` fails with `Operation not permitted` for a non-owner > - This is not specific to any one deployment: the `.` self-entry EPERM can bite every sandbox provider built on the shared managed runtime as soon as the extracting user does not own the target directory, which is the norm for hardened non-root sandboxes > - This pull request archives the directory's top-level entries by name instead of `.`, so there is no `./` self-entry and extraction never touches the target dir's metadata > - The benefit is that workspace sync works in any sandbox where the target dir is non-root or not owned by the extracting user, without GNU-only tar flags ## Linked Issues or Issue Description No existing issue; describing in-PR (bug). - **What happens:** managed sandbox runs that sync the workspace fail at upload with `tar: .: Cannot utime: Operation not permitted` / `tar: .: Cannot change mode to ... : Operation not permitted`, aborting the run before the harness starts. - **Where:** `packages/adapter-utils/src/sandbox-managed-runtime.ts`, in `createTarballFromDirectory` (archives `.`). - **When:** the extraction target directory is not owned by the (non-root) user extracting the tar inside the sandbox. - Closely related (different root cause): #6560 (E2B workspace upload + lease idle failures). ## What Changed - `createTarballFromDirectory` enumerates the directory's top-level entries with `fs.readdir` and passes them by name after `--` (guards flag-like filenames) instead of archiving `.`, eliminating the `./` self-entry that triggers the EPERM. - Empty workspaces (legitimate for blank-workspace runs) write a valid 1024-byte all-zero EOF tar instead of invoking `tar` with no paths. - `--exclude` patterns continue to apply (to nested matches and any named entry). ## Verification - `pnpm --filter @paperclipai/adapter-utils build` (tsc clean) - `pnpm exec vitest run packages/adapter-utils/src/sandbox-managed-runtime.test.ts` runs green - New tests: uploaded workspace/asset tarballs contain no `.`/`./` member yet still extract correctly; empty workspace produces a valid (no-op) tarball. Existing managed-runtime sync test unchanged. - Manually verified in a hardened (non-root, gVisor) sandbox pod: with the fix, the workspace upload that previously aborted with the EPERM now succeeds. That deployment is the reproduction and verification environment; the fix itself is provider-agnostic. ## Risks Low. Behavior is unchanged for owned/root targets; the archive contents are the same minus the `./` self-entry (which tar recreates implicitly on extract). Portable across GNU/BSD/busybox tar (no GNU-only `--no-overwrite-dir`). No API/migration/UI impact. ## Model Used Claude Opus 4.8 (`claude-opus-4-8`, 1M context), extended thinking + tool use, via Claude Code. ## 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 (bug fix in shared sandbox utils, not core feature work) - [x] I have searched GitHub for duplicate or related PRs and linked them above (#6560) - [x] I have either (a) linked existing issues OR (b) described the issue in-PR following the relevant issue template - [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) - [ ] I have updated relevant documentation to reflect my changes (n/a, internal behavior, no docs reference this) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (the only finding was the description-template P2, resolved by this description; the latest review covers the current head with no code findings and all CI gates are green) - [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 4.8 (1M context) <noreply@anthropic.com>
366 lines
13 KiB
TypeScript
366 lines
13 KiB
TypeScript
import { execFile as execFileCallback } from "node:child_process";
|
|
import { constants as fsConstants, promises as fs } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { captureDirectorySnapshot, mergeDirectoryWithBaseline } from "./workspace-restore-merge.js";
|
|
|
|
const execFile = promisify(execFileCallback);
|
|
|
|
export interface SandboxRemoteExecutionSpec {
|
|
transport: "sandbox";
|
|
provider: string;
|
|
sandboxId: string;
|
|
remoteCwd: string;
|
|
timeoutMs: number;
|
|
apiKey: string | null;
|
|
}
|
|
|
|
export interface SandboxManagedRuntimeAsset {
|
|
key: string;
|
|
localDir: string;
|
|
followSymlinks?: boolean;
|
|
exclude?: string[];
|
|
}
|
|
|
|
export interface SandboxManagedRuntimeClient {
|
|
makeDir(remotePath: string): Promise<void>;
|
|
writeFile(remotePath: string, bytes: ArrayBuffer): Promise<void>;
|
|
readFile(remotePath: string): Promise<Buffer | Uint8Array | ArrayBuffer>;
|
|
listFiles(remotePath: string): Promise<string[]>;
|
|
remove(remotePath: string): Promise<void>;
|
|
run(command: string, options: { timeoutMs: number }): Promise<void>;
|
|
}
|
|
|
|
export interface PreparedSandboxManagedRuntime {
|
|
spec: SandboxRemoteExecutionSpec;
|
|
workspaceLocalDir: string;
|
|
workspaceRemoteDir: string;
|
|
runtimeRootDir: string;
|
|
assetDirs: Record<string, string>;
|
|
restoreWorkspace(): Promise<void>;
|
|
}
|
|
|
|
function asObject(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {};
|
|
}
|
|
|
|
function asString(value: unknown): string {
|
|
return typeof value === "string" ? value : "";
|
|
}
|
|
|
|
function asNumber(value: unknown): number {
|
|
return typeof value === "number" ? value : Number(value);
|
|
}
|
|
|
|
function shellQuote(value: string) {
|
|
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
|
}
|
|
|
|
export function parseSandboxRemoteExecutionSpec(value: unknown): SandboxRemoteExecutionSpec | null {
|
|
const parsed = asObject(value);
|
|
const transport = asString(parsed.transport).trim();
|
|
const provider = asString(parsed.provider).trim();
|
|
const sandboxId = asString(parsed.sandboxId).trim();
|
|
const remoteCwd = asString(parsed.remoteCwd).trim();
|
|
const timeoutMs = asNumber(parsed.timeoutMs);
|
|
|
|
if (
|
|
transport !== "sandbox" ||
|
|
provider.length === 0 ||
|
|
sandboxId.length === 0 ||
|
|
remoteCwd.length === 0 ||
|
|
!Number.isFinite(timeoutMs) ||
|
|
timeoutMs <= 0
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
transport: "sandbox",
|
|
provider,
|
|
sandboxId,
|
|
remoteCwd,
|
|
timeoutMs,
|
|
apiKey: asString(parsed.apiKey).trim() || null,
|
|
};
|
|
}
|
|
|
|
export function buildSandboxExecutionSessionIdentity(spec: SandboxRemoteExecutionSpec | null) {
|
|
if (!spec) return null;
|
|
return {
|
|
transport: "sandbox",
|
|
provider: spec.provider,
|
|
sandboxId: spec.sandboxId,
|
|
remoteCwd: spec.remoteCwd,
|
|
} as const;
|
|
}
|
|
|
|
export function sandboxExecutionSessionMatches(saved: unknown, current: SandboxRemoteExecutionSpec | null): boolean {
|
|
const currentIdentity = buildSandboxExecutionSessionIdentity(current);
|
|
if (!currentIdentity) return false;
|
|
const parsedSaved = asObject(saved);
|
|
return (
|
|
asString(parsedSaved.transport) === currentIdentity.transport &&
|
|
asString(parsedSaved.provider) === currentIdentity.provider &&
|
|
asString(parsedSaved.sandboxId) === currentIdentity.sandboxId &&
|
|
asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd
|
|
);
|
|
}
|
|
|
|
async function withTempDir<T>(prefix: string, fn: (dir: string) => Promise<T>): Promise<T> {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
try {
|
|
return await fn(dir);
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
async function execTar(args: string[]): Promise<void> {
|
|
await execFile("tar", args, {
|
|
env: {
|
|
...process.env,
|
|
COPYFILE_DISABLE: "1",
|
|
},
|
|
maxBuffer: 32 * 1024 * 1024,
|
|
});
|
|
}
|
|
|
|
async function createTarballFromDirectory(input: {
|
|
localDir: string;
|
|
archivePath: string;
|
|
exclude?: string[];
|
|
followSymlinks?: boolean;
|
|
}): Promise<void> {
|
|
const excludeArgs = ["._*", ...(input.exclude ?? [])].flatMap((entry) => ["--exclude", entry]);
|
|
// Archive the directory's top-level entries BY NAME rather than ".". Archiving
|
|
// "." embeds a "./" self-entry whose mode/mtime tar then tries to restore onto
|
|
// the extraction target directory; that chmod/utime fails with "Operation not
|
|
// permitted" when the target is a directory the extracting (non-root) user does
|
|
// not own, e.g. an emptyDir mount in a hardened/gVisor sandbox pod. Enumerating
|
|
// entries avoids the self-entry entirely and is portable across GNU/BSD/busybox
|
|
// tar (no GNU-only --no-overwrite-dir needed). --exclude still filters nested
|
|
// matches and any named entry it matches.
|
|
const entries = (await fs.readdir(input.localDir)).sort((left, right) => left.localeCompare(right));
|
|
if (entries.length === 0) {
|
|
// A workspace can legitimately be empty (blank-workspace agent runs). Write a
|
|
// valid empty tar archive (1024-byte all-zero EOF marker) so extraction is a
|
|
// clean no-op rather than tar refusing to create an empty archive.
|
|
await fs.writeFile(input.archivePath, Buffer.alloc(1024));
|
|
return;
|
|
}
|
|
await execTar([
|
|
"-c",
|
|
// Prevent macOS bsdtar from embedding LIBARCHIVE.xattr.* PAX extended
|
|
// headers for extended attributes (e.g. com.apple.provenance). GNU tar on
|
|
// Linux does not recognise these proprietary headers and fails extraction
|
|
// with "This does not look like a tar archive". COPYFILE_DISABLE=1 (set in
|
|
// execTar) already suppresses AppleDouble ._* sidecar files; --no-xattrs
|
|
// additionally suppresses the inline PAX xattr entries.
|
|
"--no-xattrs",
|
|
...(input.followSymlinks ? ["-h"] : []),
|
|
"-f",
|
|
input.archivePath,
|
|
"-C",
|
|
input.localDir,
|
|
...excludeArgs,
|
|
"--",
|
|
...entries,
|
|
]);
|
|
}
|
|
|
|
async function extractTarballToDirectory(input: {
|
|
archivePath: string;
|
|
localDir: string;
|
|
}): Promise<void> {
|
|
await fs.mkdir(input.localDir, { recursive: true });
|
|
await execTar(["-xf", input.archivePath, "-C", input.localDir]);
|
|
}
|
|
|
|
async function walkDirectory(root: string, relative = ""): Promise<string[]> {
|
|
const current = path.join(root, relative);
|
|
const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
|
|
const out: string[] = [];
|
|
for (const entry of entries) {
|
|
const nextRelative = relative ? path.posix.join(relative, entry.name) : entry.name;
|
|
out.push(nextRelative);
|
|
if (entry.isDirectory()) {
|
|
out.push(...(await walkDirectory(root, nextRelative)));
|
|
}
|
|
}
|
|
return out.sort((left, right) => right.length - left.length);
|
|
}
|
|
|
|
function isRelativePathOrDescendant(relative: string, candidate: string): boolean {
|
|
return relative === candidate || relative.startsWith(`${candidate}/`);
|
|
}
|
|
|
|
export async function mirrorDirectory(
|
|
sourceDir: string,
|
|
targetDir: string,
|
|
options: { preserveAbsent?: string[] } = {},
|
|
): Promise<void> {
|
|
await fs.mkdir(targetDir, { recursive: true });
|
|
const preserveAbsent = new Set(options.preserveAbsent ?? []);
|
|
const shouldPreserveAbsent = (relative: string) =>
|
|
[...preserveAbsent].some((candidate) => isRelativePathOrDescendant(relative, candidate));
|
|
|
|
const sourceEntries = new Set(await walkDirectory(sourceDir));
|
|
const targetEntries = await walkDirectory(targetDir);
|
|
for (const relative of targetEntries) {
|
|
if (shouldPreserveAbsent(relative)) continue;
|
|
if (!sourceEntries.has(relative)) {
|
|
await fs.rm(path.join(targetDir, relative), { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
const copyEntry = async (relative: string) => {
|
|
const sourcePath = path.join(sourceDir, relative);
|
|
const targetPath = path.join(targetDir, relative);
|
|
const stats = await fs.lstat(sourcePath);
|
|
|
|
if (stats.isDirectory()) {
|
|
await fs.mkdir(targetPath, { recursive: true });
|
|
return;
|
|
}
|
|
|
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
await fs.rm(targetPath, { recursive: true, force: true }).catch(() => undefined);
|
|
if (stats.isSymbolicLink()) {
|
|
const linkTarget = await fs.readlink(sourcePath);
|
|
await fs.symlink(linkTarget, targetPath);
|
|
return;
|
|
}
|
|
|
|
await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_FICLONE).catch(async () => {
|
|
await fs.copyFile(sourcePath, targetPath);
|
|
});
|
|
await fs.chmod(targetPath, stats.mode);
|
|
};
|
|
|
|
const entries = (await walkDirectory(sourceDir)).sort((left, right) => left.localeCompare(right));
|
|
for (const relative of entries) {
|
|
await copyEntry(relative);
|
|
}
|
|
}
|
|
|
|
function toArrayBuffer(bytes: Buffer): ArrayBuffer {
|
|
return Uint8Array.from(bytes).buffer;
|
|
}
|
|
|
|
function toBuffer(bytes: Buffer | Uint8Array | ArrayBuffer): Buffer {
|
|
if (Buffer.isBuffer(bytes)) return bytes;
|
|
if (bytes instanceof ArrayBuffer) return Buffer.from(bytes);
|
|
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
}
|
|
|
|
function tarExcludeFlags(exclude: string[] | undefined): string {
|
|
return ["._*", ...(exclude ?? [])].map((entry) => `--exclude ${shellQuote(entry)}`).join(" ");
|
|
}
|
|
|
|
export async function prepareSandboxManagedRuntime(input: {
|
|
spec: SandboxRemoteExecutionSpec;
|
|
adapterKey: string;
|
|
client: SandboxManagedRuntimeClient;
|
|
workspaceLocalDir: string;
|
|
workspaceRemoteDir?: string;
|
|
workspaceExclude?: string[];
|
|
preserveAbsentOnRestore?: string[];
|
|
assets?: SandboxManagedRuntimeAsset[];
|
|
}): Promise<PreparedSandboxManagedRuntime> {
|
|
const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd;
|
|
const runtimeRootDir = path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey);
|
|
const baselineSnapshot = await captureDirectorySnapshot(input.workspaceLocalDir, {
|
|
exclude: [...new Set([".paperclip-runtime", ...(input.preserveAbsentOnRestore ?? []), ...(input.workspaceExclude ?? [])])],
|
|
});
|
|
|
|
await withTempDir("paperclip-sandbox-sync-", async (tempDir) => {
|
|
const workspaceTarPath = path.join(tempDir, "workspace.tar");
|
|
await createTarballFromDirectory({
|
|
localDir: input.workspaceLocalDir,
|
|
archivePath: workspaceTarPath,
|
|
exclude: input.workspaceExclude,
|
|
});
|
|
const workspaceTarBytes = await fs.readFile(workspaceTarPath);
|
|
const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-upload.tar");
|
|
await input.client.makeDir(runtimeRootDir);
|
|
await input.client.writeFile(remoteWorkspaceTar, toArrayBuffer(workspaceTarBytes));
|
|
const preservedNames = new Set([".paperclip-runtime", ...(input.preserveAbsentOnRestore ?? [])]);
|
|
const findPreserveArgs = [...preservedNames].map((entry) => `! -name ${shellQuote(entry)}`).join(" ");
|
|
await input.client.run(
|
|
`sh -c ${shellQuote(
|
|
`mkdir -p ${shellQuote(workspaceRemoteDir)} && ` +
|
|
`find ${shellQuote(workspaceRemoteDir)} -mindepth 1 -maxdepth 1 ${findPreserveArgs} -exec rm -rf -- {} + && ` +
|
|
`tar -xf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} && ` +
|
|
`rm -f ${shellQuote(remoteWorkspaceTar)}`,
|
|
)}`,
|
|
{ timeoutMs: input.spec.timeoutMs },
|
|
);
|
|
|
|
for (const asset of input.assets ?? []) {
|
|
const assetTarPath = path.join(tempDir, `${asset.key}.tar`);
|
|
await createTarballFromDirectory({
|
|
localDir: asset.localDir,
|
|
archivePath: assetTarPath,
|
|
followSymlinks: asset.followSymlinks,
|
|
exclude: asset.exclude,
|
|
});
|
|
const assetTarBytes = await fs.readFile(assetTarPath);
|
|
const remoteAssetDir = path.posix.join(runtimeRootDir, asset.key);
|
|
const remoteAssetTar = path.posix.join(runtimeRootDir, `${asset.key}-upload.tar`);
|
|
await input.client.writeFile(remoteAssetTar, toArrayBuffer(assetTarBytes));
|
|
await input.client.run(
|
|
`sh -c ${shellQuote(
|
|
`rm -rf ${shellQuote(remoteAssetDir)} && ` +
|
|
`mkdir -p ${shellQuote(remoteAssetDir)} && ` +
|
|
`tar -xf ${shellQuote(remoteAssetTar)} -C ${shellQuote(remoteAssetDir)} && ` +
|
|
`rm -f ${shellQuote(remoteAssetTar)}`,
|
|
)}`,
|
|
{ timeoutMs: input.spec.timeoutMs },
|
|
);
|
|
}
|
|
});
|
|
|
|
const assetDirs = Object.fromEntries(
|
|
(input.assets ?? []).map((asset) => [asset.key, path.posix.join(runtimeRootDir, asset.key)]),
|
|
);
|
|
|
|
return {
|
|
spec: input.spec,
|
|
workspaceLocalDir: input.workspaceLocalDir,
|
|
workspaceRemoteDir,
|
|
runtimeRootDir,
|
|
assetDirs,
|
|
restoreWorkspace: async () => {
|
|
await withTempDir("paperclip-sandbox-restore-", async (tempDir) => {
|
|
const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-download.tar");
|
|
await input.client.run(
|
|
`sh -c ${shellQuote(
|
|
`mkdir -p ${shellQuote(runtimeRootDir)} && ` +
|
|
`tar -cf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} ` +
|
|
`${tarExcludeFlags(input.workspaceExclude)} .`,
|
|
)}`,
|
|
{ timeoutMs: input.spec.timeoutMs },
|
|
);
|
|
const archiveBytes = await input.client.readFile(remoteWorkspaceTar);
|
|
await input.client.remove(remoteWorkspaceTar).catch(() => undefined);
|
|
const localArchivePath = path.join(tempDir, "workspace.tar");
|
|
const extractedDir = path.join(tempDir, "workspace");
|
|
await fs.writeFile(localArchivePath, toBuffer(archiveBytes));
|
|
await extractTarballToDirectory({
|
|
archivePath: localArchivePath,
|
|
localDir: extractedDir,
|
|
});
|
|
await mergeDirectoryWithBaseline({
|
|
baseline: baselineSnapshot,
|
|
sourceDir: extractedDir,
|
|
targetDir: input.workspaceLocalDir,
|
|
});
|
|
});
|
|
},
|
|
};
|
|
}
|