mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
## Thinking Path > - Paperclip coordinates agent work and preserves its results. > - Remote execution copies completed workspace changes back to the host. > - An agent can fetch a published branch with shallow Git history. > - A full Git bundle omits the shallow boundary metadata and can fail host connectivity checks. > - This pull request carries the boundaries through the full-bundle fallback and imports through normal Git fetch. > - Recovery must also recognize completed native work on a reusable lease without treating it as ordinary executor reuse. > - Completed work can be saved without another provider turn, while existing host history remains connected. ## Linked Issues or Issue Description **What happened?** Sandbox finalization can fail with a missing parent commit after an agent checks out a separately fetched shallow branch. Re-exporting a full bundle repeats the failure because Git bundles do not encode shallow boundaries. **Expected behavior** Import the completed branch and saved files. Preserve existing host ancestry and concurrent host edits. Keep normal Git connectivity and workspace merge checks. **Steps to reproduce** 1. Prepare a sandbox from one host commit. 2. Fetch a different published branch with depth one and check it out. 3. Save a result file and restore the sandbox workspace. **Paperclip version or commit** Reproduced against master before this repair. The regression fails with the missing-parent error before the production change. Related public context: #15268 covers rewritten history integration. This repair handles shallow bundle transport before integration. Searches found no equivalent open repair. ## What Changed - Export shallow boundaries beside the full bundle when the delta import lacks history. - Unpack into a temporary shared bare repository. Keep parent connections already available on the host and import with Git's shallow fetch protocol. - Reject malformed boundary values and retain existing connectivity, branch, and concurrent-work merge guards. Keep boundary probes within 15 seconds without consuming the separate 60-second Git import budget. - Let source-bound preservation recover reusable leases only for the finalizer-selected, accepted successful native result and matching completion contract. Retain provider-stop, physical ownership and host-writer checks; ordinary reusable sources remain blocked. - Classify invalid Git metadata separately from omittable unsafe archives. Keep native finalization failed and preserve service-owned file recovery; carry the sender's dedicated rejection across the command boundary without transient retries. - Share the failure allowlist across bounded run/activity projections and cancelled-preservation selection. Migration `0416` rebuilds the matching partial index; task chat retains the non-retryable recovery notice. - Add real Git, filesystem sandbox and database recovery regressions. Update execution semantics and affected verification maps. ## Verification - Red: real filesystem sandbox restore failed with the same missing-parent/revision-walk error before the export repair. - Green: 240 focused Git, workspace restore, sandbox transport and native-finalization tests pass. Coverage includes both transfer paths, malformed/oversized metadata, a genuinely shallow host with its original boundary intact, and concurrent host work with both merge parents verified. - Red/green: a 14-second simulated probe left the final real Git fetch only 45,893 ms before the budget split. It now retains over 55 seconds after the probes; a 16-second probe exhausts its own budget before host import. - Red/green: an oversized read progress report was retried three times before its distinct metadata classification. It now aborts the first read before payload consumption, leaves the host tip unchanged and does not retry. - Red/green: the real metadata error was swallowed by best-effort export and native finalization incorrectly returned success. Both regressions now pass; all 240 focused Git/restore/native-finalization tests pass. A PostgreSQL/filesystem fault-injection test also verifies retained files, failed run, service-owned recovery barrier, no preservation receipt and no new wake. - Red/green: duplicate metadata produced 47 Git process starts before deduplication; the bounded-work regression now passes. Oversized byte and boundary inputs fail before Git probes. - Recovery red: both reusable lease policies exhausted recovery despite a selected accepted successful result. The assembled PostgreSQL/filesystem path now passes for retained and both reusable policies, including idempotent re-admission after exhausted automatic recovery, saved files, no agent wake, and once-only existing-result finalization. - Source verification controls reject ordinary reusable leases, rejected/failed/unselected results, another physical allocation owner, and an active host writer without changing recorded state. - The 78-test PostgreSQL/filesystem recovery/source suite passed before the metadata classification correction. Its new persisted metadata-failure regression also passes. Full workspace typecheck and build pass at final head `69f10daab2`. - `node scripts/check-maps.mjs` and `git diff --check` pass. - Full local `pnpm test:run` attempts did not finish. They are not counted as passes. Early focused runs exceeded 5-second and 30-second fixture deadlines; the final serial suite passed all 176 tests with a 30-second per-test budget in 56.8 seconds. - Red/green: both bounded PostgreSQL projections dropped the new code in oversized results. All 19 source/projection cases now pass, plus an actual index-predicate test that excludes a metadata repair hold and still selects ordinary cancelled work. - Red/green: an oversized sender `.git/shallow` lost its error type before host validation. The real transport test now verifies one typed rejection, no shallow-payload read, and an unchanged host tip. All 465 command/sandbox/task-chat tests pass, and token/migration gates pass. - Full typecheck/build pass after these final contract corrections at `69f10daab2`; all 20 PostgreSQL source/projection/index contracts pass. All current-head CI checks passed; Greptile is 5/5 with no unresolved review threads. Merged #18230 supplies the CI-cache and Linux shell-fixture baseline repairs; their duplicate changes have been removed from this PR. The final Runner lane passed after one justified rerun of an unchanged authentication fixture timeout (all 11 fixture tests also pass locally). Previous CI failures and incomplete local runs remain recorded; live deployment and retained-run recovery remain separate verification. ## Risks - The fallback creates a temporary shared bare clone. Metadata is limited to 64 KiB and 256 unique boundaries, duplicate/parent probes are cached, and Git import has a 60-second command budget plus a separate 15-second boundary-probe budget. It runs only after a missing-history import failure. - Incorrect boundaries could hide real host ancestry. The importer retains available parent connections, and regressions cover that case. - The full-bundle fallback can transfer a bundle twice. This preserves the existing fast delta path; removing that extra transfer is outside this correctness repair. - Preservation eligibility expands only for completed native exports on reusable leases. It creates no provider turn and leaves ordinary reusable leases ineligible. Database/filesystem regressions cover result selection and competing physical/host ownership. - The migration rebuilds one partial index; no tables, columns or user records change. There are no agent execution admission, model, trust, recovery permission, workflow or lockfile changes. ## Model Used OpenAI Codex, GPT-6, with reasoning, code execution, and browser tools. The exact serving variant and context-window size are not exposed in this session. ## 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: Paperclip <noreply@paperclip.ing>
1052 lines
45 KiB
TypeScript
1052 lines
45 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 CommandManagedDuplexChannel,
|
|
type CommandManagedRuntimeRunner,
|
|
} from "./command-managed-runtime.js";
|
|
import type { SandboxSyncOperation } from "./sandbox-managed-runtime.js";
|
|
import type { RunProcessResult } from "./server-utils.js";
|
|
|
|
/**
|
|
* An in-memory fake `CommandManagedDuplexChannel`. It holds no real process; a
|
|
* write echoes straight to the one registered data listener, so a test proves
|
|
* the channel contract carries a `Uint8Array` chunk with no string coercion in
|
|
* between. The fake never exits on its own; a test calls the listener it
|
|
* registers with `onExit` only when it needs one.
|
|
*/
|
|
function createFakeEchoDuplexChannel(): CommandManagedDuplexChannel {
|
|
let dataListener: ((chunk: Uint8Array) => void) | null = null;
|
|
return {
|
|
write(data: Uint8Array): void {
|
|
dataListener?.(data);
|
|
},
|
|
onData(listener: (chunk: Uint8Array) => void): void {
|
|
dataListener = listener;
|
|
},
|
|
onExit(): void {},
|
|
stop(): void {},
|
|
close: async (): Promise<void> => {},
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function shellQuoteForTest(value: string): string {
|
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
}
|
|
|
|
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("reports a missing sandbox file as ENOENT without masking command failures", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-remote-missing-"));
|
|
try {
|
|
const { runner } = makeSpawnRunner();
|
|
const client = createCommandManagedRuntimeClient({ runner, commandCwd: root, timeoutMs: 5000 });
|
|
const missingPath = path.join(root, "auth.json");
|
|
await expect(client.readFile(missingPath)).rejects.toMatchObject({ code: "ENOENT", path: missingPath });
|
|
await writeFile(missingPath, "present");
|
|
const failedClient = createCommandManagedRuntimeClient({
|
|
commandCwd: root, timeoutMs: 5000,
|
|
runner: { ...runner, execute: async (input) => input.args?.some((arg) => arg.startsWith("wc -c"))
|
|
? { exitCode: 1, signal: null, timedOut: false, stdout: "", stderr: "transport read failed", pid: null, startedAt: new Date().toISOString() }
|
|
: runner.execute(input) },
|
|
});
|
|
await expect(failedClient.readFile(missingPath)).rejects.toThrow("transport read failed");
|
|
await expect(client.readFile(missingPath)).resolves.toEqual(Buffer.from("present"));
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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("stages runtime assets without replacing or restoring an in-place workspace", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-runtime-assets-only-"));
|
|
cleanupDirs.push(rootDir);
|
|
|
|
const localWorkspaceDir = path.join(rootDir, "local-workspace");
|
|
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
|
|
const localHomeDir = path.join(rootDir, "local-home");
|
|
await mkdir(localWorkspaceDir, { recursive: true });
|
|
await mkdir(remoteWorkspaceDir, { recursive: true });
|
|
await mkdir(localHomeDir, { recursive: true });
|
|
await writeFile(path.join(localWorkspaceDir, "README.md"), "local workspace\n", "utf8");
|
|
await writeFile(path.join(remoteWorkspaceDir, "README.md"), "authoritative workspace\n", "utf8");
|
|
await writeFile(path.join(localHomeDir, "auth.json"), '{"token":"host"}\n', "utf8");
|
|
|
|
const { runner } = makeSpawnRunner();
|
|
let restoredAuth = "";
|
|
const prepared = await prepareCommandManagedRuntime({
|
|
runner,
|
|
spec: {
|
|
remoteCwd: remoteWorkspaceDir,
|
|
timeoutMs: 30_000,
|
|
},
|
|
adapterKey: "codex",
|
|
workspaceLocalDir: localWorkspaceDir,
|
|
syncWorkspace: false,
|
|
assets: [
|
|
{
|
|
key: "home",
|
|
localDir: localHomeDir,
|
|
restore: async ({ assetDir, readFile }) => {
|
|
restoredAuth = (await readFile(path.join(assetDir, "auth.json"))).toString("utf8");
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(prepared.workspaceRemoteDir).toBe(remoteWorkspaceDir);
|
|
expect(prepared.assetDirs.home).toBe(path.join(remoteWorkspaceDir, ".paperclip-runtime", "codex", "home"));
|
|
await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe(
|
|
"authoritative workspace\n",
|
|
);
|
|
await expect(readFile(path.join(prepared.assetDirs.home, "auth.json"), "utf8")).resolves.toBe(
|
|
'{"token":"host"}\n',
|
|
);
|
|
|
|
await writeFile(path.join(prepared.assetDirs.home, "auth.json"), '{"token":"remote"}\n', "utf8");
|
|
await prepared.restoreWorkspace();
|
|
|
|
expect(restoredAuth).toBe('{"token":"remote"}\n');
|
|
await expect(readFile(path.join(localWorkspaceDir, "README.md"), "utf8")).resolves.toBe(
|
|
"local workspace\n",
|
|
);
|
|
});
|
|
|
|
it("stages each additional project into an isolated dir on the base64/tar transport, one failure skipped", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-runtime-additional-"));
|
|
cleanupDirs.push(rootDir);
|
|
|
|
const localWorkspaceDir = path.join(rootDir, "local-workspace");
|
|
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
|
|
await mkdir(localWorkspaceDir, { recursive: true });
|
|
await mkdir(remoteWorkspaceDir, { recursive: true });
|
|
await writeFile(path.join(localWorkspaceDir, "README.md"), "anchor\n", "utf8");
|
|
|
|
const goodOne = path.join(rootDir, "src-one");
|
|
const goodTwo = path.join(rootDir, "src-two");
|
|
await mkdir(goodOne, { recursive: true });
|
|
await mkdir(path.join(goodTwo, "nested"), { recursive: true });
|
|
await writeFile(path.join(goodOne, "one.txt"), "one body\n", "utf8");
|
|
await writeFile(path.join(goodTwo, "nested", "two.txt"), "two body\n", "utf8");
|
|
|
|
// The `makeSpawnRunner` runner exposes no native syncIn, so staging rides the
|
|
// base64/tar fallback. The middle source points at a missing directory, so
|
|
// its tar build fails; failure isolation skips only it.
|
|
const { runner } = makeSpawnRunner();
|
|
const prepared = await prepareCommandManagedRuntime({
|
|
runner,
|
|
spec: {
|
|
remoteCwd: remoteWorkspaceDir,
|
|
timeoutMs: 30_000,
|
|
},
|
|
adapterKey: "claude",
|
|
workspaceLocalDir: localWorkspaceDir,
|
|
additionalSources: [
|
|
{ localPath: goodOne, projectId: "one", ignoreResolution: { kind: "other" } },
|
|
{ localPath: path.join(rootDir, "missing"), projectId: "broken", ignoreResolution: { kind: "other" } },
|
|
{ localPath: goodTwo, projectId: "two", ignoreResolution: { kind: "other" } },
|
|
],
|
|
});
|
|
|
|
const runtimeRootDir = path.posix.join(remoteWorkspaceDir, ".paperclip-runtime", "claude");
|
|
expect(Object.keys(prepared.additionalSourceDirs).sort()).toEqual(["one", "two"]);
|
|
expect(prepared.additionalSourceDirs.one).toBe(path.posix.join(runtimeRootDir, "project-one"));
|
|
expect(prepared.additionalSourceDirs.two).toBe(path.posix.join(runtimeRootDir, "project-two"));
|
|
expect(prepared.additionalSourceDirs.broken).toBeUndefined();
|
|
|
|
// Each healthy project's tree materialized in its OWN dir (nested files kept).
|
|
await expect(readFile(path.join(prepared.additionalSourceDirs.one, "one.txt"), "utf8")).resolves.toBe("one body\n");
|
|
await expect(readFile(path.join(prepared.additionalSourceDirs.two, "nested", "two.txt"), "utf8")).resolves.toBe(
|
|
"two body\n",
|
|
);
|
|
// The broken project's dir was never created.
|
|
await expect(readFile(path.join(runtimeRootDir, "project-broken"), "utf8")).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
});
|
|
|
|
it("keeps adapter detection on the profile-backed shell path", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-runtime-detect-"));
|
|
cleanupDirs.push(rootDir);
|
|
|
|
const localWorkspaceDir = path.join(rootDir, "local-workspace");
|
|
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
|
|
await mkdir(localWorkspaceDir, { recursive: true });
|
|
await mkdir(remoteWorkspaceDir, { recursive: true });
|
|
|
|
const { runner, calls } = makeSpawnRunner();
|
|
await prepareCommandManagedRuntime({
|
|
runner,
|
|
spec: {
|
|
remoteCwd: remoteWorkspaceDir,
|
|
timeoutMs: 30_000,
|
|
},
|
|
adapterKey: "claude",
|
|
workspaceLocalDir: localWorkspaceDir,
|
|
installCommand: "echo install",
|
|
detectCommand: "sh",
|
|
});
|
|
|
|
// The detection probe must be the first shell invocation, so a CLI on the
|
|
// sandbox default PATH is discoverable before we decide whether to install.
|
|
expect(calls[0]?.args?.join(" ")).toContain("command -v 'sh'");
|
|
// Detection succeeds here, so the install command must be skipped entirely;
|
|
// the remaining calls are workspace staging, never the install command.
|
|
expect(calls.some((call) => call.args?.join(" ").includes("echo install"))).toBe(false);
|
|
});
|
|
|
|
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");
|
|
|
|
const payload = Buffer.alloc(9 * 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(9 * 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(2);
|
|
expect(calls[1].args?.join(" ")).toContain("rm -rf");
|
|
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(`trap cleanup EXIT`);
|
|
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("cleans up a staged upload when rename fails", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-upload-cleanup-"));
|
|
cleanupDirs.push(rootDir);
|
|
const remotePath = path.join(rootDir, "nested", "payload.bin");
|
|
|
|
const payload = Buffer.alloc(3 * 1024 * 1024, 7);
|
|
const { runner, calls } = makeSpawnRunner({ supportsSingleStreamStdinProgress: true });
|
|
const delegatedExecute = runner.execute.bind(runner);
|
|
runner.execute = async (input) => {
|
|
const script = (input.args ?? []).join(" ");
|
|
if (script.includes("mv -f") && script.includes(".paperclip-upload.")) {
|
|
calls.push({ command: input.command, args: input.args, cwd: input.cwd, stdin: input.stdin });
|
|
return {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: "",
|
|
stderr: "rename failed",
|
|
pid: null,
|
|
startedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
return await delegatedExecute(input);
|
|
};
|
|
const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 30_000 });
|
|
|
|
await expect(client.writeFile(remotePath, toArrayBuffer(payload))).rejects.toThrow(/rename failed/);
|
|
|
|
const uploadCall = calls.find((call) => (call.args ?? []).join(" ").includes(".paperclip-upload."));
|
|
expect(uploadCall).toBeDefined();
|
|
const stagedPath = (uploadCall?.args ?? []).join(" ").match(/([/A-Za-z0-9_.-]+\.paperclip-upload\.[A-Za-z0-9-]+)/)?.[1];
|
|
expect(stagedPath).toBeDefined();
|
|
await expect(readFile(stagedPath!, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(calls.some((call) => (call.args ?? []).join(" ").includes(`rm -rf '${stagedPath}'`))).toBe(true);
|
|
await expect(readFile(remotePath, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
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("test_client_syncIn_present_even_without_native_runner_syncIn", () => {
|
|
// Phase 2 (PAP-3222): `client.syncIn` is ALWAYS present so the caller can
|
|
// delegate unconditionally. `syncOut` stays native-only (no generic outbound
|
|
// fallback in this seam).
|
|
const base = makeSpawnRunner().runner;
|
|
const client = createCommandManagedRuntimeClient({ runner: base, commandCwd: "/", timeoutMs: 1 });
|
|
expect(client.syncIn).toBeTypeOf("function");
|
|
expect(client.syncOut).toBeUndefined();
|
|
|
|
// A runner advertising only one verb still gets the fallback syncIn; syncOut
|
|
// stays undefined (native delegation needs BOTH verbs).
|
|
const onlyIn: CommandManagedRuntimeRunner = { ...base, syncIn: async () => ({ operations: [] }) };
|
|
const partial = createCommandManagedRuntimeClient({ runner: onlyIn, commandCwd: "/", timeoutMs: 1 });
|
|
expect(partial.syncIn).toBeTypeOf("function");
|
|
expect(partial.syncOut).toBeUndefined();
|
|
|
|
// With both verbs, syncIn delegates natively and syncOut is exposed.
|
|
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("base64 fallback client reports allowConcurrentSyncOperations true", () => {
|
|
// A runner with no native sync uses the base64 fallback, which always
|
|
// permits concurrent sync operations.
|
|
const base = makeSpawnRunner().runner;
|
|
const client = createCommandManagedRuntimeClient({ runner: base, commandCwd: "/", timeoutMs: 1 });
|
|
expect(client.allowConcurrentSyncOperations).toBe(true);
|
|
});
|
|
|
|
it("undeclared native runner reports allowConcurrentSyncOperations false", () => {
|
|
// A native runner (both sync verbs) that never opted into concurrency keeps
|
|
// the flag off.
|
|
const base = makeSpawnRunner().runner;
|
|
const undeclaredNative: CommandManagedRuntimeRunner = {
|
|
...base,
|
|
syncIn: async () => ({ operations: [] }),
|
|
syncOut: async () => ({ operations: [] }),
|
|
};
|
|
const client = createCommandManagedRuntimeClient({
|
|
runner: undeclaredNative,
|
|
commandCwd: "/",
|
|
timeoutMs: 1,
|
|
});
|
|
expect(client.allowConcurrentSyncOperations).toBe(false);
|
|
});
|
|
|
|
it("declared native runner reports allowConcurrentSyncOperations true", () => {
|
|
// A native runner that verified the opt-in carries the flag through to the
|
|
// client.
|
|
const base = makeSpawnRunner().runner;
|
|
const declaredNative: CommandManagedRuntimeRunner = {
|
|
...base,
|
|
allowConcurrentSyncOperations: true,
|
|
syncIn: async () => ({ operations: [] }),
|
|
syncOut: async () => ({ operations: [] }),
|
|
};
|
|
const client = createCommandManagedRuntimeClient({
|
|
runner: declaredNative,
|
|
commandCwd: "/",
|
|
timeoutMs: 1,
|
|
});
|
|
expect(client.allowConcurrentSyncOperations).toBe(true);
|
|
});
|
|
|
|
it("base64 fallback ignores a runner opt-in without both sync verbs", () => {
|
|
// A runner that sets the opt-in but exposes only one sync verb still uses
|
|
// the fallback, which permits concurrency independent of the runner flag.
|
|
const base = makeSpawnRunner().runner;
|
|
const onlyIn: CommandManagedRuntimeRunner = {
|
|
...base,
|
|
allowConcurrentSyncOperations: true,
|
|
syncIn: async () => ({ operations: [] }),
|
|
};
|
|
const client = createCommandManagedRuntimeClient({ runner: onlyIn, commandCwd: "/", timeoutMs: 1 });
|
|
expect(client.allowConcurrentSyncOperations).toBe(true);
|
|
});
|
|
|
|
it("test_client_syncIn_delegates_to_native_runner_with_zero_execute_calls", async () => {
|
|
// With a native runner, `client.syncIn` forwards `files` + `postUploadCommands`
|
|
// to the runner and issues ZERO `execute` round-trips (the provider owns the
|
|
// transport + command execution).
|
|
let executeCalls = 0;
|
|
const forwarded: SandboxSyncOperation[][] = [];
|
|
const runner: CommandManagedRuntimeRunner = {
|
|
execute: async () => {
|
|
executeCalls += 1;
|
|
return { exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: "" };
|
|
},
|
|
syncIn: async (operations) => {
|
|
forwarded.push(operations);
|
|
return {
|
|
operations: operations.map((op) => ({
|
|
operationId: op.operationId,
|
|
filesTransferred: op.files.length,
|
|
bytesTransferred: 0,
|
|
})),
|
|
};
|
|
},
|
|
syncOut: async () => ({ operations: [] }),
|
|
};
|
|
const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 1 });
|
|
|
|
const operations: SandboxSyncOperation[] = [
|
|
{
|
|
operationId: "op-1",
|
|
files: [{ sourcePath: "/host/a", targetPath: "/remote/a", kind: "directory" }],
|
|
postUploadCommands: [{ command: "echo done", cwd: "/remote/a" }],
|
|
},
|
|
];
|
|
const result = await client.syncIn!(operations);
|
|
|
|
expect(executeCalls).toBe(0);
|
|
expect(forwarded).toEqual([operations]);
|
|
expect(result.operations[0]).toMatchObject({ operationId: "op-1", filesTransferred: 1 });
|
|
});
|
|
|
|
it("fallback syncIn tarballs+uploads a directory then runs post-upload commands in order", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-syncin-fallback-"));
|
|
cleanupDirs.push(rootDir);
|
|
const sourceDir = path.join(rootDir, "source");
|
|
const targetDir = path.join(rootDir, "target");
|
|
const markerDir = path.join(rootDir, "markers");
|
|
await mkdir(path.join(sourceDir, "nested"), { recursive: true });
|
|
await mkdir(markerDir, { recursive: true });
|
|
await writeFile(path.join(sourceDir, "file.txt"), "payload\n", "utf8");
|
|
await writeFile(path.join(sourceDir, "nested", "deep.txt"), "deep\n", "utf8");
|
|
|
|
const { runner, calls } = makeSpawnRunner({ supportsSingleStreamStdinProgress: true });
|
|
const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 30_000 });
|
|
|
|
await client.syncIn!([
|
|
{
|
|
operationId: "op-dir",
|
|
files: [{ sourcePath: sourceDir, targetPath: targetDir, kind: "directory" }],
|
|
postUploadCommands: [
|
|
{ command: "touch " + shellQuoteForTest(path.join(markerDir, "1-first")) },
|
|
{ command: "touch " + shellQuoteForTest(path.join(markerDir, "2-second")) },
|
|
],
|
|
},
|
|
]);
|
|
|
|
// Files landed via tar → untar (destroy-then-replace).
|
|
expect(await readFile(path.join(targetDir, "file.txt"), "utf8")).toBe("payload\n");
|
|
expect(await readFile(path.join(targetDir, "nested", "deep.txt"), "utf8")).toBe("deep\n");
|
|
// Both post-upload commands ran (markers exist).
|
|
await expect(readFile(path.join(markerDir, "1-first"))).resolves.toBeDefined();
|
|
await expect(readFile(path.join(markerDir, "2-second"))).resolves.toBeDefined();
|
|
|
|
// Ordering: upload → untar → command 1 → command 2. The tarball upload is the
|
|
// single stdin-backed call; the untar and the two commands follow it in order.
|
|
const scripts = calls.map((call) => (call.args ?? []).join("\n"));
|
|
const uploadIdx = scripts.findIndex((s) => s.includes(".paperclip-syncin.tar") && s.includes("base64 -d"));
|
|
const untarIdx = scripts.findIndex((s) => s.includes("tar -xf") && s.includes(targetDir));
|
|
const cmd1Idx = scripts.findIndex((s) => s.includes("1-first"));
|
|
const cmd2Idx = scripts.findIndex((s) => s.includes("2-second"));
|
|
expect(uploadIdx).toBeGreaterThanOrEqual(0);
|
|
expect(untarIdx).toBeGreaterThan(uploadIdx);
|
|
expect(cmd1Idx).toBeGreaterThan(untarIdx);
|
|
expect(cmd2Idx).toBeGreaterThan(cmd1Idx);
|
|
});
|
|
|
|
it("fallback syncIn runs a post-upload command under its own timeout, not the sync-client default", async () => {
|
|
// The run-specific timeout (`spec.timeoutMs`, stamped onto each delegated
|
|
// post-upload command) can differ from the sync client's own default. The
|
|
// fallback must honor the per-command `timeoutMs` so the delegated
|
|
// extract/cleanup/merge runs under the run limit — not the sync default.
|
|
const syncClientTimeoutMs = 30_000;
|
|
const runTimeoutMs = 7_000;
|
|
const execTimeouts: Array<number | undefined> = [];
|
|
const runner: CommandManagedRuntimeRunner = {
|
|
execute: async (input) => {
|
|
execTimeouts.push(input.timeoutMs);
|
|
return { exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: "" };
|
|
},
|
|
};
|
|
const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: syncClientTimeoutMs });
|
|
|
|
await client.syncIn!([
|
|
{
|
|
operationId: "op-timeout",
|
|
files: [],
|
|
postUploadCommands: [
|
|
{ command: "echo carries-run-timeout", timeoutMs: runTimeoutMs },
|
|
{ command: "echo defaults-to-sync-timeout" },
|
|
],
|
|
},
|
|
]);
|
|
|
|
// First command carries the run timeout; a command with no explicit timeout
|
|
// still falls back to the sync-client default (matched by the stamping in
|
|
// prepareSandboxManagedRuntime, which never leaves a delegated command bare).
|
|
expect(execTimeouts).toEqual([runTimeoutMs, syncClientTimeoutMs]);
|
|
});
|
|
|
|
it("fallback syncIn writes a mode-constrained file directly to its target and then applies the mode", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-syncin-mode-"));
|
|
cleanupDirs.push(rootDir);
|
|
const sourceFile = path.join(rootDir, "source.txt");
|
|
const targetFile = path.join(rootDir, "target.txt");
|
|
await writeFile(sourceFile, "payload\n", "utf8");
|
|
|
|
const { runner, calls } = makeSpawnRunner({ supportsSingleStreamStdinProgress: true });
|
|
const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 30_000 });
|
|
|
|
await client.syncIn!([
|
|
{
|
|
operationId: "op-mode",
|
|
files: [{ sourcePath: sourceFile, targetPath: targetFile, kind: "file", mode: 0o640 }],
|
|
},
|
|
]);
|
|
|
|
expect(await readFile(targetFile, "utf8")).toBe("payload\n");
|
|
// The write goes straight to the target path. No staging name and no
|
|
// rename step exist between the write and the chmod.
|
|
const scripts = calls.map((call) => (call.args ?? []).join(" "));
|
|
expect(scripts.some((script) => script.includes(".paperclip-syncin."))).toBe(false);
|
|
expect(scripts.some((script) => script.includes("mv -f") && script.includes(".paperclip-syncin."))).toBe(
|
|
false,
|
|
);
|
|
const chmodScript = scripts.find((script) => script.includes("chmod 640"));
|
|
expect(chmodScript).toBeDefined();
|
|
expect(chmodScript).toContain(targetFile);
|
|
});
|
|
|
|
it("post-upload commands execute verbatim and are never rewritten", async () => {
|
|
// The provider/client treats each command as opaque: it is executed VERBATIM,
|
|
// never concatenated with asset keys / paths or otherwise rewritten.
|
|
const executed: string[] = [];
|
|
const runner: CommandManagedRuntimeRunner = {
|
|
execute: async (input) => {
|
|
// Only capture the post-upload command executions (single `sh -c <cmd>`).
|
|
if ((input.args?.[0] === "-c") && typeof input.args?.[1] === "string") {
|
|
executed.push(input.args[1]);
|
|
}
|
|
return { exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: "" };
|
|
},
|
|
};
|
|
const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 1 });
|
|
|
|
const verbatim = "my-tool --flag 'quoted value' && echo $HOME";
|
|
await client.syncIn!([
|
|
{ operationId: "op-verbatim", files: [], postUploadCommands: [{ command: verbatim }] },
|
|
]);
|
|
|
|
// The exact string appears among executed scripts, unmodified.
|
|
expect(executed).toContain(verbatim);
|
|
});
|
|
|
|
it("post-upload command cwd that escapes the target root is rejected", async () => {
|
|
// A `cwd` that escapes the operation's target root — via `..` or an absolute
|
|
// path outside the target — is rejected BEFORE any handoff (no execute).
|
|
let executeCalls = 0;
|
|
const runner: CommandManagedRuntimeRunner = {
|
|
execute: async () => {
|
|
executeCalls += 1;
|
|
return { exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: "" };
|
|
},
|
|
};
|
|
const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 1 });
|
|
|
|
const traversal: SandboxSyncOperation[] = [
|
|
{
|
|
operationId: "op-traversal",
|
|
files: [{ sourcePath: "/host/a", targetPath: "/remote/a", kind: "directory" }],
|
|
postUploadCommands: [{ command: "echo x", cwd: "/remote/a/../etc" }],
|
|
},
|
|
];
|
|
await expect(client.syncIn!(traversal)).rejects.toThrow(/confined absolute POSIX path|escapes/);
|
|
|
|
const absoluteEscape: SandboxSyncOperation[] = [
|
|
{
|
|
operationId: "op-escape",
|
|
files: [{ sourcePath: "/host/a", targetPath: "/remote/a", kind: "directory" }],
|
|
postUploadCommands: [{ command: "echo x", cwd: "/etc/passwd" }],
|
|
},
|
|
];
|
|
await expect(client.syncIn!(absoluteEscape)).rejects.toThrow(/escapes the operation's target root/);
|
|
|
|
// A confined cwd (equal to the target root) passes confinement — it fails
|
|
// later at tar time (the source dir does not exist), which is a DIFFERENT
|
|
// error than a confinement rejection.
|
|
const confined: SandboxSyncOperation[] = [
|
|
{
|
|
operationId: "op-confined",
|
|
files: [{ sourcePath: "/host/a", targetPath: "/remote/a", kind: "directory" }],
|
|
postUploadCommands: [{ command: "echo x", cwd: "/remote/a" }],
|
|
},
|
|
];
|
|
let confinementRejected = false;
|
|
try {
|
|
await client.syncIn!(confined);
|
|
} catch (error) {
|
|
confinementRejected = /escapes the operation's target root|confined absolute POSIX path/.test(
|
|
(error as Error).message,
|
|
);
|
|
}
|
|
expect(confinementRejected).toBe(false);
|
|
|
|
// Confinement rejected before any exec for the escape cases.
|
|
expect(executeCalls).toBe(0);
|
|
});
|
|
|
|
it("fallback syncIn aborts on the first non-zero exit", async () => {
|
|
// The first non-zero post-upload command aborts the operation: syncIn rejects,
|
|
// the remaining commands do NOT run, and there is no silent partial fallback.
|
|
const executed: string[] = [];
|
|
const runner: CommandManagedRuntimeRunner = {
|
|
execute: async (input) => {
|
|
const script = input.args?.[1] ?? "";
|
|
executed.push(script);
|
|
const isFailing = script.includes("FAIL-COMMAND");
|
|
return {
|
|
exitCode: isFailing ? 3 : 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: "",
|
|
stderr: isFailing ? "boom" : "",
|
|
pid: null,
|
|
startedAt: "",
|
|
};
|
|
},
|
|
};
|
|
const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 1 });
|
|
|
|
await expect(
|
|
client.syncIn!([
|
|
{
|
|
operationId: "op-failfast",
|
|
files: [],
|
|
postUploadCommands: [
|
|
{ command: "echo before" },
|
|
{ command: "FAIL-COMMAND" },
|
|
{ command: "echo SHOULD-NOT-RUN" },
|
|
],
|
|
},
|
|
]),
|
|
).rejects.toThrow(/exit code 3|boom/);
|
|
|
|
expect(executed.some((s) => s.includes("echo before"))).toBe(true);
|
|
expect(executed.some((s) => s.includes("FAIL-COMMAND"))).toBe(true);
|
|
expect(executed.some((s) => s.includes("SHOULD-NOT-RUN"))).toBe(false);
|
|
});
|
|
|
|
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).toBe(3 + Math.ceil(payload.byteLength / (3 * 1024 * 1024)));
|
|
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/,
|
|
);
|
|
await expect(client.run("tar -cf workspace-download.tar .", { timeoutMs: 30_000 }))
|
|
.rejects.toMatchObject({ exitCode: 2, timedOut: false });
|
|
});
|
|
|
|
it("test_channel_round_trips_all_byte_values", () => {
|
|
const channel = createFakeEchoDuplexChannel();
|
|
const allByteValues = Uint8Array.from({ length: 256 }, (_, value) => value);
|
|
const received: Uint8Array[] = [];
|
|
channel.onData((chunk) => {
|
|
received.push(chunk);
|
|
});
|
|
|
|
channel.write(allByteValues);
|
|
|
|
expect(received).toHaveLength(1);
|
|
// A byte value of zero must survive. A UTF-8 string channel loses it: a
|
|
// JavaScript string can hold the code point U+0000, but a C-style consumer
|
|
// downstream of a string channel often treats it as a terminator.
|
|
expect(received[0]).toEqual(allByteValues);
|
|
expect(Array.from(received[0] ?? [])).toEqual(Array.from({ length: 256 }, (_, value) => value));
|
|
});
|
|
});
|