From 0ccba45e4d21091ee488203e260509214ec24142 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Mon, 27 Jul 2026 21:17:16 -0700 Subject: [PATCH] feat(sandbox-providers): native providers honor postUploadCommands (Daytona executes; Kubernetes executes) (#10347) ## Thinking Path > - Paperclip is the control plane for autonomous AI companies, so runtime handoffs have to preserve the exact behavior an agent asked for. > - The sandbox-provider layer is where uploaded files and follow-up commands become a real in-sandbox operation. > - The provider-delegable sync-in seam already exists from the prior PR; without this follow-up, native providers can still accept command-bearing uploads and silently drop the commands. > - That is a fail-open gap for native sandbox providers, because the file transfer succeeds while the intended post-upload work never runs. > - This pull request teaches Daytona and Kubernetes to execute `postUploadCommands` in order, inside the sandbox, after the files land. > - The benefit is consistent and safer sync semantics: native providers either run the commands as requested or fail fast instead of pretending the operation completed fully. ## Linked Issues or Issue Description This PR builds on the previously merged provider-delegable sync-in seam and closes the remaining gap for native providers that still dropped `postUploadCommands`. Problem: - A sync operation could include ordered `postUploadCommands`, but a native provider could finish the file upload and skip the commands entirely. - That creates fail-open behavior for command-bearing uploads, especially when the caller relies on the provider to execute the follow-up action in the sandbox. Proposed fix: - Execute `postUploadCommands` inside the sandbox after file placement. - Preserve the provided command order. - Fail fast on the first non-zero exit or timeout. - Keep command execution verbatim and confine any provided `cwd` under the workspace root. Related public PR: - Refs: #10340 ## What Changed - Daytona `performSyncIn` now executes ordered `postUploadCommands` through the existing `executeCommand` seam. - Kubernetes `performSyncIn` now executes ordered `postUploadCommands` through its streaming pod exec path. - Added workspace confinement for provided `cwd` values and defaulted missing `cwd` to the remote root. - Added tests covering the new post-upload command execution behavior in both provider packages. ## Verification - Latest validation recorded on the handoff branch: `@paperclipai/plugin-sdk` and `@paperclipai/plugin-kubernetes` typechecks passed. - Daytona Vitest: `63/63` passing in `plugin.test.ts`. - Kubernetes Vitest: `195/195` passing, including `file-sync.test.ts`. - `git log --oneline origin/master..HEAD` showed a single expected commit on the branch. ## Risks - Command execution semantics are stricter now, so malformed commands or a bad `cwd` will fail the sync instead of being ignored. - The change makes provider behavior more explicit, which can surface previously hidden failures in callers that assumed commands were optional. - Timeout behavior may differ slightly between providers, so the failure mode is intentionally fail-fast. ## Model Used OpenAI Codex, GPT-5-based coding agent with tool use enabled. ## 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 --- .../daytona/src/file-sync.ts | 75 +++++++ .../daytona/src/plugin.test.ts | 203 ++++++++++++++++++ .../kubernetes/src/file-sync.ts | 75 +++++++ .../kubernetes/test/unit/file-sync.test.ts | 197 +++++++++++++++++ packages/plugins/sdk/src/index.ts | 1 + 5 files changed, 551 insertions(+) diff --git a/packages/plugins/sandbox-providers/daytona/src/file-sync.ts b/packages/plugins/sandbox-providers/daytona/src/file-sync.ts index df406dfbc1..e8f5c5bb0a 100644 --- a/packages/plugins/sandbox-providers/daytona/src/file-sync.ts +++ b/packages/plugins/sandbox-providers/daytona/src/file-sync.ts @@ -7,12 +7,18 @@ import { promisify } from "node:util"; import type { FileDownloadRequest, FileDownloadResponse, FileUpload, Sandbox } from "@daytonaio/sdk"; import type { PluginEnvironmentSyncResult, + PluginPostUploadCommand, PluginSyncFileMapping, PluginSyncOperation, } from "@paperclipai/plugin-sdk"; const execFileAsync = promisify(execFile); +/** Convert a millisecond timeout to the whole-seconds value the Daytona SDK expects. */ +function toTimeoutSeconds(timeoutMs: number): number { + return Math.max(1, Math.ceil(timeoutMs / 1000)); +} + // Reserved scratch-name stem for staged uploads/downloads and remote tarballs. // The runtime's base64 fallback stages to `.paperclip-upload`; the native // transport reuses the same reserved prefix so a provider temp never collides @@ -549,6 +555,65 @@ async function syncInDirectoryMapping(input: { }); } +/** + * Execute an operation's ordered `postUploadCommands` in-sandbox AFTER its files + * have landed (Phase 3 / Security Conditions C1–C4). Commands run in array order, + * fail-fast: the first non-zero exit or timeout throws and stops the rest — no + * silent partial fallback (C4). Each `command` string is executed VERBATIM via the + * exec seam; the provider never rewrites, concatenates, or appends a shell fragment + * to it (C1/C3) — the working directory rides `executeCommand`'s structured `cwd` + * argument, never a `cd &&` prefix on the command. Before exec, a present `cwd` is + * re-validated under the workspace remote dir with the same lexical + * ({@link assertConfinedSandboxPath}) + realpath/symlink ({@link assertSandboxPathsConfined}) + * guards used for file placement (C2): `..`, absolute-escape, and symlink-escape + * are rejected fail-closed before any command runs. An absent `cwd` defaults to the + * provider-resolved remote dir — never a process default cwd. + * + * Shared by the file- and directory-mapping paths: it runs once per operation, + * after every mapping of that operation has been placed. + */ +async function runPostUploadCommands(input: { + sandbox: Sandbox; + commands: PluginPostUploadCommand[]; + remoteDir: string; + timeoutSeconds: number; +}): Promise { + const { sandbox, commands, remoteDir, timeoutSeconds } = input; + for (const command of commands) { + // C2: re-confine the command cwd before exec. Absent → the remote dir (never a + // process default cwd); the remote dir is the confinement root itself, so only + // an explicit cwd carries untrusted input worth re-validating. + let cwd = remoteDir; + if (command.cwd != null) { + assertConfinedSandboxPath(remoteDir, command.cwd, "post-upload command cwd"); + await assertSandboxPathsConfined({ + sandbox, + remoteDir, + paths: [command.cwd], + timeoutSeconds, + label: "post-upload command cwd symlink-escape guard", + }); + cwd = command.cwd; + } + // C1/C3: run the command VERBATIM with a structured cwd (no string rewrite). + // C4: first non-zero exit or timeout throws and aborts the remaining commands. + const commandTimeoutSeconds = + command.timeoutMs != null ? toTimeoutSeconds(command.timeoutMs) : timeoutSeconds; + const result = await sandbox.process.executeCommand( + command.command, + cwd, + undefined, + commandTimeoutSeconds, + ); + if ((result.exitCode ?? 1) !== 0) { + const detail = (result.result ?? result.artifacts?.stdout ?? "").toString().trim(); + throw new Error( + `Daytona post-upload command failed (exit ${result.exitCode ?? "unknown"})${detail ? `: ${detail}` : ""}`, + ); + } + } +} + export async function performSyncIn(input: { sandbox: Sandbox; operations: PluginSyncOperation[]; @@ -583,6 +648,16 @@ export async function performSyncIn(input: { bytesTransferred += dirResult.bytesTransferred; } + // Run the operation's ordered post-upload commands AFTER every file/directory + // mapping of this operation has landed (Phase 3 / C1–C4). Absent/empty → no + // extra exec, byte-identical to a pre-contract operation. + await runPostUploadCommands({ + sandbox: input.sandbox, + commands: operation.postUploadCommands ?? [], + remoteDir: input.remoteDir, + timeoutSeconds: input.timeoutSeconds, + }); + operations.push({ operationId: operation.operationId, filesTransferred, bytesTransferred }); } return { operations }; diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index fe33be90cb..148a2366f0 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -2762,6 +2762,209 @@ describe("daytona native file-sync hooks", () => { expect(linkStat.isSymbolicLink()).toBe(true); expect(await fs.readlink(path.join(restored, "shortcut"))).toBe("nested/data.txt"); }); + + // ------------------------------------------------------------------------- + // Post-upload commands (Phase 3 / Security Conditions C1–C4). Daytona runs an + // operation's ordered `postUploadCommands` in-sandbox AFTER `uploadFiles`, + // fail-fast, with the command `cwd` re-confined under the workspace remote dir. + // ------------------------------------------------------------------------- + + it("runs post-upload commands in array order AFTER uploadFiles, each verbatim via the exec seam", async () => { + const hostDir = await makeHostDir(); + const source = path.join(hostDir, "config.txt"); + await fs.writeFile(source, "plain"); + + const sandbox = createMockSandbox(); + mockGet.mockResolvedValue(sandbox); + + await plugin.definition.onEnvironmentSyncIn?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + config: { timeoutMs: 300000, reuseLease: false }, + lease: syncLease(), + operations: [ + { + operationId: "op-cmd", + files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }], + postUploadCommands: [ + { command: "codex-auth-merge --first" }, + { command: "chmod 600 config.txt" }, + ], + }, + ], + }); + + // Both commands ran, VERBATIM (first arg is the exact authored string — the + // provider never rewrote/concatenated a shell fragment onto it: C1/C3). + const findCall = (cmd: string) => + sandbox.process.executeCommand.mock.calls.find(([c]: [string]) => c === cmd); + expect(findCall("codex-auth-merge --first")).toBeDefined(); + expect(findCall("chmod 600 config.txt")).toBeDefined(); + + // Ordered: the first command's exec precedes the second's (C4 array order). + const orderOf = (cmd: string) => { + const idx = sandbox.process.executeCommand.mock.calls.findIndex(([c]: [string]) => c === cmd); + return sandbox.process.executeCommand.mock.invocationCallOrder[idx]; + }; + expect(orderOf("codex-auth-merge --first")).toBeLessThan(orderOf("chmod 600 config.txt")); + + // Upload happened BEFORE the first command. + expect(sandbox.fs.uploadFiles.mock.invocationCallOrder[0]).toBeLessThan( + orderOf("codex-auth-merge --first"), + ); + + // Absent `cwd` defaults to the provider-resolved remote dir — never a process + // default cwd (C2). The command's structured cwd argument is REMOTE_DIR. + expect(findCall("codex-auth-merge --first")?.[1]).toBe(REMOTE_DIR); + }); + + it("aborts the operation fail-loud on a non-zero post-upload command exit, skipping the remainder (C4)", async () => { + const hostDir = await makeHostDir(); + const source = path.join(hostDir, "config.txt"); + await fs.writeFile(source, "plain"); + + const sandbox = createMockSandbox(); + // The first command exits non-zero; every transfer/guard script stays green. + sandbox.process.executeCommand.mockImplementation(async (command: string) => { + if (command === "failing-command") { + return { exitCode: 7, result: "boom", artifacts: { stdout: "boom" } }; + } + return { exitCode: 0, result: "", artifacts: { stdout: "" } }; + }); + mockGet.mockResolvedValue(sandbox); + + await expect( + plugin.definition.onEnvironmentSyncIn?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + config: { timeoutMs: 300000, reuseLease: false }, + lease: syncLease(), + operations: [ + { + operationId: "op-fail", + files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }], + postUploadCommands: [{ command: "failing-command" }, { command: "should-not-run" }], + }, + ], + }), + ).rejects.toThrow(/post-upload command failed \(exit 7\)/); + + // Fail-fast: the command after the failing one never executed. + expect( + sandbox.process.executeCommand.mock.calls.some(([c]: [string]) => c === "should-not-run"), + ).toBe(false); + }); + + it("rejects a post-upload command cwd that escapes the remote dir lexically, before any exec (C2)", async () => { + const hostDir = await makeHostDir(); + const source = path.join(hostDir, "config.txt"); + await fs.writeFile(source, "plain"); + + for (const badCwd of [`${REMOTE_DIR}/../etc`, "/etc"]) { + const sandbox = createMockSandbox(); + mockGet.mockResolvedValue(sandbox); + await expect( + plugin.definition.onEnvironmentSyncIn?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + config: { timeoutMs: 300000, reuseLease: false }, + lease: syncLease(), + operations: [ + { + operationId: "op-escape", + files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }], + postUploadCommands: [{ command: "run-me", cwd: badCwd }], + }, + ], + }), + ).rejects.toThrow(/not a confined absolute path|escapes the workspace remote dir/); + // The command never ran — lexical confinement rejected it before exec. + expect(sandbox.process.executeCommand.mock.calls.some(([c]: [string]) => c === "run-me")).toBe( + false, + ); + } + }); + + it("rejects a post-upload command whose cwd resolves outside the root via a symlink (realpath guard, C2)", async () => { + const hostDir = await makeHostDir(); + const source = path.join(hostDir, "config.txt"); + await fs.writeFile(source, "plain"); + + const cwd = `${REMOTE_DIR}/link`; + const sandbox = createMockSandbox(); + // The in-sandbox realpath symlink-escape guard for THIS cwd fails closed (exit + // 42), simulating a sandbox-planted symlink that resolves out of root. + sandbox.process.executeCommand.mockImplementation(async (command: string) => { + if (command.includes("_pc_resolve") && command.includes(cwd)) { + return { exitCode: 42, result: "ESCAPE", artifacts: { stdout: "ESCAPE" } }; + } + return { exitCode: 0, result: "", artifacts: { stdout: "" } }; + }); + mockGet.mockResolvedValue(sandbox); + + await expect( + plugin.definition.onEnvironmentSyncIn?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + config: { timeoutMs: 300000, reuseLease: false }, + lease: syncLease(), + operations: [ + { + operationId: "op-symlink", + files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }], + postUploadCommands: [{ command: "run-me", cwd }], + }, + ], + }), + ).rejects.toThrow(/symlink-escape guard|command failed/i); + expect(sandbox.process.executeCommand.mock.calls.some(([c]: [string]) => c === "run-me")).toBe( + false, + ); + }); + + it("issues no extra exec when an operation has no post-upload commands (backward-compat)", async () => { + const hostDir = await makeHostDir(); + const source = path.join(hostDir, "config.txt"); + await fs.writeFile(source, "plain"); + + const baseline = createMockSandbox(); + mockGet.mockResolvedValue(baseline); + await plugin.definition.onEnvironmentSyncIn?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + config: { timeoutMs: 300000, reuseLease: false }, + lease: syncLease(), + operations: [ + { operationId: "op-plain", files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }] }, + ], + }); + const baselineExecCount = baseline.process.executeCommand.mock.calls.length; + + // Same operation, now with an (empty) postUploadCommands array — must be + // byte-identical: an absent/empty command list adds zero execs. + const withEmpty = createMockSandbox(); + mockGet.mockResolvedValue(withEmpty); + await plugin.definition.onEnvironmentSyncIn?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + config: { timeoutMs: 300000, reuseLease: false }, + lease: syncLease(), + operations: [ + { + operationId: "op-plain", + files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }], + postUploadCommands: [], + }, + ], + }); + expect(withEmpty.process.executeCommand.mock.calls.length).toBe(baselineExecCount); + }); }); describe("daytona manifest memory config", () => { diff --git a/packages/plugins/sandbox-providers/kubernetes/src/file-sync.ts b/packages/plugins/sandbox-providers/kubernetes/src/file-sync.ts index da259cbab4..f482efd769 100644 --- a/packages/plugins/sandbox-providers/kubernetes/src/file-sync.ts +++ b/packages/plugins/sandbox-providers/kubernetes/src/file-sync.ts @@ -54,6 +54,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { PluginEnvironmentSyncResult, + PluginPostUploadCommand, PluginSyncFileMapping, PluginSyncOperation, } from "@paperclipai/plugin-sdk"; @@ -574,6 +575,70 @@ async function syncInDirectoryMapping(input: { }); } +/** + * Execute an operation's ordered `postUploadCommands` in the pod AFTER its files + * have landed (Phase 3 / Security Conditions C1–C4 + C7). Kubernetes is a native + * provider, and once the inbound-staging router stops diverting command-bearing + * (custom-provision) assets away from the native seam, a K8s deployment receives + * these operations — so it EXECUTES them symmetrically with Daytona rather than + * silently dropping them (C7 fail-open). + * + * Commands run in array order, fail-fast: the first non-zero exit or timeout throws + * and stops the rest (C4). Each `command` is executed VERBATIM: it rides as a + * positional argument (`$1`) to a fixed wrapper, run by `sh -c "$1"`, and is never + * string-concatenated into the wrapper — so the provider adds no shell fragment of + * its own (C1/C3), exactly as the generic fallback runs `sh -c `. Before + * the command runs, the wrapper re-confines the command `cwd` under the workspace + * remote dir with the same realpath + `/proc/self/fd`-pinned open used for file + * placement (C2): a `..`, absolute-escape, or symlink-escape `cwd` is rejected + * fail-closed (exit 42) before the command runs, and the pin makes the confinement + * race-free against a post-resolve ancestor swap. `cwd` is validated lexically on + * the host first; when absent it defaults to the remote dir — never a process + * default cwd. + * + * Shared by the file- and directory-mapping paths: it runs once per operation, + * after every mapping of that operation has been placed. + */ +async function runPostUploadCommands(input: { + exec: PodStreamExec; + commands: PluginPostUploadCommand[]; + remoteDir: string; + timeoutMs: number; +}): Promise { + const { exec, commands, remoteDir, timeoutMs } = input; + for (const command of commands) { + const cwd = command.cwd ?? remoteDir; + // C2 lexical guard on the host before the pod ever sees the cwd. + assertConfinedSandboxPath(remoteDir, cwd, "post-upload command cwd"); + // The wrapper confines `cwd` ($1) through a realpath + /proc/self/fd pin, cd's + // into the pinned inode, then execs the VERBATIM command ($2). Both `cwd` and + // the command ride as positional parameters — the wrapper interpolates NEITHER + // into its own script text, so the command runs byte-for-byte as authored. + const wrapper = [ + ...canonicalizerPreamble(shQuote(remoteDir)), + `_pc_real=$(_pc_resolve "$1") || { echo "ESCAPE" >&2; exit 42; };`, + `case "$_pc_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE" >&2; exit 42 ;; esac;`, + `exec 9<"$_pc_real" || { echo "open failed" >&2; exit 46; };`, + `_pc_fd_real=$(_pc_resolve /proc/self/fd/9) || { echo "ESCAPE" >&2; exit 42; };`, + `case "$_pc_fd_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE" >&2; exit 42 ;; esac;`, + `cd /proc/self/fd/9 || { echo "cd failed" >&2; exit 46; };`, + `exec /bin/sh -c "$2" pc-post-upload;`, + ].join("\n"); + const commandTimeoutMs = command.timeoutMs ?? timeoutMs; + // Positional args: $0=pc-post-upload, $1=cwd, $2=the verbatim command string. + const result = await exec(["/bin/sh", "-c", wrapper, "pc-post-upload", cwd, command.command], { + timeoutMs: commandTimeoutMs, + maxStderrBytes: SYNC_STDERR_CAP_BYTES, + }); + if (result.exitCode !== 0) { + const detail = (result.stderr || "").trim(); + throw new Error( + `Kubernetes post-upload command failed (exit ${result.exitCode})${detail ? `: ${detail}` : ""}`, + ); + } + } +} + export async function performSyncIn(input: { exec: PodStreamExec; operations: PluginSyncOperation[]; @@ -608,6 +673,16 @@ export async function performSyncIn(input: { bytesTransferred += dirResult.bytesTransferred; } + // Run the operation's ordered post-upload commands AFTER every file/directory + // mapping of this operation has landed (Phase 3 / C1–C4 + C7). Absent/empty → + // no extra exec, byte-identical to a pre-contract operation. + await runPostUploadCommands({ + exec: input.exec, + commands: operation.postUploadCommands ?? [], + remoteDir: input.remoteDir, + timeoutMs: input.timeoutMs, + }); + operations.push({ operationId: operation.operationId, filesTransferred, bytesTransferred }); } return { operations }; diff --git a/packages/plugins/sandbox-providers/kubernetes/test/unit/file-sync.test.ts b/packages/plugins/sandbox-providers/kubernetes/test/unit/file-sync.test.ts index 1c2a9dafc3..b4b1fafde9 100644 --- a/packages/plugins/sandbox-providers/kubernetes/test/unit/file-sync.test.ts +++ b/packages/plugins/sandbox-providers/kubernetes/test/unit/file-sync.test.ts @@ -559,3 +559,200 @@ describe("kubernetes onEnvironmentSyncOut (native single-exec transfer)", () => await expect(fs.stat(target)).rejects.toThrow(); }); }); + +// --------------------------------------------------------------------------- +// Post-upload commands (Phase 3 / Security Conditions C1–C4 + C7). Kubernetes is +// a native provider, so it EXECUTES an operation's ordered `postUploadCommands` +// symmetrically with Daytona after `uploadFiles` — never silently dropping them +// (C7). Each command runs in the pod over its own exec, fail-fast, with the `cwd` +// re-confined under the workspace remote dir. The injected exec runs the real +// host shell, so these assertions observe true command side-effects. +// --------------------------------------------------------------------------- +describe("kubernetes onEnvironmentSyncIn (post-upload commands)", () => { + it("runs post-upload commands in array order AFTER the upload, each verbatim as a positional arg", async () => { + const remoteDir = await makeTmp("k8s-sandbox-"); + const host = await makeTmp("k8s-host-"); + const src = path.join(host, "config.txt"); + await fs.writeFile(src, "hello"); + + const { exec, calls } = makeRealExec(); + await performSyncIn({ + exec, + remoteDir, + timeoutMs: 30_000, + operations: [ + { + operationId: "op-cmd", + files: [{ sourcePath: src, targetPath: path.join(remoteDir, "config.txt"), kind: "file" }], + // First command reads the just-uploaded file (proves upload-before-command); + // second appends (proves array order). cwd absent → the remote dir. + postUploadCommands: [ + { command: "cat config.txt > out.txt" }, + { command: "printf DONE >> out.txt" }, + ], + }, + ], + }); + + // Upload-before-commands AND order: out.txt is the first command's read of the + // uploaded bytes, then the second command's append. + expect(await fs.readFile(path.join(remoteDir, "out.txt"), "utf-8")).toBe("helloDONE"); + + // One exec for the transfer, then one exec per command (single-exec-per-command). + expect(calls).toHaveLength(3); + // C1/C3: each command rides as the FINAL positional argument, byte-for-byte + // unmutated — the provider concatenated no shell fragment onto it. + const cmdCalls = calls.filter((c) => c.command.includes("cat config.txt > out.txt") || c.command.includes("printf DONE >> out.txt")); + expect(cmdCalls).toHaveLength(2); + expect(cmdCalls[0].command[cmdCalls[0].command.length - 1]).toBe("cat config.txt > out.txt"); + expect(cmdCalls[1].command[cmdCalls[1].command.length - 1]).toBe("printf DONE >> out.txt"); + // Absent cwd defaults to the remote dir (the penultimate positional arg), never + // a process default cwd (C2). + expect(cmdCalls[0].command[cmdCalls[0].command.length - 2]).toBe(remoteDir); + }); + + it("runs a command in an explicit confined cwd", async () => { + const remoteDir = await makeTmp("k8s-sandbox-"); + const host = await makeTmp("k8s-host-"); + const src = path.join(host, "seed.txt"); + await fs.writeFile(src, "x"); + const subDir = path.join(remoteDir, "sub"); + + const { exec } = makeRealExec(); + await performSyncIn({ + exec, + remoteDir, + timeoutMs: 30_000, + operations: [ + { + operationId: "op-cwd", + files: [{ sourcePath: src, targetPath: path.join(subDir, "seed.txt"), kind: "file" }], + postUploadCommands: [{ command: "pwd -P > where.txt", cwd: subDir }], + }, + ], + }); + // The command ran with its cwd = subDir: `where.txt` lands there (relative + // write), and its physical cwd (`pwd -P`) is the realpath of subDir. + expect((await fs.readFile(path.join(subDir, "where.txt"), "utf-8")).trim()).toBe( + await fs.realpath(subDir), + ); + }); + + it("aborts the operation fail-loud on a non-zero post-upload command exit, skipping the remainder (C4)", async () => { + const remoteDir = await makeTmp("k8s-sandbox-"); + const host = await makeTmp("k8s-host-"); + const src = path.join(host, "config.txt"); + await fs.writeFile(src, "hello"); + + const { exec } = makeRealExec(); + await expect( + performSyncIn({ + exec, + remoteDir, + timeoutMs: 30_000, + operations: [ + { + operationId: "op-fail", + files: [{ sourcePath: src, targetPath: path.join(remoteDir, "config.txt"), kind: "file" }], + postUploadCommands: [ + { command: "exit 3" }, + { command: "touch should_not_exist.txt" }, + ], + }, + ], + }), + ).rejects.toThrow(/post-upload command failed \(exit 3\)/); + // Fail-fast: the command after the failing one never ran. + await expect(fs.stat(path.join(remoteDir, "should_not_exist.txt"))).rejects.toThrow(); + }); + + it("rejects a post-upload command cwd that escapes the remote dir lexically, before any exec (C2)", async () => { + const remoteDir = await makeTmp("k8s-sandbox-"); + const host = await makeTmp("k8s-host-"); + const src = path.join(host, "config.txt"); + await fs.writeFile(src, "hello"); + + for (const badCwd of [`${remoteDir}/../escape`, "/etc"]) { + const { exec, calls } = makeRealExec(); + await expect( + performSyncIn({ + exec, + remoteDir, + timeoutMs: 30_000, + operations: [ + { + operationId: "op-escape", + files: [{ sourcePath: src, targetPath: path.join(remoteDir, "config.txt"), kind: "file" }], + postUploadCommands: [{ command: "touch pwned.txt", cwd: badCwd }], + }, + ], + }), + ).rejects.toThrow(/escapes|not a confined/); + // Rejected before the command exec: only the transfer exec ran, no command. + expect(calls.some((c) => c.command.includes("touch pwned.txt"))).toBe(false); + } + }); + + it("rejects a post-upload command whose cwd resolves outside the root via a symlink (realpath guard, C2)", async () => { + const remoteDir = await makeTmp("k8s-sandbox-"); + const host = await makeTmp("k8s-host-"); + const outside = await makeTmp("k8s-outside-"); + const src = path.join(host, "config.txt"); + await fs.writeFile(src, "hello"); + // A symlink LEXICALLY inside the root that resolves to an out-of-root dir. + await fs.symlink(outside, path.join(remoteDir, "evil")); + const cwd = path.join(remoteDir, "evil"); + + const { exec } = makeRealExec(); + await expect( + performSyncIn({ + exec, + remoteDir, + timeoutMs: 30_000, + operations: [ + { + operationId: "op-symlink", + files: [{ sourcePath: src, targetPath: path.join(remoteDir, "config.txt"), kind: "file" }], + postUploadCommands: [{ command: "touch pwned.txt", cwd }], + }, + ], + }), + ).rejects.toThrow(/ESCAPE|exit 42/); + // The command never ran through the symlink: nothing landed in the outside dir. + await expect(fs.stat(path.join(outside, "pwned.txt"))).rejects.toThrow(); + }); + + it("issues no extra exec when an operation has no post-upload commands (backward-compat)", async () => { + const remoteDir = await makeTmp("k8s-sandbox-"); + const host = await makeTmp("k8s-host-"); + const src = path.join(host, "config.txt"); + await fs.writeFile(src, "hello"); + + const { exec: baseExec, calls: baseCalls } = makeRealExec(); + await performSyncIn({ + exec: baseExec, + remoteDir, + timeoutMs: 30_000, + operations: [ + { operationId: "op-plain", files: [{ sourcePath: src, targetPath: path.join(remoteDir, "config.txt"), kind: "file" }] }, + ], + }); + + const { exec: emptyExec, calls: emptyCalls } = makeRealExec(); + await performSyncIn({ + exec: emptyExec, + remoteDir, + timeoutMs: 30_000, + operations: [ + { + operationId: "op-plain", + files: [{ sourcePath: src, targetPath: path.join(remoteDir, "config.txt"), kind: "file" }], + postUploadCommands: [], + }, + ], + }); + // An absent/empty command list adds zero execs — byte-identical to today. + expect(emptyCalls).toHaveLength(baseCalls.length); + expect(emptyCalls).toHaveLength(1); + }); +}); diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 1dabc362e3..d601080095 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -183,6 +183,7 @@ export type { PluginEnvironmentExecuteParams, PluginEnvironmentExecuteResult, PluginSyncFileMapping, + PluginPostUploadCommand, PluginSyncOperation, PluginEnvironmentSyncInParams, PluginEnvironmentSyncOutParams,