Files
PaperClipAI/packages/adapter-utils/src/command-managed-runtime.test.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

473 lines
20 KiB
TypeScript

import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile as execFileCallback, spawn } from "node:child_process";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import {
createCommandManagedRuntimeClient,
prepareCommandManagedRuntime,
type CommandManagedRuntimeRunner,
} from "./command-managed-runtime.js";
import type { RunProcessResult } from "./server-utils.js";
const execFile = promisify(execFileCallback);
interface SpawnRunnerHandle {
runner: CommandManagedRuntimeRunner;
calls: Array<{ command: string; args?: string[]; cwd?: string; stdin?: string }>;
}
// A runner that actually executes the shell scripts (piping stdin through a real
// pipe so multi-MB payloads work) and replays stdout through onLog in several
// chunks so the streaming readFile byte-counter is exercised.
function makeSpawnRunner(options: {
supportsSingleStreamStdinProgress?: boolean;
maxStdoutBytes?: number;
} = {}): SpawnRunnerHandle {
const calls: Array<{ command: string; args?: string[]; cwd?: string; stdin?: string }> = [];
const runner: CommandManagedRuntimeRunner = {
supportsSingleStreamStdinProgress: options.supportsSingleStreamStdinProgress,
execute: async (input) =>
await new Promise<RunProcessResult>((resolve) => {
calls.push({ command: input.command, args: input.args, cwd: input.cwd, stdin: input.stdin });
const startedAt = new Date().toISOString();
const command =
input.command === "sh" ? "/bin/sh" : input.command === "bash" ? "/bin/bash" : input.command;
const child = spawn(command, input.args ?? [], {
cwd: input.cwd,
env: { ...process.env, ...input.env },
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString("utf8");
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString("utf8");
});
child.on("error", () => {
resolve({ exitCode: 127, signal: null, timedOut: false, stdout, stderr, pid: null, startedAt });
});
child.on("close", async (code) => {
if (
options.maxStdoutBytes != null &&
Buffer.byteLength(stdout, "utf8") > options.maxStdoutBytes
) {
resolve({
exitCode: 1,
signal: null,
timedOut: false,
stdout,
stderr: `stdout exceeded ${options.maxStdoutBytes} bytes`,
pid: child.pid ?? null,
startedAt,
});
return;
}
if (input.onLog && stdout.length > 0) {
const chunkSize = Math.max(1, Math.ceil(stdout.length / 4));
for (let offset = 0; offset < stdout.length; offset += chunkSize) {
await input.onLog("stdout", stdout.slice(offset, offset + chunkSize));
}
}
resolve({
exitCode: code ?? 0,
signal: null,
timedOut: false,
stdout,
stderr,
pid: child.pid ?? null,
startedAt,
});
});
if (input.stdin != null) child.stdin.write(input.stdin);
child.stdin.end();
}),
};
return { runner, calls };
}
function toArrayBuffer(buffer: Buffer): ArrayBuffer {
return Uint8Array.from(buffer).buffer;
}
async function withBase64StringByteLimit<T>(limitBytes: number, fn: () => Promise<T>): Promise<T> {
const originalToString = Buffer.prototype.toString;
Buffer.prototype.toString = function patchedToString(
this: Buffer,
encoding?: BufferEncoding,
start?: number,
end?: number,
) {
if (encoding === "base64" && this.byteLength > limitBytes) {
throw new Error(`test guard: attempted to base64-encode ${this.byteLength} bytes at once`);
}
return originalToString.call(this, encoding, start, end);
} as typeof Buffer.prototype.toString;
try {
return await fn();
} finally {
Buffer.prototype.toString = originalToString;
}
}
describe("command managed runtime", () => {
const cleanupDirs: string[] = [];
afterEach(async () => {
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
it("keeps the runtime overlay out of sandbox workspace sync by default", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-runtime-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
await mkdir(path.join(localWorkspaceDir, ".paperclip-runtime"), { recursive: true });
await mkdir(remoteWorkspaceDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "local workspace\n", "utf8");
await writeFile(path.join(localWorkspaceDir, ".paperclip-runtime", "state.json"), "{\"keep\":true}\n", "utf8");
const calls: Array<{
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
}> = [];
const runner = {
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
}): Promise<RunProcessResult> => {
calls.push({ ...input });
const startedAt = new Date().toISOString();
const env = {
...process.env,
...input.env,
};
const command =
input.command === "sh" ? "/bin/sh" : input.command === "bash" ? "/bin/bash" : input.command;
const args = [...(input.args ?? [])];
if (
input.stdin != null &&
(input.command === "sh" || input.command === "bash") &&
(args[0] === "-c" || args[0] === "-lc") &&
typeof args[1] === "string"
) {
env.PAPERCLIP_TEST_STDIN = input.stdin;
args[1] = `printf '%s' \"$PAPERCLIP_TEST_STDIN\" | (${args[1]})`;
}
try {
const result = await execFile(command, args, {
cwd: input.cwd,
env,
maxBuffer: 32 * 1024 * 1024,
timeout: input.timeoutMs,
});
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout: result.stdout,
stderr: result.stderr,
pid: null,
startedAt,
};
} catch (error) {
const err = error as NodeJS.ErrnoException & {
stdout?: string;
stderr?: string;
code?: string | number | null;
signal?: NodeJS.Signals | null;
killed?: boolean;
};
return {
exitCode: typeof err.code === "number" ? err.code : null,
signal: err.signal ?? null,
timedOut: Boolean(err.killed && input.timeoutMs),
stdout: err.stdout ?? "",
stderr: err.stderr ?? "",
pid: null,
startedAt,
};
}
},
};
const prepared = await prepareCommandManagedRuntime({
runner,
spec: {
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
},
adapterKey: "claude",
workspaceLocalDir: localWorkspaceDir,
});
await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("local workspace\n");
await expect(readFile(path.join(remoteWorkspaceDir, ".paperclip-runtime", "state.json"), "utf8")).rejects
.toMatchObject({ code: "ENOENT" });
// The single-stream upload pipes the tarball through exactly one stdin-backed
// process (the speed fix); nothing else streams stdin.
expect(calls.filter((call) => call.stdin != null).length).toBe(1);
await mkdir(path.join(remoteWorkspaceDir, ".paperclip-runtime"), { recursive: true });
await writeFile(path.join(remoteWorkspaceDir, "README.md"), "remote workspace\n", "utf8");
await writeFile(path.join(remoteWorkspaceDir, ".paperclip-runtime", "remote-state.json"), "{\"remote\":true}\n", "utf8");
await prepared.restoreWorkspace();
await expect(readFile(path.join(localWorkspaceDir, "README.md"), "utf8")).resolves.toBe("remote workspace\n");
await expect(readFile(path.join(localWorkspaceDir, ".paperclip-runtime", "state.json"), "utf8")).resolves
.toBe("{\"keep\":true}\n");
await expect(readFile(path.join(localWorkspaceDir, ".paperclip-runtime", "remote-state.json"), "utf8")).rejects
.toMatchObject({ code: "ENOENT" });
// Restore streams the download through `base64`/onLog (no stdin), so the only
// stdin-backed call remains the single upload from prepare.
expect(calls.filter((call) => call.stdin != null).length).toBe(1);
});
it("runs setup commands from a stable root cwd when staging into a nested remote workspace dir", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-runtime-nested-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteBaseDir = path.join(rootDir, "remote-base");
const remoteWorkspaceDir = path.join(remoteBaseDir, ".paperclip-runtime", "runs", "test", "workspace");
await mkdir(localWorkspaceDir, { recursive: true });
await mkdir(remoteBaseDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "local workspace\n", "utf8");
const { runner, calls } = makeSpawnRunner();
await prepareCommandManagedRuntime({
runner,
spec: {
remoteCwd: remoteBaseDir,
timeoutMs: 30_000,
},
adapterKey: "codex",
workspaceLocalDir: localWorkspaceDir,
workspaceRemoteDir: remoteWorkspaceDir,
});
expect(calls.length).toBeGreaterThan(0);
expect(calls.every((call) => call.cwd === "/")).toBe(true);
await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("local workspace\n");
});
it("uploads a multi-MB payload in a single process and preserves exact bytes", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-write-"));
cleanupDirs.push(rootDir);
const remotePath = path.join(rootDir, "nested", "payload.bin");
// ~3 MB of every byte value so the test catches any non-binary-safe handling.
const payload = Buffer.alloc(3 * 1024 * 1024);
for (let i = 0; i < payload.length; i++) payload[i] = i % 256;
const { runner, calls } = makeSpawnRunner({ supportsSingleStreamStdinProgress: true });
const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 30_000 });
const progress: Array<{ done: number; total: number | null }> = [];
await withBase64StringByteLimit(4 * 1024 * 1024, async () => {
await client.writeFile(remotePath, toArrayBuffer(payload), {
onProgress: (done, total) => {
progress.push({ done, total });
},
});
});
// Exactly one upload process: O(1) round-trips regardless of payload size.
expect(calls.length).toBe(1);
expect(calls[0].stdin).toBeTypeOf("string");
const written = await readFile(remotePath);
expect(written.equals(payload)).toBe(true);
// Progress is monotonically non-decreasing and reaches the total.
expect(progress.length).toBeGreaterThan(0);
for (let i = 1; i < progress.length; i++) {
expect(progress[i].done).toBeGreaterThanOrEqual(progress[i - 1].done);
}
expect(progress.at(-1)).toEqual({ done: payload.length, total: payload.length });
});
it("stages a single-file write to <path>.paperclip-upload then atomically renames it (single-stream path)", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-atomic-"));
cleanupDirs.push(rootDir);
const remotePath = path.join(rootDir, "nested", "payload.bin");
const { runner, calls } = makeSpawnRunner({ supportsSingleStreamStdinProgress: true });
const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 30_000 });
await client.writeFile(remotePath, toArrayBuffer(Buffer.from("hello atomic\n")));
// Characterization guardrail: the legacy single-file transport must keep its
// stage-then-atomic-rename shape (temp .paperclip-upload + `mv -f`).
const script = (calls[0].args ?? []).join(" ");
expect(script).toContain(`${remotePath}.paperclip-upload`);
expect(script).toContain(`mv -f`);
expect(script.indexOf(".paperclip-upload")).toBeLessThan(script.indexOf("mv -f"));
expect(await readFile(remotePath, "utf8")).toBe("hello atomic\n");
});
it("stages a single-file write to a temp then renames it on the chunked fallback path too", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-atomic-fallback-"));
cleanupDirs.push(rootDir);
const remotePath = path.join(rootDir, "nested", "payload.bin");
const payload = Buffer.alloc(10 * 1024 * 1024);
for (let i = 0; i < payload.length; i++) payload[i] = i % 256;
const { runner, calls } = makeSpawnRunner({ supportsSingleStreamStdinProgress: false });
const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 30_000 });
await client.writeFile(remotePath, toArrayBuffer(payload));
const scripts = calls.map((call) => (call.args ?? []).join(" "));
expect(scripts.some((script) => script.includes(`${remotePath}.paperclip-upload`))).toBe(true);
expect(scripts.some((script) => script.includes(`mv -f`))).toBe(true);
expect((await readFile(remotePath)).equals(payload)).toBe(true);
});
it("leaves the client without syncIn/syncOut unless the runner supports both (fallback preserved)", () => {
const base = makeSpawnRunner().runner;
expect(createCommandManagedRuntimeClient({ runner: base, commandCwd: "/", timeoutMs: 1 }).syncIn).toBeUndefined();
const onlyIn: CommandManagedRuntimeRunner = { ...base, syncIn: async () => ({ operations: [] }) };
const partial = createCommandManagedRuntimeClient({ runner: onlyIn, commandCwd: "/", timeoutMs: 1 });
expect(partial.syncIn).toBeUndefined();
expect(partial.syncOut).toBeUndefined();
const both: CommandManagedRuntimeRunner = {
...base,
syncIn: async () => ({ operations: [] }),
syncOut: async () => ({ operations: [] }),
};
const native = createCommandManagedRuntimeClient({ runner: both, commandCwd: "/", timeoutMs: 1 });
expect(native.syncIn).toBeTypeOf("function");
expect(native.syncOut).toBeTypeOf("function");
});
it("falls back to chunked upload progress when the runner cannot report mid-stream stdin progress", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-write-fallback-"));
cleanupDirs.push(rootDir);
const remotePath = path.join(rootDir, "nested", "payload.bin");
const payload = Buffer.alloc(12 * 1024 * 1024);
for (let i = 0; i < payload.length; i++) payload[i] = i % 256;
const { runner, calls } = makeSpawnRunner({ supportsSingleStreamStdinProgress: false });
const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 30_000 });
const progress: Array<{ done: number; total: number | null }> = [];
await client.writeFile(remotePath, toArrayBuffer(payload), {
onProgress: (done, total) => {
progress.push({ done, total });
},
});
const written = await readFile(remotePath);
expect(written.equals(payload)).toBe(true);
// Provider-backed sandbox runners cannot surface mid-flight progress for a
// single stdin RPC, so we intentionally use several large append commands.
expect(calls.length).toBeGreaterThan(2);
const stdinCalls = calls.filter((call) => call.stdin != null);
expect(stdinCalls.length).toBeGreaterThan(2);
expect(stdinCalls.every((call) => Buffer.byteLength(call.stdin ?? "", "utf8") <= 4.1 * 1024 * 1024)).toBe(true);
expect(progress.length).toBeGreaterThan(2);
for (let i = 1; i < progress.length; i++) {
expect(progress[i].done).toBeGreaterThanOrEqual(progress[i - 1].done);
}
expect(progress.at(-1)).toEqual({ done: payload.length, total: payload.length });
});
it("falls back to bounded chunks when the runner does not explicitly opt in", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-write-fallback-no-progress-"));
cleanupDirs.push(rootDir);
const remotePath = path.join(rootDir, "nested", "payload.bin");
const payload = Buffer.alloc(12 * 1024 * 1024);
for (let i = 0; i < payload.length; i++) payload[i] = i % 256;
const { runner, calls } = makeSpawnRunner();
const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 30_000 });
await withBase64StringByteLimit(4 * 1024 * 1024, async () => {
await client.writeFile(remotePath, toArrayBuffer(payload));
});
const written = await readFile(remotePath);
expect(written.equals(payload)).toBe(true);
// A runner that doesn't mark single-stream stdin support must avoid passing
// the whole base64 archive as one string, so we expect multiple append calls.
const stdinCalls = calls.filter((call) => call.stdin != null);
expect(stdinCalls.length).toBeGreaterThan(1);
expect(stdinCalls.every((call) => Buffer.byteLength(call.stdin ?? "", "utf8") <= 4.1 * 1024 * 1024)).toBe(true);
});
it("downloads in bounded stdout chunks and reports monotonic byte progress to the total", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-read-"));
cleanupDirs.push(rootDir);
const remotePath = path.join(rootDir, "download.bin");
const payload = Buffer.alloc(7 * 1024 * 1024);
for (let i = 0; i < payload.length; i++) payload[i] = (i * 7) % 256;
await writeFile(remotePath, payload);
const { runner, calls } = makeSpawnRunner({ maxStdoutBytes: 5 * 1024 * 1024 });
const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 30_000 });
const progress: Array<{ done: number; total: number | null }> = [];
const bytes = await client.readFile(remotePath, {
onProgress: (done, total) => {
progress.push({ done, total });
},
});
expect(Buffer.from(bytes as ArrayBuffer).equals(payload)).toBe(true);
// The old single `base64 < file` path would exceed the runner's stdout cap.
// The bounded path reads with several small `dd | base64` commands instead.
expect(calls.some((call) => call.args?.join(" ").includes("base64 <"))).toBe(false);
expect(calls.filter((call) => call.args?.join(" ").includes("dd if=")).length).toBeGreaterThan(1);
expect(progress.length).toBeGreaterThan(1);
for (let i = 1; i < progress.length; i++) {
expect(progress[i].done).toBeGreaterThanOrEqual(progress[i - 1].done);
}
expect(progress.every((entry) => entry.total === payload.length)).toBe(true);
expect(progress.at(-1)?.done).toBe(payload.length);
});
it("includes stdout diagnostics when a managed runtime command fails", async () => {
const startedAt = new Date().toISOString();
const runner: CommandManagedRuntimeRunner = {
execute: async () => ({
exitCode: 2,
signal: null,
timedOut: false,
stdout: "tar: workspace-download.tar: Cannot open: Permission denied\n",
stderr: "",
pid: null,
startedAt,
}),
};
const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 30_000 });
await expect(client.run("tar -cf workspace-download.tar .", { timeoutMs: 30_000 })).rejects.toThrow(
/stdout: tar: workspace-download\.tar: Cannot open: Permission denied/,
);
});
});