perf(adapter-utils): content-hash-skip process-session remote script write + lock inbound round-trip count (#10377)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The sandbox runtime depends on efficiently staging files into remote environments > - The process-session bridge was rewriting a static remote script on every start, even when the remote copy already matched > - That caused unnecessary round trips and made the startup path noisier than it needed to be > - This pull request adds a hash-skip path for the process-session script write and keeps the existing bridge-entrypoint hash gate on the same helper > - It also locks in the reduced inbound round-trip count so the collapse from the prior PR cannot silently regress > - The benefit is fewer remote execs on warm starts and a stronger guard against performance regressions ## Linked Issues or Issue Description - Refs: https://github.com/paperclipai/paperclip/pull/10354 ### Feature request-style description **Subsystem affected** - Cross-cutting (packages/adapter-utils and sandbox staging helpers) **Problem or motivation** - The process-session bootstrap path was rewriting a static remote script on every start even when the remote file already matched the host content. - That added avoidable remote execs and latency to warm starts. - The inbound staging collapse from the previous PR also needed a regression lock so it could not silently drift back to extra round trips. **Proposed solution** - Route the process-session remote script upload through the existing hash-skip helper used by the sandbox callback bridge entrypoint. - Keep the bridge-entrypoint behavior unchanged by delegating it to the same helper. - Add a regression test that asserts the inbound staging path still collapses to the expected round-trip count. **Alternatives considered** - Keep the existing unconditional write path and accept the extra execs on warm starts. Rejected because it preserves avoidable overhead. - Add a second specialized helper just for process-session scripts. Rejected because the bridge-entrypoint logic already solved the same problem and should stay aligned. **Roadmap alignment** - This fits the roadmap items around cloud/sandbox agents and enforced outcomes by reducing bootstrap waste and preventing performance regressions. - It does not introduce new product surface area, telemetry, or user-facing workflow changes. **Additional context** - The implementation preserves the existing upload/verify/rename behavior when the remote content differs and only skips the write when the hash already matches. - PR #10354 collapsed the inbound staging path; this PR preserves that win. ## What Changed - Added a shared hash-skip helper for remote text-file synchronization so unchanged content skips the write path after a single remote hash check. - Switched the process-session remote script upload to use that helper, preserving the existing write/verify/rename behavior when the remote content differs. - Refactored the sandbox callback bridge entrypoint sync to use the same helper without changing its observable behavior. - Added a mocked-native-runner regression test that locks the inbound staging path to one `client.syncIn` round-trip per step and zero direct write/run execs. ## Verification - `tsc --noEmit` - `pnpm --filter @paperclipai/adapter-utils exec vitest run` - Full adapter-utils vitest sweep: 338 passed / 4 skipped ## Risks - Low risk: the helper changes when a write occurs, not the script contents or the remote execution surface. - A hash-check failure now fails loudly instead of silently rewriting, which is safer but could surface provider-side issues earlier than before. - The regression test is intentionally specific to the current collapsed staging path, so future architectural changes will need test updates. ## Model Used OpenAI Codex (GPT-5, tool-using coding agent) ## 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>
This commit is contained in:
parent
341993ebae
commit
9ff7e1caf3
|
|
@ -22,6 +22,7 @@ import {
|
|||
sandboxCallbackBridgeDirectories,
|
||||
startSandboxCallbackBridgeServer,
|
||||
startSandboxCallbackBridgeWorker,
|
||||
syncRemoteTextFileWithHashSkip,
|
||||
} from "./sandbox-callback-bridge.js";
|
||||
import {
|
||||
createSandboxRunLogTailFactory,
|
||||
|
|
@ -1258,11 +1259,33 @@ async function writeProcessSessionProxyScript(dir: string, port: number, token:
|
|||
return proxyPath;
|
||||
}
|
||||
|
||||
// Content-hash-skip the process-session remote script write, mirroring the
|
||||
// sandbox callback bridge entrypoint sha256 gate. The script is a static
|
||||
// Paperclip-authored `.mjs` that only changes when the build changes, so on a
|
||||
// warm start (same sandbox, script already present) the single sha-gate exec
|
||||
// skips the ~3-exec base64 upload entirely. `syncRemoteTextFileWithHashSkip`
|
||||
// fails loud on a check error rather than silently re-uploading.
|
||||
async function syncProcessSessionRemoteScript(input: {
|
||||
client: ReturnType<typeof createCommandManagedSandboxCallbackBridgeQueueClient>;
|
||||
runner: CommandManagedRuntimeRunner;
|
||||
remoteCwd: string;
|
||||
remoteScriptDir: string;
|
||||
remoteScriptPath: string;
|
||||
}): Promise<void> {
|
||||
await input.client.writeTextFile(input.remoteScriptPath, getProcessSessionRemoteSource());
|
||||
timeoutMs?: number | null;
|
||||
shellCommand?: "bash" | "sh" | null;
|
||||
}): Promise<{ uploaded: boolean }> {
|
||||
const { uploaded } = await syncRemoteTextFileWithHashSkip({
|
||||
runner: input.runner,
|
||||
remoteCwd: input.remoteCwd,
|
||||
remoteDir: input.remoteScriptDir,
|
||||
remotePath: input.remoteScriptPath,
|
||||
body: getProcessSessionRemoteSource(),
|
||||
label: "Process session remote script",
|
||||
action: "sync process session remote script",
|
||||
lockDir: path.posix.join(input.remoteScriptDir, ".paperclip-process-session-script.lock"),
|
||||
timeoutMs: input.timeoutMs,
|
||||
shellCommand: input.shellCommand,
|
||||
});
|
||||
return { uploaded };
|
||||
}
|
||||
|
||||
async function readRemoteJsonFiles(input: {
|
||||
|
|
@ -1342,7 +1365,14 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
|
|||
|
||||
await client.makeDir(stdinDir);
|
||||
await client.makeDir(eventsDir);
|
||||
await syncProcessSessionRemoteScript({ client, remoteScriptPath });
|
||||
await syncProcessSessionRemoteScript({
|
||||
runner,
|
||||
remoteCwd: target.remoteCwd,
|
||||
remoteScriptDir: bridgeRuntimeDir,
|
||||
remoteScriptPath,
|
||||
timeoutMs,
|
||||
shellCommand,
|
||||
});
|
||||
|
||||
// Resolve the launch env AFTER the env-independent setup above, so a caller
|
||||
// can defer it until an upstream dependency (e.g. the paperclip bridge's env)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
createSandboxCallbackBridgeAsset,
|
||||
createSandboxCallbackBridgeToken,
|
||||
sandboxCallbackBridgeDirectories,
|
||||
syncRemoteTextFileWithHashSkip,
|
||||
syncSandboxCallbackBridgeEntrypoint,
|
||||
startSandboxCallbackBridgeServer,
|
||||
startSandboxCallbackBridgeWorker,
|
||||
|
|
@ -862,6 +863,135 @@ describe("sandbox callback bridge", () => {
|
|||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
// The process-session remote script is a static, Paperclip-authored `.mjs`
|
||||
// written into the sandbox on every bridge start. `syncRemoteTextFileWithHashSkip`
|
||||
// (which now backs that write, mirroring the bridge-entrypoint sha256 gate)
|
||||
// content-hash-skips it so a warm start where the remote script already matches
|
||||
// costs ZERO write execs instead of the prior ~3 (prepare/append/finalize base64
|
||||
// upload).
|
||||
it("test_process_session_script_skipped_when_remote_hash_matches: warm start with a matching remote hash writes 0 execs", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-hashskip-warm-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const remoteDir = path.join(rootDir, "runtime", "codex", "process-sessions");
|
||||
const remotePath = path.posix.join(remoteDir, "paperclip-process-session-remote.mjs");
|
||||
const lockDir = path.posix.join(remoteDir, ".paperclip-process-session-script.lock");
|
||||
const body = "console.log('process session remote script v1');\n";
|
||||
|
||||
let execCount = 0;
|
||||
const inner = createExecRunner();
|
||||
const runner = {
|
||||
execute: async (input: Parameters<typeof inner.execute>[0]) => {
|
||||
execCount += 1;
|
||||
return inner.execute(input);
|
||||
},
|
||||
};
|
||||
const args = {
|
||||
runner,
|
||||
remoteCwd: rootDir,
|
||||
remoteDir,
|
||||
remotePath,
|
||||
body,
|
||||
label: "Process session remote script",
|
||||
action: "sync process session remote script",
|
||||
lockDir,
|
||||
timeoutMs: 30_000,
|
||||
} as const;
|
||||
|
||||
// Cold start: the script is uploaded (single sha-gate exec that writes).
|
||||
const first = await syncRemoteTextFileWithHashSkip(args);
|
||||
expect(first.uploaded).toBe(true);
|
||||
await expect(readFile(remotePath, "utf8")).resolves.toBe(body);
|
||||
|
||||
// Warm start: the remote hash matches, so the write is skipped entirely.
|
||||
execCount = 0;
|
||||
const second = await syncRemoteTextFileWithHashSkip(args);
|
||||
expect(second.uploaded).toBe(false);
|
||||
// A single hash-gate round-trip that performed 0 writes (down from ~3 execs).
|
||||
expect(execCount).toBe(1);
|
||||
// sha is still returned on the skip path so callers get a well-formed result.
|
||||
expect(second.sha256).toBe(first.sha256);
|
||||
// The remote file is unchanged and no upload/partial/lock leftovers remain.
|
||||
await expect(readFile(remotePath, "utf8")).resolves.toBe(body);
|
||||
await expect(
|
||||
readdir(remoteDir).then((entries) =>
|
||||
entries.filter(
|
||||
(entry) =>
|
||||
entry.endsWith(".paperclip-upload.b64") ||
|
||||
entry.endsWith(".partial") ||
|
||||
entry === ".paperclip-process-session-script.lock",
|
||||
),
|
||||
),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("test_process_session_script_rewritten_on_hash_mismatch: a mismatched remote hash still rewrites the script", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-hashskip-cold-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const remoteDir = path.join(rootDir, "runtime", "codex", "process-sessions");
|
||||
const remotePath = path.posix.join(remoteDir, "paperclip-process-session-remote.mjs");
|
||||
const lockDir = path.posix.join(remoteDir, ".paperclip-process-session-script.lock");
|
||||
const body = "console.log('process session remote script v2');\n";
|
||||
|
||||
// Pre-seed the remote with a DIFFERENT script (a prior/stale build).
|
||||
await mkdir(remoteDir, { recursive: true });
|
||||
await writeFile(remotePath, "console.log('stale remote script');\n", "utf8");
|
||||
|
||||
const result = await syncRemoteTextFileWithHashSkip({
|
||||
runner: createExecRunner(),
|
||||
remoteCwd: rootDir,
|
||||
remoteDir,
|
||||
remotePath,
|
||||
body,
|
||||
label: "Process session remote script",
|
||||
action: "sync process session remote script",
|
||||
lockDir,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
expect(result.uploaded).toBe(true);
|
||||
await expect(readFile(remotePath, "utf8")).resolves.toBe(body);
|
||||
});
|
||||
|
||||
it("fails loud when the hash-skip sync exec errors instead of silently re-uploading", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-hashskip-fail-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const remoteDir = path.join(rootDir, "runtime", "codex", "process-sessions");
|
||||
const remotePath = path.posix.join(remoteDir, "paperclip-process-session-remote.mjs");
|
||||
const lockDir = path.posix.join(remoteDir, ".paperclip-process-session-script.lock");
|
||||
|
||||
// A runner whose exec fails: the hash-gate cannot be evaluated. The write
|
||||
// must surface the failure, never swallow it and re-upload behind a green
|
||||
// return value.
|
||||
const runner = {
|
||||
execute: async () => ({
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "hash gate boom",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(
|
||||
syncRemoteTextFileWithHashSkip({
|
||||
runner,
|
||||
remoteCwd: rootDir,
|
||||
remoteDir,
|
||||
remotePath,
|
||||
body: "console.log('never written');\n",
|
||||
label: "Process session remote script",
|
||||
action: "sync process session remote script",
|
||||
lockDir,
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
).rejects.toThrow(/sync process session remote script/i);
|
||||
|
||||
// Nothing was written to the remote path on the failure path.
|
||||
await expect(readFile(remotePath, "utf8")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("permits the documented heartbeat surface and denies unrelated routes", () => {
|
||||
const allowed: Array<{ method: string; path: string }> = [
|
||||
{ method: "GET", path: "/api/agents/me" },
|
||||
|
|
|
|||
|
|
@ -777,34 +777,54 @@ export async function startSandboxCallbackBridgeWorker(input: {
|
|||
};
|
||||
}
|
||||
|
||||
export async function syncSandboxCallbackBridgeEntrypoint(input: {
|
||||
/**
|
||||
* Content-hash-skip write of a Paperclip-authored text file into the sandbox, in
|
||||
* a SINGLE remote exec. The body's sha256 is computed on the host; the one shell
|
||||
* round-trip skips the write entirely when the remote file already hashes to the
|
||||
* same value (warm start — 0 write execs), otherwise it uploads (base64 over
|
||||
* stdin), verifies the decoded bytes, and atomically renames into place. A
|
||||
* PID-liveness lock serializes concurrent writers to the same path and the
|
||||
* verify step guards against a torn upload.
|
||||
*
|
||||
* Fail loudly: a non-zero remote exit (surfaced by `requireSuccessfulResult`) or
|
||||
* malformed result JSON throws rather than silently re-uploading and masking a
|
||||
* failed check. The only intentional degradation is when the remote has neither
|
||||
* `sha256sum` nor `shasum` — then the skip cannot be proven and we conservatively
|
||||
* re-upload (and the post-upload verify is best-effort, as noted inline).
|
||||
*/
|
||||
export async function syncRemoteTextFileWithHashSkip(input: {
|
||||
runner: CommandManagedRuntimeRunner;
|
||||
remoteCwd: string;
|
||||
assetRemoteDir: string;
|
||||
bridgeAsset: SandboxCallbackBridgeAsset;
|
||||
remoteDir: string;
|
||||
remotePath: string;
|
||||
body: string;
|
||||
// Human-readable noun phrase used in fail-loud messages, e.g.
|
||||
// "Sandbox callback bridge entrypoint" / "Process session remote script".
|
||||
label: string;
|
||||
// Short action label for `requireSuccessfulResult`, e.g.
|
||||
// "sync sandbox callback bridge entrypoint".
|
||||
action: string;
|
||||
lockDir: string;
|
||||
timeoutMs?: number | null;
|
||||
shellCommand?: "bash" | "sh" | null;
|
||||
}): Promise<{ remoteEntrypoint: string; sha256: string; uploaded: boolean }> {
|
||||
}): Promise<{ uploaded: boolean; sha256: string }> {
|
||||
const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS);
|
||||
const shellCommand = preferredShellForSandbox(input.shellCommand);
|
||||
const remoteEntrypoint = path.posix.join(input.assetRemoteDir, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT);
|
||||
const remoteEntrypointPartial = `${remoteEntrypoint}.partial`;
|
||||
const remoteUploadPath = `${remoteEntrypoint}.paperclip-upload.b64`;
|
||||
const remoteLockDir = path.posix.join(input.assetRemoteDir, ".paperclip-bridge-upload.lock");
|
||||
const entrypointSource = await fs.readFile(input.bridgeAsset.entrypoint, "utf8");
|
||||
const entrypointBase64 = toBuffer(Buffer.from(entrypointSource, "utf8")).toString("base64");
|
||||
const sha256 = createHash("sha256").update(entrypointSource, "utf8").digest("hex");
|
||||
const remotePartial = `${input.remotePath}.partial`;
|
||||
const remoteUploadPath = `${input.remotePath}.paperclip-upload.b64`;
|
||||
const base64Body = toBuffer(Buffer.from(input.body, "utf8")).toString("base64");
|
||||
const sha256 = createHash("sha256").update(input.body, "utf8").digest("hex");
|
||||
|
||||
const syncResult = await runShell(
|
||||
input.runner,
|
||||
input.remoteCwd,
|
||||
[
|
||||
"set -eu",
|
||||
`remote_dir=${shellQuote(input.assetRemoteDir)}`,
|
||||
`remote_path=${shellQuote(remoteEntrypoint)}`,
|
||||
`remote_partial=${shellQuote(remoteEntrypointPartial)}`,
|
||||
`remote_dir=${shellQuote(input.remoteDir)}`,
|
||||
`remote_path=${shellQuote(input.remotePath)}`,
|
||||
`remote_partial=${shellQuote(remotePartial)}`,
|
||||
`remote_upload=${shellQuote(remoteUploadPath)}`,
|
||||
`lock_dir=${shellQuote(remoteLockDir)}`,
|
||||
`lock_dir=${shellQuote(input.lockDir)}`,
|
||||
`expected_sha=${shellQuote(sha256)}`,
|
||||
"hash_file() {",
|
||||
" if command -v sha256sum >/dev/null 2>&1; then",
|
||||
|
|
@ -818,7 +838,7 @@ export async function syncSandboxCallbackBridgeEntrypoint(input: {
|
|||
" return 127",
|
||||
"}",
|
||||
"mkdir -p \"$remote_dir\"",
|
||||
...buildRemotePidLockAcquireScript("\"$lock_dir\"", "Timed out acquiring sandbox callback bridge upload lock."),
|
||||
...buildRemotePidLockAcquireScript("\"$lock_dir\"", `Timed out acquiring ${input.label} upload lock.`),
|
||||
...buildRemotePidLockCleanupScript("\"$lock_dir\"", [
|
||||
"rm -f \"$remote_upload\" \"$remote_partial\"",
|
||||
]),
|
||||
|
|
@ -839,30 +859,56 @@ export async function syncSandboxCallbackBridgeEntrypoint(input: {
|
|||
// best-effort and we trust base64-decode + atomic rename below.
|
||||
"if partial_sha=\"$(hash_file \"$remote_partial\" 2>/dev/null)\"; then",
|
||||
" if [ \"$partial_sha\" != \"$expected_sha\" ]; then",
|
||||
" echo \"Sandbox callback bridge entrypoint upload sha mismatch.\" >&2",
|
||||
` echo ${shellQuote(`${input.label} upload sha mismatch.`)} >&2`,
|
||||
" exit 1",
|
||||
" fi",
|
||||
"else",
|
||||
" echo \"Sandbox callback bridge entrypoint sha verify skipped: no sha256sum/shasum on remote.\" >&2",
|
||||
` echo ${shellQuote(`${input.label} sha verify skipped: no sha256sum/shasum on remote.`)} >&2`,
|
||||
"fi",
|
||||
"mv \"$remote_partial\" \"$remote_path\"",
|
||||
"printf '{\"uploaded\":true}\\n'",
|
||||
].join("\n"),
|
||||
timeoutMs,
|
||||
shellCommand,
|
||||
entrypointBase64,
|
||||
base64Body,
|
||||
);
|
||||
requireSuccessfulResult("sync sandbox callback bridge entrypoint", syncResult);
|
||||
requireSuccessfulResult(input.action, syncResult);
|
||||
|
||||
let uploaded = false;
|
||||
try {
|
||||
uploaded = JSON.parse(syncResult.stdout.trim())?.uploaded === true;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Sandbox callback bridge sync wrote invalid result JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`${input.label} sync wrote invalid result JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { uploaded, sha256 };
|
||||
}
|
||||
|
||||
export async function syncSandboxCallbackBridgeEntrypoint(input: {
|
||||
runner: CommandManagedRuntimeRunner;
|
||||
remoteCwd: string;
|
||||
assetRemoteDir: string;
|
||||
bridgeAsset: SandboxCallbackBridgeAsset;
|
||||
timeoutMs?: number | null;
|
||||
shellCommand?: "bash" | "sh" | null;
|
||||
}): Promise<{ remoteEntrypoint: string; sha256: string; uploaded: boolean }> {
|
||||
const remoteEntrypoint = path.posix.join(input.assetRemoteDir, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT);
|
||||
const entrypointSource = await fs.readFile(input.bridgeAsset.entrypoint, "utf8");
|
||||
const { uploaded, sha256 } = await syncRemoteTextFileWithHashSkip({
|
||||
runner: input.runner,
|
||||
remoteCwd: input.remoteCwd,
|
||||
remoteDir: input.assetRemoteDir,
|
||||
remotePath: remoteEntrypoint,
|
||||
body: entrypointSource,
|
||||
label: "Sandbox callback bridge entrypoint",
|
||||
action: "sync sandbox callback bridge entrypoint",
|
||||
lockDir: path.posix.join(input.assetRemoteDir, ".paperclip-bridge-upload.lock"),
|
||||
timeoutMs: input.timeoutMs,
|
||||
shellCommand: input.shellCommand,
|
||||
});
|
||||
|
||||
return {
|
||||
remoteEntrypoint,
|
||||
sha256,
|
||||
|
|
|
|||
|
|
@ -1363,6 +1363,126 @@ describe("sandbox managed runtime", () => {
|
|||
expect(prepared.workspaceRemoteDir).toBe(remoteWorkspaceDir);
|
||||
});
|
||||
|
||||
// Regression lock: a representative `codex_local` start stages its inbound
|
||||
// bytes — git history, workspace overlay, and the managed Codex `home` asset
|
||||
// (auth.json merge) — as EXACTLY ONE `syncIn` operation each. Every inbound step
|
||||
// is routed through `client.syncIn` (one native `uploadFiles` round-trip per
|
||||
// operation, with the extract/merge carried as provider-executed
|
||||
// `postUploadCommands`), with no separate custom-provision diversion. Assert
|
||||
// the collapsed round-trip count so a future change that re-inlines a
|
||||
// `writeFile`+`run` sequence — or fans one staging step across multiple
|
||||
// operations — fails loudly here instead of silently regressing the start path.
|
||||
it("collapses a representative codex_local start to one syncIn round-trip per inbound staging step", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-codex-roundtrip-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const sourceRepoDir = path.join(rootDir, "source-repo");
|
||||
const localWorkspaceDir = path.join(rootDir, "local-workspace");
|
||||
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
|
||||
const homeDir = path.join(rootDir, "codex-home");
|
||||
|
||||
// Git-backed workspace → git history + workspace overlay are two staging steps.
|
||||
await mkdir(sourceRepoDir, { recursive: true });
|
||||
await git(sourceRepoDir, ["init"]);
|
||||
await git(sourceRepoDir, ["checkout", "-b", "main"]);
|
||||
await git(sourceRepoDir, ["config", "user.name", "Paperclip Test"]);
|
||||
await git(sourceRepoDir, ["config", "user.email", "test@paperclip.dev"]);
|
||||
await writeFile(path.join(sourceRepoDir, "tracked.txt"), "tracked\n", "utf8");
|
||||
await git(sourceRepoDir, ["add", "tracked.txt"]);
|
||||
await git(sourceRepoDir, ["commit", "-m", "base"]);
|
||||
await git(sourceRepoDir, ["worktree", "add", "-b", "work", localWorkspaceDir, "HEAD"]);
|
||||
|
||||
// Managed Codex home with an auth.json that a custom post-upload command
|
||||
// merges in-sandbox — the credential path is routed onto native uploadFiles.
|
||||
await mkdir(homeDir, { recursive: true });
|
||||
await writeFile(path.join(homeDir, "auth.json"), "{\"OPENAI_API_KEY\":\"sk-test\"}\n", "utf8");
|
||||
await writeFile(path.join(homeDir, "config.toml"), "model = \"gpt\"\n", "utf8");
|
||||
|
||||
// A native runner delegates every staging step to `syncIn`; ANY direct
|
||||
// writeFile/run exec is a collapse regression.
|
||||
const directWrites: string[] = [];
|
||||
const directRuns: string[] = [];
|
||||
const client: SandboxManagedRuntimeClient = {
|
||||
makeDir: async (remotePath) => {
|
||||
await mkdir(remotePath, { recursive: true });
|
||||
},
|
||||
writeFile: async (remotePath, bytes) => {
|
||||
directWrites.push(remotePath);
|
||||
await mkdir(path.dirname(remotePath), { recursive: true });
|
||||
await writeFile(remotePath, Buffer.from(bytes));
|
||||
},
|
||||
readFile: async (remotePath) => await readFile(remotePath),
|
||||
listFiles: async () => [],
|
||||
remove: async (remotePath) => {
|
||||
await rm(remotePath, { recursive: true, force: true });
|
||||
},
|
||||
run: async (command) => {
|
||||
directRuns.push(command);
|
||||
await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 });
|
||||
},
|
||||
};
|
||||
const captured: SandboxSyncOperation[] = [];
|
||||
attachNativeRecordingSyncIn(client, captured);
|
||||
|
||||
const q = (value: string) => `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
||||
const prepared = await prepareSandboxManagedRuntime({
|
||||
spec: {
|
||||
transport: "sandbox",
|
||||
provider: "test",
|
||||
sandboxId: "sandbox-1",
|
||||
remoteCwd: remoteWorkspaceDir,
|
||||
timeoutMs: 30_000,
|
||||
apiKey: null,
|
||||
},
|
||||
adapterKey: "codex",
|
||||
client,
|
||||
workspaceLocalDir: localWorkspaceDir,
|
||||
assets: [{
|
||||
key: "home",
|
||||
localDir: homeDir,
|
||||
provision: {
|
||||
stageFiles: [{ name: "home-merge.sh", contents: "#!/bin/sh\ntar -xf \"$2\" -C \"$1\"\n" }],
|
||||
postUploadCommand: ({ assetTarPath, assetDir, runtimeRootDir }) =>
|
||||
`mkdir -p ${q(assetDir)} && ` +
|
||||
`sh ${q(path.posix.join(runtimeRootDir, "home-merge.sh"))} ${q(assetDir)} ${q(assetTarPath)} && ` +
|
||||
`rm -f ${q(assetTarPath)}`,
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
// The orchestrator delegated everything to syncIn: no re-inlined writeFile/run.
|
||||
expect(directWrites).toEqual([]);
|
||||
expect(directRuns).toEqual([]);
|
||||
|
||||
// The collapsed count: exactly three inbound round-trips — git, workspace, home.
|
||||
expect(captured).toHaveLength(3);
|
||||
const gitOp = captured.find((op) =>
|
||||
op.files.some((mapping) => mapping.targetPath.endsWith("git-workspace-upload.tar")),
|
||||
);
|
||||
const workspaceOp = captured.find((op) =>
|
||||
op.files.some((mapping) => mapping.targetPath.endsWith("workspace-upload.tar")),
|
||||
);
|
||||
const homeOp = captured.find((op) =>
|
||||
op.files.some((mapping) => mapping.targetPath.endsWith("home-upload.tar")),
|
||||
);
|
||||
expect(gitOp).toBeDefined();
|
||||
expect(workspaceOp).toBeDefined();
|
||||
expect(homeOp).toBeDefined();
|
||||
|
||||
// Every operation is a single native uploadFiles (all `file` mappings) whose
|
||||
// extract/merge rides as an ordered provider-executed post-upload command.
|
||||
for (const op of captured) {
|
||||
expect(op.files.length).toBeGreaterThanOrEqual(1);
|
||||
expect(op.files.every((mapping) => mapping.kind === "file")).toBe(true);
|
||||
expect(op.postUploadCommands ?? []).not.toHaveLength(0);
|
||||
}
|
||||
// Operation ids are distinct, so "3 operations" is 3 real round-trips.
|
||||
expect(new Set(captured.map((op) => op.operationId)).size).toBe(3);
|
||||
|
||||
// The credential asset actually materialized through the native seam.
|
||||
await expect(readFile(path.join(prepared.assetDirs.home, "auth.json"), "utf8"))
|
||||
.resolves.toBe("{\"OPENAI_API_KEY\":\"sk-test\"}\n");
|
||||
});
|
||||
|
||||
it("keeps the sandbox runtime core free of Codex-specific string literals", async () => {
|
||||
const coreSource = await readFile(new URL("./sandbox-managed-runtime.ts", import.meta.url), "utf8");
|
||||
// The seam must be generic: no adapter (Codex) knowledge may live in the core.
|
||||
|
|
|
|||
Loading…
Reference in New Issue