mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## Thinking Path > - Paperclip preserves agent work across failed runs and server restarts. > - A remote workspace must have a durable save before its source can be released. > - Portable capture normally writes a Git mirror and a full archive inside the sandbox. > - A full sandbox can prevent capture before the provider's streaming download starts. > - Retrying that write cannot create the required free space. > - This change transfers a checked standalone source and seals its archive on the controller. > - The source remains retained unless the existing durable save completes. ## Linked Issues or Issue Description Related: #18644 reduced temporary capture space. #18172 added the provider's disk-free directory transfer. This repair connects portable capture to that transfer when the archive itself cannot be created. **What happened?** Workspace capture returns `remote_workspace_sandbox_disk_full` after provider work has finished. File recovery repeats the same sandbox write. **Expected behavior** Recover a stable standalone workspace without requiring another full archive on the source disk. Preserve staged changes, untracked work, confined links and the original source. **Steps to reproduce** 1. Create a standalone Git repository with a commit, staged changes and an untracked result. 2. Use the existing real-command transport fixture. Inject ENOSPC into the sandbox helper write or archive write. 3. Run portable capture. Before this change it rejects the helper failure. 4. With the change, restore the saved archive after removing the fixture source. Check the commit, staged content, untracked result and Git connectivity. ## What Changed - Attempt one controller-side capture after an exact sandbox disk-full failure. - Read a source inventory without installing a helper. Use the existing provider bulk transfer with confined links and runtime exclusions. - Compare the transferred inventory with source fingerprints before and after sealing. - Reject copied Git pointers, alternates and administrative symlinks. Parse config as data without includes; allow only portable keys before controller Git normalization. - Preserve socket exclusions. Retain the source and remove incomplete controller staging after failure. - Update execution semantics and focused regression coverage. No database migration or new execution authority. ## Verification - TDD red on base `2f35eb1080`: the new full-sandbox test failed with `remote_workspace_sandbox_disk_full` before production edits. - Green: 202 transport and archive tests passed before the additional Git configuration guards. At head `8541086cb5`, all 116 transport tests and the PostgreSQL durability acceptance pass. The final `e24e173ec9` exclusion guard passes those 116 cases plus its new Git-history regression across the full run and focused rerun. Environment: macOS arm64, Node 26.4.0; real Node/Git commands and filesystem transfer. - Review regressions were also red before their fixes: an internal Git config symlink bypassed the check, and `core.attributesFile` could select a controller path. Git administrative symlinks and nonportable config now fail before normalization. The focused safety selection passes 11 cases. - Cases cover helper and archive ENOSPC, commit/staged/untracked continuity after source removal, confined links, sockets, changed source, corrupt or extra copied files, unsafe Git metadata, retained diagnostic text and failed download. - Real PostgreSQL acceptance passes: normal workspace save recovers one sandbox ENOSPC, commits the encrypted snapshot, clears its writer, loses the original allocation, then independently reloads the saved result twice. The first acceptance attempt used an incorrect expected status (`available`); the documented state is `committed`. No production change was needed for that correction. - Review regressions for untracked Python/Node environments and excessive Git roots failed before their fixes. Dependency exclusions now match normal capture. Controller parsing starts only after whole-tree/repository budgets and layout checks; refspec/settings budgets and a shared two-minute verification/normalization deadline bound further work. The excessive-root test verifies zero Git subprocesses. - Real provider-boundary red/green: Daytona direct tar and capacity-fallback PTY transfers first dropped `._result.txt`. The new optional mapping contract preserves all user names and excludes only current transfer scratch. Kubernetes implements the same contract. Explicit caller exclusions and archive validation remain in force. - Standalone Linux qualification on Node 24.22.2/Debian bookworm: fresh shared/SDK build; Daytona and Kubernetes typecheck and build; Daytona file-sync/capacity/diagnostics 68/68 pass; Kubernetes file-sync 31/31 pass, including the actual `/proc` case. Qualification was refreshed again on the final `be6f7feacc` source tree: current shared/SDK rebuilt; all 815 staged source/config/manifest hashes match the delivered tree before and after testing. An initial container run as root bypassed a read-only file test; the same source passes as uid 1000. Installing zstd enabled eight initially skipped cases. - A further review regression proved `.git/pyvenv.cfg` could exclude Git history as a dependency. The inventory now handles `.git` before dependency filtering, matching normal capture. The full capture/restore test verifies the original commit and saved work. The first green attempt incorrectly compared command-result object identity; correcting the test to compare stdout passed with no production change. - Further red/green regressions cover read-only private-copy cleanup, a regular file replaced by a FIFO before open, case aliases of Git administrative metadata, and permission preservation under restrictive umask. Nonblocking no-follow file handles verify identity before and after reading. Cleanup changes only private controller directories. Provider extraction preserves ordinary permissions, refuses special mode bits before writing, and does not adopt source ownership, ACLs, attributes or flags. Final macOS Daytona tests pass 65/65; the final Linux qualification above was repeated on these exact provider sources. - Focused strict TypeScript compilation of the inventory and archive modules passes. - The first sandboxed archive run failed three Unix socket fixtures with EPERM. The permitted run outside that filesystem sandbox passed all tests. - Initial full local server typecheck could not resolve the unbuilt Runner package and produced cascading errors. Exact-head `e24e173ec9` CI typecheck, build, Runner and browser checks pass. That CI attempt failed two server shards: the unchanged retained-cleanup backlog test timed out at 15 seconds and passes locally unchanged; ten cache-reclaim fixture oracles failed because directory fallback consumed their sealed-archive counters/faults. Final commit `caed4e4f83` isolates the intended unavailable-directory boundary while preserving archive counts, recovery status, integrity and no-second-reclamation assertions. Those ten failures reproduced locally before the fixture edit. The full affected file passes 394/395; its sole other HTTP socket-hang-up case passes unchanged in an isolated rerun. This is combined coverage of all 395 cases, not a single complete green run. The final branch integrates master `53645a0104` at `98ea07225592930cbb0d18bcf250bc64f6bb13b4`, preserving upstream workspace phase timings. After integration: 117 transport cases, the PostgreSQL durability acceptance, the existing object-storage/timing regression, and 12 affected cache cases pass. The expanded recovery file now has 421 cases. Final-head `be6f7feacc45c1e32a26a2417e973f1e3752fd8d` CI passes all 55 applicable checks (three skipped, one neutral review), including the expanded recovery suite, typecheck, builds and Runner coverage. [CI result](https://github.com/paperclipai/paperclip/actions/runs/42251345554). - Provider exclusion regressions were red through four real Linux transfer paths: Daytona direct archive, direct safe-link repair, capacity PTY repair, and Kubernetes streaming. A root socket and nested regular file share a basename; literal glob-character files/subtrees also survive. Preserve mode now anchors tar exclusions as literal root-relative paths and escapes `find -path` pruning. Default callers keep glob semantics. Receipt-count regressions were also red before correcting preserved-output counting. All 31 Kubernetes and 68 Daytona tests, builds and typechecks pass on the final source with 815 matching inputs. - The final semantics documentation explicitly records special mode bits, the stricter config allowlist (including innocuous unlisted keys), and provider qualification limited to Daytona/Kubernetes; these address the optional current-head review observations without broadening controller Git authority. - No Daytona credential is available in this session. Provider transfer is a local filesystem boundary in these tests. Hosted task recovery and rollout remain unverified. ## Risks - Recovery reads the source repeatedly and transfers the standalone tree. It can take longer than normal capture. - Linked worktrees, initialized submodules, Git administrative symlinks, nonportable Git configuration, special mode bits and alternate object stores remain retained if the normal capture cannot finish. This fallback does not follow their pointers onto the controller. - A provider without usable directory export, a changed source, or insufficient controller space cannot produce a receipt. The original source stays retained. - The server, SDK and affected provider plugins must be built from this revision together. An older provider can fail inventory verification on names it omits; it cannot publish an incomplete save. No merge, publication or deployment is included. ## Model Used OpenAI GPT-6 family through Codex, with reasoning, code editing, shell tools and automated tests. The exact serving model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub references) - [x] My branch name describes the change 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>
917 lines
38 KiB
TypeScript
917 lines
38 KiB
TypeScript
import { createServer } from "node:net";
|
||
import { afterEach, describe, expect, it } from "vitest";
|
||
import { promises as fs } from "node:fs";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import { spawn } from "node:child_process";
|
||
import {
|
||
assertConfinedSandboxPath,
|
||
parseTarVerboseListingLine,
|
||
performSyncIn,
|
||
performSyncOut,
|
||
splitLinkEntryOnce,
|
||
type PodStreamExec,
|
||
} from "../../src/file-sync.js";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test harness
|
||
//
|
||
// The K8s hooks transfer files over a single streaming exec per operation. In
|
||
// production that exec streams raw tar bytes over the pod's exec WebSocket
|
||
// (stdin from a host file, stdout into a host file); here the injected
|
||
// `PodStreamExec` runs the generated `sh -c` script against the REAL host shell,
|
||
// piping the caller's `stdin` Readable into the child and the child's stdout into
|
||
// the caller's `stdout` Writable, using a host temp dir as the stand-in "sandbox"
|
||
// workspace root. This exercises the actual tar/head/mv/realpath command shapes
|
||
// end-to-end (a true round-trip) while recording every exec so we can assert the
|
||
// single-exec contract and command shape.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface RecordedCall {
|
||
command: string[];
|
||
script: string;
|
||
}
|
||
|
||
function makeRealExec(): { exec: PodStreamExec; calls: RecordedCall[] } {
|
||
const calls: RecordedCall[] = [];
|
||
const exec: PodStreamExec = async (command, io) => {
|
||
calls.push({ command, script: command[2] ?? "" });
|
||
return await new Promise((resolve, reject) => {
|
||
const child = spawn(command[0], command.slice(1));
|
||
let err = "";
|
||
child.stderr.on("data", (chunk: Buffer) => {
|
||
err += chunk.toString("utf-8");
|
||
});
|
||
child.on("error", reject);
|
||
if (io.stdout) {
|
||
io.stdout.on("error", reject);
|
||
child.stdout.pipe(io.stdout);
|
||
} else {
|
||
child.stdout.resume();
|
||
}
|
||
if (io.stdin) {
|
||
io.stdin.on("error", reject);
|
||
io.stdin.pipe(child.stdin);
|
||
} else {
|
||
child.stdin.end();
|
||
}
|
||
child.on("close", (code) => {
|
||
resolve({ exitCode: code ?? 0, stderr: err });
|
||
});
|
||
});
|
||
};
|
||
return { exec, calls };
|
||
}
|
||
|
||
// A stub exec that emits a controlled number of bytes to the caller's stdout
|
||
// sink without running any shell — used to drive the outbound disk-guard trip
|
||
// with a pod-authored payload larger than the host allows. Rejects if the sink
|
||
// errors (the guard fired). Records calls so a test can assert the exec ran.
|
||
function makeOutputExec(totalBytes: number): { exec: PodStreamExec; calls: RecordedCall[] } {
|
||
const calls: RecordedCall[] = [];
|
||
const exec: PodStreamExec = async (command, io) => {
|
||
calls.push({ command, script: command[2] ?? "" });
|
||
return await new Promise((resolve, reject) => {
|
||
const sink = io.stdout;
|
||
if (!sink) {
|
||
resolve({ exitCode: 0, stderr: "" });
|
||
return;
|
||
}
|
||
sink.on("error", reject);
|
||
sink.on("finish", () => resolve({ exitCode: 0, stderr: "" }));
|
||
sink.end(Buffer.alloc(totalBytes, 0x41));
|
||
});
|
||
};
|
||
return { exec, calls };
|
||
}
|
||
|
||
const tmpDirs: string[] = [];
|
||
async function makeTmp(prefix: string): Promise<string> {
|
||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||
tmpDirs.push(dir);
|
||
return dir;
|
||
}
|
||
|
||
afterEach(async () => {
|
||
await Promise.all(tmpDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||
});
|
||
|
||
describe("kubernetes file-sync path confinement", () => {
|
||
it("rejects a target path that escapes the workspace remote dir", () => {
|
||
expect(() => assertConfinedSandboxPath("/workspace", "/workspace/../etc/passwd", "target")).toThrow(
|
||
/escapes|not a confined/,
|
||
);
|
||
expect(() => assertConfinedSandboxPath("/workspace", "/etc/passwd", "target")).toThrow(
|
||
/escapes|not a confined/,
|
||
);
|
||
expect(() => assertConfinedSandboxPath("/workspace", "relative/path", "target")).toThrow(
|
||
/not a confined/,
|
||
);
|
||
});
|
||
|
||
it("accepts a target path inside the remote dir", () => {
|
||
expect(() => assertConfinedSandboxPath("/workspace", "/workspace/a/b.txt", "target")).not.toThrow();
|
||
expect(() => assertConfinedSandboxPath("/workspace", "/workspace", "target")).not.toThrow();
|
||
});
|
||
});
|
||
|
||
describe("kubernetes onEnvironmentSyncIn (native single-exec transfer)", () => {
|
||
it("transfers all file mappings of an operation in a SINGLE exec, staging to a temp then mv -f, applying secret mode 0600 with no widened window", async () => {
|
||
const remoteDir = await makeTmp("k8s-sandbox-");
|
||
const host = await makeTmp("k8s-host-");
|
||
const plainSrc = path.join(host, "plain.txt");
|
||
const secretSrc = path.join(host, "auth.json");
|
||
await fs.writeFile(plainSrc, "hello world");
|
||
await fs.writeFile(secretSrc, "{\"token\":\"s3cr3t\"}");
|
||
|
||
const { exec, calls } = makeRealExec();
|
||
const result = await performSyncIn({
|
||
exec,
|
||
remoteDir,
|
||
timeoutMs: 30_000,
|
||
operations: [
|
||
{
|
||
operationId: "op-alpha",
|
||
files: [
|
||
{ sourcePath: plainSrc, targetPath: path.join(remoteDir, "plain.txt"), kind: "file" },
|
||
{
|
||
sourcePath: secretSrc,
|
||
targetPath: path.join(remoteDir, "nested/auth.json"),
|
||
kind: "file",
|
||
mode: 0o600,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
|
||
// Single exec for the whole file operation — NOT one exec per file/chunk.
|
||
expect(calls).toHaveLength(1);
|
||
// Atomic-replace shape and raw streamed-extract shape present in the script.
|
||
expect(calls[0].script).toContain("mv -f");
|
||
expect(calls[0].script).toContain("head -c");
|
||
expect(calls[0].script).toContain("tar -xf -");
|
||
// Streaming transport: no base64 round-trip anywhere in the script.
|
||
expect(calls[0].script).not.toContain("base64");
|
||
// Files landed with correct contents.
|
||
expect(await fs.readFile(path.join(remoteDir, "plain.txt"), "utf-8")).toBe("hello world");
|
||
expect(await fs.readFile(path.join(remoteDir, "nested/auth.json"), "utf-8")).toBe(
|
||
"{\"token\":\"s3cr3t\"}",
|
||
);
|
||
// Secret landed 0600.
|
||
expect((await fs.stat(path.join(remoteDir, "nested/auth.json"))).mode & 0o777).toBe(0o600);
|
||
// No leftover reserved scratch dir in the workspace root.
|
||
const leftovers = (await fs.readdir(remoteDir)).filter((e) => e.startsWith(".paperclip-upload"));
|
||
expect(leftovers).toEqual([]);
|
||
// Per-operation counts.
|
||
expect(result.operations).toEqual([
|
||
{ operationId: "op-alpha", filesTransferred: 2, bytesTransferred: expect.any(Number) },
|
||
]);
|
||
expect(result.operations[0].bytesTransferred).toBeGreaterThan(0);
|
||
});
|
||
|
||
it("transfers a directory mapping honoring exclude, and emits tar -h only when followSymlinks is true", async () => {
|
||
const remoteDir = await makeTmp("k8s-sandbox-");
|
||
const host = await makeTmp("k8s-host-");
|
||
const srcDir = path.join(host, "tree");
|
||
await fs.mkdir(path.join(srcDir, "sub"), { recursive: true });
|
||
await fs.writeFile(path.join(srcDir, "keep.txt"), "keep");
|
||
await fs.writeFile(path.join(srcDir, "skip.log"), "skip");
|
||
await fs.writeFile(path.join(srcDir, "sub", "data.bin"), "data");
|
||
|
||
// Preserve-symlink case (followSymlinks falsy → no -h).
|
||
{
|
||
const { exec, calls } = makeRealExec();
|
||
await performSyncIn({
|
||
exec,
|
||
remoteDir,
|
||
timeoutMs: 30_000,
|
||
operations: [
|
||
{
|
||
operationId: "op-dir",
|
||
files: [
|
||
{
|
||
sourcePath: srcDir,
|
||
targetPath: path.join(remoteDir, "dst"),
|
||
kind: "directory",
|
||
exclude: ["*.log"],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
expect(calls).toHaveLength(1);
|
||
expect(await fs.readFile(path.join(remoteDir, "dst/keep.txt"), "utf-8")).toBe("keep");
|
||
expect(await fs.readFile(path.join(remoteDir, "dst/sub/data.bin"), "utf-8")).toBe("data");
|
||
// Exclude honored.
|
||
await expect(fs.stat(path.join(remoteDir, "dst/skip.log"))).rejects.toThrow();
|
||
}
|
||
|
||
// Dereference case: followSymlinks true → -h on the host tar-create.
|
||
{
|
||
const derefSrc = path.join(host, "deref");
|
||
await fs.mkdir(derefSrc, { recursive: true });
|
||
await fs.writeFile(path.join(derefSrc, "real.txt"), "realbytes");
|
||
await fs.symlink(path.join(derefSrc, "real.txt"), path.join(derefSrc, "link.txt"));
|
||
const derefTarget = await makeTmp("k8s-sandbox2-");
|
||
const { exec } = makeRealExec();
|
||
await performSyncIn({
|
||
exec,
|
||
remoteDir: derefTarget,
|
||
timeoutMs: 30_000,
|
||
operations: [
|
||
{
|
||
operationId: "op-deref",
|
||
files: [
|
||
{
|
||
sourcePath: derefSrc,
|
||
targetPath: path.join(derefTarget, "out"),
|
||
kind: "directory",
|
||
followSymlinks: true,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
const linkStat = await fs.lstat(path.join(derefTarget, "out/link.txt"));
|
||
// Dereferenced: the link became a regular file carrying the bytes.
|
||
expect(linkStat.isSymbolicLink()).toBe(false);
|
||
expect(await fs.readFile(path.join(derefTarget, "out/link.txt"), "utf-8")).toBe("realbytes");
|
||
}
|
||
});
|
||
|
||
it("preserves a symlink as a link when followSymlinks is falsy", async () => {
|
||
const remoteDir = await makeTmp("k8s-sandbox-");
|
||
const host = await makeTmp("k8s-host-");
|
||
const src = path.join(host, "tree");
|
||
await fs.mkdir(src, { recursive: true });
|
||
await fs.writeFile(path.join(src, "real.txt"), "realbytes");
|
||
await fs.symlink("real.txt", path.join(src, "link.txt"));
|
||
|
||
const { exec } = makeRealExec();
|
||
await performSyncIn({
|
||
exec,
|
||
remoteDir,
|
||
timeoutMs: 30_000,
|
||
operations: [
|
||
{
|
||
operationId: "op",
|
||
files: [{ sourcePath: src, targetPath: path.join(remoteDir, "out"), kind: "directory" }],
|
||
},
|
||
],
|
||
});
|
||
const linkStat = await fs.lstat(path.join(remoteDir, "out/link.txt"));
|
||
expect(linkStat.isSymbolicLink()).toBe(true);
|
||
});
|
||
|
||
it("rejects a file mapping whose target escapes the remote dir before any exec runs", async () => {
|
||
const remoteDir = await makeTmp("k8s-sandbox-");
|
||
const host = await makeTmp("k8s-host-");
|
||
const src = path.join(host, "x.txt");
|
||
await fs.writeFile(src, "x");
|
||
const { exec, calls } = makeRealExec();
|
||
await expect(
|
||
performSyncIn({
|
||
exec,
|
||
remoteDir,
|
||
timeoutMs: 30_000,
|
||
operations: [
|
||
{
|
||
operationId: "op",
|
||
files: [{ sourcePath: src, targetPath: `${remoteDir}/../escape.txt`, kind: "file" }],
|
||
},
|
||
],
|
||
}),
|
||
).rejects.toThrow(/escapes|not a confined/);
|
||
expect(calls).toHaveLength(0);
|
||
});
|
||
|
||
it("refuses to create a target dir through a sandbox-planted symlink ancestor, mutating nothing outside the root (confine-before-mkdir)", async () => {
|
||
const remoteDir = await makeTmp("k8s-sandbox-");
|
||
const outside = await makeTmp("k8s-outside-");
|
||
const host = await makeTmp("k8s-host-");
|
||
const src = path.join(host, "x.txt");
|
||
await fs.writeFile(src, "payload");
|
||
// A sandbox process planted `evil` inside the workspace as a symlink to an
|
||
// out-of-root dir. The target is LEXICALLY confined (no `..`), so only the
|
||
// in-pod realpath guard can catch it — and it must catch it BEFORE mkdir -p
|
||
// follows the link and creates the tree outside the root.
|
||
await fs.symlink(outside, path.join(remoteDir, "evil"));
|
||
|
||
const { exec } = makeRealExec();
|
||
await expect(
|
||
performSyncIn({
|
||
exec,
|
||
remoteDir,
|
||
timeoutMs: 30_000,
|
||
operations: [
|
||
{
|
||
operationId: "op",
|
||
files: [
|
||
{ sourcePath: src, targetPath: path.join(remoteDir, "evil", "sub", "f.txt"), kind: "file" },
|
||
],
|
||
},
|
||
],
|
||
}),
|
||
).rejects.toThrow(/ESCAPE|exit 42/);
|
||
// The escape was rejected before mkdir ran: nothing was created outside root.
|
||
await expect(fs.stat(path.join(outside, "sub"))).rejects.toThrow();
|
||
expect(await fs.readdir(outside)).toEqual([]);
|
||
});
|
||
|
||
it("refuses to extract a directory mapping through a symlink ancestor, mutating nothing outside the root", async () => {
|
||
const remoteDir = await makeTmp("k8s-sandbox-");
|
||
const outside = await makeTmp("k8s-outside-");
|
||
const host = await makeTmp("k8s-host-");
|
||
const srcDir = path.join(host, "tree");
|
||
await fs.mkdir(srcDir, { recursive: true });
|
||
await fs.writeFile(path.join(srcDir, "a.txt"), "aaa");
|
||
await fs.symlink(outside, path.join(remoteDir, "evil"));
|
||
|
||
const { exec } = makeRealExec();
|
||
await expect(
|
||
performSyncIn({
|
||
exec,
|
||
remoteDir,
|
||
timeoutMs: 30_000,
|
||
operations: [
|
||
{
|
||
operationId: "op",
|
||
files: [
|
||
{ sourcePath: srcDir, targetPath: path.join(remoteDir, "evil", "dst"), kind: "directory" },
|
||
],
|
||
},
|
||
],
|
||
}),
|
||
).rejects.toThrow(/ESCAPE|exit 42/);
|
||
await expect(fs.stat(path.join(outside, "dst"))).rejects.toThrow();
|
||
expect(await fs.readdir(outside)).toEqual([]);
|
||
});
|
||
|
||
it("streams a payload with no in-memory cap: a file larger than the former 100 MB ceiling transfers in one exec", async () => {
|
||
// The streaming transport moves raw tar bytes over stdin straight to disk, so
|
||
// there is no whole-payload buffer to bound. A payload the old base64-buffered
|
||
// transport would have rejected now transfers fine. We assert the shape (one
|
||
// exec, byte-exact `head -c`) on a modestly large file — enough to prove the
|
||
// path never accumulates the payload as a string.
|
||
const remoteDir = await makeTmp("k8s-sandbox-");
|
||
const host = await makeTmp("k8s-host-");
|
||
const src = path.join(host, "big.bin");
|
||
const payload = Buffer.alloc(2 * 1024 * 1024, 7); // 2 MiB
|
||
await fs.writeFile(src, payload);
|
||
const { exec, calls } = makeRealExec();
|
||
const result = await performSyncIn({
|
||
exec,
|
||
remoteDir,
|
||
timeoutMs: 30_000,
|
||
operations: [
|
||
{
|
||
operationId: "op",
|
||
files: [{ sourcePath: src, targetPath: path.join(remoteDir, "big.bin"), kind: "file" }],
|
||
},
|
||
],
|
||
});
|
||
expect(calls).toHaveLength(1);
|
||
expect(calls[0].script).toMatch(/head -c \d+/);
|
||
const landed = await fs.readFile(path.join(remoteDir, "big.bin"));
|
||
expect(landed.equals(payload)).toBe(true);
|
||
expect(result.operations[0].bytesTransferred).toBe(payload.length);
|
||
});
|
||
});
|
||
|
||
describe("kubernetes onEnvironmentSyncOut (native single-exec transfer)", () => {
|
||
// The pod's pinned descriptor protocol depends on Linux /proc/self/fd.
|
||
it.skipIf(process.platform !== "linux")("preserves source entry names when requested while retaining explicit exclusions", async () => {
|
||
const remoteDir = await makeTmp("k8s-source-entry-"), hostOut = await makeTmp("k8s-source-output-");
|
||
await fs.mkdir(path.join(remoteDir, "nested"));
|
||
const names = ["._result.txt", ".paperclip-upload-result", "nested/._result.txt", "nested/.paperclip-upload-result"];
|
||
await fs.chmod(path.join(remoteDir, "nested"), 0o775);
|
||
for (const name of names) {
|
||
await fs.writeFile(path.join(remoteDir, name), `user work: ${name}`);
|
||
await fs.chmod(path.join(remoteDir, name), 0o664);
|
||
}
|
||
await fs.writeFile(path.join(remoteDir, "excluded.txt"), "excluded by caller");
|
||
const { exec } = makeRealExec();
|
||
await performSyncOut({ exec, remoteDir, timeoutMs: 30_000, operations: [{ operationId: "preserve", files: [{
|
||
kind: "directory", sourcePath: remoteDir, targetPath: hostOut, preserveSourceEntries: true, exclude: ["excluded.txt"],
|
||
}] }] });
|
||
for (const name of names) {
|
||
expect(await fs.readFile(path.join(hostOut, name), "utf8")).toBe(`user work: ${name}`);
|
||
expect(await fs.readFile(path.join(remoteDir, name), "utf8")).toBe(`user work: ${name}`);
|
||
expect((await fs.stat(path.join(hostOut, name))).mode & 0o777).toBe(0o664);
|
||
}
|
||
expect((await fs.stat(path.join(hostOut, "nested"))).mode & 0o777).toBe(0o775);
|
||
await expect(fs.stat(path.join(hostOut, "excluded.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
||
});
|
||
|
||
it.skipIf(process.platform !== "linux")("preserves literal excluded paths without excluding same basenames or glob matches", async () => {
|
||
const remoteDir = await makeTmp("k8s-literal-"), hostOut = await makeTmp("k8s-literal-out-");
|
||
await fs.mkdir(path.join(remoteDir, "nested"));
|
||
const socket = createServer();
|
||
await new Promise<void>((resolve, reject) => { socket.once("error", reject); socket.listen(path.join(remoteDir, "ipc"), resolve); });
|
||
try {
|
||
const names = ["nested/ipc", "nested/omit[ab]*?", "omitaZZx", "nested/skip[ab]*?/saved.txt"];
|
||
for (const name of names) {
|
||
await fs.mkdir(path.dirname(path.join(remoteDir, name)), { recursive: true });
|
||
await fs.writeFile(path.join(remoteDir, name), `literal work: ${name}`);
|
||
}
|
||
await fs.writeFile(path.join(remoteDir, "omit[ab]*?"), "excluded literal file");
|
||
await fs.mkdir(path.join(remoteDir, "skip[ab]*?"));
|
||
await fs.writeFile(path.join(remoteDir, "skip[ab]*?/excluded.txt"), "excluded subtree");
|
||
const { exec } = makeRealExec();
|
||
const result = await performSyncOut({ exec, remoteDir, timeoutMs: 30_000, operations: [{ operationId: "literal", files: [{
|
||
kind: "directory", sourcePath: remoteDir, targetPath: hostOut, preserveSourceEntries: true, exclude: ["ipc", "omit[ab]*?", "skip[ab]*?"],
|
||
}] }] });
|
||
expect(result.operations[0].filesTransferred).toBe(4);
|
||
for (const name of names) {
|
||
expect(await fs.readFile(path.join(hostOut, name), "utf8")).toBe(`literal work: ${name}`);
|
||
expect(await fs.readFile(path.join(remoteDir, name), "utf8")).toBe(`literal work: ${name}`);
|
||
}
|
||
for (const name of ["ipc", "omit[ab]*?", "skip[ab]*?"]) await expect(fs.lstat(path.join(hostOut, name))).rejects.toMatchObject({ code: "ENOENT" });
|
||
expect((await fs.lstat(path.join(remoteDir, "ipc"))).isSocket()).toBe(true);
|
||
} finally { await new Promise<void>(resolve => socket.close(() => resolve())); }
|
||
});
|
||
|
||
it.skipIf(process.platform !== "linux").each(["file", "directory"])("refuses preserved %s special modes before host writes", async kind => {
|
||
const remoteDir = await makeTmp("k8s-special-source-"), hostOut = await makeTmp("k8s-special-output-");
|
||
await fs.mkdir(path.join(remoteDir, "nested"));
|
||
await fs.writeFile(path.join(remoteDir, "result.txt"), "source work");
|
||
await fs.writeFile(path.join(hostOut, "retained.txt"), "existing host work");
|
||
const source = path.join(remoteDir, kind === "directory" ? "nested" : "result.txt");
|
||
const mode = kind === "directory" ? 0o1775 : 0o1664;
|
||
await fs.chmod(source, mode);
|
||
expect((await fs.stat(source)).mode & 0o7777).toBe(mode);
|
||
const { exec } = makeRealExec();
|
||
await expect(performSyncOut({ exec, remoteDir, timeoutMs: 30_000, operations: [{ operationId: "preserve", files: [{
|
||
kind: "directory", sourcePath: remoteDir, targetPath: hostOut, preserveSourceEntries: true,
|
||
}] }] })).rejects.toThrow("special mode bits");
|
||
expect(await fs.readdir(hostOut)).toEqual(["retained.txt"]);
|
||
expect(await fs.readFile(path.join(hostOut, "retained.txt"), "utf8")).toBe("existing host work");
|
||
expect((await fs.stat(source)).mode & 0o7777).toBe(mode);
|
||
expect(await fs.readFile(path.join(remoteDir, "result.txt"), "utf8")).toBe("source work");
|
||
});
|
||
|
||
it("streams all file mappings back over a SINGLE exec and reassembles them at host targets, preserving mode and per-operation counts", async () => {
|
||
const remoteDir = await makeTmp("k8s-sandbox-");
|
||
const hostOut = await makeTmp("k8s-hostout-");
|
||
// Simulate sandbox-side files.
|
||
await fs.writeFile(path.join(remoteDir, "result.txt"), "computed output");
|
||
await fs.writeFile(path.join(remoteDir, "secret.key"), "PRIVATE");
|
||
await fs.chmod(path.join(remoteDir, "secret.key"), 0o600);
|
||
|
||
const plainTarget = path.join(hostOut, "a/result.txt");
|
||
const secretTarget = path.join(hostOut, "b/secret.key");
|
||
|
||
const { exec, calls } = makeRealExec();
|
||
const result = await performSyncOut({
|
||
exec,
|
||
remoteDir,
|
||
timeoutMs: 30_000,
|
||
operations: [
|
||
{
|
||
operationId: "op-out",
|
||
files: [
|
||
{ sourcePath: path.join(remoteDir, "result.txt"), targetPath: plainTarget, kind: "file" },
|
||
{
|
||
sourcePath: path.join(remoteDir, "secret.key"),
|
||
targetPath: secretTarget,
|
||
kind: "file",
|
||
mode: 0o600,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(calls).toHaveLength(1);
|
||
expect(await fs.readFile(plainTarget, "utf-8")).toBe("computed output");
|
||
expect(await fs.readFile(secretTarget, "utf-8")).toBe("PRIVATE");
|
||
expect((await fs.stat(secretTarget)).mode & 0o777).toBe(0o600);
|
||
expect(result.operations).toEqual([
|
||
{ operationId: "op-out", filesTransferred: 2, bytesTransferred: expect.any(Number) },
|
||
]);
|
||
// Reserved snapshot scratch cleaned up from the workspace root.
|
||
const leftovers = (await fs.readdir(remoteDir)).filter((e) => e.startsWith(".paperclip-upload"));
|
||
expect(leftovers).toEqual([]);
|
||
});
|
||
|
||
it("round-trips a directory back to the host, preserving a symlink when followSymlinks is falsy", async () => {
|
||
const remoteDir = await makeTmp("k8s-sandbox-");
|
||
const hostOut = await makeTmp("k8s-hostout-");
|
||
const srcDir = path.join(remoteDir, "artifacts");
|
||
await fs.mkdir(path.join(srcDir, "sub"), { recursive: true });
|
||
await fs.writeFile(path.join(srcDir, "a.txt"), "aaa");
|
||
await fs.writeFile(path.join(srcDir, "sub", "b.txt"), "bbb");
|
||
await fs.symlink("a.txt", path.join(srcDir, "link.txt"));
|
||
|
||
const target = path.join(hostOut, "restored");
|
||
const { exec, calls } = makeRealExec();
|
||
const result = await performSyncOut({
|
||
exec,
|
||
remoteDir,
|
||
timeoutMs: 30_000,
|
||
operations: [
|
||
{
|
||
operationId: "op-dir-out",
|
||
files: [{ sourcePath: srcDir, targetPath: target, kind: "directory" }],
|
||
},
|
||
],
|
||
});
|
||
expect(calls).toHaveLength(1);
|
||
expect(await fs.readFile(path.join(target, "a.txt"), "utf-8")).toBe("aaa");
|
||
expect(await fs.readFile(path.join(target, "sub/b.txt"), "utf-8")).toBe("bbb");
|
||
expect((await fs.lstat(path.join(target, "link.txt"))).isSymbolicLink()).toBe(true);
|
||
expect(result.operations[0].filesTransferred).toBeGreaterThanOrEqual(2);
|
||
});
|
||
|
||
it("rejects an outbound source that escapes the remote dir before any exec runs", async () => {
|
||
const remoteDir = await makeTmp("k8s-sandbox-");
|
||
const hostOut = await makeTmp("k8s-hostout-");
|
||
const { exec, calls } = makeRealExec();
|
||
await expect(
|
||
performSyncOut({
|
||
exec,
|
||
remoteDir,
|
||
timeoutMs: 30_000,
|
||
operations: [
|
||
{
|
||
operationId: "op",
|
||
files: [
|
||
{ sourcePath: "/etc/passwd", targetPath: path.join(hostOut, "leak"), kind: "file" },
|
||
],
|
||
},
|
||
],
|
||
}),
|
||
).rejects.toThrow(/escapes|not a confined/);
|
||
expect(calls).toHaveLength(0);
|
||
});
|
||
|
||
it("snapshots the outbound source through a pinned FD (never re-opening by name) so a post-resolve replacement cannot redirect the copy", async () => {
|
||
const remoteDir = await makeTmp("k8s-sandbox-");
|
||
const hostOut = await makeTmp("k8s-hostout-");
|
||
await fs.writeFile(path.join(remoteDir, "result.txt"), "computed output");
|
||
const target = path.join(hostOut, "result.txt");
|
||
|
||
const { exec, calls } = makeRealExec();
|
||
await performSyncOut({
|
||
exec,
|
||
remoteDir,
|
||
timeoutMs: 30_000,
|
||
operations: [
|
||
{
|
||
operationId: "op",
|
||
files: [
|
||
{ sourcePath: path.join(remoteDir, "result.txt"), targetPath: target, kind: "file" },
|
||
],
|
||
},
|
||
],
|
||
});
|
||
// Correct bytes copied — through the FD, not the name.
|
||
expect(await fs.readFile(target, "utf-8")).toBe("computed output");
|
||
// The copy reads the pinned FD, and the source is never re-opened by its
|
||
// resolved name after validation (which is what the TOCTOU exploited).
|
||
expect(calls[0].script).toContain("exec 7<");
|
||
expect(calls[0].script).toContain("cp -- /proc/self/fd/7");
|
||
expect(calls[0].script).not.toMatch(/cp -- "\$_pc_real"/);
|
||
});
|
||
|
||
it("rejects an outbound source that is a symlink resolving outside the root, writing no target", async () => {
|
||
const remoteDir = await makeTmp("k8s-sandbox-");
|
||
const outside = await makeTmp("k8s-outside-");
|
||
const hostOut = await makeTmp("k8s-hostout-");
|
||
await fs.writeFile(path.join(outside, "secret"), "PRIVATE");
|
||
// A symlink LEXICALLY inside the root that resolves to an out-of-root file —
|
||
// only the in-pod realpath/FD guard can reject it.
|
||
await fs.symlink(path.join(outside, "secret"), path.join(remoteDir, "link"));
|
||
const target = path.join(hostOut, "leak");
|
||
|
||
const { exec } = makeRealExec();
|
||
await expect(
|
||
performSyncOut({
|
||
exec,
|
||
remoteDir,
|
||
timeoutMs: 30_000,
|
||
operations: [
|
||
{
|
||
operationId: "op",
|
||
files: [{ sourcePath: path.join(remoteDir, "link"), targetPath: target, kind: "file" }],
|
||
},
|
||
],
|
||
}),
|
||
).rejects.toThrow(/ESCAPE|exit 42/);
|
||
await expect(fs.stat(target)).rejects.toThrow();
|
||
});
|
||
|
||
it("fails closed when the outbound payload exceeds the streamed-output disk guard, writing no target", async () => {
|
||
const remoteDir = await makeTmp("k8s-sandbox-");
|
||
const hostOut = await makeTmp("k8s-hostout-");
|
||
await fs.writeFile(path.join(remoteDir, "result.txt"), "computed output");
|
||
const target = path.join(hostOut, "result.txt");
|
||
// The (untrusted) pod streams far more than a 1KB guard allows; the host must
|
||
// trip the streamed-bytes guard and abort before it can fill the disk, never
|
||
// extracting a target.
|
||
const { exec, calls } = makeOutputExec(4096);
|
||
await expect(
|
||
performSyncOut({
|
||
exec,
|
||
remoteDir,
|
||
timeoutMs: 30_000,
|
||
maxOutputBytes: 1024,
|
||
operations: [
|
||
{
|
||
operationId: "op",
|
||
files: [
|
||
{ sourcePath: path.join(remoteDir, "result.txt"), targetPath: target, kind: "file" },
|
||
],
|
||
},
|
||
],
|
||
}),
|
||
).rejects.toThrow(/disk guard|exceeded/i);
|
||
// The exec ran (source was confined), but the oversize stream is aborted, so
|
||
// nothing lands at the host target.
|
||
expect(calls).toHaveLength(1);
|
||
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);
|
||
});
|
||
});
|
||
|
||
|
||
describe("parseTarVerboseListingLine", () => {
|
||
it("parses GNU tar listing lines (file, dir, symlink, hardlink, numeric owner)", () => {
|
||
expect(parseTarVerboseListingLine("-rw-r--r-- daytona/daytona 7560 2026-08-11 21:43 AGENTS.md")).toEqual({
|
||
typeFlag: "-",
|
||
rest: "AGENTS.md",
|
||
});
|
||
expect(parseTarVerboseListingLine("drwxr-xr-x daytona/daytona 0 2026-08-11 21:43 nested/")).toEqual({
|
||
typeFlag: "d",
|
||
rest: "nested/",
|
||
});
|
||
expect(
|
||
parseTarVerboseListingLine("lrwxrwxrwx daytona/daytona 0 2026-08-11 21:43 shortcut -> nested/data.txt"),
|
||
).toEqual({ typeFlag: "l", rest: "shortcut -> nested/data.txt" });
|
||
expect(
|
||
parseTarVerboseListingLine("hrw-r--r-- daytona/daytona 0 2026-08-11 21:43 copy.txt link to data.txt"),
|
||
).toEqual({ typeFlag: "h", rest: "copy.txt link to data.txt" });
|
||
expect(parseTarVerboseListingLine("-rw-r--r-- 0/0 12 2026-08-11 21:43 root-owned.txt")).toEqual({
|
||
typeFlag: "-",
|
||
rest: "root-owned.txt",
|
||
});
|
||
});
|
||
|
||
it("parses bsdtar (macOS) listing lines, including year-form dates", () => {
|
||
expect(parseTarVerboseListingLine("-rw-r--r-- 0 daytona daytona 7560 Aug 11 21:43 AGENTS.md")).toEqual({
|
||
typeFlag: "-",
|
||
rest: "AGENTS.md",
|
||
});
|
||
expect(parseTarVerboseListingLine("drwxr-xr-x 0 daytona daytona 0 Aug 11 21:43 nested/")).toEqual({
|
||
typeFlag: "d",
|
||
rest: "nested/",
|
||
});
|
||
expect(
|
||
parseTarVerboseListingLine("lrwxr-xr-x 0 daytona daytona 0 Aug 11 21:43 shortcut -> nested/data.txt"),
|
||
).toEqual({ typeFlag: "l", rest: "shortcut -> nested/data.txt" });
|
||
expect(
|
||
parseTarVerboseListingLine("hrw-r--r-- 0 daytona daytona 0 Aug 11 21:43 copy.txt link to data.txt"),
|
||
).toEqual({ typeFlag: "h", rest: "copy.txt link to data.txt" });
|
||
expect(parseTarVerboseListingLine("-rw-r--r-- 0 daytona daytona 7560 Aug 11 2025 old.txt")).toEqual({
|
||
typeFlag: "-",
|
||
rest: "old.txt",
|
||
});
|
||
});
|
||
|
||
it("keeps the true member name for bsdtar lines with numeric uid/gid, so traversal stays visible", () => {
|
||
// With unresolvable ids bsdtar prints bare numbers; a looser GNU-first parse
|
||
// would read this shape shifted by one field and report the member name as
|
||
// "21:43 ../escape.txt", hiding the leading "../" from the traversal check.
|
||
expect(parseTarVerboseListingLine("-rw-r--r-- 0 1001 1001 7560 Aug 11 21:43 ../escape.txt")).toEqual({
|
||
typeFlag: "-",
|
||
rest: "../escape.txt",
|
||
});
|
||
});
|
||
|
||
it("returns null (fail closed) for lines matching neither dialect", () => {
|
||
expect(parseTarVerboseListingLine("not a tar listing line")).toBeNull();
|
||
expect(parseTarVerboseListingLine("tar: Error is not recoverable: exiting now")).toBeNull();
|
||
// Device nodes carry "major,minor" instead of a byte count in both dialects.
|
||
expect(parseTarVerboseListingLine("crw-rw-rw- root/root 1,3 2026-08-11 21:43 dev/null")).toBeNull();
|
||
expect(parseTarVerboseListingLine("crw-rw-rw- 0 root wheel 1,3 Aug 11 21:43 dev/null")).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("splitLinkEntryOnce", () => {
|
||
it("splits a clean single-delimiter link field", () => {
|
||
expect(splitLinkEntryOnce("shortcut -> nested/data.txt", " -> ")).toEqual({
|
||
name: "shortcut",
|
||
target: "nested/data.txt",
|
||
});
|
||
expect(splitLinkEntryOnce("copy.txt link to data.txt", " link to ")).toEqual({
|
||
name: "copy.txt",
|
||
target: "data.txt",
|
||
});
|
||
});
|
||
|
||
it("returns null (fail closed) when the delimiter is absent or appears more than once", () => {
|
||
expect(splitLinkEntryOnce("no delimiter here", " -> ")).toBeNull();
|
||
// A link name or target embedding the delimiter makes the split point
|
||
// unresolvable; either split choice can hide an escaping target.
|
||
expect(splitLinkEntryOnce("evil -> decoy -> ../../outside.txt", " -> ")).toBeNull();
|
||
expect(splitLinkEntryOnce("a link to b link to ../../outside.txt", " link to ")).toBeNull();
|
||
});
|
||
});
|