mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - An agent runs through an adapter. Before you use an environment, the
adapter's environment test probes it and reports structured checks
> - The grok adapter's environment test stages the managed account into
a remote environment first. It gave the runtime its resolved `cwd` as
the host workspace directory
> - For a remote target that `cwd` is a path inside the sandbox. It does
not exist on the host that runs the test
> - A new workspace ignore scan reads that host directory. The scan
failed on the missing directory, and the error escaped the environment
test
> - The user got a server error instead of a result with checks
> - This pull request stages from a host temporary directory, sends the
remote path separately, and reports a staging failure as a check
> - The benefit is that the grok environment test always answers with
checks, and a workspace directory that does not exist no longer stops a
runtime preparation
## Linked Issues or Issue Description
No existing issue. The problem, in the bug report format:
**What happened**
The grok environment test failed with `Error: Workspace ignore scan
failed: git-ignore-scan-failed`. The route returned a server error, so
the user saw no checks at all.
**Expected behavior**
The environment test always returns `{status, checks}`. Every other
problem it finds (an invalid working directory, a command it cannot
resolve, a probe that times out) becomes a check with a level. A
credential staging problem must do the same.
**Steps to reproduce**
1. Configure a grok agent with a managed AI connection.
2. Point the agent at a remote (sandbox) environment.
3. Run the environment test for that environment.
**Paperclip version or commit**
Present on master. Both halves landed on 2026-09-12: the call site in
#13247, and the scan that it trips in #13353.
## What Changed
- `packages/adapters/grok-local/src/server/test.ts` stages the managed
account through a fresh host temporary directory and passes the remote
path as `workspaceRemoteDir`. This is the split `codex-local` and
`opencode-local` already use.
- A failure while staging becomes a `grok_environment_unprepared` check
with level `error`, instead of an exception that escapes the function.
The probe does not run after it, because there is no prepared
environment to probe.
- The temporary directory is removed in the existing `finally` block.
- `packages/adapter-utils/src/sandbox-managed-runtime.ts` treats a
`workspaceLocalDir` that does not exist as "nothing to sync". A
directory that is not there has no files for ignore rules to govern,
nothing to stage, and nothing to restore.
- Tests: the grok environment test now covers the managed-connection
remote branch, which had no coverage. `sandbox-file-sync.test.ts` covers
a preparation whose workspace directory does not exist.
## Verification
```
npx vitest run packages/adapters/grok-local/src/server/test.test.ts # 9 passed
npx vitest run packages/adapter-utils/src/sandbox-file-sync.test.ts \
packages/adapter-utils/src/sandbox-managed-runtime.test.ts # 90 passed
cd packages/adapter-utils && npx tsc --noEmit # clean
cd packages/adapters/grok-local && npx tsc --noEmit # clean
```
The two new grok tests fail against the old call site: the first asserts
the runtime never receives the remote path as its host workspace
directory, and the second asserts a staging failure becomes a check
instead of an exception.
## Risks
Low risk, and limited to environment preparation.
- The adapter change only affects the managed-connection remote branch
of one adapter's environment test.
- The `adapter-utils` change makes a preparation that used to throw now
continue with no workspace sync. A real workspace is unaffected, because
the directory exists in that case and the scan runs exactly as before.
- No migration. No API change.
## Model Used
- Claude Fable 5 (`claude-fable-5`), 1M context, extended thinking, run
through Claude Code with tool use and code execution.
## 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
- [ ] I have updated relevant documentation to reflect my changes — no
documented behavior changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green — pending first run
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups —
pending first review
- [x] I will address all Greptile and reviewer comments before
requesting merge
483 lines
24 KiB
TypeScript
483 lines
24 KiB
TypeScript
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { execFile as execFileCallback } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
assertSyncOperationsConfined,
|
|
prepareSandboxManagedRuntime,
|
|
type SandboxManagedRuntimeClient,
|
|
type SandboxSyncOperation,
|
|
} from "./sandbox-managed-runtime.js";
|
|
|
|
const execFile = promisify(execFileCallback);
|
|
|
|
interface RecordingClient {
|
|
client: SandboxManagedRuntimeClient;
|
|
syncInOps: SandboxSyncOperation[][];
|
|
syncOutOps: SandboxSyncOperation[][];
|
|
}
|
|
|
|
// A filesystem-backed client that additionally exposes native syncIn/syncOut,
|
|
// mirroring a provider that opted into the sync verbs and HONORS an operation's
|
|
// ordered `postUploadCommands` after its files land (PR-2 contract: execute or
|
|
// fail-closed, never silently ignore). The native transfer is a faithful copy
|
|
// honoring followSymlinks; single-file mappings stream verbatim.
|
|
function makeNativeClient(): RecordingClient {
|
|
const syncInOps: SandboxSyncOperation[][] = [];
|
|
const syncOutOps: SandboxSyncOperation[][] = [];
|
|
|
|
const transferDirectory = async (
|
|
sourcePath: string,
|
|
targetPath: string,
|
|
followSymlinks: boolean | undefined,
|
|
): Promise<number> => {
|
|
await rm(targetPath, { recursive: true, force: true });
|
|
await mkdir(targetPath, { recursive: true });
|
|
// followSymlinks true dereferences to bytes (like tar -h); falsy preserves links.
|
|
const copyArgs = followSymlinks ? ["-RL"] : ["-a"];
|
|
await execFile("cp", [...copyArgs, `${sourcePath}/.`, targetPath]);
|
|
const entries = await readdir(targetPath, { withFileTypes: true }).catch(() => []);
|
|
return entries.length;
|
|
};
|
|
|
|
const applyOperations = async (operations: SandboxSyncOperation[]) => ({
|
|
operations: await Promise.all(operations.map(async (operation) => {
|
|
let filesTransferred = 0;
|
|
for (const mapping of operation.files) {
|
|
if (mapping.kind === "directory") {
|
|
filesTransferred += await transferDirectory(mapping.sourcePath, mapping.targetPath, mapping.followSymlinks);
|
|
} else {
|
|
await mkdir(path.dirname(mapping.targetPath), { recursive: true });
|
|
await writeFile(mapping.targetPath, await readFile(mapping.sourcePath));
|
|
filesTransferred += 1;
|
|
}
|
|
}
|
|
// Honor the operation's ordered post-upload commands (PR-2), fail-fast.
|
|
for (const command of operation.postUploadCommands ?? []) {
|
|
await execFile("sh", ["-c", command.command], { maxBuffer: 32 * 1024 * 1024 });
|
|
}
|
|
return { operationId: operation.operationId, filesTransferred, bytesTransferred: 0 };
|
|
})),
|
|
});
|
|
|
|
const client: SandboxManagedRuntimeClient = {
|
|
makeDir: async (remotePath) => { await mkdir(remotePath, { recursive: true }); },
|
|
writeFile: async (remotePath, bytes) => {
|
|
await mkdir(path.dirname(remotePath), { recursive: true });
|
|
await writeFile(remotePath, Buffer.from(bytes));
|
|
},
|
|
readFile: async (remotePath) => await readFile(remotePath),
|
|
listFiles: async (remotePath) => {
|
|
const entries = await readdir(remotePath, { withFileTypes: true }).catch(() => []);
|
|
return entries.filter((e) => e.isFile()).map((e) => e.name).sort();
|
|
},
|
|
remove: async (remotePath) => { await rm(remotePath, { recursive: true, force: true }); },
|
|
run: async (command) => { await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); },
|
|
syncIn: async (operations) => { syncInOps.push(operations); return applyOperations(operations); },
|
|
syncOut: async (operations) => { syncOutOps.push(operations); return applyOperations(operations); },
|
|
};
|
|
|
|
return { client, syncInOps, syncOutOps };
|
|
}
|
|
|
|
describe("sandbox native file sync", () => {
|
|
const cleanupDirs: string[] = [];
|
|
afterEach(async () => {
|
|
while (cleanupDirs.length > 0) {
|
|
const dir = cleanupDirs.pop();
|
|
if (dir) await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("syncs a selected repository subfolder without parent files, history, or ignored files", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-native-nested-workspace-"));
|
|
cleanupDirs.push(rootDir);
|
|
const repo = path.join(rootDir, "repo");
|
|
const selectedDir = path.join(repo, "project");
|
|
const remoteDir = path.join(rootDir, "remote");
|
|
await mkdir(selectedDir, { recursive: true });
|
|
await execFile("git", ["-C", repo, "init"]);
|
|
await writeFile(path.join(repo, "outside.txt"), "outside boundary\n");
|
|
await writeFile(path.join(repo, ".gitignore"), "project/private.txt\n");
|
|
await writeFile(path.join(selectedDir, "draft.md"), "preserved draft\n");
|
|
await execFile("git", ["-C", repo, "add", "."]);
|
|
await execFile("git", ["-C", repo, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "base"]);
|
|
await writeFile(path.join(selectedDir, "private.txt"), "stay local\n");
|
|
|
|
const { client } = makeNativeClient();
|
|
const prepared = await prepareSandboxManagedRuntime({
|
|
spec: { transport: "sandbox", provider: "test", sandboxId: "s1", remoteCwd: remoteDir, timeoutMs: 30_000, apiKey: null },
|
|
adapterKey: "test-adapter",
|
|
client,
|
|
workspaceLocalDir: selectedDir,
|
|
});
|
|
|
|
expect(await readFile(path.join(remoteDir, "draft.md"), "utf8")).toBe("preserved draft\n");
|
|
for (const absent of [".git", "outside.txt", "project", "private.txt"]) {
|
|
await expect(lstat(path.join(remoteDir, absent))).rejects.toMatchObject({ code: "ENOENT" });
|
|
}
|
|
await writeFile(path.join(remoteDir, "draft.md"), "continued draft\n");
|
|
await prepared.restoreWorkspace();
|
|
expect(await readFile(path.join(selectedDir, "draft.md"), "utf8")).toBe("continued draft\n");
|
|
expect(await readFile(path.join(selectedDir, "private.txt"), "utf8")).toBe("stay local\n");
|
|
expect(await readFile(path.join(repo, "outside.txt"), "utf8")).toBe("outside boundary\n");
|
|
});
|
|
|
|
it("prepares a runtime whose workspace directory does not exist without failing the ignore scan", async () => {
|
|
// An env test staging only credential assets can hand the runtime a
|
|
// workspace path that never existed on this host. A directory with no
|
|
// files has nothing for ignore rules to govern, so the scan is skipped
|
|
// rather than failed (git-ignore-scan-failed took down the whole
|
|
// preparation in production).
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-native-absent-workspace-"));
|
|
cleanupDirs.push(rootDir);
|
|
const remoteDir = path.join(rootDir, "remote");
|
|
|
|
const { client } = makeNativeClient();
|
|
const prepared = await prepareSandboxManagedRuntime({
|
|
spec: { transport: "sandbox", provider: "test", sandboxId: "s1", remoteCwd: remoteDir, timeoutMs: 30_000, apiKey: null },
|
|
adapterKey: "test-adapter",
|
|
client,
|
|
workspaceLocalDir: path.join(rootDir, "never-created"),
|
|
});
|
|
await prepared.restoreWorkspace();
|
|
});
|
|
|
|
it("fails preparation when the workspace cannot be read for a reason other than absence", async () => {
|
|
// Only absence means "nothing to sync". A workspace that is there but
|
|
// unreadable must not quietly become an empty remote workspace, so any
|
|
// other access error still fails the preparation. A path whose parent is
|
|
// a file gives a deterministic non-ENOENT error on every platform.
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-native-unreadable-workspace-"));
|
|
cleanupDirs.push(rootDir);
|
|
const notADirectory = path.join(rootDir, "a-file");
|
|
await writeFile(notADirectory, "not a directory\n");
|
|
|
|
const { client } = makeNativeClient();
|
|
await expect(prepareSandboxManagedRuntime({
|
|
spec: { transport: "sandbox", provider: "test", sandboxId: "s1", remoteCwd: path.join(rootDir, "remote"), timeoutMs: 30_000, apiKey: null },
|
|
adapterKey: "test-adapter",
|
|
client,
|
|
workspaceLocalDir: path.join(notADirectory, "workspace"),
|
|
})).rejects.toMatchObject({ code: "ENOTDIR" });
|
|
});
|
|
|
|
it("prefers the native path for default-provision asset inbound and workspace outbound", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-native-sync-"));
|
|
cleanupDirs.push(rootDir);
|
|
const localWorkspaceDir = path.join(rootDir, "local-workspace");
|
|
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
|
|
const localAssetsDir = path.join(rootDir, "local-assets");
|
|
await mkdir(localWorkspaceDir, { recursive: true });
|
|
await mkdir(localAssetsDir, { recursive: true });
|
|
await writeFile(path.join(localWorkspaceDir, "README.md"), "local workspace\n", "utf8");
|
|
await writeFile(path.join(localAssetsDir, "skill.md"), "skill body\n", "utf8");
|
|
|
|
const { client, syncInOps, syncOutOps } = makeNativeClient();
|
|
const prepared = await prepareSandboxManagedRuntime({
|
|
spec: { transport: "sandbox", provider: "test", sandboxId: "s1", remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000, apiKey: null },
|
|
adapterKey: "test-adapter",
|
|
client,
|
|
workspaceLocalDir: localWorkspaceDir,
|
|
assets: [{ key: "skills", localDir: localAssetsDir }],
|
|
});
|
|
|
|
// Both the workspace and the default-provision asset stage through syncIn:
|
|
// each uploads a single tar as a `file` mapping with an opaque operationId,
|
|
// and the extract runs as the operation's ordered post-upload command.
|
|
const inboundOps = syncInOps.flat();
|
|
expect(inboundOps.length).toBe(2);
|
|
const assetOp = inboundOps.find((op) =>
|
|
op.files.some((mapping) => mapping.targetPath.endsWith("skills-upload.tar")),
|
|
);
|
|
expect(assetOp).toBeDefined();
|
|
expect(assetOp!.operationId).toMatch(/^sync-op-\d+$/);
|
|
expect(assetOp!.operationId).not.toContain("skills");
|
|
expect(assetOp!.files).toHaveLength(1);
|
|
expect(assetOp!.files[0]).toMatchObject({
|
|
targetPath: path.posix.join(prepared.runtimeRootDir, "skills-upload.tar"),
|
|
kind: "file",
|
|
});
|
|
// Default provision → a plain destroy-then-replace tar extract post-command.
|
|
expect(assetOp!.postUploadCommands).toHaveLength(1);
|
|
expect(assetOp!.postUploadCommands![0].command).toContain("tar -xf");
|
|
expect(await readFile(path.join(prepared.assetDirs.skills, "skill.md"), "utf8")).toBe("skill body\n");
|
|
|
|
// Mutate the sandbox workspace, then restore through the native outbound path.
|
|
await writeFile(path.join(remoteWorkspaceDir, "README.md"), "remote workspace\n", "utf8");
|
|
await writeFile(path.join(remoteWorkspaceDir, "new.txt"), "added\n", "utf8");
|
|
await prepared.restoreWorkspace();
|
|
|
|
const outboundOps = syncOutOps.flat();
|
|
expect(outboundOps.length).toBe(1);
|
|
expect(outboundOps[0].files[0]).toMatchObject({ sourcePath: remoteWorkspaceDir, kind: "directory" });
|
|
expect(await readFile(path.join(localWorkspaceDir, "README.md"), "utf8")).toBe("remote workspace\n");
|
|
expect(await readFile(path.join(localWorkspaceDir, "new.txt"), "utf8")).toBe("added\n");
|
|
});
|
|
|
|
it("stamps the run-specific timeout onto every delegated post-upload command", async () => {
|
|
// The extract/wipe/merge commands are delegated to the provider through
|
|
// `syncIn` as `postUploadCommands`. They MUST carry the run-specific timeout
|
|
// (`spec.timeoutMs`) — the same limit the pre-syncIn code passed to
|
|
// `client.run` — not the provider sync client's own default. When the two
|
|
// differ, a command left without a `timeoutMs` outlives (or is killed under)
|
|
// the wrong limit; here a distinctive `spec.timeoutMs` proves propagation.
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-native-timeout-"));
|
|
cleanupDirs.push(rootDir);
|
|
const localWorkspaceDir = path.join(rootDir, "local-workspace");
|
|
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
|
|
const defaultAssetDir = path.join(rootDir, "default-asset");
|
|
const customAssetDir = path.join(rootDir, "custom-asset");
|
|
await mkdir(localWorkspaceDir, { recursive: true });
|
|
await mkdir(defaultAssetDir, { recursive: true });
|
|
await mkdir(customAssetDir, { recursive: true });
|
|
await writeFile(path.join(localWorkspaceDir, "README.md"), "ws\n", "utf8");
|
|
await writeFile(path.join(defaultAssetDir, "skill.md"), "skill\n", "utf8");
|
|
await writeFile(path.join(customAssetDir, "cred.txt"), "secret\n", "utf8");
|
|
|
|
const runTimeoutMs = 7_000;
|
|
const { client, syncInOps } = makeNativeClient();
|
|
await prepareSandboxManagedRuntime({
|
|
spec: { transport: "sandbox", provider: "test", sandboxId: "s1", remoteCwd: remoteWorkspaceDir, timeoutMs: runTimeoutMs, apiKey: null },
|
|
adapterKey: "test-adapter",
|
|
client,
|
|
workspaceLocalDir: localWorkspaceDir,
|
|
assets: [
|
|
{ key: "skills", localDir: defaultAssetDir },
|
|
{
|
|
key: "creds",
|
|
localDir: customAssetDir,
|
|
provision: { postUploadCommand: ({ assetTarPath, assetDir }) =>
|
|
`rm -rf ${assetDir} && mkdir -p ${assetDir} && tar -xf ${assetTarPath} -C ${assetDir} && rm -f ${assetTarPath}` },
|
|
},
|
|
],
|
|
});
|
|
|
|
// Workspace extract + default-asset extract + custom-provision merge — every
|
|
// delegated command across every operation carries the run timeout.
|
|
const commands = syncInOps.flat().flatMap((op) => op.postUploadCommands ?? []);
|
|
expect(commands.length).toBeGreaterThanOrEqual(3);
|
|
for (const command of commands) {
|
|
expect(command.timeoutMs).toBe(runTimeoutMs);
|
|
}
|
|
});
|
|
|
|
it("routes a custom-provision asset through syncIn with its bespoke post-upload command (native)", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-native-custom-"));
|
|
cleanupDirs.push(rootDir);
|
|
const localWorkspaceDir = path.join(rootDir, "local-workspace");
|
|
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
|
|
const localAssetsDir = path.join(rootDir, "local-assets");
|
|
await mkdir(localWorkspaceDir, { recursive: true });
|
|
await mkdir(localAssetsDir, { recursive: true });
|
|
await writeFile(path.join(localWorkspaceDir, "README.md"), "ws\n", "utf8");
|
|
await writeFile(path.join(localAssetsDir, "cred.txt"), "secret\n", "utf8");
|
|
|
|
const { client, syncInOps } = makeNativeClient();
|
|
const prepared = await prepareSandboxManagedRuntime({
|
|
spec: { transport: "sandbox", provider: "test", sandboxId: "s1", remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000, apiKey: null },
|
|
adapterKey: "test-adapter",
|
|
client,
|
|
workspaceLocalDir: localWorkspaceDir,
|
|
assets: [{
|
|
key: "creds",
|
|
localDir: localAssetsDir,
|
|
// A bespoke post-upload command (e.g. a credential merge) rides syncIn as
|
|
// the operation's ordered post-upload command — no native-diversion gate.
|
|
provision: { postUploadCommand: ({ assetTarPath, assetDir }) =>
|
|
`rm -rf ${assetDir} && mkdir -p ${assetDir} && tar -xf ${assetTarPath} -C ${assetDir} && rm -f ${assetTarPath}` },
|
|
}],
|
|
});
|
|
|
|
// The custom asset now rides syncIn (native uploadFiles), carrying its
|
|
// bespoke command as the operation's ordered post-upload command.
|
|
const credsOp = syncInOps.flat().find((op) =>
|
|
op.files.some((mapping) => mapping.targetPath.endsWith("creds-upload.tar")),
|
|
);
|
|
expect(credsOp).toBeDefined();
|
|
expect(credsOp!.files.every((mapping) => mapping.kind === "file")).toBe(true);
|
|
expect(credsOp!.postUploadCommands).toHaveLength(1);
|
|
expect(credsOp!.postUploadCommands![0].command).toContain("tar -xf");
|
|
expect(await readFile(path.join(prepared.assetDirs.creds, "cred.txt"), "utf8")).toBe("secret\n");
|
|
});
|
|
|
|
it("stages each additional project into its own isolated dir via a native directory syncIn", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-native-additional-"));
|
|
cleanupDirs.push(rootDir);
|
|
const localWorkspaceDir = path.join(rootDir, "local-workspace");
|
|
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
|
|
await mkdir(localWorkspaceDir, { recursive: true });
|
|
await writeFile(path.join(localWorkspaceDir, "README.md"), "anchor\n", "utf8");
|
|
|
|
// Three referenced projects, each with a distinctive file, staged as plain
|
|
// read-only trees.
|
|
const projects = [
|
|
{ projectId: "alpha", localDir: path.join(rootDir, "src-alpha"), file: "alpha.txt", body: "alpha body\n" },
|
|
{ projectId: "bravo", localDir: path.join(rootDir, "src-bravo"), file: "bravo.txt", body: "bravo body\n" },
|
|
{ projectId: "charlie", localDir: path.join(rootDir, "src-charlie"), file: "charlie.txt", body: "charlie body\n" },
|
|
];
|
|
for (const project of projects) {
|
|
await mkdir(project.localDir, { recursive: true });
|
|
await writeFile(path.join(project.localDir, project.file), project.body, "utf8");
|
|
}
|
|
|
|
const { client, syncInOps } = makeNativeClient();
|
|
const prepared = await prepareSandboxManagedRuntime({
|
|
spec: { transport: "sandbox", provider: "test", sandboxId: "s1", remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000, apiKey: null },
|
|
adapterKey: "test-adapter",
|
|
client,
|
|
workspaceLocalDir: localWorkspaceDir,
|
|
additionalSources: projects.map((project) => ({
|
|
localPath: project.localDir,
|
|
projectId: project.projectId,
|
|
ignoreResolution: { kind: "other" },
|
|
})),
|
|
});
|
|
|
|
// Each project lands in its OWN `project-<projectId>` directory under the
|
|
// runtime root, and its file materializes there.
|
|
const projectDirs = projects.map((project) => {
|
|
const dir = prepared.additionalSourceDirs[project.projectId];
|
|
expect(dir).toBe(path.posix.join(prepared.runtimeRootDir, `project-${project.projectId}`));
|
|
return dir;
|
|
});
|
|
for (const [index, project] of projects.entries()) {
|
|
expect(await readFile(path.join(projectDirs[index], project.file), "utf8")).toBe(project.body);
|
|
}
|
|
|
|
// The target dirs are pairwise distinct and never nested inside one another.
|
|
for (const outer of projectDirs) {
|
|
for (const inner of projectDirs) {
|
|
if (outer === inner) continue;
|
|
expect(inner.startsWith(`${outer}/`)).toBe(false);
|
|
}
|
|
}
|
|
|
|
// Each project rides its own `syncIn` operation as a single `directory`
|
|
// mapping, source = the host checkout dir, target = the isolated project dir.
|
|
const inboundOps = syncInOps.flat();
|
|
for (const [index, project] of projects.entries()) {
|
|
const op = inboundOps.find((candidate) =>
|
|
candidate.files.some((mapping) => mapping.targetPath === projectDirs[index]),
|
|
);
|
|
expect(op).toBeDefined();
|
|
expect(op!.operationId).toMatch(/^sync-op-\d+$/);
|
|
expect(op!.operationId).not.toContain(project.projectId);
|
|
expect(op!.files).toHaveLength(1);
|
|
expect(op!.files[0]).toMatchObject({
|
|
sourcePath: project.localDir,
|
|
targetPath: projectDirs[index],
|
|
kind: "directory",
|
|
});
|
|
// Plain read-only tree — no post-upload extract/wipe/merge command.
|
|
expect(op!.postUploadCommands ?? []).toHaveLength(0);
|
|
}
|
|
});
|
|
|
|
it("isolates one additional project's sync failure and stages the rest", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-native-additional-fail-"));
|
|
cleanupDirs.push(rootDir);
|
|
const localWorkspaceDir = path.join(rootDir, "local-workspace");
|
|
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
|
|
const goodDir = path.join(rootDir, "src-good");
|
|
await mkdir(localWorkspaceDir, { recursive: true });
|
|
await mkdir(goodDir, { recursive: true });
|
|
await writeFile(path.join(localWorkspaceDir, "README.md"), "anchor\n", "utf8");
|
|
await writeFile(path.join(goodDir, "good.txt"), "good body\n", "utf8");
|
|
|
|
// The middle source points at a directory that does not exist, so its native
|
|
// transfer fails. Failure isolation must skip only it and stage the rest.
|
|
const { client } = makeNativeClient();
|
|
const prepared = await prepareSandboxManagedRuntime({
|
|
spec: { transport: "sandbox", provider: "test", sandboxId: "s1", remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000, apiKey: null },
|
|
adapterKey: "test-adapter",
|
|
client,
|
|
workspaceLocalDir: localWorkspaceDir,
|
|
additionalSources: [
|
|
{ localPath: goodDir, projectId: "good-a", ignoreResolution: { kind: "other" } },
|
|
{ localPath: path.join(rootDir, "does-not-exist"), projectId: "broken", ignoreResolution: { kind: "other" } },
|
|
{ localPath: goodDir, projectId: "good-b", ignoreResolution: { kind: "other" } },
|
|
],
|
|
});
|
|
|
|
// Both healthy projects staged; the broken one is absent, not fatal.
|
|
expect(Object.keys(prepared.additionalSourceDirs).sort()).toEqual(["good-a", "good-b"]);
|
|
expect(prepared.additionalSourceDirs.broken).toBeUndefined();
|
|
expect(await readFile(path.join(prepared.additionalSourceDirs["good-a"], "good.txt"), "utf8")).toBe("good body\n");
|
|
expect(await readFile(path.join(prepared.additionalSourceDirs["good-b"], "good.txt"), "utf8")).toBe("good body\n");
|
|
});
|
|
|
|
it("dereferences symlinks only when followSymlinks is true (native honors the flag)", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-native-symlink-"));
|
|
cleanupDirs.push(rootDir);
|
|
const localWorkspaceDir = path.join(rootDir, "local-workspace");
|
|
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
|
|
const assetsPreserve = path.join(rootDir, "assets-preserve");
|
|
const assetsDeref = path.join(rootDir, "assets-deref");
|
|
const target = path.join(rootDir, "target.md");
|
|
await mkdir(localWorkspaceDir, { recursive: true });
|
|
await mkdir(assetsPreserve, { recursive: true });
|
|
await mkdir(assetsDeref, { recursive: true });
|
|
await writeFile(path.join(localWorkspaceDir, "README.md"), "ws\n", "utf8");
|
|
await writeFile(target, "link body\n", "utf8");
|
|
await symlink(target, path.join(assetsPreserve, "link.md"));
|
|
await symlink(target, path.join(assetsDeref, "link.md"));
|
|
|
|
const { client } = makeNativeClient();
|
|
const prepared = await prepareSandboxManagedRuntime({
|
|
spec: { transport: "sandbox", provider: "test", sandboxId: "s1", remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000, apiKey: null },
|
|
adapterKey: "test-adapter",
|
|
client,
|
|
workspaceLocalDir: localWorkspaceDir,
|
|
assets: [
|
|
{ key: "preserve", localDir: assetsPreserve, followSymlinks: false },
|
|
{ key: "deref", localDir: assetsDeref, followSymlinks: true },
|
|
],
|
|
});
|
|
|
|
expect((await lstat(path.join(prepared.assetDirs.preserve, "link.md"))).isSymbolicLink()).toBe(true);
|
|
const dereffed = await lstat(path.join(prepared.assetDirs.deref, "link.md"));
|
|
expect(dereffed.isSymbolicLink()).toBe(false);
|
|
expect(await readFile(path.join(prepared.assetDirs.deref, "link.md"), "utf8")).toBe("link body\n");
|
|
});
|
|
});
|
|
|
|
describe("assertSyncOperationsConfined", () => {
|
|
const op = (targetPath: string, sourcePath = "/host/src"): SandboxSyncOperation[] => [
|
|
{ operationId: "sync-op-1", files: [{ sourcePath, targetPath, kind: "directory" }] },
|
|
];
|
|
|
|
it("accepts targets within an allowed root", () => {
|
|
expect(() => assertSyncOperationsConfined(op("/remote/ws/sub"), {
|
|
sourceRoots: ["/host/src"], targetRoots: ["/remote/ws"],
|
|
})).not.toThrow();
|
|
});
|
|
|
|
it("rejects a relative target", () => {
|
|
expect(() => assertSyncOperationsConfined(op("relative/path"), {
|
|
sourceRoots: ["/host/src"], targetRoots: ["/remote/ws"],
|
|
})).toThrow(/confined absolute path/);
|
|
});
|
|
|
|
it("rejects a parent-traversal escape", () => {
|
|
expect(() => assertSyncOperationsConfined(op("/remote/ws/../etc/passwd"), {
|
|
sourceRoots: ["/host/src"], targetRoots: ["/remote/ws"],
|
|
})).toThrow(/confined absolute path|escapes its confinement root/);
|
|
});
|
|
|
|
it("rejects an absolute target outside every root", () => {
|
|
expect(() => assertSyncOperationsConfined(op("/etc/passwd"), {
|
|
sourceRoots: ["/host/src"], targetRoots: ["/remote/ws"],
|
|
})).toThrow(/escapes its confinement root/);
|
|
});
|
|
|
|
it("rejects a source outside every source root", () => {
|
|
expect(() => assertSyncOperationsConfined(op("/remote/ws/ok", "/etc/shadow"), {
|
|
sourceRoots: ["/host/src"], targetRoots: ["/remote/ws"],
|
|
})).toThrow(/escapes its confinement root/);
|
|
});
|
|
});
|