diff --git a/packages/plugins/sandbox-providers/daytona/src/file-sync.ts b/packages/plugins/sandbox-providers/daytona/src/file-sync.ts new file mode 100644 index 0000000000..df406dfbc1 --- /dev/null +++ b/packages/plugins/sandbox-providers/daytona/src/file-sync.ts @@ -0,0 +1,787 @@ +import path from "node:path"; +import os from "node:os"; +import { promises as fs } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { FileDownloadRequest, FileDownloadResponse, FileUpload, Sandbox } from "@daytonaio/sdk"; +import type { + PluginEnvironmentSyncResult, + PluginSyncFileMapping, + PluginSyncOperation, +} from "@paperclipai/plugin-sdk"; + +const execFileAsync = promisify(execFile); + +// Reserved scratch-name stem for staged uploads/downloads and remote tarballs. +// The runtime's base64 fallback stages to `.paperclip-upload`; the native +// transport reuses the same reserved prefix so a provider temp never collides +// with a real target or with the fallback's scratch name. +const SCRATCH_PREFIX = ".paperclip-upload"; + +function scratchName(suffix = ""): string { + return `${SCRATCH_PREFIX}-${randomUUID()}${suffix}`; +} + +/** + * Single-quote a path for safe interpolation into a sandbox shell command. Every + * path handed to `sandbox.process.executeCommand` (tar extract / `mv -f` rename) + * MUST pass through this so a path containing shell metacharacters is transferred + * literally, never interpreted. + */ +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +/** + * Convert a POSIX numeric mode (e.g. `0o600`) to the octal string the Daytona + * SDK's `setFilePermissions` expects (e.g. `"600"`), masked to the permission + * bits so an accidental type flag never widens the mode. + */ +function toOctalModeString(mode: number): string { + return (mode & 0o7777).toString(8).padStart(3, "0"); +} + +/** + * Host-side complete-mediation guard applied as defense-in-depth below the + * orchestrator's own confinement. Every sandbox-side path (the sync target for + * inbound, the sync source for outbound) MUST canonicalize inside the workspace + * remote dir; absolute escapes and `..` traversal are rejected fail-closed before + * any bytes move. Sandbox paths on the server are POSIX. + */ +export function assertConfinedSandboxPath(remoteDir: string, candidate: string, label: string): void { + const normalizedRoot = path.posix.normalize(remoteDir); + const normalized = path.posix.normalize(candidate); + if ( + !path.posix.isAbsolute(normalized) || + normalized === ".." || + normalized.includes("/../") || + normalized.endsWith("/..") + ) { + throw new Error(`Daytona sync ${label} path is not a confined absolute path: ${candidate}`); + } + const prefix = normalizedRoot.endsWith("/") ? normalizedRoot : `${normalizedRoot}/`; + if (normalized !== normalizedRoot && !normalized.startsWith(prefix)) { + throw new Error(`Daytona sync ${label} path escapes the workspace remote dir: ${candidate}`); + } +} + +async function withHostTempDir(fn: (dir: string) => Promise): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-daytona-sync-")); + try { + return await fn(dir); + } finally { + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); + } +} + +/** + * Build a host-side tarball of a directory, mirroring the runtime's own + * `createTarballFromDirectory`: archive top-level entries by name (no "." self + * entry), suppress AppleDouble/xattr sidecars, honor `exclude`, and reproduce the + * `followSymlinks` → `-h` mapping so the native path is observationally identical + * to the base64 fallback's tar. + */ +async function createHostTarball(input: { + localDir: string; + archivePath: string; + exclude?: string[]; + followSymlinks?: boolean; +}): Promise { + const excludeArgs = ["._*", ...(input.exclude ?? [])].flatMap((entry) => ["--exclude", entry]); + const entries = (await fs.readdir(input.localDir)).sort((left, right) => left.localeCompare(right)); + if (entries.length === 0) { + // An empty source is valid (blank workspace / empty asset dir). Write a valid + // empty tar (1024-byte zero EOF marker) so extraction is a clean no-op. + await fs.writeFile(input.archivePath, Buffer.alloc(1024)); + return; + } + await execFileAsync( + "tar", + [ + "-c", + "--no-xattrs", + ...(input.followSymlinks ? ["-h"] : []), + "-f", + input.archivePath, + "-C", + input.localDir, + ...excludeArgs, + "--", + ...entries, + ], + { env: { ...process.env, COPYFILE_DISABLE: "1" }, maxBuffer: 32 * 1024 * 1024 }, + ); +} + +/** + * True when `relative` (a POSIX path) escapes its anchoring directory once + * normalized: an absolute path, `..`, or a `..`-leading traversal all break out. + */ +function posixPathEscapes(relative: string): boolean { + const normalized = path.posix.normalize(relative); + return normalized === ".." || normalized.startsWith("../") || path.posix.isAbsolute(normalized); +} + +/** + * Reject a sandbox-authored tarball before extraction if any member would land + * outside the extraction dir. The archive is produced by the (untrusted) sandbox, + * so `tar -xf` on the host must never be handed an archive whose entries carry + * absolute paths or `../` traversal, nor a symlink/hardlink member whose target + * escapes the tree — the latter would let a follow-up member be written through + * the link to an arbitrary host path. Legitimate in-tree relative links (targets + * that resolve back inside the archive, e.g. `shortcut -> nested/data.txt`) are + * preserved. Parses the `-tvf` verbose listing so both member names and link + * targets are inspected; any unparseable line fails closed. + */ +async function assertTarballEntriesConfined(archivePath: string): Promise { + const { stdout } = await execFileAsync("tar", ["-tvf", archivePath], { + env: { ...process.env, COPYFILE_DISABLE: "1" }, + maxBuffer: 32 * 1024 * 1024, + }); + const lines = stdout.split("\n").filter((line) => line.trim().length > 0); + for (const line of lines) { + // GNU tar -tvf: " /