Files
PaperClipAI/packages/adapter-utils/src/remote-managed-runtime.test.ts
T
DottaandPaperclip d172197117 feat(storage): add plain directory sync with conflict preflight (#14416)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent runs use workspace transport to restore and collect files.
> - Some directories belong to the agent across tasks.
> - Those directories need plain file transport without task Git state.
> - A concurrent file edit must be detected before a merge changes any
file.
> - This pull request adds optional plain-directory sync and conflict
preflight.
> - Existing task workspace sync keeps its defaults.

## Linked Issues or Issue Description

Refs #14325. This is the transport prerequisite for a replacement of its
instruction revision design with current agent files.

## What Changed

- Add an opt-in plain-directory transport mode to command and sandbox
runtimes.
- Add strict merge preflight for file edits, deletions, and directory
changes.
- Accept identical replay after an interrupted merge. Preserve competing
changes.
- Set the compiled OpenCode test executable to 0755, independent of the
CI host’s file-creation mask. Preserve the original startup error if
cleanup also fails.

## Verification

- At `ced53ae532ce6966cad5a83575d83db61af98126`, all 212 targeted
transport tests passed across workspace restore, remote managed runtime,
SSH fixture, and execution-target sandbox suites. Adapter-utils
typecheck passed.
- Real isolated SSH retry fixture previously passed with
`PAPERCLIP_ENABLE_DARWIN_SSH_ENV_LAB=1`; stale deleted files remain
absent while gitignored binary bytes survive.
- Dependent PR #14420 passed real native local, legacy local, and native
Daytona persistence E2E at `169fab46d5af21caa2269b4c1b29b69c933a6951`,
which includes all production transport changes through `ced53ae53`; the
subsequent two commits only fix the OpenCode test fixture. Nine tasks,
three server restarts, and all cleanup checks passed.
- A hosted OpenCode fixture failed twice at `ced53ae53`. Reproduced the
failure locally and in Linux with `umask 0002`: the compiler created a
group-writable executable, correctly rejected by the qualified launch
boundary. Explicit 0755 permissions fix the test without weakening the
production guard. The focused test and non-root Linux reproduction now
pass under that same mask.
- Before rebase, head `69e97de0475d34aac5d532e559a405eaf015fd2b`
includes the deterministic fixture permission fix and preserves original
bootstrap diagnostics. All production transport code is unchanged since
the 212-test validation. Fresh Greptile review is 5/5 on this exact head
with no unresolved findings. All 54 current-head checks passed, with two
conditional skips. The full CI run completed successfully, including the
previously failing OpenCode runner shard.

- Merge validation on rebased head
`c509d79dd190c5cb00dc65edfde209097ff21465`: all five commits are
patch-identical to the reviewed branch. All 54 checks passed with two
conditional skips, and fresh Greptile review is 5/5 with no findings.
One retry cleared an npm archive 404 and a Cursor fixture timeout.

## Risks

- New behavior is opt-in. Existing task snapshot behavior retains its
defaults.
- Generic strict merge preflight remains opt-in. The dependent
agent-folder feature rebases changed paths before applying them to
provide per-file last-sync-wins; it does not create a conflict-review
queue.
- This change adds no database migration, dependency, or UI.

## Model Used

OpenAI Codex, GPT-6 family. The session does not expose a more specific
model ID or context-window size. Reasoning, code execution, and tool use
assisted this change. Live provider validation used `gpt-5.6-sol`.

## 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>
2026-09-29 07:59:11 -05:00

353 lines
14 KiB
TypeScript

import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const {
prepareWorkspaceForSshExecution,
restoreWorkspaceFromSshExecution,
runSshCommand,
syncDirectoryToSsh,
} = vi.hoisted(() => ({
prepareWorkspaceForSshExecution: vi.fn(async () => ({ gitBacked: false })),
restoreWorkspaceFromSshExecution: vi.fn(async () => undefined),
runSshCommand: vi.fn(async () => ({
stdout: Buffer.from('{"token":"remote"}\n').toString("base64"),
stderr: "",
})),
syncDirectoryToSsh: vi.fn(async (_input: { localDir: string }) => undefined),
}));
vi.mock("./ssh.js", () => ({
prepareWorkspaceForSshExecution,
restoreWorkspaceFromSshExecution,
runSshCommand,
syncDirectoryToSsh,
}));
import { prepareRemoteManagedRuntime } from "./remote-managed-runtime.js";
import { resolveReferencedSourceIgnore } from "./sandbox-managed-runtime.js";
import { setExpensiveWorkspaceGitExecutor } from "./git-workspace-sync.js";
describe("remote managed runtime", () => {
const cleanupDirs: string[] = [];
it("stages all files over SSH without Git or cache exclusions and restores the same baseline", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-plain-"));
cleanupDirs.push(root);
await mkdir(path.join(root, "node_modules"));
await writeFile(path.join(root, ".gitignore"), "node_modules/\n");
await writeFile(path.join(root, "node_modules", "personal.bin"), Buffer.from([0, 255, 1]));
const prepared = await prepareRemoteManagedRuntime({
spec: { host: "127.0.0.1", port: 2222, username: "fixture", remoteWorkspacePath: "/app", remoteCwd: "/app",
privateKey: "PRIVATE KEY", knownHosts: "KNOWN HOSTS", strictHostKeyChecking: true },
runId: "plain", adapterKey: "test", workspaceLocalDir: root,
workspaceFileMode: "all", workspaceExclude: ["explicitly-excluded"],
});
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledWith(expect.objectContaining({
localDir: root, remoteDir: prepared.workspaceRemoteDir, workspaceFileMode: "all", workspaceExclude: ["explicitly-excluded"],
}));
await prepared.restoreWorkspace();
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(expect.objectContaining({
restoreGitHistory: false, baselineSnapshot: expect.objectContaining({
exclude: [".paperclip-runtime", "explicitly-excluded"],
entries: expect.any(Map),
}),
}));
const args = vi.mocked(restoreWorkspaceFromSshExecution).mock.calls[0] as unknown as [{ baselineSnapshot: { entries: Map<string, unknown> } }];
expect(args[0].baselineSnapshot.entries.has("node_modules/personal.bin")).toBe(true);
});
afterEach(async () => {
vi.clearAllMocks();
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
it("restores runtime assets without restoring an in-place SSH workspace", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-remote-runtime-assets-only-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const homeDir = path.join(rootDir, "home");
await mkdir(workspaceDir, { recursive: true });
await mkdir(homeDir, { recursive: true });
await writeFile(path.join(homeDir, "auth.json"), '{"token":"host"}\n', "utf8");
let restoredAuth = "";
const prepared = await prepareRemoteManagedRuntime({
spec: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: "/app",
remoteCwd: "/app",
privateKey: "PRIVATE KEY",
knownHosts: "KNOWN HOSTS",
strictHostKeyChecking: true,
},
runId: "run-in-place",
adapterKey: "codex",
workspaceLocalDir: workspaceDir,
workspaceRemoteDir: "/app",
syncWorkspace: false,
assets: [
{
key: "home",
localDir: homeDir,
restore: async ({ assetDir, readFile }) => {
restoredAuth = (await readFile(path.posix.join(assetDir, "auth.json"))).toString("utf8");
},
},
],
});
expect(prepareWorkspaceForSshExecution).not.toHaveBeenCalled();
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({
localDir: homeDir,
remoteDir: "/app/.paperclip-runtime/codex/home",
}));
await prepared.restoreWorkspace();
expect(restoreWorkspaceFromSshExecution).not.toHaveBeenCalled();
expect(runSshCommand).toHaveBeenCalledWith(
expect.anything(),
"base64 < '/app/.paperclip-runtime/codex/home/auth.json'",
{ maxBuffer: 1024 * 1024 },
);
expect(restoredAuth).toBe('{"token":"remote"}\n');
});
it("stages each additional project into its own isolated SSH dir, isolating one failure", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-remote-runtime-additional-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const firstDir = path.join(rootDir, "referenced-first");
const secondDir = path.join(rootDir, "referenced-second");
const brokenDir = path.join(rootDir, "referenced-broken");
await mkdir(workspaceDir, { recursive: true });
// The transfer rejects only for the broken project's directory.
syncDirectoryToSsh.mockImplementation(async (input: { localDir: string }) => {
if (input.localDir === brokenDir) throw new Error("ssh transfer failed");
return undefined;
});
const prepared = await prepareRemoteManagedRuntime({
spec: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: "/app",
remoteCwd: "/app",
privateKey: "PRIVATE KEY",
knownHosts: "KNOWN HOSTS",
strictHostKeyChecking: true,
},
runId: "run-additional",
adapterKey: "codex",
workspaceLocalDir: workspaceDir,
workspaceRemoteDir: "/app",
syncWorkspace: false,
additionalSources: [
{ localPath: firstDir, projectId: "first", ignoreResolution: { kind: "other" } },
{ localPath: brokenDir, projectId: "broken", ignoreResolution: { kind: "other" } },
{ localPath: secondDir, projectId: "second", ignoreResolution: { kind: "other" } },
],
});
// Each healthy project staged into its OWN isolated dir under the runtime
// root; the broken one is skipped, not fatal.
expect(Object.keys(prepared.additionalSourceDirs).sort()).toEqual(["first", "second"]);
expect(prepared.additionalSourceDirs.first).toBe("/app/.paperclip-runtime/codex/project-first");
expect(prepared.additionalSourceDirs.second).toBe("/app/.paperclip-runtime/codex/project-second");
expect(prepared.additionalSourceDirs.broken).toBeUndefined();
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({
localDir: firstDir,
remoteDir: "/app/.paperclip-runtime/codex/project-first",
}));
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({
localDir: secondDir,
remoteDir: "/app/.paperclip-runtime/codex/project-second",
}));
});
it("skips an additional project whose localPath is not absolute", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-remote-runtime-relative-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const healthyDir = path.join(rootDir, "referenced-healthy");
await mkdir(workspaceDir, { recursive: true });
const prepared = await prepareRemoteManagedRuntime({
spec: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: "/app",
remoteCwd: "/app",
privateKey: "PRIVATE KEY",
knownHosts: "KNOWN HOSTS",
strictHostKeyChecking: true,
},
runId: "run-relative",
adapterKey: "codex",
workspaceLocalDir: workspaceDir,
workspaceRemoteDir: "/app",
syncWorkspace: false,
additionalSources: [
{ localPath: "relative/referenced", projectId: "relative", ignoreResolution: { kind: "other" } },
{ localPath: healthyDir, projectId: "healthy", ignoreResolution: { kind: "other" } },
],
});
// The relative-path project never reaches the transfer and is skipped; the
// absolute-path project still stages.
expect(Object.keys(prepared.additionalSourceDirs)).toEqual(["healthy"]);
expect(syncDirectoryToSsh).not.toHaveBeenCalledWith(expect.objectContaining({
localDir: "relative/referenced",
}));
});
it("passes a project's resolved Git-ignored paths to the SSH exclude list, escaped", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-remote-runtime-ignore-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const projectDir = path.join(rootDir, "referenced-project");
await mkdir(workspaceDir, { recursive: true });
await prepareRemoteManagedRuntime({
spec: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: "/app",
remoteCwd: "/app",
privateKey: "PRIVATE KEY",
knownHosts: "KNOWN HOSTS",
strictHostKeyChecking: true,
},
runId: "run-ignore",
adapterKey: "codex",
workspaceLocalDir: workspaceDir,
workspaceRemoteDir: "/app",
syncWorkspace: false,
additionalSources: [
{
localPath: projectDir,
projectId: "proj",
ignoreResolution: { kind: "git", ignoredPaths: ["secret.env", "build", "weird[1].txt"] },
},
],
});
const call = syncDirectoryToSsh.mock.calls.find((entry) => entry[0].localDir === projectDir);
expect(call).toBeDefined();
const exclude = (call![0] as { exclude?: string[] }).exclude ?? [];
// The resolved ignored paths ride the exclude list, glob-escaped, on top of
// the fixed heavy-directory excludes the SSH lane already applies.
expect(exclude).toContain("secret.env");
expect(exclude).toContain("build");
expect(exclude).toContain("weird\\[1].txt");
expect(exclude).toContain("node_modules");
});
it("skips a project whose ignore resolution failed without ever calling syncDirectoryToSsh for it", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-remote-runtime-failed-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const healthyDir = path.join(rootDir, "referenced-healthy");
const failedDir = path.join(rootDir, "referenced-failed");
await mkdir(workspaceDir, { recursive: true });
const prepared = await prepareRemoteManagedRuntime({
spec: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: "/app",
remoteCwd: "/app",
privateKey: "PRIVATE KEY",
knownHosts: "KNOWN HOSTS",
strictHostKeyChecking: true,
},
runId: "run-failed",
adapterKey: "codex",
workspaceLocalDir: workspaceDir,
workspaceRemoteDir: "/app",
syncWorkspace: false,
additionalSources: [
{ localPath: healthyDir, projectId: "healthy", ignoreResolution: { kind: "other" } },
{ localPath: failedDir, projectId: "failed", ignoreResolution: { kind: "failed", reason: "git status timed out" } },
],
});
// Fail closed: the failed project never reaches the transfer at all — no
// bytes are sent for it — while the healthy project still stages.
expect(Object.keys(prepared.additionalSourceDirs)).toEqual(["healthy"]);
expect(syncDirectoryToSsh).not.toHaveBeenCalledWith(expect.objectContaining({ localDir: failedDir }));
});
it("never leaks a raw absolute path into the remote per-project staging warning", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-remote-runtime-redact-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const failedDir = path.join(rootDir, "referenced-failed");
await mkdir(workspaceDir, { recursive: true });
await mkdir(failedDir, { recursive: true });
// A raw toplevel string that makes `failedDir` a non-descendant, carrying
// a sensitive absolute path — exactly the shape a caught Git diagnostic
// could embed. `resolveReferencedSourceIgnore` is the single choke point
// that must reduce it to the fixed category before anything downstream
// (here, the remote lane's warning) ever sees it.
const sensitivePath = "/srv/alice/project";
let ignoreResolution;
try {
setExpensiveWorkspaceGitExecutor(async (input) => {
if (input.operation === "referenced_source.toplevel") {
return { stdout: `${sensitivePath}\n`, stderr: "" };
}
return { stdout: "", stderr: "" };
});
ignoreResolution = await resolveReferencedSourceIgnore(failedDir);
} finally {
setExpensiveWorkspaceGitExecutor(null);
}
expect(ignoreResolution.kind).toBe("failed");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
try {
await prepareRemoteManagedRuntime({
spec: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: "/app",
remoteCwd: "/app",
privateKey: "PRIVATE KEY",
knownHosts: "KNOWN HOSTS",
strictHostKeyChecking: true,
},
runId: "run-redact",
adapterKey: "codex",
workspaceLocalDir: workspaceDir,
workspaceRemoteDir: "/app",
syncWorkspace: false,
additionalSources: [{ localPath: failedDir, projectId: "failed", ignoreResolution }],
});
const warnedText = warnSpy.mock.calls.map((call) => call.join(" ")).join("\n");
expect(warnedText).toContain("failed");
expect(warnedText).not.toContain(sensitivePath);
expect(warnedText).not.toContain(failedDir);
} finally {
warnSpy.mockRestore();
}
});
});