mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Give tasks durable company-scoped workspace bindings independent of project assignment, channel defaults and authorized repository preparation. Reuse verified input seeds and sparse output recovery while preserving trust, privacy and controller provisioning boundaries.
Full CI and E2E, Superagent, legacy checks and Greptile Apex 5/5 passed on reviewed head 753680b61c. All review findings are resolved; the PR records focused verification and historical acceptance limits.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
265 lines
18 KiB
TypeScript
265 lines
18 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import fs from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { createRequire } from "node:module";
|
|
import childProcess, { execFileSync } from "node:child_process";
|
|
import { prepareAdapterExecutionTargetRuntime } from "@paperclipai/adapter-utils/execution-target";
|
|
import { captureDirectorySnapshot } from "@paperclipai/adapter-utils/workspace-restore-merge";
|
|
import { prepareSandboxManagedRuntime } from "@paperclipai/adapter-utils/sandbox-managed-runtime";
|
|
import { agentDirectoryBaselineDigest, agentDirectoryProbeProgram, observeLocalAgentDirectory, probeAgentDirectory, retireAgentDirectoryTransferScratch, agentDirectoryTransferCleanupProgram } from "../services/agent-directory-probe.js";
|
|
|
|
describe("stable live agent directory observation", () => {
|
|
let root: string;
|
|
beforeEach(() => {
|
|
root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), "agent-directory-probe-")));
|
|
fs.mkdirSync(join(root, "notes"));
|
|
fs.writeFileSync(join(root, "AGENTS.md"), "Instructions\n");
|
|
fs.writeFileSync(join(root, "notes", "memory.bin"), Buffer.from([0, 1, 255]));
|
|
});
|
|
afterEach(() => { vi.restoreAllMocks(); fs.rmSync(root, { recursive: true, force: true }); });
|
|
function productionPrograms(): { probe: string; cleanup: string } {
|
|
// Product/dev starts the server with tsx. Vitest's own transform does not
|
|
// reproduce the lexical helpers injected into function.toString() there.
|
|
const loader = createRequire(import.meta.url).resolve("tsx");
|
|
const source = new URL("../services/agent-directory-probe.ts", import.meta.url).href;
|
|
const program = `import { agentDirectoryProbeProgram, agentDirectoryTransferCleanupProgram } from ${JSON.stringify(source)}; process.stdout.write(JSON.stringify({probe:agentDirectoryProbeProgram,cleanup:agentDirectoryTransferCleanupProgram}));`;
|
|
return JSON.parse(execFileSync(process.execPath, ["--import", loader, "--input-type=module", "--eval", program], {
|
|
cwd: root, env: { TSX_DISABLE_CACHE: "1", HOME: root, TMPDIR: root }, encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024,
|
|
}));
|
|
}
|
|
it("runs a self-contained live probe after the production tsx transform", () => {
|
|
const { probe } = productionPrograms();
|
|
const observe = () => JSON.parse(execFileSync(process.execPath, ["-e", probe, root], {
|
|
env: {}, encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024, stdio: "pipe",
|
|
}));
|
|
const before = probeAgentDirectory(root);
|
|
expect(observe()).toEqual(before);
|
|
fs.writeFileSync(join(root, "AGENTS.md"), "Changed instructions\n");
|
|
expect(observe()).toEqual(probeAgentDirectory(root));
|
|
expect(observe().digest).not.toBe(before.digest);
|
|
fs.symlinkSync(join(root, "AGENTS.md"), join(root, "unsafe"));
|
|
expect(observe).toThrow();
|
|
});
|
|
it("runs bounded transfer cleanup after the production tsx transform", () => {
|
|
const { cleanup } = productionPrograms();
|
|
const scratch = join(root, ".paperclip-runtime", "agent-files");
|
|
const retire = () => execFileSync(process.execPath, ["-e", cleanup, root], {
|
|
env: {}, encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024, stdio: "pipe",
|
|
});
|
|
fs.mkdirSync(scratch, { recursive: true });
|
|
fs.writeFileSync(join(scratch, "unexpected"), "retain");
|
|
expect(retire).toThrow();
|
|
expect(fs.readFileSync(join(scratch, "unexpected"), "utf8")).toBe("retain");
|
|
fs.unlinkSync(join(scratch, "unexpected"));
|
|
retire();
|
|
expect(fs.existsSync(join(root, ".paperclip-runtime"))).toBe(false);
|
|
expect(fs.readFileSync(join(root, "AGENTS.md"), "utf8")).toBe("Instructions\n");
|
|
expect(fs.readFileSync(join(root, "notes", "memory.bin"))).toEqual(Buffer.from([0, 1, 255]));
|
|
});
|
|
it("matches the complete materialized baseline and the actual remote Node program", async () => {
|
|
const baseline = await captureDirectorySnapshot(root);
|
|
const observed = probeAgentDirectory(root);
|
|
expect(observed.digest).toBe(agentDirectoryBaselineDigest(baseline));
|
|
const remote = JSON.parse(execFileSync(process.execPath, ["-e", agentDirectoryProbeProgram, root], { encoding: "utf8", env: {}, timeout: 10_000 }));
|
|
expect(remote).toEqual(observed);
|
|
});
|
|
it.runIf(process.platform === "darwin")("accepts only the observing Darwin host's fixed system aliases", () => {
|
|
const alias = root.replace(/^\/private(?=\/(tmp|var|etc)\/)/, "");
|
|
expect(alias).not.toBe(root);
|
|
const observed = probeAgentDirectory(root);
|
|
expect(probeAgentDirectory(alias)).toEqual(observed);
|
|
const remote = JSON.parse(execFileSync(process.execPath, ["-e", agentDirectoryProbeProgram, alias], { encoding: "utf8", env: {}, timeout: 10_000 }));
|
|
expect(remote).toEqual(observed);
|
|
// A Linux remote cannot inherit the controller's Darwin exception.
|
|
expect(() => execFileSync(process.execPath, ["-e", agentDirectoryProbeProgram.replace("platform:process.platform", "platform:'linux'"), alias],
|
|
{ encoding: "utf8", env: {}, timeout: 10_000, stdio: "pipe" })).toThrow();
|
|
fs.symlinkSync(root, join(root, "redirect"));
|
|
expect(() => probeAgentDirectory(`${alias}/redirect`)).toThrow();
|
|
});
|
|
it("observes the complete agent directory after the actual remote transfer", async () => {
|
|
const remote = `${root}-remote`;
|
|
fs.mkdirSync(remote);
|
|
const target = { kind: "remote" as const, transport: "sandbox" as const, remoteCwd: remote,
|
|
runner: { execute: async (input: Parameters<import("@paperclipai/adapter-utils/command-managed-runtime").CommandManagedRuntimeRunner["execute"]>[0]) => ({
|
|
stdout: execFileSync(input.command, input.args ?? [], { cwd: input.cwd, input: input.stdin, encoding: "utf8",
|
|
env: { PATH: process.env.PATH, ...input.env }, timeout: input.timeoutMs, maxBuffer: 32 * 1024 * 1024 }),
|
|
stderr: "", exitCode: 0, signal: null, timedOut: false, pid: null, startedAt: new Date().toISOString(),
|
|
}) } };
|
|
let runtime: Awaited<ReturnType<typeof prepareAdapterExecutionTargetRuntime>> | undefined;
|
|
try {
|
|
const baseline = await captureDirectorySnapshot(root);
|
|
runtime = await prepareAdapterExecutionTargetRuntime({ target, runId: "probe-transfer", adapterKey: "agent-files",
|
|
workspaceLocalDir: root, workspaceRemoteDir: remote, syncWorkspace: true, workspaceBaseline: baseline,
|
|
workspaceGitSnapshot: null, workspaceRepositories: [], workspaceFileMode: "all", workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] });
|
|
expect(fs.readdirSync(`${remote}/.paperclip-runtime`)).toEqual(["agent-files"]);
|
|
expect(fs.readdirSync(`${remote}/.paperclip-runtime/agent-files`)).toEqual([]);
|
|
execFileSync(process.execPath, ["-e", agentDirectoryTransferCleanupProgram, remote], { env: {}, timeout: 10_000 });
|
|
expect(() => probeAgentDirectory(remote)).not.toThrow();
|
|
expect(probeAgentDirectory(remote).digest).toBe(agentDirectoryBaselineDigest(baseline));
|
|
} finally { await runtime?.cleanupWorkspaceSnapshot?.(); fs.rmSync(remote, { recursive: true, force: true }); }
|
|
});
|
|
it.each([false, true])("round-trips the raw agent-directory repository namespace without discovery (recovering=%s)", async recovering => {
|
|
const remoteCwd = `${root}-remote`;
|
|
const agentHome = join(remoteCwd, ".paperclip-runtime", "agent-files", "agent", "original-run");
|
|
const scratch = join(remoteCwd, ".paperclip-runtime", "paperclip-runner", "agent-file-transfers", "agent", "original-run");
|
|
const rawFiles = {
|
|
".paperclip-repositories/notes.txt": "Ordinary agent memory\n",
|
|
".paperclip-repositories/archive/.git/config": "Raw nested configuration\n",
|
|
".paperclip-repositories/archive/.git/hooks/post-commit": "Raw nested hook\n",
|
|
".git/HEAD": "Raw root Git metadata\n",
|
|
"node_modules/memory.bin": "Ordinary cache directory bytes\n",
|
|
};
|
|
for (const [relative, contents] of Object.entries(rawFiles)) {
|
|
fs.mkdirSync(join(root, relative, ".."), { recursive: true });
|
|
fs.writeFileSync(join(root, relative), contents);
|
|
}
|
|
fs.mkdirSync(agentHome, { recursive: true });
|
|
if (recovering) fs.cpSync(root, agentHome, { recursive: true });
|
|
const target = { kind: "remote" as const, transport: "sandbox" as const, remoteCwd,
|
|
runner: { execute: async (input: Parameters<import("@paperclipai/adapter-utils/command-managed-runtime").CommandManagedRuntimeRunner["execute"]>[0]) => ({
|
|
stdout: execFileSync(input.command, input.args ?? [], { cwd: input.cwd, input: input.stdin, encoding: "utf8",
|
|
env: { PATH: process.env.PATH, ...input.env }, timeout: input.timeoutMs, maxBuffer: 32 * 1024 * 1024 }),
|
|
stderr: "", exitCode: 0, signal: null, timedOut: false, pid: null, startedAt: new Date().toISOString(),
|
|
}) } };
|
|
let runtime: Awaited<ReturnType<typeof prepareAdapterExecutionTargetRuntime>> | undefined;
|
|
try {
|
|
runtime = await prepareAdapterExecutionTargetRuntime({ target, runId: "plain-agent-directory", adapterKey: "agent-files",
|
|
workspaceLocalDir: root, workspaceRemoteDir: agentHome, runtimeRootDir: scratch, syncWorkspace: true,
|
|
workspaceInboundMode: recovering ? "adopt_remote" : undefined, workspaceBaseline: await captureDirectorySnapshot(root),
|
|
workspaceGitSnapshot: null, workspaceRepositories: [], workspaceFileMode: "all",
|
|
workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] });
|
|
for (const [relative, contents] of Object.entries(rawFiles)) expect(fs.readFileSync(join(agentHome, relative), "utf8")).toBe(contents);
|
|
expect(probeAgentDirectory(agentHome).digest).toBe(probeAgentDirectory(root).digest);
|
|
fs.writeFileSync(join(agentHome, ".paperclip-repositories/notes.txt"), "Updated memory\n");
|
|
fs.writeFileSync(join(agentHome, ".paperclip-repositories/archive/.git/hooks/post-commit"), "Updated raw hook\n");
|
|
fs.unlinkSync(join(agentHome, "notes/memory.bin"));
|
|
await runtime.restoreWorkspace();
|
|
expect(fs.readFileSync(join(root, ".paperclip-repositories/notes.txt"), "utf8")).toBe("Updated memory\n");
|
|
expect(fs.readFileSync(join(root, ".paperclip-repositories/archive/.git/hooks/post-commit"), "utf8")).toBe("Updated raw hook\n");
|
|
expect(fs.existsSync(join(root, "notes/memory.bin"))).toBe(false);
|
|
expect(fs.readFileSync(join(root, ".git/HEAD"), "utf8")).toBe(rawFiles[".git/HEAD"]);
|
|
expect(probeAgentDirectory(agentHome).digest).toBe(probeAgentDirectory(root).digest);
|
|
} finally { await runtime?.cleanupWorkspaceSnapshot?.(); fs.rmSync(remoteCwd, { recursive: true, force: true }); }
|
|
});
|
|
it("keeps restart fallback transfer scratch outside the native memory directory", async () => {
|
|
const remoteCwd = `${root}-remote`;
|
|
const agentHome = join(remoteCwd, ".paperclip-runtime", "agent-files", "agent", "original-run");
|
|
const scratch = join(remoteCwd, ".paperclip-runtime", "paperclip-runner", "agent-file-transfers", "agent", "original-run");
|
|
fs.mkdirSync(agentHome, { recursive: true });
|
|
fs.cpSync(root, agentHome, { recursive: true });
|
|
const target = { kind: "remote" as const, transport: "sandbox" as const, remoteCwd,
|
|
// A newly bound controller target may have no native sync hooks. Exercise
|
|
// the production command/base64 fallback rather than a transport mock.
|
|
runner: { execute: async (input: Parameters<import("@paperclipai/adapter-utils/command-managed-runtime").CommandManagedRuntimeRunner["execute"]>[0]) => ({
|
|
stdout: execFileSync(input.command, input.args ?? [], { cwd: input.cwd, input: input.stdin, encoding: "utf8",
|
|
env: { PATH: process.env.PATH, ...input.env }, timeout: input.timeoutMs, maxBuffer: 32 * 1024 * 1024 }),
|
|
stderr: "", exitCode: 0, signal: null, timedOut: false, pid: null, startedAt: new Date().toISOString(),
|
|
}) } };
|
|
let runtime: Awaited<ReturnType<typeof prepareAdapterExecutionTargetRuntime>> | undefined;
|
|
try {
|
|
const baseline = await captureDirectorySnapshot(root);
|
|
runtime = await prepareAdapterExecutionTargetRuntime({ target, runId: "restart-transfer", adapterKey: "agent-files",
|
|
workspaceLocalDir: root, workspaceRemoteDir: agentHome, runtimeRootDir: scratch,
|
|
syncWorkspace: true, workspaceInboundMode: "adopt_remote", workspaceBaseline: baseline,
|
|
workspaceGitSnapshot: null, workspaceRepositories: [], workspaceFileMode: "all", workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] });
|
|
const memory = "0123456789abcdef0123456789abcdef\n";
|
|
fs.writeFileSync(join(agentHome, "notes", "memory.txt"), memory);
|
|
await runtime.restoreWorkspace();
|
|
expect(fs.readFileSync(join(root, "notes", "memory.txt"), "utf8")).toBe(memory);
|
|
// A transfer must not introduce a reserved path that the product's own
|
|
// full agent-directory probe rejects, including after controller recovery.
|
|
expect(() => probeAgentDirectory(agentHome)).not.toThrow();
|
|
expect(fs.existsSync(join(agentHome, ".paperclip-runtime"))).toBe(false);
|
|
expect(runtime.runtimeRootDir).toBe(scratch);
|
|
expect(fs.readdirSync(scratch)).toEqual([]);
|
|
} finally { await runtime?.cleanupWorkspaceSnapshot?.(); fs.rmSync(remoteCwd, { recursive: true, force: true }); }
|
|
});
|
|
it.each(["outside", "reserved-root", "relative", "traversal", "trailing-slash", "nul"])("rejects %s transfer scratch before filesystem work", async kind => {
|
|
const reserved = join(root, ".paperclip-runtime");
|
|
const invalid = {
|
|
outside: `${root}-foreign/scratch`, "reserved-root": reserved, relative: ".paperclip-runtime/scratch",
|
|
traversal: `${reserved}/../../scratch`, "trailing-slash": `${reserved}/scratch/`, nul: `${reserved}/scratch\0`,
|
|
}[kind]!;
|
|
await expect(prepareSandboxManagedRuntime({
|
|
spec: { transport: "sandbox", provider: "fixture", sandboxId: "owned", remoteCwd: root, apiKey: null },
|
|
adapterKey: "agent-files", client: {} as never, workspaceLocalDir: root, runtimeRootDir: invalid,
|
|
})).rejects.toThrow("Transfer scratch must remain within the lease runtime tree");
|
|
expect(fs.existsSync(reserved)).toBe(false);
|
|
});
|
|
it.each(["file", "extra-directory", "symlink", "race"])("fails closed on %s in transfer-owned scratch without deleting contents", kind => {
|
|
const parent = join(root, ".paperclip-runtime"), scratch = join(parent, "agent-files");
|
|
fs.mkdirSync(scratch, { recursive: true });
|
|
const retained = join(parent, "retained.txt");
|
|
if (kind === "file") fs.writeFileSync(join(scratch, "retained.txt"), "keep");
|
|
if (kind === "extra-directory") fs.mkdirSync(join(parent, "unknown"));
|
|
if (kind === "symlink") { fs.rmdirSync(scratch); fs.symlinkSync(join(root, "notes"), scratch); }
|
|
if (kind === "race") {
|
|
const rmdir = fs.rmdirSync;
|
|
vi.spyOn(fs, "rmdirSync").mockImplementation(((directory: fs.PathLike) => {
|
|
if (directory === scratch) fs.writeFileSync(retained, "concurrent bytes");
|
|
return rmdir(directory);
|
|
}) as typeof rmdir);
|
|
}
|
|
expect(() => retireAgentDirectoryTransferScratch(root)).toThrow();
|
|
expect(fs.existsSync(parent)).toBe(true);
|
|
if (kind === "file") expect(fs.readFileSync(join(scratch, "retained.txt"), "utf8")).toBe("keep");
|
|
if (kind === "race") expect(fs.readFileSync(retained, "utf8")).toBe("concurrent bytes");
|
|
expect(fs.existsSync(join(root, "notes", "memory.bin"))).toBe(true);
|
|
});
|
|
it("runs the local observation in its bounded credential-free child", async () => {
|
|
expect(await observeLocalAgentDirectory(root)).toEqual(probeAgentDirectory(root));
|
|
});
|
|
it.each(["timeout", "oversized", "malformed"])("rejects %s local-child observations", async kind => {
|
|
const stub = vi.spyOn(childProcess, "execFile").mockImplementation(((file: string, args: string[], options: object, callback: (error: Error | null, stdout: string, stderr: string) => void) => {
|
|
expect(file).toBe(process.execPath);
|
|
expect(args).toEqual(["-e", agentDirectoryProbeProgram, root]);
|
|
expect(options).toMatchObject({ cwd: root, env: {}, timeout: 10_000, maxBuffer: 1024, killSignal: "SIGKILL" });
|
|
callback(kind === "timeout" ? Object.assign(new Error("timed out"), { killed: true, signal: "SIGKILL" }) : null,
|
|
kind === "oversized" ? "x".repeat(1025) : "{}", "");
|
|
return {} as childProcess.ChildProcess;
|
|
}) as typeof childProcess.execFile);
|
|
await expect(observeLocalAgentDirectory(root)).rejects.toThrow();
|
|
expect(stub).toHaveBeenCalledOnce();
|
|
});
|
|
it.each(["edit", "add", "remove", "mode"])("detects a legitimate %s", change => {
|
|
const before = probeAgentDirectory(root);
|
|
if (change === "edit") fs.writeFileSync(join(root, "AGENTS.md"), "New instructions\n");
|
|
if (change === "add") fs.writeFileSync(join(root, "new.txt"), "New");
|
|
if (change === "remove") fs.unlinkSync(join(root, "notes", "memory.bin"));
|
|
if (change === "mode") fs.chmodSync(join(root, "AGENTS.md"), 0o700);
|
|
expect(probeAgentDirectory(root).digest).not.toBe(before.digest);
|
|
});
|
|
it.each(["symlink", "hardlink", "reserved"])("rejects %s entries", kind => {
|
|
if (kind === "symlink") fs.symlinkSync(join(root, "AGENTS.md"), join(root, "unsafe"));
|
|
if (kind === "hardlink") fs.linkSync(join(root, "AGENTS.md"), join(root, "unsafe"));
|
|
if (kind === "reserved") fs.mkdirSync(join(root, ".paperclip-runtime"));
|
|
expect(() => probeAgentDirectory(root)).toThrow();
|
|
});
|
|
it("detects file mutation during a bounded read", () => {
|
|
const read = fs.readSync;
|
|
let changed = false;
|
|
vi.spyOn(fs, "readSync").mockImplementation(((...args: Parameters<typeof read>) => {
|
|
const result = Reflect.apply(read, fs, args);
|
|
if (!changed) { changed = true; fs.appendFileSync(join(root, "AGENTS.md"), "Concurrent change"); }
|
|
return result;
|
|
}) as typeof read);
|
|
expect(() => probeAgentDirectory(root)).toThrow(/changed|grew/);
|
|
});
|
|
it("fails on unreadable observations instead of reporting unchanged", () => {
|
|
vi.spyOn(fs, "openSync").mockImplementation(() => { throw Object.assign(new Error("denied"), { code: "EACCES" }); });
|
|
expect(() => probeAgentDirectory(root)).toThrow("denied");
|
|
});
|
|
it("does not let matching replacement bytes preserve the pinned root identity", () => {
|
|
const before = probeAgentDirectory(root);
|
|
const retired = `${root}-retired`;
|
|
fs.renameSync(root, retired);
|
|
try { fs.cpSync(retired, root, { recursive: true }); expect(probeAgentDirectory(root).identity).not.toBe(before.identity); }
|
|
finally { fs.rmSync(retired, { recursive: true, force: true }); }
|
|
});
|
|
it("rejects excluded/incomplete baselines", async () => {
|
|
const baseline = await captureDirectorySnapshot(root);
|
|
expect(() => agentDirectoryBaselineDigest({ ...baseline, exclude: ["notes"] })).toThrow("Incomplete");
|
|
});
|
|
});
|