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 <noreply@paperclip.ing>
This commit is contained in:
parent
1cd09ed555
commit
0ccba45e4d
|
|
@ -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 `<path>.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<void> {
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 <command>`. 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<void> {
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -183,6 +183,7 @@ export type {
|
|||
PluginEnvironmentExecuteParams,
|
||||
PluginEnvironmentExecuteResult,
|
||||
PluginSyncFileMapping,
|
||||
PluginPostUploadCommand,
|
||||
PluginSyncOperation,
|
||||
PluginEnvironmentSyncInParams,
|
||||
PluginEnvironmentSyncOutParams,
|
||||
|
|
|
|||
Loading…
Reference in New Issue