diff --git a/packages/adapter-utils/src/command-managed-runtime.test.ts b/packages/adapter-utils/src/command-managed-runtime.test.ts index 7dc7d3a02a..a59fab3725 100644 --- a/packages/adapter-utils/src/command-managed-runtime.test.ts +++ b/packages/adapter-utils/src/command-managed-runtime.test.ts @@ -10,6 +10,7 @@ import { prepareCommandManagedRuntime, type CommandManagedRuntimeRunner, } from "./command-managed-runtime.js"; +import type { SandboxSyncOperation } from "./sandbox-managed-runtime.js"; import type { RunProcessResult } from "./server-utils.js"; const execFile = promisify(execFileCallback); @@ -93,6 +94,10 @@ function toArrayBuffer(buffer: Buffer): ArrayBuffer { return Uint8Array.from(buffer).buffer; } +function shellQuoteForTest(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + async function withBase64StringByteLimit(limitBytes: number, fn: () => Promise): Promise { const originalToString = Buffer.prototype.toString; Buffer.prototype.toString = function patchedToString( @@ -344,7 +349,8 @@ describe("command managed runtime", () => { }); // Exactly one upload process: O(1) round-trips regardless of payload size. - expect(calls.length).toBe(1); + expect(calls.length).toBe(2); + expect(calls[1].args?.join(" ")).toContain("rm -rf"); expect(calls[0].stdin).toBeTypeOf("string"); const written = await readFile(remotePath); @@ -371,11 +377,49 @@ describe("command managed runtime", () => { // 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); @@ -393,15 +437,23 @@ describe("command managed runtime", () => { expect((await readFile(remotePath)).equals(payload)).toBe(true); }); - it("leaves the client without syncIn/syncOut unless the runner supports both (fallback preserved)", () => { + 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; - expect(createCommandManagedRuntimeClient({ runner: base, commandCwd: "/", timeoutMs: 1 }).syncIn).toBeUndefined(); + 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).toBeUndefined(); + 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: [] }), @@ -412,6 +464,311 @@ describe("command managed runtime", () => { expect(native.syncOut).toBeTypeOf("function"); }); + 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 stages mode-constrained files before chmod and rename", 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"); + const scripts = calls.map((call) => (call.args ?? []).join(" ")); + expect(scripts).toHaveLength(5); + expect(scripts[0]).toContain(targetFile + ".paperclip-syncin."); + expect(scripts[0]).toContain(".paperclip-upload."); + expect(scripts[1]).toContain("rm -rf"); + expect(scripts[1]).toContain(".paperclip-upload."); + expect(scripts[2]).toContain("chmod 640"); + expect(scripts[2]).toContain(targetFile + ".paperclip-syncin."); + expect(scripts[3]).toContain("mv -f"); + expect(scripts[3]).toContain(targetFile + ".paperclip-syncin."); + expect(scripts[3]).toContain(targetFile); + expect(scripts[4]).toContain("rm -rf"); + expect(scripts[4]).toContain(targetFile + ".paperclip-syncin."); + }); + + it("fallback syncIn cleans up a staged file when chmod fails before rename", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-syncin-cleanup-")); + 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 delegatedExecute = runner.execute.bind(runner); + runner.execute = async (input) => { + const script = (input.args ?? []).join(" "); + if (script.includes("chmod 600")) { + calls.push({ command: input.command, args: input.args, cwd: input.cwd, stdin: input.stdin }); + return { + exitCode: 1, + signal: null, + timedOut: false, + stdout: "", + stderr: "chmod failed", + pid: null, + startedAt: new Date().toISOString(), + }; + } + return await delegatedExecute(input); + }; + const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 30_000 }); + + await expect( + client.syncIn!([ + { + operationId: "op-cleanup", + files: [{ sourcePath: sourceFile, targetPath: targetFile, kind: "file", mode: 0o600 }], + }, + ]), + ).rejects.toThrow(/chmod failed/); + + const chmodCall = calls.find((call) => (call.args ?? []).join(" ").includes("chmod 600")); + expect(chmodCall).toBeDefined(); + const stagedPath = (chmodCall?.args ?? []).join(" ").match(/chmod 600 '([^']+)'/)?.[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(targetFile, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("test_post_upload_commands_execute_verbatim_not_rewritten (C1 opaque)", 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 `). + 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("test_post_upload_command_cwd_escaping_target_root_is_rejected (C2)", 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("test_fallback_syncIn_aborts_and_rejects_on_first_nonzero_exit (C4 fail-fast)", 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("test_single_stream_writeFile_collapses_roundtrips_under_96MiB", async () => { + // Research A1: with single-stream enabled a ≤96 MiB write is ONE round-trip; + // without it, the chunked path is `2 + ceil(bytes / 3 MiB)`. Same payload, + // same client API — only the runner capability flag differs. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-single-stream-collapse-")); + cleanupDirs.push(rootDir); + const payload = Buffer.alloc(9 * 1024 * 1024, 7); // 9 MiB → chunked = 2 + 3 = 5 execs + + const single = makeSpawnRunner({ supportsSingleStreamStdinProgress: true }); + const singleClient = createCommandManagedRuntimeClient({ runner: single.runner, commandCwd: "/", timeoutMs: 30_000 }); + await singleClient.writeFile(path.join(rootDir, "single.bin"), toArrayBuffer(payload)); + expect(single.calls.length).toBe(2); + + const chunked = makeSpawnRunner({ supportsSingleStreamStdinProgress: false }); + const chunkedClient = createCommandManagedRuntimeClient({ runner: chunked.runner, commandCwd: "/", timeoutMs: 30_000 }); + await chunkedClient.writeFile(path.join(rootDir, "chunked.bin"), toArrayBuffer(payload)); + // 3 (init temp + final mv + cleanup) + ceil(9MiB / 3MiB) = 6 round-trips. + expect(chunked.calls.length).toBe(3 + Math.ceil(payload.byteLength / (3 * 1024 * 1024))); + expect(chunked.calls.length).toBeGreaterThan(single.calls.length); + + expect((await readFile(path.join(rootDir, "single.bin"))).equals(payload)).toBe(true); + expect((await readFile(path.join(rootDir, "chunked.bin"))).equals(payload)).toBe(true); + }); + 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); diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts index 399eca9479..58a5d08510 100644 --- a/packages/adapter-utils/src/command-managed-runtime.ts +++ b/packages/adapter-utils/src/command-managed-runtime.ts @@ -1,5 +1,9 @@ +import { promises as fs } from "node:fs"; +import { randomUUID } from "node:crypto"; +import os from "node:os"; import path from "node:path"; import { + createTarballFromDirectory, prepareSandboxManagedRuntime, type PreparedSandboxManagedRuntime, type SandboxManagedRuntimeAsset, @@ -122,6 +126,73 @@ function requireSuccessfulResult(result: RunProcessResult, action: string): void throw new Error(`${action} failed with exit code ${result.exitCode ?? "null"}${detail}`); } +function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer { + // Copy out of the (possibly pooled) Node Buffer so the ArrayBuffer we hand to + // the client transport owns exactly these bytes. + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer; +} + +// Named builder (Security Condition C3): extract an uploaded tarball into its +// target directory as a clean destroy-then-replace, then remove the tarball. +// Every path is shell-quoted; the fallback NEVER concatenates untrusted asset +// keys / file names into the shell. +function buildSyncInExtractDirectoryCommand(input: { remoteTarPath: string; targetDir: string }): string { + return ( + `rm -rf ${shellQuote(input.targetDir)} && ` + + `mkdir -p ${shellQuote(input.targetDir)} && ` + + `tar -xf ${shellQuote(input.remoteTarPath)} -C ${shellQuote(input.targetDir)} && ` + + `rm -f ${shellQuote(input.remoteTarPath)}` + ); +} + +// Named builder (C3): apply a POSIX mode to a placed file. Octal literal, quoted +// path; no interpolation of untrusted values. +function buildSyncInChmodCommand(input: { mode: number; targetPath: string }): string { + return `chmod ${(input.mode & 0o7777).toString(8)} ${shellQuote(input.targetPath)}`; +} +function buildSyncInRenameCommand(input: { sourcePath: string; targetPath: string }): string { + return "mv -f " + shellQuote(input.sourcePath) + " " + shellQuote(input.targetPath); +} + +function buildUniqueStagingPath(input: { targetPath: string; suffix: string }): string { + return `${input.targetPath}${input.suffix}.${randomUUID()}`; +} + +async function bestEffortRemoveRemotePath(client: SandboxManagedRuntimeClient, remotePath: string): Promise { + await client.remove(remotePath).catch(() => undefined); +} + +/** + * Host-side confinement guard for a sync operation's post-upload command `cwd` + * (Security Condition C2). Runs BEFORE any handoff — native delegation OR the + * generic fallback — so an out-of-root `cwd` is rejected fail-closed before a + * provider ever sees it. `cwd` (when present) MUST be an absolute POSIX path with + * no `..` segment, confined to (equal to or under) one of the operation's own + * file-mapping target paths. Commands with no `cwd` are unconstrained here and + * default to the runtime's stable command cwd at exec time. + */ +export function assertPostUploadCommandsConfined(operations: readonly SandboxSyncOperation[]): void { + for (const operation of operations) { + const commands = operation.postUploadCommands ?? []; + if (commands.length === 0) continue; + const targetRoots = operation.files.map((mapping) => path.posix.normalize(mapping.targetPath)); + for (const command of commands) { + if (command.cwd == null) continue; + const raw = command.cwd; + if (!path.posix.isAbsolute(raw) || raw.split("/").includes("..")) { + throw new Error(`post-upload command cwd is not a confined absolute POSIX path: ${raw}`); + } + const normalized = path.posix.normalize(raw); + const within = targetRoots.some( + (root) => normalized === root || normalized.startsWith(`${root}/`), + ); + if (!within) { + throw new Error(`post-upload command cwd escapes the operation's target root: ${raw}`); + } + } + } +} + export function createCommandManagedRuntimeClient(input: { runner: CommandManagedRuntimeRunner; commandCwd: string; @@ -158,47 +229,52 @@ export function createCommandManagedRuntimeClient(input: { const total = buffer.byteLength; const encodedLength = base64EncodedLength(total); const remoteDir = path.posix.dirname(remotePath); - const remoteTempPath = `${remotePath}.paperclip-upload`; + const remoteTempPath = buildUniqueStagingPath({ targetPath: remotePath, suffix: ".paperclip-upload" }); const canUseSingleStreamProgressPath = input.runner.supportsSingleStreamStdinProgress === true; - // Primary path: a single round-trip. Stream the entire base64 body to one - // `base64 -d` process via stdin, decode straight into a temp file, then - // atomically rename into place. This replaces the previous loop that did - // one `printf >> tmpfile` shell round-trip per 32 KB — thousands of serial - // processes for a large workspace — with exactly one process. - if ( - encodedLength <= REMOTE_WRITE_SINGLE_STREAM_MAX_BASE64_BYTES && - canUseSingleStreamProgressPath - ) { - const body = buffer.toString("base64"); - await options?.onProgress?.(0, total); + try { + // Primary path: a single round-trip. Stream the entire base64 body to one + // `base64 -d` process via stdin, decode straight into a temp file, then + // atomically rename into place. This replaces the previous loop that did + // one `printf >> tmpfile` shell round-trip per 32 KB — thousands of serial + // processes for a large workspace — with exactly one process. + if ( + encodedLength <= REMOTE_WRITE_SINGLE_STREAM_MAX_BASE64_BYTES && + canUseSingleStreamProgressPath + ) { + const body = buffer.toString("base64"); + await options?.onProgress?.(0, total); + await runShell( + `cleanup() { rm -f ${shellQuote(remoteTempPath)}; }; trap cleanup EXIT INT TERM; ` + + `mkdir -p ${shellQuote(remoteDir)} && ` + + `base64 -d > ${shellQuote(remoteTempPath)} && ` + + `mv -f ${shellQuote(remoteTempPath)} ${shellQuote(remotePath)}`, + { stdin: body }, + ); + await options?.onProgress?.(total, total); + return; + } + + // Bounded fallback for payloads too large to hand the runner as one stdin + // string: append the base64 body to a remote temp file in large chunks + // (orders of magnitude fewer round-trips than the old 32 KB loop), decoding + // each self-contained chunk on arrival and emitting progress per write, + // then atomically rename into place. await runShell( `mkdir -p ${shellQuote(remoteDir)} && ` + - `base64 -d > ${shellQuote(remoteTempPath)} && ` + - `mv -f ${shellQuote(remoteTempPath)} ${shellQuote(remotePath)}`, - { stdin: body }, + `rm -f ${shellQuote(remoteTempPath)} && : > ${shellQuote(remoteTempPath)}`, ); + for (let offset = 0; offset < total; offset += REMOTE_WRITE_FALLBACK_DECODED_CHUNK_SIZE) { + const end = Math.min(total, offset + REMOTE_WRITE_FALLBACK_DECODED_CHUNK_SIZE); + const chunk = buffer.subarray(offset, end).toString("base64"); + await runShell(`base64 -d >> ${shellQuote(remoteTempPath)}`, { stdin: chunk }); + await options?.onProgress?.(end, total); + } + await runShell(`mv -f ${shellQuote(remoteTempPath)} ${shellQuote(remotePath)}`); await options?.onProgress?.(total, total); - return; + } finally { + await bestEffortRemoveRemotePath(client, remoteTempPath); } - - // Bounded fallback for payloads too large to hand the runner as one stdin - // string: append the base64 body to a remote temp file in large chunks - // (orders of magnitude fewer round-trips than the old 32 KB loop), decoding - // each self-contained chunk on arrival and emitting progress per write, - // then atomically rename into place. - await runShell( - `mkdir -p ${shellQuote(remoteDir)} && ` + - `rm -f ${shellQuote(remoteTempPath)} && : > ${shellQuote(remoteTempPath)}`, - ); - for (let offset = 0; offset < total; offset += REMOTE_WRITE_FALLBACK_DECODED_CHUNK_SIZE) { - const end = Math.min(total, offset + REMOTE_WRITE_FALLBACK_DECODED_CHUNK_SIZE); - const chunk = buffer.subarray(offset, end).toString("base64"); - await runShell(`base64 -d >> ${shellQuote(remoteTempPath)}`, { stdin: chunk }); - await options?.onProgress?.(end, total); - } - await runShell(`mv -f ${shellQuote(remoteTempPath)} ${shellQuote(remotePath)}`); - await options?.onProgress?.(total, total); }, readFile: async (remotePath, options) => { // Chunked reads intentionally query the remote size first, even without @@ -272,13 +348,110 @@ export function createCommandManagedRuntimeClient(input: { }, }; - // Expose the native sync capability to the orchestrator only when the runner - // supports BOTH directions; a provider that advertises just one verb (or - // neither) keeps the byte-identical base64 fallback for both. - const { syncIn, syncOut } = input.runner; - if (syncIn && syncOut) { - client.syncIn = (operations) => syncIn(operations); - client.syncOut = (operations) => syncOut(operations); + // Generic base64-tar fallback for `syncIn` on runners without native sync: + // place each operation's files (host-side tarball → `writeFile` → destroy-then- + // replace untar for directories, direct `writeFile` for single files), then run + // the operation's ordered `postUploadCommands` fail-fast. Byte-for-byte + // behavior-equivalent to the caller-inlined tar path it will replace. All exec + // rides the shared `execute` seam so `execCount`/`providerExecMs` still + // attribute (Open Q1). + const fallbackSyncIn = async (operations: SandboxSyncOperation[]): Promise => { + const resultOperations: SandboxSyncResult["operations"] = []; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-syncin-fallback-")); + try { + for (const operation of operations) { + let filesTransferred = 0; + let bytesTransferred = 0; + for (const [index, mapping] of operation.files.entries()) { + const cleanupPaths: string[] = []; + try { + if (mapping.kind === "directory") { + const archivePath = path.join(tempDir, `syncin-${index}.tar`); + await createTarballFromDirectory({ + localDir: mapping.sourcePath, + archivePath, + exclude: mapping.exclude, + followSymlinks: mapping.followSymlinks, + }); + const tarBytes = await fs.readFile(archivePath); + const remoteTarPath = buildUniqueStagingPath({ + targetPath: mapping.targetPath, + suffix: ".paperclip-syncin.tar", + }); + cleanupPaths.push(remoteTarPath); + await client.writeFile(remoteTarPath, bufferToArrayBuffer(tarBytes)); + await client.run( + buildSyncInExtractDirectoryCommand({ remoteTarPath, targetDir: mapping.targetPath }), + { timeoutMs: input.timeoutMs }, + ); + bytesTransferred += tarBytes.byteLength; + } else { + const fileBytes = await fs.readFile(mapping.sourcePath); + const targetPathForWrite = mapping.mode != null + ? buildUniqueStagingPath({ targetPath: mapping.targetPath, suffix: ".paperclip-syncin" }) + : mapping.targetPath; + if (mapping.mode != null) cleanupPaths.push(targetPathForWrite); + await client.writeFile(targetPathForWrite, bufferToArrayBuffer(fileBytes)); + if (mapping.mode != null) { + await client.run( + buildSyncInChmodCommand({ mode: mapping.mode, targetPath: targetPathForWrite }), + { timeoutMs: input.timeoutMs }, + ); + await client.run( + buildSyncInRenameCommand({ sourcePath: targetPathForWrite, targetPath: mapping.targetPath }), + { timeoutMs: input.timeoutMs }, + ); + } + bytesTransferred += fileBytes.byteLength; + } + } finally { + for (const cleanupPath of cleanupPaths.reverse()) { + await bestEffortRemoveRemotePath(client, cleanupPath); + } + } + filesTransferred += 1; + } + // Ordered, fail-fast post-upload commands (C1 opaque / C4 fail-loud). Each + // command string is executed VERBATIM — never rewritten, concatenated, or + // appended to. First non-zero exit or timeout throws and stops the rest. + for (const command of operation.postUploadCommands ?? []) { + const result = await input.runner.execute({ + command: shellCommand, + args: shellCommandArgs(command.command), + cwd: command.cwd ?? input.commandCwd, + timeoutMs: command.timeoutMs ?? input.timeoutMs, + }); + requireSuccessfulResult(result, command.command); + } + resultOperations.push({ + operationId: operation.operationId, + filesTransferred, + bytesTransferred, + }); + } + } finally { + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + } + return { operations: resultOperations }; + }; + + // `client.syncIn` is ALWAYS present: it delegates to the runner's native + // transport when the provider advertises BOTH sync verbs, otherwise it runs the + // generic fallback above. Either way, post-upload command `cwd` confinement (C2) + // is validated on the host BEFORE any handoff. `syncOut` stays native-only — + // there is no generic outbound fallback in this seam. + const nativeSyncIn = input.runner.syncIn; + const nativeSyncOut = input.runner.syncOut; + const hasNativeBoth = Boolean(nativeSyncIn && nativeSyncOut); + client.syncIn = async (operations) => { + assertPostUploadCommandsConfined(operations); + if (hasNativeBoth) { + return await nativeSyncIn!(operations); + } + return await fallbackSyncIn(operations); + }; + if (hasNativeBoth) { + client.syncOut = (operations) => nativeSyncOut!(operations); } return client; diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts index e2f7237a92..13561e67ae 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts @@ -1,3 +1,4 @@ +import { promises as fsPromises } from "node:fs"; import { lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -12,6 +13,18 @@ import { type SandboxManagedRuntimeClient, } from "./sandbox-managed-runtime.js"; +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promises: { + ...actual.promises, + chmod: vi.fn(actual.promises.chmod), + rename: vi.fn(actual.promises.rename), + }, + }; +}); + const execFile = promisify(execFileCallback); async function git(cwd: string, args: string[]): Promise { @@ -64,6 +77,63 @@ describe("sandbox managed runtime", () => { await expect(readFile(path.join(targetDir, "stale.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); }); + it("applies file mode on a staged sibling before renaming into place", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-copy-mode-")); + cleanupDirs.push(rootDir); + const sourceDir = path.join(rootDir, "source"); + const targetDir = path.join(rootDir, "target"); + const relativePath = path.join("nested", "script.sh"); + const sourcePath = path.join(sourceDir, relativePath); + const targetPath = path.join(targetDir, relativePath); + await mkdir(path.dirname(sourcePath), { recursive: true }); + await writeFile(sourcePath, "#!/bin/sh\necho hello\n", { mode: 0o600 }); + await mkdir(targetDir, { recursive: true }); + + const chmodMock = vi.mocked(fsPromises.chmod); + const renameMock = vi.mocked(fsPromises.rename); + chmodMock.mockClear(); + renameMock.mockClear(); + + await mirrorDirectory(sourceDir, targetDir); + + await expect(readFile(targetPath, "utf8")).resolves.toBe("#!/bin/sh\necho hello\n"); + expect(chmodMock).toHaveBeenCalledTimes(1); + expect(renameMock).toHaveBeenCalledTimes(1); + expect(chmodMock.mock.calls[0]?.[0]).toContain(".paperclip-copy."); + expect(chmodMock.mock.calls[0]?.[0]).not.toBe(targetPath); + expect(chmodMock.mock.invocationCallOrder[0]).toBeLessThan(renameMock.mock.invocationCallOrder[0]); + }); + + it("cleans up a staged sibling when chmod fails before rename", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-copy-cleanup-")); + cleanupDirs.push(rootDir); + const sourceDir = path.join(rootDir, "source"); + const targetDir = path.join(rootDir, "target"); + const relativePath = path.join("nested", "script.sh"); + const sourcePath = path.join(sourceDir, relativePath); + const targetPath = path.join(targetDir, relativePath); + await mkdir(path.dirname(sourcePath), { recursive: true }); + await writeFile(sourcePath, "#!/bin/sh\necho hello\n", { mode: 0o600 }); + await mkdir(targetDir, { recursive: true }); + + const chmodMock = vi.mocked(fsPromises.chmod); + const renameMock = vi.mocked(fsPromises.rename); + chmodMock.mockClear(); + renameMock.mockClear(); + chmodMock.mockImplementationOnce(async () => { + throw new Error("chmod failed"); + }); + + await expect(mirrorDirectory(sourceDir, targetDir)).rejects.toThrow(/chmod failed/); + + expect(chmodMock).toHaveBeenCalledTimes(1); + expect(renameMock).not.toHaveBeenCalled(); + const stagedPath = chmodMock.mock.calls[0]?.[0]; + expect(stagedPath).toContain(".paperclip-copy."); + await expect(readFile(stagedPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(targetPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("syncs workspace and assets through a provider-neutral sandbox client", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-managed-")); cleanupDirs.push(rootDir); diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index 9eae6aadf3..bbf708021f 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -1,4 +1,5 @@ import { execFile as execFileCallback } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { constants as fsConstants, promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -137,6 +138,35 @@ export interface SandboxSyncFileMapping { followSymlinks?: boolean; } +/** + * A control command run against the sandbox after a sync operation's files have + * landed. Mirrors the plugin SDK `PluginPostUploadCommand`; kept as a local + * structural type so `adapter-utils` does not depend on the plugin SDK. Ordered + * within {@link SandboxSyncOperation.postUploadCommands} and executed in array + * order, fail-fast (first non-zero exit or timeout aborts the operation). + * + * SECURITY — command origin (Stage-1 design review, condition C1). `command` is + * a **Paperclip/adapter-authored control operation**: it may be supplied ONLY by + * core/adapter code. No server route, issue/comment content, project/workspace + * file content, provider-plugin callback, or arbitrary adapter config may supply + * a raw `command` string; any path embedded in it MUST be built by adapter/core + * helpers from already-confined paths and shell-quoted (C3). Providers treat the + * command as **opaque** — execute or reject, never rewrite/concatenate/append. + */ +export interface SandboxPostUploadCommand { + /** The opaque, adapter-authored shell command to run after upload. */ + command: string; + /** + * Working directory for the command. When present, MUST be an absolute POSIX + * path confined under the operation's allowed sandbox target root (C2). When + * absent, defaults to the runtime's stable command cwd — never a process + * default cwd. + */ + cwd?: string; + /** Optional per-command timeout in milliseconds. */ + timeoutMs?: number; +} + /** * An ordered, opaque unit of work handed to the native sync transport. The * `operationId` is an opaque, non-sensitive token authored by the orchestrator @@ -146,6 +176,13 @@ export interface SandboxSyncFileMapping { export interface SandboxSyncOperation { operationId: string; files: SandboxSyncFileMapping[]; + /** + * Optional ordered control commands run after this operation's files land, in + * array order, fail-fast. Absent means "no commands" — byte-identical to a + * pre-contract operation. See {@link SandboxPostUploadCommand} for the command + * origin/confinement security contract (C1–C4). + */ + postUploadCommands?: SandboxPostUploadCommand[]; } export interface SandboxSyncResult { @@ -244,6 +281,10 @@ function buildDefaultExtractRuntimeAssetCommand(input: { `rm -f ${shellQuote(input.remoteAssetTar)}`; } +function buildUniqueStagingPath(input: { targetPath: string; suffix: string }): string { + return `${input.targetPath}${input.suffix}.${randomUUID()}`; +} + export function parseSandboxRemoteExecutionSpec(value: unknown): SandboxRemoteExecutionSpec | null { const parsed = asObject(value); const transport = asString(parsed.transport).trim(); @@ -314,7 +355,7 @@ async function execTar(args: string[]): Promise { }); } -async function createTarballFromDirectory(input: { +export async function createTarballFromDirectory(input: { localDir: string; archivePath: string; exclude?: string[]; @@ -397,10 +438,17 @@ async function copyWorkspaceEntry(sourceRoot: string, targetRoot: string, relati return; } - await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_FICLONE).catch(async () => { - await fs.copyFile(sourcePath, targetPath); - }); - await fs.chmod(targetPath, stats.mode); + const stagedTargetPath = buildUniqueStagingPath({ targetPath, suffix: ".paperclip-copy" }); + await fs.rm(stagedTargetPath, { recursive: true, force: true }).catch(() => undefined); + try { + await fs.copyFile(sourcePath, stagedTargetPath, fsConstants.COPYFILE_FICLONE).catch(async () => { + await fs.copyFile(sourcePath, stagedTargetPath); + }); + await fs.chmod(stagedTargetPath, stats.mode); + await fs.rename(stagedTargetPath, targetPath); + } finally { + await fs.rm(stagedTargetPath, { recursive: true, force: true }).catch(() => undefined); + } } export async function mirrorDirectory( diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 631ccd0a01..d9b78d416b 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -693,6 +693,41 @@ export interface PluginSyncFileMapping { followSymlinks?: boolean; } +/** + * A single control command run against the sandbox after a sync operation's + * files have landed. Ordered within {@link PluginSyncOperation.postUploadCommands} + * and executed in array order, fail-fast (the first non-zero exit or timeout + * aborts the operation). + * + * SECURITY — command origin (Stage-1 design review, condition C1). `command` is + * a **Paperclip/adapter-authored control operation**: it may be supplied ONLY by + * core/adapter code. No server route, issue/comment content, project/workspace + * file content, provider-plugin callback, or arbitrary adapter config may supply + * a raw `command` string, and any path embedded in it MUST be built by + * adapter/core helpers from already-confined paths and shell-quoted (C3). A + * provider MUST treat the command as **opaque**: it may execute or reject it, but + * MUST NOT rewrite, concatenate, or append provider-decided shell fragments to + * it. + */ +export interface PluginPostUploadCommand { + /** + * The opaque, adapter-authored shell command to run after upload. Executed + * verbatim by the provider (never rewritten/concatenated). See the security + * note above. + */ + command: string; + /** + * Working directory for the command. When present, MUST be an absolute POSIX + * path confined under the operation's allowed sandbox target root (condition + * C2); providers re-validate it before exec. When absent, the provider + * defaults to the resolved sync remote/runtime root — never a process default + * cwd. + */ + cwd?: string; + /** Optional per-command timeout in milliseconds. */ + timeoutMs?: number; +} + /** * An ordered, opaque unit of work handed to a sync hook. The `operationId` is an * opaque, non-sensitive token authored by the orchestrator; a provider MUST NOT @@ -701,6 +736,13 @@ export interface PluginSyncFileMapping { export interface PluginSyncOperation { operationId: string; files: PluginSyncFileMapping[]; + /** + * Optional ordered control commands run after this operation's files land, in + * array order, fail-fast. Absent means "no commands" — byte-identical to a + * pre-contract operation. See {@link PluginPostUploadCommand} for the command + * origin/confinement security contract (C1–C4). + */ + postUploadCommands?: PluginPostUploadCommand[]; } export interface PluginEnvironmentSyncInParams extends PluginEnvironmentDriverBaseParams { diff --git a/packages/plugins/sdk/tests/environment-sync-negotiation.test.ts b/packages/plugins/sdk/tests/environment-sync-negotiation.test.ts index 512f60cfd4..786ca64d98 100644 --- a/packages/plugins/sdk/tests/environment-sync-negotiation.test.ts +++ b/packages/plugins/sdk/tests/environment-sync-negotiation.test.ts @@ -166,6 +166,66 @@ describe("environment sync verb negotiation", () => { } }); + it("test_sync_in_forwards_post_upload_commands_to_plugin_hook", async () => { + // Phase 1 (PAP-3222): the optional ordered `postUploadCommands` must survive + // the host→worker JSON-RPC hop to `onEnvironmentSyncIn` UNCHANGED — same + // order, same fields — and an operation that omits the field must arrive with + // it `undefined` (byte-identical to a pre-contract operation). + const received: PluginEnvironmentSyncInParams["operations"][] = []; + const worker = startTestWorker( + definePlugin({ + async setup() {}, + async onEnvironmentSyncIn(params): Promise { + received.push(params.operations); + return { + operations: params.operations.map((op) => ({ + operationId: op.operationId, + filesTransferred: op.files.length, + bytesTransferred: 0, + })), + }; + }, + }), + ); + try { + await worker.callWorker("initialize", { manifest: MANIFEST, config: {}, databaseNamespace: null }); + const inParams: PluginEnvironmentSyncInParams = { + driverKey: "sandbox", + companyId: "company", + environmentId: "env", + config: {}, + lease: { providerLeaseId: "lease-1" }, + operations: [ + { + operationId: "op-with-commands", + files: [{ sourcePath: "/host/a", targetPath: "/remote/a", kind: "directory" }], + postUploadCommands: [ + { command: "tar -xf /remote/a.tar -C /remote/a" }, + { command: "merge-auth /remote/a", cwd: "/remote/a", timeoutMs: 30_000 }, + ], + }, + { + operationId: "op-without-commands", + files: [{ sourcePath: "/host/b", targetPath: "/remote/b", kind: "directory" }], + }, + ], + }; + await worker.callWorker("environmentSyncIn", inParams); + + expect(received).toHaveLength(1); + const [withCommands, withoutCommands] = received[0]; + // Present: forwarded unchanged, order preserved, no rewriting. + expect(withCommands.postUploadCommands).toEqual([ + { command: "tar -xf /remote/a.tar -C /remote/a" }, + { command: "merge-auth /remote/a", cwd: "/remote/a", timeoutMs: 30_000 }, + ]); + // Absent: arrives undefined (backward compatible). + expect(withoutCommands.postUploadCommands).toBeUndefined(); + } finally { + worker.stop(); + } + }); + it("throws METHOD_NOT_IMPLEMENTED when the sync hooks are absent", async () => { const worker = startTestWorker(definePlugin({ async setup() {} })); try { diff --git a/packages/plugins/sdk/tests/protocol.postupload.test.ts b/packages/plugins/sdk/tests/protocol.postupload.test.ts new file mode 100644 index 0000000000..b75874f88c --- /dev/null +++ b/packages/plugins/sdk/tests/protocol.postupload.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import type { PluginPostUploadCommand, PluginSyncOperation } from "../src/protocol.js"; + +// Phase 1 contract test (PAP-3222 / PAP-3159 #2+#4): the sync operation carries +// an OPTIONAL, ordered `postUploadCommands` array whose absence is +// indistinguishable from today's behavior. The field is a structured control +// command — `{ command; cwd?; timeoutMs? }` — never free interpolation, and per +// Security Condition C1 may be authored ONLY by Paperclip/adapter code. +describe("PluginSyncOperation.postUploadCommands", () => { + it("test_plugin_sync_operation_carries_ordered_post_upload_commands", () => { + const commands: PluginPostUploadCommand[] = [ + { command: "tar -xf /runtime/asset.tar -C /runtime/asset" }, + { command: "merge-auth /runtime/asset", cwd: "/runtime/asset", timeoutMs: 30_000 }, + ]; + const operation: PluginSyncOperation = { + operationId: "sync-op-1", + files: [ + { sourcePath: "/host/asset", targetPath: "/runtime/asset", kind: "directory" }, + ], + postUploadCommands: commands, + }; + + // The field is present, is the SAME ordered array we supplied, and preserves + // order (no reordering, no rewriting of the opaque command strings). + expect(operation.postUploadCommands).toBeDefined(); + expect(operation.postUploadCommands).toHaveLength(2); + expect(operation.postUploadCommands?.[0]?.command).toBe( + "tar -xf /runtime/asset.tar -C /runtime/asset", + ); + expect(operation.postUploadCommands?.[1]).toEqual({ + command: "merge-auth /runtime/asset", + cwd: "/runtime/asset", + timeoutMs: 30_000, + }); + }); + + it("absent postUploadCommands is undefined (backward compatible)", () => { + const operation: PluginSyncOperation = { + operationId: "sync-op-2", + files: [{ sourcePath: "/host/a", targetPath: "/runtime/a", kind: "directory" }], + }; + expect(operation.postUploadCommands).toBeUndefined(); + }); +}); diff --git a/server/src/__tests__/environment-execution-target.test.ts b/server/src/__tests__/environment-execution-target.test.ts index dadcea97d9..6255e846f4 100644 --- a/server/src/__tests__/environment-execution-target.test.ts +++ b/server/src/__tests__/environment-execution-target.test.ts @@ -258,12 +258,16 @@ describe("resolveEnvironmentExecutionTarget", () => { }); const runner = (target as { runner?: { + supportsSingleStreamStdinProgress?: boolean; execCount(): number; providerExecMs(): number; providerGetMs(): number; execute(input: { command: string; args?: string[] }): Promise; } }).runner; expect(runner).toBeTruthy(); + // Single-stream stdin upload is enabled (research A1 / PAP-3159 #2): a + // ≤96 MiB writeFile collapses to one round-trip. + expect(runner!.supportsSingleStreamStdinProgress).toBe(false); expect(runner!.execCount()).toBe(0); expect(runner!.providerExecMs()).toBe(0); expect(runner!.providerGetMs()).toBe(0); diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index e609646ef4..8e1828d07a 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -92,6 +92,10 @@ export async function resolveEnvironmentExecutionTarget(input: { streamRunLogs: parsed.config.streamRunLogs !== false, runner: input.environmentRuntime && input.lease ? { + // Provider-backed sandbox RPCs do not surface bounded mid-stream + // progress for a single stdin upload, so keep the capability disabled + // here. The client falls back to the chunked upload path when this is + // false. supportsSingleStreamStdinProgress: false, // Round-trip counter + provider-duration accumulators on the single // host→sandbox exec seam (Open Q1). `measureStartupStep` reads the