Files
PaperClipAI/packages/adapter-utils/src/sandbox-managed-runtime.ts
T
b247bf7150 feat(runtime): opt-in sandbox file-sync lifecycle hooks (API + provider docs) (#10013)
## Thinking Path

> - Paperclip is an open-source AI-agent management platform; agents run
tasks inside sandboxed environments (Daytona, Kubernetes, E2B, etc.)
> - The control-plane ↔ sandbox file-transfer path flows through the
`environmentExecute` seam in `protocol.ts` — the only verb available to
plugins — which forces a base64-over-exec chunked loop for every file
move: workspace files, assets, Codex home sync
> - This transport is correct and safe, but it bypasses provider-native
bulk/streaming APIs (Daytona `uploadFiles`, K8s `FastUploadInterceptor`
/ volume mounts), leaving significant throughput on the table for large
workspaces
> - The right fix is an opt-in seam extension: providers with faster
native transfer declare two optional verbs; providers that do not opt in
stay on the existing fallback with zero code or behavior change required
> - This PR adds the first layer of that extension — two optional verbs
(`environmentSyncIn` / `environmentSyncOut`) in the plugin SDK, the
runtime plumbing to prefer the native path for the two clean
destroy-then-replace cases, and a doc for the contract
> - The core correctness invariant is byte-identical fallback: if no
provider opts in, execution is exactly what ships today;
`assertSyncOperationsConfined` enforces host-side path confinement for
providers that do opt in
> - No provider advertises the verbs yet → zero production behavior
change; future PRs wire up Daytona and K8s providers against this
contract

## Linked Issues or Issue Description

No public GitHub issue exists for this feature. Description follows the
`feature_request` issue template:

**Subsystem affected:**
packages/plugins — plugin system; packages/adapter-utils — adapter
runtime; server/ — EnvironmentRuntimeService

**Problem or motivation:**
Sandbox file transfers currently always use a base64-over-exec chunked
loop regardless of what the underlying provider supports. For workspaces
larger than a few MB this becomes the dominant wall-clock cost of every
sandbox run, and it bypasses bulk/stream APIs that providers like
Daytona already expose natively.

**Proposed solution:**
Add two optional, opt-in plugin hooks — `onEnvironmentSyncIn` /
`onEnvironmentSyncOut` — to the plugin SDK. When a provider defines both
hooks and both are advertised via the existing `supportedMethods`
negotiation, the runtime prefers the native path for the two clean
destroy-then-replace transfer cases; all other cases fall back to the
existing byte-identical base64 transport.

**Alternatives considered:**
An unconditional verb would require every provider to implement or stub
the verb. The opt-in / `METHOD_NOT_IMPLEMENTED` pattern (already used by
`environmentExecute`) preserves backward compatibility with zero
provider changes required.

**Roadmap alignment:**
Consistent with the ✅ "Cloud / Sandbox agents" and ✅ "Plugin system"
milestones; extends the plugin seam rather than adding
control-plane-level logic.

**Additional context:**
Searched open pull requests and issues for duplicate sandbox file-sync /
native-transfer work; none found.

## What Changed

- **`packages/plugins/sdk`**
- `protocol.ts`: two new optional `HostToWorkerMethods` —
`environmentSyncIn` / `environmentSyncOut` — plus generic
`SyncOperation`, `SyncFileMapping`, and `SyncOutcome` types
- `define-plugin.ts`: optional `onEnvironmentSyncIn` /
`onEnvironmentSyncOut` fields on `PluginDefinition`; worker advertises
each verb only when its hook is defined (else `METHOD_NOT_IMPLEMENTED`,
mirroring `environmentExecute`)
  - `worker-rpc-host.ts`: route new verbs to plugin hooks
  - `index.ts`: re-export new public types
- **`packages/adapter-utils`**
- `command-managed-runtime.ts`: expose optional `syncIn` / `syncOut` on
`CommandManagedRuntimeRunner` (available only when both verbs are
advertised); add `assertSyncOperationsConfined` host-side
path-confinement guard
- `sandbox-managed-runtime.ts`: `SandboxManagedRuntimeClient` gains
optional `syncIn` / `syncOut`; orchestrator prefers native path for
default-provision asset inbound and workspace-download-into-fresh-dir
outbound; all other paths keep the existing base64 fallback
- `sandbox-file-sync.test.ts` (new): 234-line characterization suite —
native-opt-in branch, fallback branch, `assertSyncOperationsConfined`
escape-path rejection, `followSymlinks` → tar `-h`
- `command-managed-runtime.test.ts`: negotiation + native-sync +
confinement tests
- **`server/src/services/environment-runtime.ts`**:
`EnvironmentRuntimeService` delegates to `syncIn` / `syncOut`, gated on
advertised support
- **`server/src/services/environment-execution-target.ts`**: minor
typing fix alongside the new verbs
- **`doc/plugins/SANDBOX_FILE_SYNC_HOOKS.md`** (new): documents the full
contract — opt-in / no-op guarantee, operation ordering,
provider-may-tar, atomicity, `followSymlinks`, secret modes (0600, no
window), path confinement, `operationId` opacity, resource bounds,
shell-quoting

## Verification

```bash
# SDK suite
pnpm --filter packages/plugins/sdk test

# Adapter-utils suite (includes new sandbox-file-sync characterization tests)
pnpm --filter packages/adapter-utils test
# Expected: 255 pass / 4 skip

# Type-check across affected packages
pnpm --filter packages/plugins/sdk typecheck
pnpm --filter packages/adapter-utils typecheck
# Server changed-file spot check:
cd server && npx tsc --noEmit --skipLibCheck 2>&1 | grep -E "environment-(runtime|execution-target)" | head -20
```

Key behavioral invariant to spot-check: with no provider opting in (the
current state), run any sandbox task and confirm file-transfer behavior
is byte-for-byte identical to what the pre-PR code produces. The
characterization tests assert this at the unit level.

## Risks

- **Zero production risk today**: no provider advertises
`environmentSyncIn` / `environmentSyncOut`, so the new code paths are
unreachable in production; all real traffic stays on the existing base64
fallback
- **Path confinement**: `assertSyncOperationsConfined` rejects any
`targetPath` that escapes the declared root — this is the primary
security boundary for future providers. The test suite covers
escape-path rejection
- **Atomicity**: the contract delegates atomicity to providers; the doc
explicitly calls out that directory-level ops are not guaranteed atomic
- **Secret transport**: credential assets (e.g., Codex `auth.json`,
directory mappings) continue to use the existing tar path — they do not
go through the new verbs in any current provider

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

Provider: Anthropic  
Model: `claude-sonnet-4-6` (Claude Sonnet 4.6)  
Context window: 200 K tokens  
Capabilities: extended tool use, multi-file code generation, agentic
reasoning via the Paperclip agent framework

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs: #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [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
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Harold Kim <harold@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-22 10:08:03 -07:00

946 lines
37 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 {
buildRemoteGitDeltaBundleScript,
createImportedGitRef,
createRemoteGitExportRef,
deleteLocalGitRef,
fetchGitBundleIntoLocalRef,
GIT_ARCHIVE_EXCLUDES,
integrateImportedGitHead,
readGitWorkspaceSnapshot,
resetLocalGitIndexToHead,
withShallowGitWorkspaceClone,
} from "./git-workspace-sync.js";
import { captureDirectorySnapshot, mergeDirectoryWithBaseline } from "./workspace-restore-merge.js";
import {
createRuntimeProgressReporter,
type RuntimeProgressDirection,
type RuntimeProgressPhase,
type RuntimeProgressSink,
type RuntimeStatusPhase,
type RuntimeStatusSink,
} from "./runtime-progress.js";
import { isRelativePathOrDescendant, shouldExcludePath } from "./exclude-patterns.js";
const execFile = promisify(execFileCallback);
const SANDBOX_WORKSPACE_HEAVY_DIR_NAMES = [
"node_modules",
"vendor",
"dist",
"build",
"out",
"coverage",
".next",
".turbo",
".cache",
] as const;
const SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES = SANDBOX_WORKSPACE_HEAVY_DIR_NAMES.flatMap((entry) => [
entry,
`${entry}/*`,
`*/${entry}`,
`*/${entry}/*`,
]);
export interface SandboxRemoteExecutionSpec {
transport: "sandbox";
provider: string;
sandboxId: string;
remoteCwd: string;
timeoutMs: number;
apiKey: string | null;
}
/**
* Remote paths handed to an asset's `provision.extractCommand`. All are POSIX
* paths inside the sandbox: `assetTarPath` is the uploaded asset tarball,
* `assetDir` is where the asset should be materialized, and `runtimeRootDir`
* is the directory any `stageFiles` were written into.
*/
export interface SandboxManagedRuntimeAssetProvisionContext {
assetTarPath: string;
assetDir: string;
runtimeRootDir: string;
}
/**
* Per-asset inbound provisioning contribution. The core is adapter-agnostic:
* an asset that supplies neither `stageFiles` nor `extractCommand` is extracted
* with a plain `tar -xf`. An adapter that needs custom provisioning (e.g. a
* credential merge) supplies helper files via `stageFiles` and the shell
* command that consumes them via `extractCommand`.
*/
export interface SandboxManagedRuntimeAssetProvision {
/**
* Extra files written into `runtimeRootDir` (alongside the asset tar) before
* the extract command runs — typically helper scripts the extract command
* invokes. Contents may be raw bytes or a UTF-8 string.
*/
stageFiles?: { name: string; contents: Buffer | string }[];
/**
* Builds the shell command that materializes the uploaded asset tar into
* `assetDir`. Defaults to a plain `tar -xf` extraction when omitted.
*/
extractCommand?: (ctx: SandboxManagedRuntimeAssetProvisionContext) => string;
}
/**
* Context passed to an asset's `restore` contribution during teardown.
* `assetDir` is the asset's directory inside the sandbox and `readFile` reads
* a file back from the sandbox as raw bytes.
*/
export interface SandboxManagedRuntimeAssetRestoreContext {
assetDir: string;
readFile: (remotePath: string) => Promise<Buffer>;
}
export interface SandboxManagedRuntimeAsset {
key: string;
localDir: string;
followSymlinks?: boolean;
exclude?: string[];
/** Optional inbound provisioning contribution (staged files + extract command). */
provision?: SandboxManagedRuntimeAssetProvision;
/**
* Optional teardown/outbound contribution, invoked once per asset during
* `restoreWorkspace`. Defaults to a no-op when omitted.
*/
restore?: (ctx: SandboxManagedRuntimeAssetRestoreContext) => Promise<void>;
}
/**
* Per-call byte-level progress hook. `transferredBytes`/`totalBytes` are decoded
* file bytes (not the base64 wire size). `totalBytes` is null when the size is
* not known up front. The transport is the source of truth for byte counts; the
* orchestrator owns the phase label and direction.
*/
export interface SandboxTransferProgressOptions {
onProgress?: (transferredBytes: number, totalBytes: number | null) => void | Promise<void>;
}
/**
* A single source→target file or directory transfer within a sync operation.
* Mirrors the plugin SDK `PluginSyncFileMapping`; kept as a local structural
* type so `adapter-utils` does not depend on the plugin SDK. For `syncIn`,
* `sourcePath` is a host path and `targetPath` a sandbox path; for `syncOut` the
* direction is reversed. Sandbox paths are POSIX.
*/
export interface SandboxSyncFileMapping {
sourcePath: string;
targetPath: string;
kind: "file" | "directory";
mode?: number;
exclude?: string[];
followSymlinks?: boolean;
}
/**
* An ordered, opaque unit of work handed to the native sync transport. The
* `operationId` is an opaque, non-sensitive token authored by the orchestrator
* (never a caller/asset identifier that could leak intent); a provider MUST NOT
* interpret it.
*/
export interface SandboxSyncOperation {
operationId: string;
files: SandboxSyncFileMapping[];
}
export interface SandboxSyncResult {
operations: { operationId: string; filesTransferred: number; bytesTransferred: number }[];
}
export interface SandboxManagedRuntimeClient {
makeDir(remotePath: string): Promise<void>;
writeFile(remotePath: string, bytes: ArrayBuffer, options?: SandboxTransferProgressOptions): Promise<void>;
readFile(
remotePath: string,
options?: SandboxTransferProgressOptions,
): Promise<Buffer | Uint8Array | ArrayBuffer>;
listFiles(remotePath: string): Promise<string[]>;
remove(remotePath: string): Promise<void>;
run(command: string, options: { timeoutMs: number }): Promise<void>;
/**
* Optional native inbound transfer. Present only when the sandbox provider
* advertises both `environmentSyncIn` and `environmentSyncOut`; otherwise the
* orchestrator falls back to the tar + base64 `writeFile`/`run` path so
* behavior is byte-identical to a provider that never opted in.
*/
syncIn?(operations: SandboxSyncOperation[]): Promise<SandboxSyncResult>;
/** Optional native outbound transfer. See {@link syncIn}. */
syncOut?(operations: SandboxSyncOperation[]): Promise<SandboxSyncResult>;
}
/**
* Host-side complete-mediation guard for native sync operations. The orchestrator
* authors every `targetPath`, but the native transport crosses the host↔sandbox
* trust boundary, so we canonicalize and confine each mapping's source and target
* to an orchestrator-owned root before handing the operation to a provider.
* Absolute escapes and `..` traversal are rejected fail-closed. Sandbox and host
* paths on the server are POSIX.
*/
export function assertSyncOperationsConfined(
operations: SandboxSyncOperation[],
roots: { sourceRoots: string[]; targetRoots: string[] },
): void {
const confine = (candidate: string, allowed: string[], label: string): void => {
const normalized = path.posix.normalize(candidate);
if (!path.posix.isAbsolute(normalized) || normalized === ".." || normalized.includes("/../") || normalized.endsWith("/..")) {
throw new Error(`sync operation ${label} path is not a confined absolute path: ${candidate}`);
}
const within = allowed.some((root) => {
const normalizedRoot = path.posix.normalize(root);
const prefix = normalizedRoot.endsWith("/") ? normalizedRoot : `${normalizedRoot}/`;
return normalized === normalizedRoot || normalized.startsWith(prefix);
});
if (!within) {
throw new Error(`sync operation ${label} path escapes its confinement root: ${candidate}`);
}
};
for (const operation of operations) {
for (const mapping of operation.files) {
confine(mapping.sourcePath, roots.sourceRoots, "source");
confine(mapping.targetPath, roots.targetRoots, "target");
}
}
}
export interface PreparedSandboxManagedRuntime {
spec: SandboxRemoteExecutionSpec;
workspaceLocalDir: string;
workspaceRemoteDir: string;
runtimeRootDir: string;
assetDirs: Record<string, string>;
restoreWorkspace(onProgress?: RuntimeProgressSink): 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, `'\"'\"'`)}'`;
}
function buildDefaultExtractRuntimeAssetCommand(input: {
remoteAssetDir: string;
remoteAssetTar: string;
}): string {
return `rm -rf ${shellQuote(input.remoteAssetDir)} && ` +
`mkdir -p ${shellQuote(input.remoteAssetDir)} && ` +
`tar -xf ${shellQuote(input.remoteAssetTar)} -C ${shellQuote(input.remoteAssetDir)} && ` +
`rm -f ${shellQuote(input.remoteAssetTar)}`;
}
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);
}
async function copyWorkspaceEntry(sourceRoot: string, targetRoot: string, relative: string): Promise<void> {
const sourcePath = path.join(sourceRoot, relative);
const targetPath = path.join(targetRoot, 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);
}
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 entries = (await walkDirectory(sourceDir)).sort((left, right) => left.localeCompare(right));
for (const relative of entries) {
await copyWorkspaceEntry(sourceDir, targetDir, relative);
}
}
async function copySelectedWorkspaceEntries(input: {
sourceDir: string;
targetDir: string;
relativePaths: string[];
exclude: string[];
}): Promise<void> {
await fs.mkdir(input.targetDir, { recursive: true });
for (const relative of input.relativePaths) {
if (shouldExcludePath(relative, input.exclude)) continue;
const sourceStats = await fs.lstat(path.join(input.sourceDir, relative)).catch(() => null);
if (!sourceStats) continue;
await copyWorkspaceEntry(input.sourceDir, input.targetDir, 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(" ");
}
function createRemoteTarballFromDirectoryCommand(input: {
remoteDir: string;
archivePath: string;
exclude?: string[];
}): string {
// Match the local archive path: name top-level entries explicitly so tar
// does not include a "." self-entry that it later tries to chmod/utime.
return [
`mkdir -p ${shellQuote(path.posix.dirname(input.archivePath))}`,
`cd ${shellQuote(input.remoteDir)}`,
"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(input.archivePath)} bs=1024 count=1; ` +
`else tar -cf ${shellQuote(input.archivePath)} ${tarExcludeFlags(input.exclude)} -- "$@"; fi`,
].join(" && ");
}
async function emitRuntimeStatus(
sink: RuntimeStatusSink | undefined,
phase: RuntimeStatusPhase,
message: string,
): Promise<void> {
if (!sink) return;
await Promise.resolve(sink({ phase, message })).catch(() => undefined);
}
function mergeExcludes(...groups: Array<string[] | undefined>): string[] {
return [...new Set(groups.flatMap((group) => group ?? []))];
}
function preserveFindArgs(entries: string[]): string {
return entries.map((entry) => `! -name ${shellQuote(entry)}`).join(" ");
}
async function removeDeletedPathsInSandbox(input: {
client: SandboxManagedRuntimeClient;
spec: SandboxRemoteExecutionSpec;
remoteDir: string;
deletedPaths: string[];
}): Promise<void> {
if (input.deletedPaths.length === 0) return;
const quotedPaths = input.deletedPaths.map((entry) => shellQuote(entry)).join(" ");
await input.client.run(
`sh -c ${shellQuote(`cd ${shellQuote(input.remoteDir)} && rm -rf -- ${quotedPaths}`)}`,
{ timeoutMs: input.spec.timeoutMs },
);
}
// Bridge a single byte-level transfer to the throttled progress reporter. The
// transport reports decoded bytes via `options.onProgress`; the reporter turns
// them into a throttled, fully-formatted log line. `finish()` emits the terminal
// completion line (idempotent) once the transfer returns.
function makeTransferProgress(
sink: RuntimeProgressSink | undefined,
phase: RuntimeProgressPhase,
direction: RuntimeProgressDirection,
label?: string,
runtimeStatus?: {
sink: RuntimeStatusSink | undefined;
phase: RuntimeStatusPhase;
},
): {
options: SandboxTransferProgressOptions | undefined;
finish: (doneBytes?: number, totalBytes?: number | null) => Promise<void>;
} {
if (!sink && !runtimeStatus?.sink) {
return { options: undefined, finish: async () => {} };
}
const reporter = createRuntimeProgressReporter({
sink: async (line) => {
await sink?.(line);
if (runtimeStatus?.sink) {
await emitRuntimeStatus(
runtimeStatus.sink,
runtimeStatus.phase,
line.replace(/^\[paperclip\]\s*/, "").trim(),
);
}
},
phase,
direction,
target: "sandbox",
label,
});
return {
options: {
onProgress: async (transferredBytes, totalBytes) => {
await reporter.report(transferredBytes, totalBytes);
},
},
finish: async (doneBytes, totalBytes) => {
await reporter.complete(doneBytes, totalBytes);
},
};
}
export async function prepareSandboxManagedRuntime(input: {
spec: SandboxRemoteExecutionSpec;
adapterKey: string;
client: SandboxManagedRuntimeClient;
workspaceLocalDir: string;
workspaceRemoteDir?: string;
workspaceExclude?: string[];
preserveAbsentOnRestore?: string[];
assets?: SandboxManagedRuntimeAsset[];
// Upload progress sink. Threaded for the byte-counting transport rewrite; the
// child task wires it into writeFile/readFile.
onProgress?: RuntimeProgressSink;
onRuntimeProgress?: RuntimeStatusSink;
}): Promise<PreparedSandboxManagedRuntime> {
const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd;
const runtimeRootDir = path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey);
const gitSnapshot = await readGitWorkspaceSnapshot(input.workspaceLocalDir);
const gitIgnoredExcludes = gitSnapshot?.ignoredPaths;
const workspaceArchiveExclude = mergeExcludes(
SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES,
[...GIT_ARCHIVE_EXCLUDES],
input.workspaceExclude,
gitIgnoredExcludes,
);
const restoreExclude = mergeExcludes(
SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES,
[...GIT_ARCHIVE_EXCLUDES],
[".paperclip-runtime"],
input.preserveAbsentOnRestore,
input.workspaceExclude,
gitIgnoredExcludes,
);
const baselineSnapshot = await captureDirectorySnapshot(input.workspaceLocalDir, {
exclude: restoreExclude,
});
// Prefer the provider's native file transport when it advertised the sync
// verbs; otherwise every branch below falls back to the byte-identical tar +
// base64 `writeFile`/`run` path. `nextSyncOperationId` emits opaque, ordered,
// non-sensitive tokens — never a caller/asset identifier.
const nativeSyncIn = typeof input.client.syncIn === "function";
const nativeSyncOut = typeof input.client.syncOut === "function";
let syncOperationSeq = 0;
const nextSyncOperationId = () => `sync-op-${++syncOperationSeq}`;
await withTempDir("paperclip-sandbox-sync-", async (tempDir) => {
const preservedNames = new Set([
".paperclip-runtime",
...(gitSnapshot ? [".git"] : []),
...(input.preserveAbsentOnRestore ?? []),
]);
if (gitSnapshot) {
await emitRuntimeStatus(input.onRuntimeProgress, "git_sync", "Syncing git history to sandbox");
await withShallowGitWorkspaceClone({
localDir: input.workspaceLocalDir,
snapshot: gitSnapshot,
}, async (cloneDir) => {
// The git-workspace and workspace transfers preserve `.paperclip-runtime`
// on the target (and the git overlay merges on top rather than replacing),
// which the generic destroy-then-replace file mapping cannot express, so
// they always take the tar path. Native transfer is used for the clean
// destroy-then-replace cases (default-provision assets inbound; the
// workspace download into a fresh host dir outbound).
const gitTarPath = path.join(tempDir, "git-workspace.tar");
await createTarballFromDirectory({
localDir: cloneDir,
archivePath: gitTarPath,
exclude: [".paperclip-runtime"],
});
const gitTarBytes = await fs.readFile(gitTarPath);
const remoteGitTar = path.posix.join(runtimeRootDir, "git-workspace-upload.tar");
await input.client.makeDir(runtimeRootDir);
const gitUpload = makeTransferProgress(
input.onProgress,
"Syncing",
"to",
"git history",
{ sink: input.onRuntimeProgress, phase: "git_sync" },
);
await input.client.writeFile(remoteGitTar, toArrayBuffer(gitTarBytes), gitUpload.options);
await gitUpload.finish(gitTarBytes.byteLength, gitTarBytes.byteLength);
await input.client.run(
`sh -c ${shellQuote(
`mkdir -p ${shellQuote(workspaceRemoteDir)} && ` +
`find ${shellQuote(workspaceRemoteDir)} -mindepth 1 -maxdepth 1 ${preserveFindArgs([".paperclip-runtime"])} -exec rm -rf -- {} + && ` +
`tar -xf ${shellQuote(remoteGitTar)} -C ${shellQuote(workspaceRemoteDir)} && ` +
`rm -f ${shellQuote(remoteGitTar)}`,
)}`,
{ timeoutMs: input.spec.timeoutMs },
);
});
}
const workspaceTarPath = path.join(tempDir, "workspace.tar");
const workspaceArchiveDir = gitSnapshot ? path.join(tempDir, "workspace-overlay") : input.workspaceLocalDir;
await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing workspace to sandbox");
if (gitSnapshot) {
await copySelectedWorkspaceEntries({
sourceDir: input.workspaceLocalDir,
targetDir: workspaceArchiveDir,
relativePaths: gitSnapshot.overlayPaths,
exclude: workspaceArchiveExclude,
});
}
await createTarballFromDirectory({
localDir: workspaceArchiveDir,
archivePath: workspaceTarPath,
exclude: gitSnapshot ? undefined : workspaceArchiveExclude,
});
const workspaceTarBytes = await fs.readFile(workspaceTarPath);
const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-upload.tar");
await input.client.makeDir(runtimeRootDir);
const workspaceUpload = makeTransferProgress(
input.onProgress,
"Syncing",
"to",
"workspace",
{ sink: input.onRuntimeProgress, phase: "config_sync" },
);
await input.client.writeFile(
remoteWorkspaceTar,
toArrayBuffer(workspaceTarBytes),
workspaceUpload.options,
);
await workspaceUpload.finish(workspaceTarBytes.byteLength, workspaceTarBytes.byteLength);
const extractWorkspaceTarCommand = gitSnapshot
? `mkdir -p ${shellQuote(workspaceRemoteDir)} && ` +
`tar -xf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} && ` +
`rm -f ${shellQuote(remoteWorkspaceTar)}`
: `mkdir -p ${shellQuote(workspaceRemoteDir)} && ` +
`find ${shellQuote(workspaceRemoteDir)} -mindepth 1 -maxdepth 1 ${preserveFindArgs([...preservedNames])} -exec rm -rf -- {} + && ` +
`tar -xf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} && ` +
`rm -f ${shellQuote(remoteWorkspaceTar)}`;
await input.client.run(
`sh -c ${shellQuote(extractWorkspaceTarCommand)}`,
{ timeoutMs: input.spec.timeoutMs },
);
if (gitSnapshot) {
await removeDeletedPathsInSandbox({
client: input.client,
spec: input.spec,
remoteDir: workspaceRemoteDir,
deletedPaths: gitSnapshot.deletedPaths,
});
}
for (const asset of input.assets ?? []) {
await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing runtime assets to sandbox");
const remoteAssetDir = path.posix.join(runtimeRootDir, asset.key);
// Assets with custom provisioning (staged helper files or a bespoke extract
// command such as a credential merge) do more than a plain directory
// replacement, so they cannot be expressed as a generic file mapping and
// always take the tar path. A default-provisioned asset is a clean
// destroy-then-replace of its own directory, which the native transport
// reproduces exactly.
const usesCustomProvision =
Boolean(asset.provision?.extractCommand) || (asset.provision?.stageFiles?.length ?? 0) > 0;
if (nativeSyncIn && !usesCustomProvision) {
const operations: SandboxSyncOperation[] = [{
operationId: nextSyncOperationId(),
files: [{
sourcePath: asset.localDir,
targetPath: remoteAssetDir,
kind: "directory",
exclude: asset.exclude,
followSymlinks: asset.followSymlinks,
}],
}];
assertSyncOperationsConfined(operations, {
sourceRoots: [asset.localDir],
targetRoots: [runtimeRootDir],
});
const assetUpload = makeTransferProgress(
input.onProgress,
"Syncing",
"to",
asset.key,
{ sink: input.onRuntimeProgress, phase: "config_sync" },
);
await input.client.syncIn!(operations);
await assetUpload.finish(0, 0);
continue;
}
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 remoteAssetTar = path.posix.join(runtimeRootDir, `${asset.key}-upload.tar`);
const assetUpload = makeTransferProgress(
input.onProgress,
"Syncing",
"to",
asset.key,
{ sink: input.onRuntimeProgress, phase: "config_sync" },
);
await input.client.writeFile(remoteAssetTar, toArrayBuffer(assetTarBytes), assetUpload.options);
await assetUpload.finish(assetTarBytes.byteLength, assetTarBytes.byteLength);
for (const stageFile of asset.provision?.stageFiles ?? []) {
const stageBytes = typeof stageFile.contents === "string"
? Buffer.from(stageFile.contents)
: stageFile.contents;
const safeName = stageFile.name;
if (/[\\/]|\.\.(\.|$)/.test(safeName) || safeName === "..") {
throw new Error(`provision stageFile.name must be a simple basename, got: ${safeName}`);
}
await input.client.writeFile(
path.posix.join(runtimeRootDir, safeName),
toArrayBuffer(stageBytes),
);
}
const extractCommand = asset.provision?.extractCommand?.({
assetTarPath: remoteAssetTar,
assetDir: remoteAssetDir,
runtimeRootDir,
}) ?? buildDefaultExtractRuntimeAssetCommand({ remoteAssetDir, remoteAssetTar });
await input.client.run(
`sh -c ${shellQuote(extractCommand)}`,
{ 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 (onProgress?: RuntimeProgressSink) => {
const restoreSink = onProgress ?? input.onProgress;
await withTempDir("paperclip-sandbox-restore-", async (tempDir) => {
let importedRef: string | null = null;
let importedHead: string | null = null;
let remoteWorkspaceStatus = "dirty";
try {
if (gitSnapshot) {
await emitRuntimeStatus(input.onRuntimeProgress, "export", "Exporting git changes from sandbox");
importedRef = createImportedGitRef("sandbox");
const remoteGitBundle = path.posix.join(runtimeRootDir, "git-delta.bundle");
const remoteWorkspaceStatusPath = path.posix.join(runtimeRootDir, "workspace-status.txt");
const exportRef = createRemoteGitExportRef("sandbox");
await input.client.run(
`sh -c ${shellQuote(buildRemoteGitDeltaBundleScript({
remoteDir: workspaceRemoteDir,
baseSha: gitSnapshot.headCommit,
exportRef,
bundlePath: remoteGitBundle,
statusPath: remoteWorkspaceStatusPath,
}))}`,
{ timeoutMs: input.spec.timeoutMs },
);
const gitExport = makeTransferProgress(
restoreSink,
"Exporting git history",
"from",
undefined,
{ sink: input.onRuntimeProgress, phase: "export" },
);
const bundleBytes = await input.client.readFile(remoteGitBundle, gitExport.options);
const bundleBuffer = toBuffer(bundleBytes);
await gitExport.finish(bundleBuffer.byteLength, bundleBuffer.byteLength);
await input.client.remove(remoteGitBundle).catch(() => undefined);
remoteWorkspaceStatus = await input.client.readFile(remoteWorkspaceStatusPath)
.then((bytes) => toBuffer(bytes).toString("utf8").trim())
.catch(() => "dirty");
remoteWorkspaceStatus = remoteWorkspaceStatus === "clean" ? "clean" : "dirty";
await input.client.remove(remoteWorkspaceStatusPath).catch(() => undefined);
const bundlePath = path.join(tempDir, "git-delta.bundle");
await fs.writeFile(bundlePath, bundleBuffer);
importedHead = await fetchGitBundleIntoLocalRef({
localDir: input.workspaceLocalDir,
bundlePath,
exportRef,
importedRef,
baseSha: gitSnapshot.headCommit,
});
}
await emitRuntimeStatus(input.onRuntimeProgress, "restore", "Restoring workspace from sandbox");
const extractedDir = path.join(tempDir, "workspace");
if (nativeSyncOut) {
// Native outbound: the provider materializes the sandbox workspace into
// a fresh host directory. It is a clean destroy-then-replace into a
// temp dir the orchestrator just created, so it maps exactly to a
// generic directory file mapping; the host-side baseline merge below is
// unchanged.
const operations: SandboxSyncOperation[] = [{
operationId: nextSyncOperationId(),
files: [{
sourcePath: workspaceRemoteDir,
targetPath: extractedDir,
kind: "directory",
exclude: restoreExclude,
}],
}];
assertSyncOperationsConfined(operations, {
sourceRoots: [workspaceRemoteDir],
targetRoots: [extractedDir],
});
await fs.mkdir(extractedDir, { recursive: true });
const workspaceRestore = makeTransferProgress(
restoreSink,
"Restoring",
"from",
"workspace",
{ sink: input.onRuntimeProgress, phase: "restore" },
);
await input.client.syncOut!(operations);
await workspaceRestore.finish(0, 0);
} else {
const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-download.tar");
await input.client.run(
`sh -c ${shellQuote(createRemoteTarballFromDirectoryCommand({
remoteDir: workspaceRemoteDir,
archivePath: remoteWorkspaceTar,
exclude: restoreExclude,
}))}`,
{ timeoutMs: input.spec.timeoutMs },
);
const workspaceRestore = makeTransferProgress(
restoreSink,
"Restoring",
"from",
"workspace",
{ sink: input.onRuntimeProgress, phase: "restore" },
);
const archiveBytes = await input.client.readFile(remoteWorkspaceTar, workspaceRestore.options);
const archiveBuffer = toBuffer(archiveBytes);
await workspaceRestore.finish(archiveBuffer.byteLength, archiveBuffer.byteLength);
await input.client.remove(remoteWorkspaceTar).catch(() => undefined);
const localArchivePath = path.join(tempDir, "workspace.tar");
await fs.writeFile(localArchivePath, archiveBuffer);
await extractTarballToDirectory({
archivePath: localArchivePath,
localDir: extractedDir,
});
}
const gitHeadToIntegrate = importedHead;
await mergeDirectoryWithBaseline({
baseline: baselineSnapshot,
sourceDir: extractedDir,
targetDir: input.workspaceLocalDir,
beforeApply: gitHeadToIntegrate
? async () => {
await integrateImportedGitHead({
localDir: input.workspaceLocalDir,
importedHead: gitHeadToIntegrate,
});
}
: undefined,
afterApply: gitSnapshot
? async () => {
await resetLocalGitIndexToHead({
localDir: input.workspaceLocalDir,
checkWorkingTreeClean: remoteWorkspaceStatus === "clean",
});
}
: undefined,
});
// Per-asset teardown/outbound contributions. Generic: an asset with
// no `restore` is a no-op. The contribution reads back from the
// sandbox (e.g. a refreshed credential) via the provided `readFile`.
for (const asset of input.assets ?? []) {
if (!asset.restore) continue;
await asset.restore({
assetDir: path.posix.join(runtimeRootDir, asset.key),
readFile: async (remotePath) => toBuffer(await input.client.readFile(remotePath)),
});
}
} finally {
await emitRuntimeStatus(input.onRuntimeProgress, "finalize", "Finalizing sandbox workspace");
if (importedRef) {
await deleteLocalGitRef({ localDir: input.workspaceLocalDir, ref: importedRef });
}
}
});
},
};
}