mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 14:02:36 +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>
107 lines
6.8 KiB
TypeScript
107 lines
6.8 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { execFile } from "node:child_process";
|
|
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { canonicalProviderPackJson, verifyRemoteProviderPack, type ProviderPackIdentity } from "./remote-provider-pack-verification.js";
|
|
|
|
const sha = (text: string) => `sha256:${createHash("sha256").update(text).digest("hex")}`;
|
|
const manifest = (revision: string): ProviderPackIdentity => {
|
|
const payload = {
|
|
compatibility: { opencode: 1, acpx: 1 },
|
|
pins: { opencode: "1.18.34", nodeMinimum: "24.11.0" },
|
|
target: { platform: "linux", architecture: "x64" },
|
|
runnerSourceRevision: revision.repeat(40),
|
|
artifacts: Object.fromEntries(Object.entries({
|
|
nodeCommand: "node_modules/node/bin/node", opencodeCommand: "node_modules/.bin/opencode",
|
|
productionLock: "pnpm-lock.yaml", opencodeExecutable: "node_modules/opencode-ai/bin/opencode.exe",
|
|
opencodeProxy: "dist/cli/opencode-app-server-proxy.cjs", acpxSidecar: "dist/cli/acpx-runtime-sidecar.cjs",
|
|
grokLauncher: "dist/providers/grok/launcher.cjs",
|
|
}).map(([name, path]) => [name, { path, sha256: sha(`${revision}-${name}`) }])) as ProviderPackIdentity["payload"]["artifacts"],
|
|
};
|
|
return { schema: "paperclip-runner/remote-provider-pack/v1", payload, digest: sha(canonicalProviderPackJson(payload)) };
|
|
};
|
|
|
|
describe("installed provider pack compatibility", () => {
|
|
it("reuses a protected pack across app revisions without executing verification commands", async () => {
|
|
const installed = manifest("a");
|
|
const current = structuredClone(installed);
|
|
current.payload.runnerSourceRevision = "b".repeat(40);
|
|
current.digest = sha(canonicalProviderPackJson(current.payload));
|
|
const root = await mkdtemp(join(tmpdir(), "installed-pack-compatibility-"));
|
|
await mkdir(dirname(join(root, installed.payload.artifacts.nodeCommand.path)), { recursive: true });
|
|
await symlink(process.execPath, join(root, installed.payload.artifacts.nodeCommand.path));
|
|
await writeFile(join(root, "provider-pack.json"), JSON.stringify(installed));
|
|
const execute = vi.fn(async (request: Parameters<CommandManagedRuntimeRunner["execute"]>[0]) => {
|
|
try {
|
|
const result = await promisify(execFile)(request.command, request.args ?? [], { cwd: root, timeout: 2_000 });
|
|
return { ...result, exitCode: 0, signal: null, timedOut: false, pid: null, startedAt: new Date().toISOString() };
|
|
} catch (error) {
|
|
const failed = error as Error & { code: number; stdout: string; stderr: string };
|
|
if (typeof failed.code !== "number") throw error;
|
|
return { stdout: failed.stdout, stderr: failed.stderr, exitCode: failed.code, signal: null, timedOut: false, pid: null, startedAt: new Date().toISOString() };
|
|
}
|
|
});
|
|
const input = { runner: { execute }, cwd: root, packRoot: root, manifest: current };
|
|
try {
|
|
// Exercise the old complete-manifest comparison in a real Node process.
|
|
// Only provenance changed; provider bytes and interface are identical.
|
|
await expect(verifyRemoteProviderPack(input)).rejects.toThrow("manifest mismatch");
|
|
expect(execute).toHaveBeenCalledOnce();
|
|
execute.mockClear();
|
|
const selected = await verifyRemoteProviderPack({ ...input,
|
|
installation: { root, manifest: installed, immutable: true }, provider: { kind: "opencode" } });
|
|
expect(selected).toEqual(installed);
|
|
expect(execute).not.toHaveBeenCalled();
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
const reseal = (pack: ProviderPackIdentity) => ({ ...pack, digest: sha(canonicalProviderPackJson(pack.payload)) });
|
|
const check = (installed: ProviderPackIdentity, options: { immutable?: boolean; requireExactIdentity?: boolean } = {}) => {
|
|
const execute = vi.fn();
|
|
return verifyRemoteProviderPack({ runner: { execute }, cwd: "/workspace", packRoot: "/opt/paperclip-runner/provider-pack",
|
|
manifest: manifest("b"), installation: { root: "/opt/paperclip-runner/provider-pack", manifest: installed, immutable: options.immutable ?? true },
|
|
provider: { kind: "opencode" }, requireExactIdentity: options.requireExactIdentity });
|
|
};
|
|
it.each(["interface", "platform", "schema", "artifact_path", "digest", "legacy"])("rejects an incompatible %s", async field => {
|
|
let pack = manifest("a");
|
|
if (field === "interface") pack.payload.compatibility!.opencode += 1;
|
|
if (field === "legacy") delete pack.payload.compatibility;
|
|
if (field === "platform") pack.payload.target.architecture = "arm64";
|
|
if (field === "schema") pack.schema = "unknown";
|
|
if (field === "artifact_path") pack.payload.artifacts.nodeCommand.path = "../node";
|
|
pack = reseal(pack);
|
|
if (field === "digest") pack.digest = sha("corrupt");
|
|
await expect(check(pack)).rejects.toThrow("runner_remote_provider_artifact_incompatible");
|
|
});
|
|
it("keeps writable installations at the content-verification boundary", async () => {
|
|
await expect(check(manifest("b"), { immutable: false })).rejects.toThrow("installation is incompatible");
|
|
});
|
|
it("does not replace an admitted pack during reattachment", async () => {
|
|
await expect(check(manifest("a"), { requireExactIdentity: true })).rejects.toThrow("incompatible");
|
|
await expect(check(manifest("b"), { requireExactIdentity: true })).resolves.toEqual(manifest("b"));
|
|
});
|
|
it("ignores unrelated provider and dependency changes for OpenCode", async () => {
|
|
const pack = manifest("a");
|
|
pack.payload.compatibility!.acpx += 1;
|
|
pack.payload.pins.acpx = "different-version";
|
|
pack.payload.acpxProfileDigests = { codex: sha("different-codex") };
|
|
await expect(check(reseal(pack))).resolves.toEqual(reseal(pack));
|
|
});
|
|
it("checks only the selected ACPX profile", async () => {
|
|
const current = manifest("b"), installed = manifest("a");
|
|
current.payload.acpxProfileDigests = { claude: sha("claude"), codex: sha("codex") };
|
|
installed.payload.acpxProfileDigests = { claude: sha("claude"), codex: sha("other-codex") };
|
|
const input = { runner: { execute: vi.fn() }, cwd: "/workspace", packRoot: "/opt/paperclip-runner/provider-pack",
|
|
manifest: reseal(current), installation: { root: "/opt/paperclip-runner/provider-pack", manifest: reseal(installed), immutable: true },
|
|
provider: { kind: "acpx" as const, agent: "claude" } };
|
|
await expect(verifyRemoteProviderPack(input)).resolves.toEqual(reseal(installed));
|
|
await expect(verifyRemoteProviderPack({ ...input, provider: { kind: "acpx", agent: "codex" } })).rejects.toThrow("profile is incompatible");
|
|
});
|
|
|
|
});
|