mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native Runner starts provider runtimes in remote execution environments. > - Sandbox images already contain qualified provider packs. > - Startup required the image pack to match the entire current app release manifest. > - An unrelated app commit could therefore cause pack verification, transfer, and installation again. > - This pull request selects protected packs by their provider interface and platform. > - The benefit is no extra pack probe, directory scan, version command, link, or upload on the compatible image path. ## Linked Issues or Issue Description **What happened?** Starting a remote task can reject a compatible image pack because its source revision or unrelated provider differs from the controller. Pack replacement added about 16 seconds in the investigated startup. Startup also repeated full content checks. **Expected behavior** Use an already qualified, protected pack when its selected provider interface and target platform are compatible. Keep the selected artifact identity for process launch and recovery. **Steps to reproduce** 1. Install a qualified provider pack in a sandbox image. 2. Start a controller built from another app commit. 3. Start a remote OpenCode task. The old complete-manifest comparison rejects the image pack even when the provider interface still works. Related repairs: #16493 reduced transfer cost. #17512 retained exact artifact authority during reattachment. This change preserves that recovery rule and changes fresh-launch pack selection. ## What Changed - Add explicit OpenCode and ACPX interface revisions to produced pack manifests. App Git SHAs and dependency pins remain provenance. - Read bounded protected-installation metadata in the existing final runner metadata query. Accept only root-owned paths that the execution user cannot write, including ancestors and manifest. Reject writable aliases. - Select and prepare the pack in server-owned environment setup before constructing the existing transport. Bind provider launch paths and digests from the selected installation. Save that identity for exact reattachment. - Keep full content verification for uploads, writable installations, and legacy packs. Reuse successful verification within one startup. Keep older attached runners compatible with the optional metadata request. - Seal image packs and document the one-time image refresh that enables reuse across later compatible releases. Keep selected ACPX agent qualification checks. ## Verification - Red/green regression: the old code rejects a protected pack from another app revision with `manifest mismatch`; the fixed path selects it without executing verification commands. - Native startup regression: select the installed pack's own Node digest with one existing runner metadata request and no pack verification or version commands. - Targeted pack, artifact, recovery, execution-driver, and Rust metadata tests pass. Incompatible interfaces/platforms, malformed identities, writable roots, path aliases, and exact reattachment are covered. - Final revision `91dc1676ff67bdbe5866c8586daac10240530cca`: workspace typecheck and build pass. All 53 current-head checks are successful, with no pending or failed checks. Greptile is 5/5 with no open findings. - The broad local `pnpm test:run` attempt reported a workspace-runtime conflict test failure; its isolated rerun passed. The broad run was stopped after that failure. Full CI now passes, including the server test shards. This is not a claim that the full local run passed. - Linux filesystem evidence: the production metadata functions read a real root-owned preinstalled pack as a non-root user in 16 microseconds median and 20 microseconds p95 across 100 warm reads. This measures metadata admission, not model or hosted response latency. - All 910 affected server tests and 57 Copilot attestation tests pass locally on the final implementation. Full CI covers the unchanged Runner transport. No provider qualification hash was refreshed. - Built the actual Linux daemon at `416e7ec7425d0d3565b4a5e1808002d650a60414` and ran its metadata command as UID 65534. The protected installation returned its real manifest with `immutable: true`; writable alias and missing-pack cases returned `immutable: false`. This is metadata/permission evidence, not provider execution. - Hosted rollout and a hosted end-to-end latency measurement have not run. ## Risks - Existing images need one rebuild to publish the compatibility declaration. Packs without it keep the legacy content-verification path. - Fast admission relies on image publication producing a qualified protected tree. Root execution and task-writable installations retain full verification. - Bump an interface revision when required runner/pack behavior changes incompatibly. Deploy a new image to roll out compatible runtime fixes or upgrades. - This removes provider-pack preparation delay. Other runner startup, context, model, and finalization costs remain separate. ## Model Used OpenAI Codex, configured model `gpt-6.1-sol`, reasoning effort `xhigh`, with tool use and code execution. The context-window size is 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 `#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>
180 lines
8.8 KiB
TypeScript
180 lines
8.8 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { setImmediate } from "node:timers/promises";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
|
|
import { createRemoteNativeArtifactPreparation } from "./native-session-executor.js";
|
|
|
|
type Request = Parameters<CommandManagedRuntimeRunner["execute"]>[0];
|
|
const roots: string[] = [];
|
|
const metadata = {
|
|
schema: "paperclip-runner/runnerd-build-metadata/v1",
|
|
binaryName: "paperclip-runnerd",
|
|
packageName: "@paperclipai/paperclip-runner",
|
|
binaryContractVersion: 2,
|
|
durableSessionCapabilities: ["unlimited_runtime", "connection_lease_renewal"],
|
|
prpTransportModes: ["dial_ws_loopback", "dial_wss", "listen_ws"],
|
|
};
|
|
|
|
async function fixture(input: {
|
|
concurrent?: boolean;
|
|
before?: (request: Request) => Promise<void>;
|
|
} = {}) {
|
|
const root = await mkdtemp(join(tmpdir(), "native-artifact-preparation-"));
|
|
roots.push(root);
|
|
const home = join(root, "home");
|
|
const bin = join(home, ".local", "bin");
|
|
const cwd = join(root, "workspace");
|
|
await mkdir(bin, { recursive: true });
|
|
await mkdir(cwd);
|
|
const runnerSource = join(bin, "paperclip-runnerd");
|
|
const codexSource = join(bin, "codex");
|
|
const runnerScript = `#!/bin/sh\n[ "$1" = --build-metadata ] || exit 17\nprintf '%s\\n' '${JSON.stringify(metadata)}'\n`;
|
|
await writeFile(runnerSource, runnerScript, { mode: 0o700 });
|
|
await writeFile(codexSource, "#!/bin/sh\nprintf '%s\\n' 'codex-cli 0.160.0'\n", { mode: 0o700 });
|
|
let active = 0;
|
|
let maxActive = 0;
|
|
const requests: Request[] = [];
|
|
const runner: CommandManagedRuntimeRunner = {
|
|
allowConcurrentSyncOperations: input.concurrent ?? true,
|
|
async execute(request) {
|
|
requests.push(request);
|
|
active++;
|
|
maxActive = Math.max(maxActive, active);
|
|
try {
|
|
await input.before?.(request);
|
|
return await new Promise((resolve, reject) => {
|
|
const startedAt = new Date().toISOString();
|
|
const child = execFile(request.command, request.args ?? [], {
|
|
cwd: request.cwd ?? cwd,
|
|
env: { HOME: home, PATH: `${bin}:${process.env.PATH ?? "/usr/bin:/bin"}` },
|
|
timeout: 10_000,
|
|
}, (error, stdout, stderr) => {
|
|
if (error && typeof error.code !== "number") return reject(error);
|
|
resolve({ exitCode: typeof error?.code === "number" ? error.code : 0,
|
|
signal: error?.signal ?? null, pid: child.pid ?? null, startedAt,
|
|
timedOut: error?.killed === true, stdout, stderr });
|
|
});
|
|
child.stdin?.end(request.stdin);
|
|
});
|
|
} finally {
|
|
active--;
|
|
}
|
|
},
|
|
};
|
|
const runtimeRoot = join(cwd, ".paperclip-runtime");
|
|
const runnerTarget = join(runtimeRoot, "bin", "paperclip-runnerd");
|
|
const codexTarget = join(runtimeRoot, "bin", "codex");
|
|
const options: Parameters<typeof createRemoteNativeArtifactPreparation>[0] = {
|
|
target: { kind: "remote", transport: "sandbox", providerKey: "daytona", remoteCwd: cwd, runner },
|
|
runner,
|
|
remoteBinary: runnerTarget,
|
|
controllerRunnerBinary: runnerSource,
|
|
remoteRuntimeRoot: runtimeRoot,
|
|
remoteCodexBinary: codexTarget,
|
|
targetPlatformArtifactVerified: true,
|
|
};
|
|
return { options, requests, runnerSource, runnerTarget, codexSource, codexTarget,
|
|
runnerScript, active: () => active, maxActive: () => maxActive };
|
|
}
|
|
|
|
const discovers = (request: Request, name: string) => request.command === "sh"
|
|
&& request.args?.some(value => value.includes(`for candidate in /opt/paperclip-runner/bin/${name} `));
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })));
|
|
});
|
|
|
|
describe("remote native artifact preparation", () => {
|
|
it("overlaps the default runner and harness while retaining both executable checks", async () => {
|
|
const f = await fixture();
|
|
await createRemoteNativeArtifactPreparation(f.options).prepare("listen_ws");
|
|
expect(f.maxActive()).toBe(2);
|
|
expect(f.active()).toBe(0);
|
|
expect(await readFile(f.runnerTarget, "utf8")).toBe(f.runnerScript);
|
|
expect(await readFile(f.runnerSource, "utf8")).toBe(f.runnerScript);
|
|
expect(f.requests.filter(request => request.command === f.runnerTarget && request.args?.[0] === "--build-metadata")).toHaveLength(1);
|
|
expect(f.requests.filter(request => request.command === f.codexTarget && request.args?.[0] === "--version")).toHaveLength(1);
|
|
expect(f.requests.some(request => request.command === "npm")).toBe(false);
|
|
});
|
|
|
|
it("reads installed pack metadata in the existing final runner probe", async () => {
|
|
const f = await fixture();
|
|
const installation = { root: "/opt/paperclip-runner/provider-pack", manifest: { digest: "selected" }, immutable: true };
|
|
await writeFile(f.runnerSource, `#!/bin/sh\n[ "$1" = --build-metadata ] || exit 17\nprintf '%s\\n' '${JSON.stringify({ ...metadata, providerPacks: [installation, { ...installation, root: "/unrequested" }] })}'\n`, { mode: 0o700 });
|
|
const artifacts = createRemoteNativeArtifactPreparation({ ...f.options, providerPackRoots: [installation.root] });
|
|
await artifacts.prepare("listen_ws");
|
|
expect(artifacts.providerPackInventory()).toEqual([installation]);
|
|
const probes = f.requests.filter(request => request.command === f.runnerTarget && request.args?.[0] === "--build-metadata");
|
|
expect(probes).toHaveLength(1);
|
|
expect(probes[0].args).toEqual(["--build-metadata", "--provider-pack-root", installation.root]);
|
|
expect(f.requests.some(request => request.args?.includes("--version") && request.command.includes("provider-pack"))).toBe(false);
|
|
});
|
|
|
|
it("reattaches an older runner without the optional pack metadata argument", async () => {
|
|
const f = await fixture();
|
|
await writeFile(f.runnerSource, `#!/bin/sh\n[ "$#" = 1 ] && [ "$1" = --build-metadata ] || exit 17\nprintf '%s\\n' '${JSON.stringify(metadata)}'\n`, { mode: 0o700 });
|
|
const artifacts = createRemoteNativeArtifactPreparation({ ...f.options, providerPackRoots: ["/opt/paperclip-runner/provider-pack"] });
|
|
await artifacts.verifyRemoteRunner("listen_ws", f.runnerSource, true);
|
|
expect(artifacts.providerPackInventory()).toEqual([]);
|
|
expect(f.requests.map(request => request.args)).toEqual([
|
|
["--build-metadata", "--provider-pack-root", "/opt/paperclip-runner/provider-pack"], ["--build-metadata"],
|
|
]);
|
|
});
|
|
|
|
it("keeps providers without concurrent sync support serial", async () => {
|
|
const f = await fixture({ concurrent: false });
|
|
await createRemoteNativeArtifactPreparation(f.options).prepare("listen_ws");
|
|
expect(f.maxActive()).toBe(1);
|
|
});
|
|
|
|
it("keeps an explicit harness install after runner verification", async () => {
|
|
const f = await fixture();
|
|
await createRemoteNativeArtifactPreparation({ ...f.options, runnerRemoteCodexPath: f.codexSource }).prepare("listen_ws");
|
|
expect(f.maxActive()).toBe(1);
|
|
const verified = f.requests.findIndex(request => request.command === f.runnerTarget);
|
|
const harness = f.requests.findIndex(request => request.command === f.codexTarget);
|
|
expect(verified).toBeGreaterThan(-1);
|
|
expect(harness).toBeGreaterThan(verified);
|
|
});
|
|
|
|
it("waits for an admitted harness operation before returning the runner failure", async () => {
|
|
const failure = new Error("runner discovery unavailable");
|
|
let releaseHarness!: () => void;
|
|
const harnessGate = new Promise<void>(resolve => { releaseHarness = resolve; });
|
|
let harnessStarted = false;
|
|
const f = await fixture({ before: async request => {
|
|
if (discovers(request, "paperclip-runnerd")) throw failure;
|
|
if (discovers(request, "codex")) { harnessStarted = true; await harnessGate; }
|
|
} });
|
|
let rejected = false;
|
|
const work = createRemoteNativeArtifactPreparation(f.options).prepare("listen_ws").catch(error => {
|
|
rejected = true;
|
|
return error;
|
|
});
|
|
try {
|
|
await setImmediate();
|
|
expect(harnessStarted).toBe(true);
|
|
expect(rejected).toBe(false);
|
|
} finally {
|
|
releaseHarness();
|
|
expect(await work).toBe(failure);
|
|
}
|
|
expect(f.active()).toBe(0);
|
|
expect(f.requests.some(request => request.command === f.codexTarget)).toBe(true);
|
|
});
|
|
|
|
it("preserves the runner failure when both concurrent preparations fail", async () => {
|
|
const runnerFailure = new Error("runner discovery unavailable");
|
|
const harnessFailure = new Error("harness discovery unavailable");
|
|
const f = await fixture({ before: async request => {
|
|
if (discovers(request, "paperclip-runnerd")) throw runnerFailure;
|
|
if (discovers(request, "codex")) throw harnessFailure;
|
|
} });
|
|
await expect(createRemoteNativeArtifactPreparation(f.options).prepare("listen_ws")).rejects.toBe(runnerFailure);
|
|
expect(f.active()).toBe(0);
|
|
});
|
|
});
|