feat(daytona): native file-sync lifecycle hooks over batch transfer (#10028)

This commit is contained in:
Nicky Leach 2026-07-22 14:54:06 -07:00 committed by GitHub
parent 39b94d0e7a
commit f215444919
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 1550 additions and 1 deletions

View File

@ -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 `<path>.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<T>(fn: (dir: string) => Promise<T>): Promise<T> {
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<void> {
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<void> {
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: "<perms> <owner>/<group> <size> <date> <time> <name>[ -> target]".
const match = line.match(/^(\S+)\s+\S+\s+\d+\s+\S+\s+\S+\s+(.*)$/);
if (!match) {
throw new Error(`Daytona syncOut refusing tarball with an unparseable entry listing: ${line}`);
}
const typeFlag = match[1][0];
let name = match[2];
let linkTarget: string | null = null;
if (typeFlag === "l") {
const idx = name.indexOf(" -> ");
if (idx === -1) throw new Error(`Daytona syncOut refusing unparseable symlink entry: ${line}`);
linkTarget = name.slice(idx + " -> ".length);
name = name.slice(0, idx);
} else if (typeFlag === "h") {
const idx = name.indexOf(" link to ");
if (idx === -1) throw new Error(`Daytona syncOut refusing unparseable hardlink entry: ${line}`);
linkTarget = name.slice(idx + " link to ".length);
name = name.slice(0, idx);
}
const cleanName = name.replace(/\/+$/, "");
if (cleanName.length > 0 && posixPathEscapes(cleanName)) {
throw new Error(`Daytona syncOut refusing tarball member that escapes the extraction dir: ${name}`);
}
if (linkTarget !== null) {
const resolved = path.posix.join(path.posix.dirname(cleanName), linkTarget);
if (path.posix.isAbsolute(linkTarget) || posixPathEscapes(resolved)) {
throw new Error(
`Daytona syncOut refusing tarball link whose target escapes the extraction dir: ${name} -> ${linkTarget}`,
);
}
}
}
}
async function extractHostTarball(input: { archivePath: string; localDir: string }): Promise<void> {
// The archive is sandbox-authored and untrusted: validate every member (and
// link target) is confined before letting host-side tar write a single byte.
await assertTarballEntriesConfined(input.archivePath);
await fs.mkdir(input.localDir, { recursive: true });
await execFileAsync("tar", ["-xf", input.archivePath, "-C", input.localDir], {
env: { ...process.env, COPYFILE_DISABLE: "1" },
maxBuffer: 32 * 1024 * 1024,
});
}
async function countHostFiles(root: string, exclude?: string[]): Promise<number> {
const excludeSet = new Set(exclude ?? []);
let total = 0;
const walk = async (dir: string): Promise<void> => {
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (excludeSet.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(full);
} else {
total += 1;
}
}
};
await walk(root).catch(() => undefined);
return total;
}
async function assertSandboxCommandOk(
sandbox: Sandbox,
command: string,
timeoutSeconds: number,
label: string,
): Promise<void> {
const result = await sandbox.process.executeCommand(command, undefined, undefined, timeoutSeconds);
if ((result.exitCode ?? 1) !== 0) {
const detail = (result.result ?? result.artifacts?.stdout ?? "").toString().trim();
throw new Error(`Daytona ${label} command failed (exit ${result.exitCode ?? "unknown"})${detail ? `: ${detail}` : ""}`);
}
}
/**
* POSIX-sh preamble defining a `_pc_resolve` canonicalizer (prefer `realpath`,
* fall back to `readlink -f`; fail closed with exit 40 if neither exists so the
* host-side lexical check is never the only line of defense) and `_pc_root` =
* the resolved workspace remote dir. Shared by every sandbox-side symlink-escape
* guard. The caller wraps the assembled script in `sh -c` so it runs under a
* POSIX shell regardless of the sandbox's default login shell.
*/
function canonicalizerPreamble(quotedRoot: string): string[] {
return [
'if command -v realpath >/dev/null 2>&1; then _pc_resolve() { realpath -- "$1"; };',
'elif command -v readlink >/dev/null 2>&1; then _pc_resolve() { readlink -f -- "$1"; };',
'else echo "no path canonicalizer available"; exit 40; fi;',
`_pc_root=$(_pc_resolve ${quotedRoot}) || { echo "cannot resolve root"; exit 41; };`,
];
}
/**
* Fail-closed guard: assert that every supplied sandbox path canonicalizes
* (through symlinks) inside the workspace remote dir. The sandbox is untrusted
* relative to the host, so a sandbox-planted symlink on an inbound target parent
* or an outbound source must never widen a transfer past the confinement root.
* Runs as a single batched `sh -c` precheck: any path whose realpath escapes
* fails the whole sync (exit 42) before any bytes move. `label` distinguishes
* the inbound vs outbound call site in the surfaced error.
*/
async function assertSandboxPathsConfined(input: {
sandbox: Sandbox;
remoteDir: string;
paths: string[];
timeoutSeconds: number;
label: string;
}): Promise<void> {
const { sandbox, remoteDir, paths, timeoutSeconds, label } = input;
if (paths.length === 0) return;
const quotedPaths = paths.map(shellQuote).join(" ");
const script = [
...canonicalizerPreamble(shellQuote(remoteDir)),
`for _pc_p in ${quotedPaths}; do`,
' _pc_real=$(_pc_resolve "$_pc_p") || { echo "ESCAPE:$_pc_p"; exit 42; };',
' case "$_pc_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE:$_pc_p"; exit 42 ;; esac;',
"done",
].join("\n");
await assertSandboxCommandOk(sandbox, `sh -c ${shellQuote(script)}`, timeoutSeconds, label);
}
/**
* Validate every outbound source AND capture a protected snapshot of it in one
* atomic sandbox-side step, then hand the snapshot paths to `downloadFiles`. This
* shrinks the TOCTOU window between validation and download to near zero: the
* guard resolves each source's realpath, confirms it is inside the remote dir,
* re-checks the resolved path is still a (non-symlink) regular file, then `cp`s
* those exact bytes to a reserved snapshot all in a single `sh -c` invocation.
*
* Two windows are closed here:
* - validationcopy: `_pc_real` is a canonical path, so a `[ -L ]`/`[ -f ]`
* re-check immediately before `cp` refuses a source the sandbox swapped for a
* symlink (or non-regular file) after `realpath` resolved, rather than letting
* `cp` follow the swap.
* - copydownload: the privileged `downloadFiles` reads the reserved snapshot,
* which is an unguessable random name that is a DIRECT child of the resolved
* workspace root no sandbox-swappable intermediate directory sits on the
* read path, and the sandbox cannot pre-plant a symlink at the leaf name.
*
* The sandbox-side `cp` runs at sandbox-user privilege, so its residual race
* cannot read anything that user could not already read; the confinement is
* defense-in-depth for the privileged host-mediated download. Returns the
* reserved snapshot paths, index-aligned with `sources`; the caller downloads
* and then removes them.
*
* Accepted residual risk (copydownload leaf swap): the sandbox user runs this
* `cp`, so it knows the reserved snapshot path and could overwrite that leaf with
* different bytes after `cp` returns but before the privileged `downloadFiles`
* opens it. This is informational, not a privilege-boundary crossing: the sandbox
* user can only substitute bytes it can already produce, and the host download
* would then receive bytes that same user could equally have written into the real
* source before the snapshot ran. The swap cannot redirect the read outside the
* confinement root the leaf is a direct child of the resolved root with no
* swappable intermediate dir, and the sandbox user cannot use it to exfiltrate any
* file it lacks read access to so no privilege escalation is possible and the
* window is accepted rather than closed.
*/
async function snapshotOutboundFileSources(input: {
sandbox: Sandbox;
remoteDir: string;
sources: string[];
timeoutSeconds: number;
}): Promise<string[]> {
const { sandbox, remoteDir, sources, timeoutSeconds } = input;
// Reserved snapshot names are a DIRECT child of remoteDir (the confinement
// root), so the privileged download leg carries no swappable intermediate dir.
const snapshots = sources.map(() => path.posix.join(remoteDir, scratchName()));
if (sources.length === 0) return snapshots;
const lines = [...canonicalizerPreamble(shellQuote(remoteDir))];
sources.forEach((source, index) => {
const quotedSource = shellQuote(source);
const quotedSnapshot = shellQuote(snapshots[index]);
lines.push(
`_pc_real=$(_pc_resolve ${quotedSource}) || { echo "ESCAPE"; exit 42; };`,
`case "$_pc_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE"; exit 42 ;; esac;`,
// Close the validation→copy window: refuse a canonical path the sandbox has
// repointed to a symlink or a non-regular file since `realpath` resolved,
// so `cp` never follows a post-validation swap.
`[ -L "$_pc_real" ] && { echo "REPLACED"; exit 44; };`,
`[ -f "$_pc_real" ] || { echo "NOTREG"; exit 45; };`,
// Copy the confined canonical bytes into the reserved snapshot so the
// subsequent download reads this immutable copy, not the live source.
`cp -- "$_pc_real" ${quotedSnapshot} || { echo "snapshot copy failed"; exit 43; };`,
);
});
await assertSandboxCommandOk(
sandbox,
`sh -c ${shellQuote(lines.join("\n"))}`,
timeoutSeconds,
"outbound symlink-escape guard",
);
return snapshots;
}
/**
* Best-effort removal of reserved sandbox-side scratch files (upload/download
* snapshots or partially promoted temps) on both the happy path and error paths,
* so a failed transfer never accumulates `.paperclip-upload-*` scratch in the
* sandbox. Swallows its own failure cleanup must never mask the original error.
*/
async function removeSandboxScratch(
sandbox: Sandbox,
paths: string[],
timeoutSeconds: number,
): Promise<void> {
if (paths.length === 0) return;
const script = paths.map((entry) => `rm -f ${shellQuote(entry)}`).join(" ; ");
await sandbox.process
.executeCommand(`sh -c ${shellQuote(script)}`, undefined, undefined, timeoutSeconds)
.catch(() => undefined);
}
// ---------------------------------------------------------------------------
// Inbound (host → sandbox)
// ---------------------------------------------------------------------------
async function syncInFileMappings(input: {
sandbox: Sandbox;
mappings: PluginSyncFileMapping[];
remoteDir: string;
timeoutSeconds: number;
}): Promise<{ filesTransferred: number; bytesTransferred: number }> {
const { sandbox, mappings, remoteDir, timeoutSeconds } = input;
if (mappings.length === 0) return { filesTransferred: 0, bytesTransferred: 0 };
const uploads: FileUpload[] = [];
const renames: { temp: string; target: string }[] = [];
const modeApplies: { temp: string; mode: number }[] = [];
const parentDirs = new Set<string>();
let bytesTransferred = 0;
for (const mapping of mappings) {
assertConfinedSandboxPath(remoteDir, mapping.targetPath, "target");
const dir = path.posix.dirname(mapping.targetPath);
parentDirs.add(dir);
// Stage each upload to a reserved temp that is a DIRECT child of the workspace
// root (`remoteDir`), never a sibling of the target. The target's parent dir is
// sandbox-writable and can be swapped for a symlink to `/etc` (or any host path)
// after validation but before `uploadFiles` opens the destination — rooting the
// privileged write directly under `remoteDir` removes that swappable intermediate
// component, so the upload cannot be redirected outside the root by a parent
// swap. `remoteDir` and the target dir share the workspace filesystem, so the
// closing `mv -f` is still an atomic same-fs rename and an interrupted upload
// never leaves a truncated file at targetPath.
const temp = path.posix.join(remoteDir, scratchName());
// A string `source` streams from the local path via the SDK's read stream
// (batched, flat per-file memory) rather than buffering the whole file.
uploads.push({ source: mapping.sourcePath, destination: temp });
renames.push({ temp, target: mapping.targetPath });
if (typeof mapping.mode === "number") {
modeApplies.push({ temp, mode: mapping.mode });
}
bytesTransferred += (await fs.stat(mapping.sourcePath)).size;
}
// Ensure every target directory exists before the bulk upload writes its temp.
const mkdirCommand = [...parentDirs].map((dir) => `mkdir -p ${shellQuote(dir)}`).join(" && ");
await assertSandboxCommandOk(sandbox, mkdirCommand, timeoutSeconds, "syncIn mkdir");
// Defense-in-depth beyond the lexical `assertConfinedSandboxPath`: a sandbox
// can replace a target parent with a symlink to `/etc` so the string check
// passes but the upload + `mv -f` resolve through it. Canonicalize every parent
// dir (now materialized) and fail closed if any escapes, BEFORE any bytes land.
await assertSandboxPathsConfined({
sandbox,
remoteDir,
paths: [...parentDirs],
timeoutSeconds,
label: "inbound symlink-escape guard",
});
// A failed upload or a mid-batch `mv -f` failure leaves reserved temps (some
// targets promoted, others not) — sweep every staged temp on any error so a
// retry never accumulates stale `.paperclip-upload-*` scratch.
try {
// One batched bulk upload (single /files/bulk-upload) for all file mappings.
await sandbox.fs.uploadFiles(uploads, timeoutSeconds);
// Apply the requested mode on the temp file BEFORE the rename so the target
// never appears at a widened window — a secret lands `0600` at targetPath from
// the instant it exists there.
for (const apply of modeApplies) {
await sandbox.fs.setFilePermissions(apply.temp, { mode: toOctalModeString(apply.mode) });
}
// Promote every staged temp onto its final target. The `mv -f` traverses the
// target's PARENT dir, which is sandbox-writable and could be swapped for a
// symlink after the earlier parent guard ran but before the rename opens it —
// redirecting the promotion outside the root. Bind the confinement re-check and
// the rename into ONE sandbox invocation: for each target, re-canonicalize its
// parent dir, confirm the resolved parent is still inside the workspace root,
// then OPEN that dir as fd 8 and `mv` into `/proc/self/fd/8/<base>`. Two races
// are closed:
// - check→open (ancestor swap): `mv "$_pc_tgt_dir"/<base>` would re-walk the
// parent path string and follow an ancestor the sandbox repointed to a
// symlink after the `case` check. Opening fd 8 PINS the directory inode, and
// an immediate re-canonicalize of `/proc/self/fd/8` confirms the pinned inode
// is still in-root before any write — an ancestor swap before the open is
// caught by this verify (fail closed, exit 42); a swap after the open cannot
// change which inode fd 8 references.
// - open→rename: `mv` targets `/proc/self/fd/8/<base>`, which resolves through
// the already-open inode rather than the path string, so the rename lands in
// the verified directory even if the path is repointed mid-command.
const renameScript = [...canonicalizerPreamble(shellQuote(remoteDir))];
for (const rename of renames) {
const parentDir = path.posix.dirname(rename.target);
const base = path.posix.basename(rename.target);
renameScript.push(
`_pc_tgt_dir=$(_pc_resolve ${shellQuote(parentDir)}) || { echo "ESCAPE"; exit 42; };`,
`case "$_pc_tgt_dir/" in "$_pc_root"/*) : ;; *) echo "ESCAPE"; exit 42 ;; esac;`,
`exec 8<"$_pc_tgt_dir" || { echo "open failed"; exit 47; };`,
`_pc_fd_dir=$(_pc_resolve /proc/self/fd/8) || { echo "ESCAPE"; exit 42; };`,
`case "$_pc_fd_dir/" in "$_pc_root"/*) : ;; *) echo "ESCAPE"; exit 42 ;; esac;`,
`mv -f ${shellQuote(rename.temp)} /proc/self/fd/8/${shellQuote(base)} || { echo "rename failed"; exit 43; };`,
`exec 8>&-;`,
);
}
await assertSandboxCommandOk(
sandbox,
`sh -c ${shellQuote(renameScript.join("\n"))}`,
timeoutSeconds,
"syncIn rename",
);
} catch (error) {
await removeSandboxScratch(sandbox, renames.map((rename) => rename.temp), timeoutSeconds);
throw error;
}
return { filesTransferred: mappings.length, bytesTransferred };
}
async function syncInDirectoryMapping(input: {
sandbox: Sandbox;
mapping: PluginSyncFileMapping;
remoteDir: string;
timeoutSeconds: number;
}): Promise<{ filesTransferred: number; bytesTransferred: number }> {
const { sandbox, mapping, remoteDir, timeoutSeconds } = input;
assertConfinedSandboxPath(remoteDir, mapping.targetPath, "target");
return withHostTempDir(async (tmp) => {
const archivePath = path.join(tmp, "sync-in.tar");
await createHostTarball({
localDir: mapping.sourcePath,
archivePath,
exclude: mapping.exclude,
followSymlinks: mapping.followSymlinks,
});
const bytesTransferred = (await fs.stat(archivePath)).size;
// The tar bytes ride the native bulk channel (string source ⇒ streamed);
// only the extract/cleanup control commands use exec.
const remoteTar = path.posix.join(remoteDir, scratchName(".tar"));
// Materialize the target dir first so the realpath guard resolves real
// components, then confirm it (and any existing parent) canonicalizes inside
// the remote dir — `tar -C` would otherwise follow a sandbox-planted symlink
// and extract our archive outside the workspace root.
await assertSandboxCommandOk(
sandbox,
`mkdir -p ${shellQuote(mapping.targetPath)}`,
timeoutSeconds,
"syncIn mkdir",
);
await assertSandboxPathsConfined({
sandbox,
remoteDir,
paths: [mapping.targetPath],
timeoutSeconds,
label: "inbound symlink-escape guard",
});
await sandbox.fs.uploadFiles([{ source: archivePath, destination: remoteTar }], timeoutSeconds);
// Bind validation and extraction into ONE sandbox invocation, then extract into
// an OPEN directory inode rather than a path string. `exec 9<"$_pc_real"` itself
// walks every ancestor of `$_pc_real` during the `open()` syscall, so a sandbox
// process that swaps an ancestor component for a symlink AFTER `_pc_resolve`
// returns but BEFORE the `open()` resolves would leave fd 9 pointing at a
// directory outside the workspace — the earlier `case` check on the resolved
// string cannot see that. Close the gap with open-then-verify: open fd 9 (which
// PINS whatever inode `open()` landed on), then re-canonicalize `/proc/self/fd/9`
// — the pinned inode's own path — and confirm it is still inside `$_pc_root`
// before extracting. If an ancestor swap redirected the open, the pinned inode
// resolves outside the root and the verify fails closed (exit 42); once the
// verify passes, the inode is fixed and `tar -C /proc/self/fd/9` chdir's through
// the magic symlink to that exact inode, so a post-open ancestor swap cannot
// redirect the write. (The initial `case` on `$_pc_real` still fails fast on a
// pre-open escape; the fd re-verify is what makes the guarantee race-free.)
const extractScript = [
...canonicalizerPreamble(shellQuote(remoteDir)),
`_pc_real=$(_pc_resolve ${shellQuote(mapping.targetPath)}) || { echo "ESCAPE"; exit 42; };`,
`case "$_pc_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE"; exit 42 ;; esac;`,
`exec 9<"$_pc_real" || { echo "open failed"; exit 46; };`,
`_pc_fd_real=$(_pc_resolve /proc/self/fd/9) || { echo "ESCAPE"; exit 42; };`,
`case "$_pc_fd_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE"; exit 42 ;; esac;`,
`tar -xf ${shellQuote(remoteTar)} -C /proc/self/fd/9 || { echo "extract failed"; exit 43; };`,
`exec 9>&-;`,
`rm -f ${shellQuote(remoteTar)};`,
].join("\n");
await assertSandboxCommandOk(
sandbox,
`sh -c ${shellQuote(extractScript)}`,
timeoutSeconds,
"syncIn extract",
);
const filesTransferred = await countHostFiles(mapping.sourcePath, mapping.exclude);
return { filesTransferred, bytesTransferred };
});
}
export async function performSyncIn(input: {
sandbox: Sandbox;
operations: PluginSyncOperation[];
remoteDir: string;
timeoutSeconds: number;
}): Promise<PluginEnvironmentSyncResult> {
const operations: PluginEnvironmentSyncResult["operations"] = [];
for (const operation of input.operations) {
let filesTransferred = 0;
let bytesTransferred = 0;
const fileMappings = operation.files.filter((mapping) => mapping.kind === "file");
const directoryMappings = operation.files.filter((mapping) => mapping.kind === "directory");
const fileResult = await syncInFileMappings({
sandbox: input.sandbox,
mappings: fileMappings,
remoteDir: input.remoteDir,
timeoutSeconds: input.timeoutSeconds,
});
filesTransferred += fileResult.filesTransferred;
bytesTransferred += fileResult.bytesTransferred;
for (const mapping of directoryMappings) {
const dirResult = await syncInDirectoryMapping({
sandbox: input.sandbox,
mapping,
remoteDir: input.remoteDir,
timeoutSeconds: input.timeoutSeconds,
});
filesTransferred += dirResult.filesTransferred;
bytesTransferred += dirResult.bytesTransferred;
}
operations.push({ operationId: operation.operationId, filesTransferred, bytesTransferred });
}
return { operations };
}
// ---------------------------------------------------------------------------
// Outbound (sandbox → host)
// ---------------------------------------------------------------------------
async function syncOutFileMappings(input: {
sandbox: Sandbox;
mappings: PluginSyncFileMapping[];
remoteDir: string;
timeoutSeconds: number;
}): Promise<{ filesTransferred: number; bytesTransferred: number }> {
const { sandbox, mappings, remoteDir, timeoutSeconds } = input;
if (mappings.length === 0) return { filesTransferred: 0, bytesTransferred: 0 };
for (const mapping of mappings) {
assertConfinedSandboxPath(remoteDir, mapping.sourcePath, "source");
}
// Close the validation→download TOCTOU: instead of re-opening each mutable
// source, validate-and-snapshot it in one atomic sandbox-side step and download
// the immutable snapshot. `snapshots` is index-aligned with `mappings`.
const snapshots = await snapshotOutboundFileSources({
sandbox,
remoteDir,
sources: mappings.map((mapping) => mapping.sourcePath),
timeoutSeconds,
});
const requests: FileDownloadRequest[] = [];
const finalize: { temp: string; target: string; source: string; snapshot: string; mode?: number }[] = [];
mappings.forEach((mapping, index) => {
const dir = path.dirname(mapping.targetPath);
// Stream each snapshot into a reserved host temp sibling, then atomic-rename
// onto the host targetPath so an interrupted download never truncates it.
const temp = path.join(dir, scratchName());
requests.push({ source: snapshots[index], destination: temp });
finalize.push({ temp, target: mapping.targetPath, source: mapping.sourcePath, snapshot: snapshots[index], mode: mapping.mode });
});
const cleanup = async (): Promise<void> => {
await Promise.all(finalize.map((entry) => fs.rm(entry.temp, { force: true }).catch(() => undefined)));
await removeSandboxScratch(sandbox, snapshots, timeoutSeconds);
};
// mkdir host target dirs up front (outside the download try) so a mkdir failure
// still runs snapshot cleanup below.
try {
for (const entry of finalize) {
await fs.mkdir(path.dirname(entry.target), { recursive: true });
}
} catch (error) {
await cleanup();
throw error;
}
let responses: FileDownloadResponse[];
try {
// One batched bulk download for all file mappings, reading the snapshots.
responses = await sandbox.fs.downloadFiles(requests, timeoutSeconds);
} catch (error) {
await cleanup();
throw error;
}
// Per-file failures surface in `.error`, not a thrown batch — fail loud on any.
// Responses are keyed by the (snapshot) request source; report the original
// sourcePath in the surfaced error for a caller-meaningful message.
const bySource = new Map(responses.map((response) => [response.source, response]));
for (const entry of finalize) {
const response = bySource.get(entry.snapshot);
if (!response || response.error) {
await cleanup();
throw new Error(
`Daytona syncOut download failed for ${entry.source}: ${response?.error ?? "no response returned"}`,
);
}
}
let bytesTransferred = 0;
try {
for (const entry of finalize) {
// chmod the temp before the rename so the target never appears at a widened
// window; rename preserves the inode's mode.
if (typeof entry.mode === "number") {
await fs.chmod(entry.temp, entry.mode);
}
bytesTransferred += (await fs.stat(entry.temp)).size;
await fs.rename(entry.temp, entry.target);
}
} catch (error) {
await cleanup();
throw error;
}
// Success: the host temps have been renamed onto their targets; remove the
// sandbox-side snapshots so no reserved scratch lingers.
await removeSandboxScratch(sandbox, snapshots, timeoutSeconds);
return { filesTransferred: mappings.length, bytesTransferred };
}
async function syncOutDirectoryMapping(input: {
sandbox: Sandbox;
mapping: PluginSyncFileMapping;
remoteDir: string;
timeoutSeconds: number;
}): Promise<{ filesTransferred: number; bytesTransferred: number }> {
const { sandbox, mapping, remoteDir, timeoutSeconds } = input;
assertConfinedSandboxPath(remoteDir, mapping.sourcePath, "source");
await assertSandboxPathsConfined({
sandbox,
remoteDir,
paths: [mapping.sourcePath],
timeoutSeconds,
label: "outbound symlink-escape guard",
});
return withHostTempDir(async (tmp) => {
const remoteTar = path.posix.join(remoteDir, scratchName(".tar"));
const excludeFlags = ["._*", ...(mapping.exclude ?? [])]
.map((entry) => `--exclude ${shellQuote(entry)}`)
.join(" ");
// Tar the source in-sandbox (naming top-level entries so no "." self-entry is
// embedded), reproducing the `followSymlinks` → `-h` mapping, then stream the
// single archive back over the native bulk channel.
const tarScript = [
`cd ${shellQuote(mapping.sourcePath)}`,
"set -- *",
'if [ "$#" -eq 1 ] && [ "$1" = "*" ] && [ ! -e "$1" ] && [ ! -L "$1" ]; then set --; fi',
'for entry in .[!.]* ..?*; do [ -e "$entry" ] || [ -L "$entry" ] || continue; set -- "$@" "$entry"; done',
`if [ "$#" -eq 0 ]; then dd if=/dev/zero of=${shellQuote(remoteTar)} bs=1024 count=1; ` +
`else tar -c --no-xattrs ${mapping.followSymlinks ? "-h " : ""}${excludeFlags} -f ${shellQuote(remoteTar)} -- "$@"; fi`,
].join(" && ");
await assertSandboxCommandOk(sandbox, `sh -c ${shellQuote(tarScript)}`, timeoutSeconds, "syncOut tar");
const localTar = path.join(tmp, "sync-out.tar");
let bytesTransferred = 0;
try {
const responses = await sandbox.fs.downloadFiles(
[{ source: remoteTar, destination: localTar }],
timeoutSeconds,
);
const response = responses.find((entry) => entry.source === remoteTar) ?? responses[0];
if (!response || response.error) {
throw new Error(
`Daytona syncOut directory download failed for ${mapping.sourcePath}: ${response?.error ?? "no response returned"}`,
);
}
bytesTransferred = (await fs.stat(localTar)).size;
await extractHostTarball({ archivePath: localTar, localDir: mapping.targetPath });
} finally {
// Best-effort remove the sandbox-side scratch tar; the host temp dir is
// cleaned by withHostTempDir.
await sandbox.fs
.deleteFile(remoteTar)
.catch(() => undefined);
}
const filesTransferred = await countHostFiles(mapping.targetPath, mapping.exclude);
return { filesTransferred, bytesTransferred };
});
}
export async function performSyncOut(input: {
sandbox: Sandbox;
operations: PluginSyncOperation[];
remoteDir: string;
timeoutSeconds: number;
}): Promise<PluginEnvironmentSyncResult> {
const operations: PluginEnvironmentSyncResult["operations"] = [];
for (const operation of input.operations) {
let filesTransferred = 0;
let bytesTransferred = 0;
const fileMappings = operation.files.filter((mapping) => mapping.kind === "file");
const directoryMappings = operation.files.filter((mapping) => mapping.kind === "directory");
const fileResult = await syncOutFileMappings({
sandbox: input.sandbox,
mappings: fileMappings,
remoteDir: input.remoteDir,
timeoutSeconds: input.timeoutSeconds,
});
filesTransferred += fileResult.filesTransferred;
bytesTransferred += fileResult.bytesTransferred;
for (const mapping of directoryMappings) {
const dirResult = await syncOutDirectoryMapping({
sandbox: input.sandbox,
mapping,
remoteDir: input.remoteDir,
timeoutSeconds: input.timeoutSeconds,
});
filesTransferred += dirResult.filesTransferred;
bytesTransferred += dirResult.bytesTransferred;
}
operations.push({ operationId: operation.operationId, filesTransferred, bytesTransferred });
}
return { operations };
}

View File

@ -1,4 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { execFileSync } from "node:child_process";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockCreate = vi.hoisted(() => vi.fn());
const mockGet = vi.hoisted(() => vi.fn());
@ -59,6 +63,11 @@ function createMockSandbox(overrides: {
createFolder: vi.fn().mockResolvedValue(undefined),
uploadFile: vi.fn().mockResolvedValue(undefined),
deleteFile: vi.fn().mockResolvedValue(undefined),
// Native batch file-transfer primitives (Daytona SDK 0.171.0). Extended
// here so the sync hooks can be exercised without a real SDK install.
uploadFiles: vi.fn().mockResolvedValue(undefined),
downloadFiles: vi.fn().mockResolvedValue([]),
setFilePermissions: vi.fn().mockResolvedValue(undefined),
},
process: {
executeCommand: vi.fn().mockResolvedValue({
@ -1235,6 +1244,699 @@ describe("Daytona sandbox provider plugin", () => {
});
});
describe("daytona native file-sync hooks", () => {
const REMOTE_DIR = "/home/daytona/paperclip-workspace";
const tempDirs: string[] = [];
async function makeHostDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "daytona-sync-test-"));
tempDirs.push(dir);
return dir;
}
function syncLease(overrides: Record<string, unknown> = {}) {
return {
providerLeaseId: "sandbox-123",
metadata: { provider: "daytona", remoteCwd: REMOTE_DIR, ...overrides },
};
}
beforeEach(() => {
process.env.DAYTONA_API_KEY = "host-key";
});
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
it("declares both sync hooks so the worker advertises the native transport", () => {
expect(plugin.definition.onEnvironmentSyncIn).toBeTypeOf("function");
expect(plugin.definition.onEnvironmentSyncOut).toBeTypeOf("function");
});
it("syncIn coalesces file mappings into one uploadFiles batch to reserved temp destinations, then one batched mv, applying secret mode via setFilePermissions before the rename", async () => {
const hostDir = await makeHostDir();
const secretSource = path.join(hostDir, "auth.json");
const plainSource = path.join(hostDir, "config.txt");
await fs.writeFile(secretSource, "credential-material");
await fs.writeFile(plainSource, "plain");
const sandbox = createMockSandbox();
mockGet.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentSyncIn?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "sync-op-1",
files: [
{ sourcePath: secretSource, targetPath: `${REMOTE_DIR}/.secret/auth.json`, kind: "file", mode: 0o600 },
{ sourcePath: plainSource, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" },
],
},
],
});
// Exactly one bulk upload for both file mappings.
expect(sandbox.fs.uploadFiles).toHaveBeenCalledTimes(1);
const [uploads] = sandbox.fs.uploadFiles.mock.calls[0] as [Array<{ source: string; destination: string }>];
expect(uploads).toHaveLength(2);
// String sources stream from the local path; destinations are reserved temps.
expect(uploads[0].source).toBe(secretSource);
for (const upload of uploads) {
expect(path.posix.basename(upload.destination)).toMatch(/^\.paperclip-upload-/);
expect(upload.destination).not.toBe(`${REMOTE_DIR}/.secret/auth.json`);
// TOCTOU-hardened: the privileged upload destination is a DIRECT child of the
// workspace root, never a sibling under the target's (sandbox-swappable)
// parent dir, so a parent symlink swap cannot redirect the write out of root.
expect(path.posix.dirname(upload.destination)).toBe(REMOTE_DIR);
}
// Secret mode applied on the TEMP path (before the rename) so the target
// never appears at a widened window; applied via setFilePermissions as "600".
expect(sandbox.fs.setFilePermissions).toHaveBeenCalledTimes(1);
const [permPath, perms] = sandbox.fs.setFilePermissions.mock.calls[0] as [string, { mode: string }];
expect(path.posix.basename(permPath)).toMatch(/^\.paperclip-upload-/);
expect(perms).toEqual({ mode: "600" });
// The setFilePermissions on the temp precedes the mv that promotes it.
const secretTemp = permPath;
const mvCall = sandbox.process.executeCommand.mock.calls.find(([cmd]) => String(cmd).includes("mv -f"));
expect(mvCall).toBeDefined();
const mvCommand = String(mvCall?.[0]);
// TOCTOU-hardened rename: each promotion re-canonicalizes the target's parent
// dir, confirms it is still confined, OPENS that dir as fd 8, re-verifies the
// pinned inode is in-root, then `mv`s into `/proc/self/fd/8/<base>` — all in ONE
// sh invocation, so neither an ancestor swap before the open nor a path swap
// after it can redirect the rename. The rename is wrapped in `sh -c '...'`, so
// inner single-quotes are shell-escaped; assert on the un-escaped components.
expect(mvCommand).toContain("_pc_resolve");
// The parent dir is opened as fd 8 and its pinned inode re-verified in-root
// before the rename, which targets the inode via /proc/self/fd/8 rather than the
// literal (swappable) path string.
expect(mvCommand).toContain(secretTemp);
expect(mvCommand).toContain('exec 8<"$_pc_tgt_dir"');
expect(mvCommand).toContain("_pc_fd_dir=$(_pc_resolve /proc/self/fd/8)");
expect(mvCommand).toContain("/proc/self/fd/8/");
expect(mvCommand).toContain("auth.json");
// Both temps are promoted (one mv line per rename).
expect(mvCommand.match(/mv -f /g)).toHaveLength(2);
expect(result).toEqual({
operations: [{ operationId: "sync-op-1", filesTransferred: 2, bytesTransferred: "credential-material".length + "plain".length }],
});
});
it("syncIn tars a directory mapping host-side honoring excludes and the followSymlinks flag, then extracts it in-sandbox via a single quoted tar command", async () => {
const hostDir = await makeHostDir();
const sourceDir = path.join(hostDir, "assets");
await fs.mkdir(path.join(sourceDir, "keep"), { recursive: true });
await fs.writeFile(path.join(sourceDir, "keep", "a.txt"), "alpha");
await fs.writeFile(path.join(sourceDir, "skip.log"), "noise");
await fs.symlink("keep/a.txt", path.join(sourceDir, "link.txt"));
const sandbox = createMockSandbox();
// Capture the tar listing inside the upload mock, before withHostTempDir
// cleans the host scratch dir.
let capturedTarListing = "";
sandbox.fs.uploadFiles.mockImplementation(async (uploads: Array<{ source: string }>) => {
capturedTarListing = execFileSync("tar", ["-tvf", uploads[0].source]).toString();
});
mockGet.mockResolvedValue(sandbox);
// Preserve-symlink case (followSymlinks falsy → no -h).
await plugin.definition.onEnvironmentSyncIn?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "sync-op-dir",
files: [
{
sourcePath: sourceDir,
targetPath: `${REMOTE_DIR}/.paperclip-runtime/assets`,
kind: "directory",
exclude: ["*.log"],
},
],
},
],
});
expect(sandbox.fs.uploadFiles).toHaveBeenCalledTimes(1);
const [uploads] = sandbox.fs.uploadFiles.mock.calls[0] as [Array<{ source: string; destination: string }>];
expect(uploads).toHaveLength(1);
expect(uploads[0].source).toMatch(/\.tar$/);
expect(path.posix.basename(uploads[0].destination)).toMatch(/^\.paperclip-upload-.*\.tar$/);
expect(uploads[0].destination.startsWith(`${REMOTE_DIR}/`)).toBe(true);
// Inspect the real host tar: excluded file gone; symlink preserved AS a link.
expect(capturedTarListing).toContain("keep/a.txt");
expect(capturedTarListing).not.toContain("skip.log");
expect(capturedTarListing).toMatch(/link\.txt ->|link\.txt link to/);
// The target dir is created by its own mkdir command (so the realpath guard
// that follows resolves real components), no longer inside the extract chain.
const mkdirCall = sandbox.process.executeCommand.mock.calls.find(
([cmd]) =>
String(cmd).includes("mkdir -p") &&
String(cmd).includes(`'${REMOTE_DIR}/.paperclip-runtime/assets'`) &&
!String(cmd).includes("tar -xf"),
);
expect(mkdirCall).toBeDefined();
// The realpath symlink-escape guard runs on the target before extraction.
const inboundGuardCall = sandbox.process.executeCommand.mock.calls.find(([cmd]) =>
String(cmd).includes("_pc_resolve"),
);
expect(inboundGuardCall).toBeDefined();
const extractCall = sandbox.process.executeCommand.mock.calls.find(([cmd]) => String(cmd).includes("tar -xf"));
expect(extractCall).toBeDefined();
const extractCommand = String(extractCall?.[0]);
// The extract binds validation and extraction into one sandbox invocation: it
// re-canonicalizes the target, opens the resolved dir as fd 9, re-verifies the
// PINNED inode (`/proc/self/fd/9`) is still in-root — closing the ancestor-swap
// race in the `open()` itself — then extracts via /proc/self/fd/9, binding
// extraction to the directory inode rather than the path string.
expect(extractCommand).toContain("_pc_resolve");
expect(extractCommand).toContain(".paperclip-runtime/assets");
expect(extractCommand).toContain("tar -xf");
expect(extractCommand).toContain('exec 9<"$_pc_real"');
expect(extractCommand).toContain("_pc_fd_real=$(_pc_resolve /proc/self/fd/9)");
expect(extractCommand).toContain("-C /proc/self/fd/9");
expect(extractCommand).toMatch(/rm -f .*\.paperclip-upload-.*\.tar/);
});
it("syncIn dereferences symlinks to bytes when followSymlinks is true (tar -h)", async () => {
const hostDir = await makeHostDir();
const sourceDir = path.join(hostDir, "deref");
await fs.mkdir(sourceDir, { recursive: true });
await fs.writeFile(path.join(sourceDir, "real.txt"), "payload");
await fs.symlink("real.txt", path.join(sourceDir, "alias.txt"));
const sandbox = createMockSandbox();
let capturedTarListing = "";
sandbox.fs.uploadFiles.mockImplementation(async (uploads: Array<{ source: string }>) => {
capturedTarListing = execFileSync("tar", ["-tvf", uploads[0].source]).toString();
});
mockGet.mockResolvedValue(sandbox);
await plugin.definition.onEnvironmentSyncIn?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "sync-op-deref",
files: [{ sourcePath: sourceDir, targetPath: `${REMOTE_DIR}/deref`, kind: "directory", followSymlinks: true }],
},
],
});
// Dereferenced: alias becomes a regular file, not a link.
expect(capturedTarListing).not.toMatch(/alias\.txt ->/);
expect(capturedTarListing).toContain("alias.txt");
});
it("syncOut reads all file mappings via one downloadFiles batch, writes each to its host target, and returns per-operation counts", async () => {
const hostDir = await makeHostDir();
const sandbox = createMockSandbox();
// The download reads sandbox-side snapshots (reserved temp names), which are
// index-aligned with the file mappings; write payloads in request order.
const payloadsInOrder = ["result-bytes", "secret-bytes"];
sandbox.fs.downloadFiles.mockImplementation(async (requests: Array<{ source: string; destination?: string }>) => {
return Promise.all(
requests.map(async (req, index) => {
await fs.writeFile(req.destination!, payloadsInOrder[index]);
return { source: req.source, result: req.destination };
}),
);
});
mockGet.mockResolvedValue(sandbox);
const resultTarget = path.join(hostDir, "result.txt");
const secretTarget = path.join(hostDir, "secret.key");
const result = await plugin.definition.onEnvironmentSyncOut?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "sync-op-out",
files: [
{ sourcePath: `${REMOTE_DIR}/out/result.txt`, targetPath: resultTarget, kind: "file" },
{ sourcePath: `${REMOTE_DIR}/out/secret.key`, targetPath: secretTarget, kind: "file", mode: 0o600 },
],
},
],
});
expect(sandbox.fs.downloadFiles).toHaveBeenCalledTimes(1);
const [requests] = sandbox.fs.downloadFiles.mock.calls[0] as [Array<{ source: string; destination: string }>];
expect(requests).toHaveLength(2);
for (const req of requests) {
expect(path.basename(req.destination)).toMatch(/^\.paperclip-upload-/);
// TOCTOU-closed: the download reads a reserved snapshot inside the remote
// dir, never the mutable original source path.
expect(req.source.startsWith(`${REMOTE_DIR}/`)).toBe(true);
expect(path.posix.basename(req.source)).toMatch(/^\.paperclip-upload-/);
}
expect(requests.map((req) => req.source)).not.toContain(`${REMOTE_DIR}/out/result.txt`);
expect(requests.map((req) => req.source)).not.toContain(`${REMOTE_DIR}/out/secret.key`);
expect(await fs.readFile(resultTarget, "utf8")).toBe("result-bytes");
expect(await fs.readFile(secretTarget, "utf8")).toBe("secret-bytes");
// Secret lands 0600 on the host target.
expect((await fs.stat(secretTarget)).mode & 0o777).toBe(0o600);
expect(result).toEqual({
operations: [
{ operationId: "sync-op-out", filesTransferred: 2, bytesTransferred: "result-bytes".length + "secret-bytes".length },
],
});
});
it("syncOut snapshot guard re-checks the resolved source is a non-symlink regular file immediately before copying (validation→copy TOCTOU)", async () => {
const hostDir = await makeHostDir();
const sandbox = createMockSandbox();
sandbox.fs.downloadFiles.mockImplementation(async (requests: Array<{ source: string; destination?: string }>) => {
return Promise.all(
requests.map(async (req) => {
await fs.writeFile(req.destination!, "bytes");
return { source: req.source, result: req.destination };
}),
);
});
mockGet.mockResolvedValue(sandbox);
await plugin.definition.onEnvironmentSyncOut?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "sync-op-out-nofollow",
files: [{ sourcePath: `${REMOTE_DIR}/out/data.txt`, targetPath: path.join(hostDir, "data.txt"), kind: "file" }],
},
],
});
// The snapshot guard runs realpath → confine → no-follow re-check → cp, all in
// one `sh -c`. The `[ -L ]`/`[ -f ]` re-check must precede the `cp` so a source
// the sandbox repointed to a symlink after realpath is refused, not followed.
const guardCall = sandbox.process.executeCommand.mock.calls.find(
([cmd]) => String(cmd).includes("_pc_resolve") && String(cmd).includes("cp --"),
);
expect(guardCall).toBeDefined();
const guardCommand = String(guardCall?.[0]);
expect(guardCommand).toContain('[ -L "$_pc_real" ]');
expect(guardCommand).toContain('[ -f "$_pc_real" ]');
const noFollowIdx = guardCommand.indexOf('[ -L "$_pc_real" ]');
const copyIdx = guardCommand.indexOf('cp -- "$_pc_real"');
expect(noFollowIdx).toBeGreaterThan(-1);
expect(copyIdx).toBeGreaterThan(noFollowIdx);
});
it("syncOut fails loud when any per-file download reports an error, and leaves no target file", async () => {
const hostDir = await makeHostDir();
const sandbox = createMockSandbox();
// Requests read snapshots (index-aligned with mappings); the second mapping
// (`missing.txt`) reports a per-file error.
sandbox.fs.downloadFiles.mockImplementation(async (requests: Array<{ source: string; destination?: string }>) => {
return requests.map((req, index) =>
index === 1
? { source: req.source, error: "not found", errorDetails: { message: "not found", statusCode: 404 } }
: { source: req.source, result: req.destination },
);
});
mockGet.mockResolvedValue(sandbox);
const okTarget = path.join(hostDir, "ok.txt");
const badTarget = path.join(hostDir, "missing.txt");
await expect(
plugin.definition.onEnvironmentSyncOut?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "sync-op-err",
files: [
{ sourcePath: `${REMOTE_DIR}/ok.txt`, targetPath: okTarget, kind: "file" },
{ sourcePath: `${REMOTE_DIR}/missing.txt`, targetPath: badTarget, kind: "file" },
],
},
],
}),
).rejects.toThrow(/download failed for .*missing\.txt: not found/);
// Fail-loud: no target file is promoted when the batch has any error.
await expect(fs.stat(okTarget)).rejects.toThrow();
await expect(fs.stat(badTarget)).rejects.toThrow();
});
it("rejects a sync target path that escapes the workspace remote dir (path confinement)", async () => {
const hostDir = await makeHostDir();
const source = path.join(hostDir, "evil.txt");
await fs.writeFile(source, "x");
const sandbox = createMockSandbox();
mockGet.mockResolvedValue(sandbox);
await expect(
plugin.definition.onEnvironmentSyncIn?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "sync-op-escape",
files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/../../etc/passwd`, kind: "file" }],
},
],
}),
).rejects.toThrow(/escapes the workspace remote dir|not a confined absolute path/);
expect(sandbox.fs.uploadFiles).not.toHaveBeenCalled();
});
it("syncOut rejects an outbound source whose in-sandbox realpath escapes the workspace remote dir, before any download", async () => {
const hostDir = await makeHostDir();
const sandbox = createMockSandbox();
// The in-sandbox realpath guard runs as a single `sh -c` probe; report the
// escape exit code (42) for that probe while leaving any other command green,
// so the guard is the only thing that can trip this test.
sandbox.process.executeCommand.mockImplementation(async (command: string) => {
if (command.includes("_pc_resolve")) {
return { exitCode: 42, result: `ESCAPE:${REMOTE_DIR}/out/link.txt`, artifacts: { stdout: "" } };
}
return { exitCode: 0, result: "bash", artifacts: { stdout: "bash" } };
});
mockGet.mockResolvedValue(sandbox);
const target = path.join(hostDir, "link.txt");
await expect(
plugin.definition.onEnvironmentSyncOut?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "sync-op-escape-out",
files: [{ sourcePath: `${REMOTE_DIR}/out/link.txt`, targetPath: target, kind: "file" }],
},
],
}),
).rejects.toThrow(/outbound symlink-escape guard command failed \(exit 42\)/);
// Fail-closed: the guard trips before any bytes are read, and no target lands.
expect(sandbox.fs.downloadFiles).not.toHaveBeenCalled();
await expect(fs.stat(target)).rejects.toThrow();
});
it("syncOut fails closed when the sandbox has no path canonicalizer to resolve the symlink guard", async () => {
const hostDir = await makeHostDir();
const sandbox = createMockSandbox();
// Neither `realpath` nor `readlink` present → the probe exits 40 rather than
// silently skipping the guard the host-side string check cannot enforce.
sandbox.process.executeCommand.mockImplementation(async (command: string) => {
if (command.includes("_pc_resolve")) {
return { exitCode: 40, result: "no path canonicalizer available", artifacts: { stdout: "" } };
}
return { exitCode: 0, result: "bash", artifacts: { stdout: "bash" } };
});
mockGet.mockResolvedValue(sandbox);
const target = path.join(hostDir, "data.txt");
await expect(
plugin.definition.onEnvironmentSyncOut?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "sync-op-no-canon",
files: [{ sourcePath: `${REMOTE_DIR}/out/data.txt`, targetPath: target, kind: "file" }],
},
],
}),
).rejects.toThrow(/outbound symlink-escape guard command failed \(exit 40\)/);
expect(sandbox.fs.downloadFiles).not.toHaveBeenCalled();
await expect(fs.stat(target)).rejects.toThrow();
});
it("syncIn rejects a file mapping whose in-sandbox target parent resolves outside the remote dir (symlinked-parent escape), before uploading", async () => {
const hostDir = await makeHostDir();
const source = path.join(hostDir, "auth.json");
await fs.writeFile(source, "credential-material");
const sandbox = createMockSandbox();
// The lexical path check passes (the target string is confined), but the
// realpath guard on the materialized parent dir resolves outside the root:
// report the escape exit (42) for the `_pc_resolve` probe, green otherwise.
sandbox.process.executeCommand.mockImplementation(async (command: string) => {
if (command.includes("_pc_resolve")) {
return { exitCode: 42, result: `ESCAPE:${REMOTE_DIR}/.secret`, artifacts: { stdout: "" } };
}
return { exitCode: 0, result: "bash", artifacts: { stdout: "bash" } };
});
mockGet.mockResolvedValue(sandbox);
await expect(
plugin.definition.onEnvironmentSyncIn?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "sync-op-in-escape",
files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/.secret/auth.json`, kind: "file", mode: 0o600 }],
},
],
}),
).rejects.toThrow(/inbound symlink-escape guard command failed \(exit 42\)/);
// Fail-closed: the guard trips after mkdir but before any bytes are uploaded.
expect(sandbox.fs.uploadFiles).not.toHaveBeenCalled();
expect(sandbox.fs.setFilePermissions).not.toHaveBeenCalled();
});
it("syncIn rejects a directory mapping whose in-sandbox target resolves outside the remote dir (symlinked-dir extraction), before uploading the tarball", async () => {
const hostDir = await makeHostDir();
const sourceDir = path.join(hostDir, "assets");
await fs.mkdir(sourceDir, { recursive: true });
await fs.writeFile(path.join(sourceDir, "a.txt"), "alpha");
const sandbox = createMockSandbox();
sandbox.process.executeCommand.mockImplementation(async (command: string) => {
if (command.includes("_pc_resolve")) {
return { exitCode: 42, result: `ESCAPE:${REMOTE_DIR}/assets`, artifacts: { stdout: "" } };
}
return { exitCode: 0, result: "bash", artifacts: { stdout: "bash" } };
});
mockGet.mockResolvedValue(sandbox);
await expect(
plugin.definition.onEnvironmentSyncIn?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "sync-op-in-dir-escape",
files: [{ sourcePath: sourceDir, targetPath: `${REMOTE_DIR}/assets`, kind: "directory" }],
},
],
}),
).rejects.toThrow(/inbound symlink-escape guard command failed \(exit 42\)/);
// Fail-closed: no tarball is uploaded and no in-sandbox extraction runs.
expect(sandbox.fs.uploadFiles).not.toHaveBeenCalled();
const extractCall = sandbox.process.executeCommand.mock.calls.find(([cmd]) => String(cmd).includes("tar -xf"));
expect(extractCall).toBeUndefined();
});
it("syncIn sweeps staged temps when the batched rename fails mid-promotion", async () => {
const hostDir = await makeHostDir();
const source = path.join(hostDir, "config.txt");
await fs.writeFile(source, "plain");
const sandbox = createMockSandbox();
// mkdir + realpath guard succeed; the promoting `mv -f` fails, leaving staged
// `.paperclip-upload-*` temps that the error path must sweep with `rm -f`.
sandbox.process.executeCommand.mockImplementation(async (command: string) => {
if (command.includes("mv -f")) {
return { exitCode: 1, result: "mv: permission denied", artifacts: { stdout: "mv: permission denied" } };
}
return { exitCode: 0, result: "bash", artifacts: { stdout: "bash" } };
});
mockGet.mockResolvedValue(sandbox);
await expect(
plugin.definition.onEnvironmentSyncIn?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "sync-op-in-rename-fail",
files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }],
},
],
}),
).rejects.toThrow(/syncIn rename command failed \(exit 1\)/);
// The upload happened, so a temp was staged; the error path cleans it up.
expect(sandbox.fs.uploadFiles).toHaveBeenCalledTimes(1);
const cleanupCall = sandbox.process.executeCommand.mock.calls.find(
([cmd]) => String(cmd).includes("rm -f") && String(cmd).includes(".paperclip-upload-"),
);
expect(cleanupCall).toBeDefined();
});
it("syncOut refuses a sandbox-authored tarball whose members escape the extraction dir (path traversal)", async () => {
const hostRoot = await makeHostDir();
const restored = path.join(hostRoot, "restored");
const sandbox = createMockSandbox();
sandbox.fs.downloadFiles.mockImplementation(async (requests: Array<{ source: string; destination?: string }>) => {
return Promise.all(
requests.map(async (req) => {
// Craft a tar containing a traversal member `../escape.txt`.
const staging = await fs.mkdtemp(path.join(os.tmpdir(), "daytona-evil-"));
tempDirs.push(staging);
await fs.mkdir(path.join(staging, "sub"), { recursive: true });
await fs.writeFile(path.join(staging, "sub", "escape.txt"), "escape");
execFileSync("tar", [
"-cf",
req.destination!,
"-C",
path.join(staging, "sub"),
"--transform",
"s,^,../,",
"escape.txt",
]);
return { source: req.source, result: req.destination };
}),
);
});
mockGet.mockResolvedValue(sandbox);
await expect(
plugin.definition.onEnvironmentSyncOut?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "sync-op-out-traversal",
files: [{ sourcePath: `${REMOTE_DIR}/proj`, targetPath: restored, kind: "directory" }],
},
],
}),
).rejects.toThrow(/escapes the extraction dir/);
// The traversal member (`../escape.txt` relative to `restored`) was never
// written above the extraction dir.
await expect(fs.stat(path.join(hostRoot, "escape.txt"))).rejects.toThrow();
});
it("round-trips a directory (syncIn then syncOut) preserving contents, a 0600 file, and a preserved symlink", async () => {
const hostRoot = await makeHostDir();
const source = path.join(hostRoot, "src");
await fs.mkdir(path.join(source, "nested"), { recursive: true });
await fs.writeFile(path.join(source, "nested", "data.txt"), "hello world");
await fs.writeFile(path.join(source, "secret"), "top-secret");
await fs.chmod(path.join(source, "secret"), 0o600);
await fs.symlink("nested/data.txt", path.join(source, "shortcut"));
// Simulate the sandbox filesystem with a host-side directory the mock tar
// commands operate on, so the round-trip exercises real tar create/extract.
const sandboxFsRoot = await makeHostDir();
const remoteTargetDir = path.join(sandboxFsRoot, "materialized");
const sandbox = createMockSandbox();
// syncIn: capture the uploaded host tar and extract it into the simulated
// sandbox dir, mirroring the in-sandbox `tar -xf`.
sandbox.fs.uploadFiles.mockImplementation(async (uploads: Array<{ source: string; destination: string }>) => {
for (const upload of uploads) {
await fs.mkdir(remoteTargetDir, { recursive: true });
execFileSync("tar", ["-xpf", upload.source, "-C", remoteTargetDir]);
}
});
// syncOut: build a tar of the simulated sandbox dir and stream it to the
// requested host destination, mirroring in-sandbox `tar -c` + downloadFiles.
sandbox.fs.downloadFiles.mockImplementation(async (requests: Array<{ source: string; destination?: string }>) => {
return Promise.all(
requests.map(async (req) => {
const entries = (await fs.readdir(remoteTargetDir)).sort();
execFileSync("tar", ["-cpf", req.destination!, "-C", remoteTargetDir, "--", ...entries]);
return { source: req.source, result: req.destination };
}),
);
});
mockGet.mockResolvedValue(sandbox);
await plugin.definition.onEnvironmentSyncIn?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{ operationId: "rt-in", files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/proj`, kind: "directory" }] },
],
});
const restored = path.join(hostRoot, "restored");
await plugin.definition.onEnvironmentSyncOut?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{ operationId: "rt-out", files: [{ sourcePath: `${REMOTE_DIR}/proj`, targetPath: restored, kind: "directory" }] },
],
});
expect(await fs.readFile(path.join(restored, "nested", "data.txt"), "utf8")).toBe("hello world");
expect(await fs.readFile(path.join(restored, "secret"), "utf8")).toBe("top-secret");
expect((await fs.stat(path.join(restored, "secret"))).mode & 0o777).toBe(0o600);
const linkStat = await fs.lstat(path.join(restored, "shortcut"));
expect(linkStat.isSymbolicLink()).toBe(true);
expect(await fs.readlink(path.join(restored, "shortcut"))).toBe("nested/data.txt");
});
});
describe("daytona manifest memory config", () => {
const memorySchema = (
manifest.environmentDrivers?.[0]?.configSchema as {

View File

@ -31,9 +31,13 @@ import type {
PluginEnvironmentReleaseLeaseParams,
PluginEnvironmentResumeLeaseParams,
PluginEnvironmentStartInteractiveSetupParams,
PluginEnvironmentSyncInParams,
PluginEnvironmentSyncOutParams,
PluginEnvironmentSyncResult,
PluginEnvironmentValidateConfigParams,
PluginEnvironmentValidationResult,
} from "@paperclipai/plugin-sdk";
import { performSyncIn, performSyncOut } from "./file-sync.js";
interface DaytonaDriverConfig {
apiKey: string | null;
@ -666,6 +670,17 @@ function buildLoginShellScript(input: {
return lines.join(" && ");
}
// The workspace remote dir is the confinement root for native file sync. It is
// recorded on the lease metadata at acquire/resume time; require it so a sync can
// never run without a concrete root to confine every sandbox path against.
function resolveSyncRemoteDir(lease: { metadata?: Record<string, unknown> | null }): string {
const remoteCwd = lease.metadata?.remoteCwd;
if (typeof remoteCwd === "string" && remoteCwd.trim().length > 0) {
return remoteCwd.trim();
}
throw new Error("Daytona file sync requires a workspace remote dir on the lease metadata.");
}
async function createSandbox(
params: PluginEnvironmentAcquireLeaseParams | PluginEnvironmentProbeParams | PluginEnvironmentStartInteractiveSetupParams,
config: DaytonaDriverConfig,
@ -1245,6 +1260,51 @@ const plugin = definePlugin({
await ensureSandboxStarted(sandbox, toTimeoutSeconds(resolveTimeoutMs(params.timeoutMs, config)));
return await executeOneShot(sandbox, params, config);
},
// Opt-in native inbound transfer. Defining this hook (with onEnvironmentSyncOut)
// makes the worker advertise `environmentSyncIn`/`environmentSyncOut`, so the
// host runner routes Daytona workspace/asset transfers through the SDK's batch
// `uploadFiles` (plus host-side tarballs for directories) instead of the
// base64-over-exec fallback. Providers that do not define these keep the
// byte-identical fallback.
async onEnvironmentSyncIn(
params: PluginEnvironmentSyncInParams,
): Promise<PluginEnvironmentSyncResult> {
if (!params.lease.providerLeaseId) {
throw new Error("Daytona syncIn requires a provider lease ID.");
}
const config = parseDriverConfig(params.config);
const remoteDir = resolveSyncRemoteDir(params.lease);
const timeoutSeconds = toTimeoutSeconds(config.timeoutMs);
const sandbox = await getSandbox(config, params.lease.providerLeaseId);
await ensureSandboxStarted(sandbox, timeoutSeconds);
return await performSyncIn({
sandbox,
operations: params.operations,
remoteDir,
timeoutSeconds,
});
},
// Opt-in native outbound transfer. See onEnvironmentSyncIn.
async onEnvironmentSyncOut(
params: PluginEnvironmentSyncOutParams,
): Promise<PluginEnvironmentSyncResult> {
if (!params.lease.providerLeaseId) {
throw new Error("Daytona syncOut requires a provider lease ID.");
}
const config = parseDriverConfig(params.config);
const remoteDir = resolveSyncRemoteDir(params.lease);
const timeoutSeconds = toTimeoutSeconds(config.timeoutMs);
const sandbox = await getSandbox(config, params.lease.providerLeaseId);
await ensureSandboxStarted(sandbox, timeoutSeconds);
return await performSyncOut({
sandbox,
operations: params.operations,
remoteDir,
timeoutSeconds,
});
},
});
export default plugin;