diff --git a/Dockerfile b/Dockerfile index 4b87db9e82f..36a9c707cf2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -194,7 +194,7 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh COPY --chown=node:node --from=build /app /app -COPY --from=runner-provider-pack /provider-pack /opt/paperclip-runner/provider-pack +COPY --chown=0:0 --from=runner-provider-pack /provider-pack /opt/paperclip-runner/provider-pack COPY --from=runner-provider-pack /provider-pack-transport/ /opt/paperclip-runner/ # Managed deployments can remap node's UID at startup. This immutable pack # contains public code and integrity metadata, never credentials; it must remain diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index cd78289ac2e..3715e0983da 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -939,8 +939,11 @@ as the source Runner package. For native OpenCode and Cursor on Daytona, a complete installed Paperclip release also selects its packaged Linux daemon and provider-pack identity automatically. -Use the matching Paperclip Daytona image: the controller verifies the image's -pack manifest, executable hashes, bridge tree, and pinned versions before launch. +Use a qualified Paperclip Daytona image. For an OS-protected pack, startup +checks the selected provider interface and platform from its small manifest in +the existing runner metadata query. It does not rescan the pack or require the +same app source revision. Writable or legacy installations retain full content +verification. The controller's operating system does not determine the remote daemon target. Native Claude remote execution also selects the Linux x64 or ARM64 daemon and @@ -2192,7 +2195,15 @@ Historical v1 `provider-pack.tar.zst` receipts remain readable. Missing sidecars remote zstd, compatible GNU tar/checksum tools, or the Node 18+ helper retain directory staging; invalid sidecars fail closed. Keep transport files outside the pack so they cannot include themselves. -Matching staged/preinstalled packs remain preferred and never transfer archives. +Compatible protected image packs remain preferred and never transfer archives. +The Runner's `src/live/provider-pack-contract.json` declares interface revisions; +bump only the affected interface when its runner/pack contract breaks. Qualify +and seal packs at image publication. The fast path requires root-owned, +non-writable ancestors and manifest, and rejects writable aliases. The selected +pack's own artifact identities feed launch and the recovery receipt. Reattachment +keeps that exact identity; compatibility is a fresh-launch selection rule. +Older packs without the declaration retain content verification until the image +is updated. Selected ACPX agent profile checks remain in force. Daytona detects gzip/zstd headers and skips redundant transport compression. ### Remote GitHub launcher staging diff --git a/docker/daytona-runner/Dockerfile b/docker/daytona-runner/Dockerfile index 649df1fc9c2..39b606cd0c5 100644 --- a/docker/daytona-runner/Dockerfile +++ b/docker/daytona-runner/Dockerfile @@ -110,7 +110,7 @@ COPY packages/paperclip-runner/src/providers/grok/platforms.json /tmp/grok-provi RUN node /tmp/grok-provision/scripts/provision-grok.mjs /opt/paperclip/providers/grok/1.0.13/grok && rm -rf /tmp/grok-provision COPY --from=runnerd-build /workspace/packages/paperclip-runner/runner/target/release/paperclip-runnerd /usr/local/bin/paperclip-runnerd -COPY --from=provider-pack-build /provider-pack /opt/paperclip-runner/provider-pack +COPY --chown=0:0 --from=provider-pack-build /provider-pack /opt/paperclip-runner/provider-pack RUN set -eu; for cli in codex claude opencode; do \ printf '#!/bin/sh\nexec /opt/paperclip-runner/provider-pack/node_modules/.bin/%s "$@"\n' "$cli" > "/usr/local/bin/$cli"; \ chmod 755 "/usr/local/bin/$cli"; \ diff --git a/docker/daytona-runner/Dockerfile.release b/docker/daytona-runner/Dockerfile.release index d146a17572b..ea27e84a6b8 100644 --- a/docker/daytona-runner/Dockerfile.release +++ b/docker/daytona-runner/Dockerfile.release @@ -4,7 +4,7 @@ FROM ${BASE_IMAGE} ARG SOURCE_REVISION USER root RUN rm -rf /opt/paperclip-runner/provider-pack -ADD provider-pack.tar.gz /opt/paperclip-runner/provider-pack/ +ADD --chown=0:0 provider-pack.tar.gz /opt/paperclip-runner/provider-pack/ COPY --chmod=0755 paperclip-runnerd /usr/local/bin/paperclip-runnerd RUN chmod -R a+rX /opt/paperclip-runner/provider-pack USER daytona diff --git a/docker/daytona-runner/README.md b/docker/daytona-runner/README.md index 1e9b5f7c2be..6b56a538f91 100644 --- a/docker/daytona-runner/README.md +++ b/docker/daytona-runner/README.md @@ -10,8 +10,8 @@ This image is the Paperclip Cloud fleet sandbox image plus a source-built `paperclip-runnerd` and immutable provider pack. The pack contains Node 24.11, OpenCode 1.18.34, the compiled OpenCode proxy, ACPX 0.13.1 sidecar, qualified ACP agents, and the production lockfile. Its manifest digests each executable bridge -and binds the pack to the runner source revision, avoiding artifact upload and -npm installation on every fresh lease. +and records the runner source revision as provenance. Its interface declaration +allows compatible app releases to reuse it without uploading or installing a pack. The fleet pins are intentionally copied from [`paperclip-cloud/fleet-sandbox-image/Dockerfile`](https://github.com/paperclipai/paperclip-cloud/blob/master/fleet-sandbox-image/Dockerfile). @@ -94,11 +94,26 @@ editor's **Configure image** flow to produce a Daytona snapshot. Set the environment image to that immutable tag or snapshot. Paperclip probes the sandbox user's `PATH` for `paperclip-runnerd` and `codex` and checks `/opt/paperclip-runner/provider-pack` for OpenCode and ACPX. It uses the pack -only when its complete manifest matches the controller's build-owned pack; -otherwise it stages the pack configured by +when its selected provider interface and platform are compatible. App source +revisions and unrelated provider pins do not force an update. Startup reads the +small manifest in the existing runner metadata query. It does not scan the pack, +run a version command, or transfer files on this path. Otherwise it stages the pack configured by `PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH`. Remote OpenCode and ACPX never fall back to host-local processes. +`src/live/provider-pack-contract.json` in the Runner package declares the +OpenCode and ACPX interface revisions. Change a revision only for an incompatible +runner/pack boundary change, and qualify the new pack before publishing it. +Dependency versions and Git SHAs remain build provenance. ACPX retains its +selected agent's qualified profile checks. + +The image build makes the pack root-owned and non-writable to the execution user. Fast admission +requires protected ancestors and manifest, with no writable path alias. Task-owned +packs and older packs without an interface declaration keep full content +verification. An attached session retains the exact pack and daemon identity +admitted at launch. Publish one new image with the interface declaration to enable +reuse across later compatible app releases. + Do not promote `paperclip-runner-e2e-20260826-v2` for OpenCode or ACPX. Build a new immutable image or snapshot from a clean committed revision and pass that full Git SHA as `PAPERCLIP_RUNNER_SOURCE_REVISION`. diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs index b1a64a8554c..938d4618008 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs @@ -1,11 +1,11 @@ use std::fs::{self, OpenOptions}; -use std::io::{self, Write}; +use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::time::Duration; #[cfg(unix)] -use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; use paperclip_runner_core::durable::{ capture_bootstrap_ticket, redact_diagnostic_text, run_durable_runner, AcpxLaunchProfile, @@ -132,6 +132,89 @@ fn build_metadata() -> serde_json::Value { }) } +// The publisher seals image installations. Check only path metadata at startup; +// no directory walk or provider executable runs here. Writable aliases are not trusted. +fn protected_pack_path(path: &Path) -> bool { + #[cfg(unix)] + return fs::symlink_metadata(path).is_ok_and(|metadata| { + metadata.uid() == 0 + && metadata.permissions().mode() & 0o022 == 0 + && !metadata.file_type().is_symlink() + && rustix::fs::access(path, rustix::fs::Access::WRITE_OK).is_err() + }); + #[cfg(not(unix))] + { + let _ = path; + false + } +} + +fn provider_pack_inventory(root: &Path) -> serde_json::Value { + let read = || -> io::Result { + let resolved = fs::canonicalize(root)?; + let manifest_path = resolved.join("provider-pack.json"); + if root != resolved + || !resolved.ancestors().all(protected_pack_path) + || !protected_pack_path(&manifest_path) + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "writable pack installation", + )); + } + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + options.custom_flags( + (rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::NONBLOCK).bits() as i32, + ); + let file = options.open(&manifest_path)?; + if !file.metadata()?.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "pack manifest is not a file", + )); + } + let mut bytes = Vec::new(); + file.take(65_537).read_to_end(&mut bytes)?; + if bytes.len() > 65_536 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "pack manifest too large", + )); + } + Ok(serde_json::from_slice(&bytes)?) + }; + match read() { + Ok(manifest) => json!({"root": root, "manifest": manifest, "immutable": true}), + Err(_) => json!({"root": root, "manifest": null, "immutable": false}), + } +} + +fn metadata_with_provider_packs(args: &[String]) -> Result { + let mut metadata = build_metadata(); + let mut packs = Vec::new(); + let mut options = args[1..].chunks_exact(2); + for pair in &mut options { + if pair[0] != "--provider-pack-root" + || packs.len() >= 3 + || !Path::new(&pair[1]).is_absolute() + { + return Err(LocalRunnerError::invalid( + "invalid provider pack metadata request", + )); + } + packs.push(provider_pack_inventory(Path::new(&pair[1]))); + } + if !options.remainder().is_empty() { + return Err(LocalRunnerError::invalid( + "missing provider pack metadata root", + )); + } + metadata["providerPacks"] = json!(packs); + Ok(metadata) +} + fn value(args: &[String], name: &str) -> Result { let index = args .iter() @@ -355,8 +438,8 @@ fn run(args: &[String]) -> Result<(), LocalRunnerError> { return paperclip_runner_core::stdio_mcp::run_proxy(Path::new(&args[1]), args.len() == 3) .map_err(|e| LocalRunnerError::invalid(e.to_string())); } - if args == ["--build-metadata"] { - println!("{}", build_metadata()); + if args.first().map(String::as_str) == Some("--build-metadata") { + println!("{}", metadata_with_provider_packs(args)?); return Ok(()); } if args.iter().any(|argument| argument == "--connect-url") @@ -466,6 +549,68 @@ mod tests { ); } + #[test] + fn pack_inventory_rejects_task_owned_roots_and_writable_aliases() { + let directory = + std::env::temp_dir().join(format!("pack-inventory-{}", uuid::Uuid::new_v4())); + fs::create_dir(&directory).unwrap(); + fs::write(directory.join("provider-pack.json"), "{}").unwrap(); + let args = vec![ + "--build-metadata".to_owned(), + "--provider-pack-root".to_owned(), + directory.to_string_lossy().into_owned(), + ]; + let metadata = metadata_with_provider_packs(&args).unwrap(); + assert_eq!(metadata["providerPacks"][0]["immutable"], false); + assert_eq!( + metadata["providerPacks"][0]["manifest"], + serde_json::Value::Null + ); + #[cfg(unix)] + { + let alias = directory.join("alias"); + std::os::unix::fs::symlink("/usr", &alias).unwrap(); + assert_eq!(provider_pack_inventory(&alias)["immutable"], false); + assert!(!protected_pack_path(&alias)); + assert!(!protected_pack_path(&directory)); + } + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn pack_metadata_requests_are_bounded_and_missing_packs_do_not_hide_runner_metadata() { + for options in [ + vec!["--provider-pack-root"], + vec!["--other", "/opt"], + vec!["--provider-pack-root", "relative"], + vec![ + "--provider-pack-root", + "/a", + "--provider-pack-root", + "/b", + "--provider-pack-root", + "/c", + "--provider-pack-root", + "/d", + ], + ] { + let mut args = vec!["--build-metadata".to_owned()]; + args.extend(options.into_iter().map(str::to_owned)); + assert!(metadata_with_provider_packs(&args).is_err()); + } + let metadata = metadata_with_provider_packs(&[ + "--build-metadata".to_owned(), + "--provider-pack-root".to_owned(), + "/missing-provider-pack".to_owned(), + ]) + .unwrap(); + assert_eq!(metadata["binaryContractVersion"], 2); + assert_eq!( + metadata["providerPacks"][0]["manifest"], + serde_json::Value::Null + ); + } + #[test] fn startup_installs_a_crypto_provider_before_tls_initialization() { let _ = run_main(vec!["--build-metadata".to_owned()]); diff --git a/packages/paperclip-runner/scripts/build-provider-pack.mjs b/packages/paperclip-runner/scripts/build-provider-pack.mjs index 585950b6a46..bc421593276 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.mjs @@ -1,4 +1,5 @@ import profiles from "../acpx-profiles.json" with { type: "json" }; +import compatibility from "../src/live/provider-pack-contract.json" with { type: "json" }; import { createHash } from "node:crypto"; import { execFileSync, spawnSync } from "node:child_process"; import { @@ -312,6 +313,7 @@ try { cwd: workspaceRoot, }).status !== 0; const payload = { + compatibility, pins: { nodeMinimum: minimumNodeVersion.join("."), codex: profiles.profiles.codex.agentRuntimeVersion, diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index 675b2c6caea..8a35f307a32 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -93,7 +93,7 @@ export * from "./compatibility.js"; export { probeAcpxClaudeInstallation, probeAcpxGrokInstallation } from "./drivers/acpx/installation-integrity.js"; export { probeAcpxPiInstallation } from "./drivers/acpx/profile-installation.js"; -export { bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary } from "./live/bundled-remote-provider-pack.js"; +export { PROVIDER_PACK_COMPATIBILITY, bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary } from "./live/bundled-remote-provider-pack.js"; export { RunnerdMuseDriver, describeRunnerdMuseDriver, type RunnerdMuseDriverOptions } from "./drivers/muse/runnerd-muse-driver.js"; export { RunnerdDotDriver, type RunnerdDotDriverOptions } from "./drivers/dot/runnerd-dot-driver.js"; diff --git a/packages/paperclip-runner/src/live/bundled-remote-provider-pack.ts b/packages/paperclip-runner/src/live/bundled-remote-provider-pack.ts index 1dd24d7588d..7b0f6265692 100644 --- a/packages/paperclip-runner/src/live/bundled-remote-provider-pack.ts +++ b/packages/paperclip-runner/src/live/bundled-remote-provider-pack.ts @@ -1,9 +1,13 @@ +import providerPackCompatibility from "./provider-pack-contract.json" with { type: "json" }; import { fileURLToPath } from "node:url"; import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { runnerBinaryTarget } from "./runner-binary.js"; +// Interface revisions change only when the runner/pack boundary changes incompatibly. +export const PROVIDER_PACK_COMPATIBILITY = Object.freeze(providerPackCompatibility); + /** Release-owned identity for the pack already installed in the Daytona image. */ export type BundledRemoteProviderPackTarget = "linux-x64" | "linux-arm64"; diff --git a/packages/paperclip-runner/src/live/provider-pack-contract.json b/packages/paperclip-runner/src/live/provider-pack-contract.json new file mode 100644 index 00000000000..a0f57806ca9 --- /dev/null +++ b/packages/paperclip-runner/src/live/provider-pack-contract.json @@ -0,0 +1,4 @@ +{ + "opencode": 1, + "acpx": 1 +} diff --git a/server/src/services/native-runtime/native-artifact-preparation.test.ts b/server/src/services/native-runtime/native-artifact-preparation.test.ts index 5e5186fe2a0..676004f5fab 100644 --- a/server/src/services/native-runtime/native-artifact-preparation.test.ts +++ b/server/src/services/native-runtime/native-artifact-preparation.test.ts @@ -100,6 +100,30 @@ describe("remote native artifact preparation", () => { 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"); diff --git a/server/src/services/native-runtime/native-runner-execution-driver.test.ts b/server/src/services/native-runtime/native-runner-execution-driver.test.ts index 830544e863b..4958984c22e 100644 --- a/server/src/services/native-runtime/native-runner-execution-driver.test.ts +++ b/server/src/services/native-runtime/native-runner-execution-driver.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; import type { RunnerIngressEndpoint } from "@paperclipai/adapter-utils/runner-connectivity"; -import { createNativeRunnerExecutionDriver } from "./native-runner-execution-driver.js"; +import { prepareNativeRemoteRunner, createNativeRunnerExecutionDriver } from "./native-runner-execution-driver.js"; const transport = vi.hoisted(() => ({ register: vi.fn(), @@ -181,3 +181,12 @@ describe("shared Runner execution driver", () => { await expect(connection.ready!()).rejects.toThrow("process_identity_invalid"); }); }); + +it("validates connectivity before resolving provider artifacts without acquiring an ingress endpoint", async () => { + const prepare = vi.fn(async () => {}); + await expect(prepareNativeRemoteRunner({ target: ssh, runId: "run", prepareRemote: prepare })).rejects.toThrow("runner_transport_ineligible"); + expect(prepare).not.toHaveBeenCalled(); + await prepareNativeRemoteRunner({ target: sandbox(), runnerIngressAuthorized: true, runId: "run", prepareRemote: prepare }); + expect(prepare).toHaveBeenCalledExactlyOnceWith("listen_ws"); + expect(transport.connect).not.toHaveBeenCalled(); +}); diff --git a/server/src/services/native-runtime/native-runner-execution-driver.ts b/server/src/services/native-runtime/native-runner-execution-driver.ts index e6c456e4c52..a7b8cda4feb 100644 --- a/server/src/services/native-runtime/native-runner-execution-driver.ts +++ b/server/src/services/native-runtime/native-runner-execution-driver.ts @@ -59,6 +59,30 @@ export function resolveRemoteRunnerTransportMode(input: { return requiredMode; } +type RemotePreparation = Pick & { runId: string }; + +async function resolveOutboundTransport(input: RemotePreparation): Promise { + const transport = await resolvePaperclipRunnerTransport({ target: input.target, runId: input.runId, + localConnectUrl: "ws://127.0.0.1/unused", runnerPublicUrl: input.runnerPublicUrl, + runnerCaBundlePath: input.runnerCaBundlePath, runnerIngressAuthorized: input.runnerIngressAuthorized === true }); + if (transport.mode === "direct_outbound" && transport.caBundlePath && !existsSync(transport.caBundlePath)) { + throw new Error("runner_direct_wss_failed: configured runner CA bundle is unavailable"); + } + return transport; +} + +/** Resolve artifacts before provider-profile binding. Endpoint acquisition stays + * in connection registration; outbound eligibility is checked before staging. + */ +export async function prepareNativeRemoteRunner(input: RemotePreparation) { + const mode = resolveRemoteRunnerTransportMode({ target: input.target, + runnerIngressAuthorized: input.runnerIngressAuthorized === true }); + if (mode === "dial_wss") await resolveOutboundTransport(input); + if (!input.prepareRemote) throw new Error("runner_remote_preparation_unavailable"); + await input.prepareRemote(mode); +} + /** Built-in environment lifecycle. PRP commands and authentication are shared; * only environment preparation, process ownership, and connection routing vary. */ @@ -116,28 +140,8 @@ export function createNativeRunnerExecutionDriver(input: NativeRunnerExecutionDr transport = await measureNativeRunnerSpan( input.trace, "runner.transport.resolve", - () => - resolvePaperclipRunnerTransport({ - target: input.target, - runId: - attachmentIdentity?.runId ?? - input.binding.runId, - localConnectUrl: "ws://127.0.0.1/unused", - runnerPublicUrl: input.runnerPublicUrl, - runnerCaBundlePath: input.runnerCaBundlePath, - runnerIngressAuthorized: - input.runnerIngressAuthorized === true, - }), + () => resolveOutboundTransport({ ...input, runId: attachmentIdentity?.runId ?? input.binding.runId }), ); - if ( - transport.mode === "direct_outbound" && - transport.caBundlePath && - !existsSync(transport.caBundlePath) - ) { - throw new Error( - "runner_direct_wss_failed: configured runner CA bundle is unavailable", - ); - } await measureNativeRunnerSpan( input.trace, "runner.artifact.prepare", diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index c0d18a93957..6d604beb461 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -1440,6 +1440,7 @@ describe("remote provider pack manifest", () => { opencodeProxy: "dist/cli/opencode-app-server-proxy.cjs", acpxSidecar: "dist/cli/acpx-runtime-sidecar.cjs", }).map(([key, path]) => [key, { path, sha256: digest }])) as Record<"nodeCommand" | "opencodeCommand", { path: string; sha256: string }>; const payload = { + compatibility: { opencode: 1, acpx: 1 }, target: { platform: "linux", architecture }, runnerSourceRevision: "a".repeat(40), pins: { nodeMinimum: "24.11.0", codex: QUALIFIED_ACPX_PROFILES.codex.agentRuntimeVersion, opencode: "1.18.34", acpx: "0.13.1", grok: QUALIFIED_ACPX_PROFILES.grok.agentRuntimeVersion, @@ -1449,6 +1450,11 @@ describe("remote provider pack manifest", () => { distDigest: digest, artifacts, bridgeDigest: "sha256:" + createHash("sha256").update(digest + "\n" + digest + "\n" + digest).digest("hex"), }; + if (provider === "cursor") Object.assign(payload, { providers: { cursor: { + version: QUALIFIED_ACPX_PROFILES.cursor.agentServerVersion, profileDigest: QUALIFIED_ACPX_PROFILES.cursor.commandDigest, + closureDigest: `sha256:${CURSOR_DISTRIBUTION_PINS["linux-x64"].closureSha256}`, qualification: "qualified", + path: "provider-assets/cursor/linux-x64", sha256: digest, + } } }); const manifest = { schema: "paperclip-runner/remote-provider-pack/v1", payload, digest: "sha256:" + createHash("sha256").update(canonical(payload)).digest("hex") }; state.bundledPackManifestPath = join(root, "provider-pack.json"); @@ -1463,11 +1469,22 @@ describe("remote provider pack manifest", () => { workspace: { ...execution.workspace, cwd: root }, session: { ...execution.session, normalizedSessionId: `bundled-${provider}-${randomUUID()}`, driverKind: provider === "opencode" ? "opencode_server" : "acpx_runtime" }, } as unknown as NativeExecutionInputV1; - const syncIn = vi.fn(async () => undefined); - const execute = vi.fn(async (command: { command: string; args?: string[] }) => { - if (command.args?.[0] === "--build-metadata") throw new Error("reached-staged-Linux-daemon-verification"); - return { exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "" }; - }); + const syncIn = vi.fn(async (_operations: { files: { kind: string }[] }[]) => undefined); + const installed = structuredClone(manifest); + if (provider === "opencode") { + installed.payload.runnerSourceRevision = "c".repeat(40); + installed.payload.artifacts.nodeCommand.sha256 = "sha256:" + "d".repeat(64); + installed.digest = "sha256:" + createHash("sha256").update(canonical(installed.payload)).digest("hex"); + } + const execute = vi.fn(async (command: { command: string; args?: string[] }) => ({ + exitCode: 0, signal: null, timedOut: false, stderr: "", + stdout: command.args?.[0] === "--build-metadata" ? JSON.stringify({ + schema: "paperclip-runner/runnerd-build-metadata/v1", binaryName: "paperclip-runnerd", + packageName: "@paperclipai/paperclip-runner", binaryContractVersion: 2, + durableSessionCapabilities: ["unlimited_runtime", "connection_lease_renewal"], prpTransportModes: ["listen_ws"], + providerPacks: [{ root: "/opt/paperclip-runner/provider-pack", manifest: installed, immutable: true }], + }) : "", + })); try { await createRunnerdBackend({ db: leaseDb(remoteExecution), execution: remoteExecution, runnerInstanceId: "bundled-image-runner", runnerIngressAuthorized: true, @@ -1491,12 +1508,17 @@ describe("remote provider pack manifest", () => { const { bundledRemoteProviderPackManifestPath } = await import("../../vendor/paperclip-runner/index.js"); expect(bundledRemoteProviderPackManifestPath).toHaveBeenCalledWith(undefined, `linux-${architecture}`); } - await expect(transport.controlPlaneRegistration({})).rejects.toThrow("reached-staged-Linux-daemon-verification"); expect(syncIn).toHaveBeenCalledWith([expect.objectContaining({ files: [expect.objectContaining({ sourcePath: state.bundledRunnerBinary, targetPath: "/workspace/.paperclip-runtime/paperclip-runner/bin/paperclip-runnerd", })] })]); expect(execute.mock.calls.some(([call]) => call.args?.[1] === "uname -s; uname -m")).toBe(false); expect(existsSync(join(transport.stateDirectory!, "provider-pack-authority.json"))).toBe(false); + expect(transport).toMatchObject({ providerPackAuthorityDigest: installed.digest, + providerNodeCommand: "/opt/paperclip-runner/provider-pack/node_modules/node/bin/node", + providerNodeCommandSha256: installed.payload.artifacts.nodeCommand.sha256 }); + expect(execute.mock.calls.filter(([call]) => call.args?.[0] === "--build-metadata")).toHaveLength(1); + expect(execute.mock.calls.some(([call]) => call.args?.[0] === "-e" || call.args?.[0] === "--version")).toBe(false); + expect(syncIn.mock.calls.every(([operations]) => operations.every(operation => operation.files.every(file => file.kind === "file")))).toBe(true); if (provider === "claude-x64") { const identity = transport.prpIdentity!; const artifact = { version: "0.0.0", digest: "sha256:" + createHash("sha256").update(daemon).digest("hex") }; @@ -1514,24 +1536,30 @@ describe("remote provider pack manifest", () => { await writeFile(state.bundledPackManifestPath!, JSON.stringify(next)); await writeFile(state.bundledRunnerBinary!, Buffer.concat([daemon, Buffer.from("new release")])); const recoveryExecute = vi.fn(async (request: { args: string[] }) => { + if (request.args[0] === "--build-metadata") return { exitCode: 0, timedOut: false, stderr: "", stdout: JSON.stringify({ + schema: "paperclip-runner/runnerd-build-metadata/v1", binaryName: "paperclip-runnerd", + packageName: "@paperclipai/paperclip-runner", binaryContractVersion: 2, + durableSessionCapabilities: ["unlimited_runtime", "connection_lease_renewal"], prpTransportModes: ["listen_ws"], + providerPacks: [{ root: "/opt/paperclip-runner/provider-pack", manifest, immutable: true }], + }) }; + if (request.args[0] === "-e" || request.args[0] === "--version") return { exitCode: 0, timedOut: false, stderr: "", stdout: request.args[0] === "--version" ? "1.18.34" : "" }; const script = request.args[1]; const stdout = script.includes("base64") ? Buffer.from(JSON.stringify({ ...identity, lifecycle: "running" })).toString("base64") : script.includes("cat --") ? `nonce\n123\n2026-09-19T10:00:00.000Z\n${identity.runnerInstanceId}\nlinux:abcd-1234:100\n` : ""; return { stdout, stderr: "", exitCode: 0, timedOut: false }; }); await createRunnerdBackend({ db: leaseDb(remoteExecution), execution: remoteExecution, - runnerInstanceId: identity.runnerInstanceId, + runnerInstanceId: identity.runnerInstanceId, runnerIngressAuthorized: true, runnerExecutionTarget: { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", - environmentId: "environment", leaseId: "lease", providerKey: "daytona", runner: { execute: recoveryExecute }, + environmentId: "environment", leaseId: "lease", providerKey: "daytona", effectiveCapabilities: { runnerWebSocketIngress: true }, runner: { execute: recoveryExecute }, sandboxLeaseAcquisition: { outcome: "resumed", providerLeaseId: "sandbox" } } as never, restartRecovery: { kind: "reattach_remote_runner", runId: remoteExecution.binding.runId, leaseOwner: "controller", controllerGeneration: 2, providerAttempt: 1, restartKind: "graceful", recoveryRequestId: null, remote: { providerLeaseId: "sandbox", remoteCwd: "/workspace" } }, }); state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!(); - expect(state.createTransport.mock.calls.at(-1)![0]).toMatchObject({ - providerPackAuthorityDigest: manifest.digest, runnerArtifact: artifact, adoptExistingRunner: { pid: 123 }, - }); + const recovered = state.createTransport.mock.calls.at(-1)![0]; + expect(recovered).toMatchObject({ providerPackAuthorityDigest: manifest.digest, runnerArtifact: artifact, adoptExistingRunner: { pid: 123 } }); } } finally { state.bundledPackManifestPath = null; state.bundledRunnerBinary = null; diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 7bea16d6e0d..b1d19d462e0 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -1,5 +1,5 @@ import { providerPackWorkspaceId, recordAdmittedRemoteProviderPack, resolveRemoteProviderPackAuthority } from "./remote-provider-pack-authority.js"; -import { verifyRemoteProviderPack as verifyRemoteProviderPackIdentity } from "./remote-provider-pack-verification.js"; +import { type ProviderPackInstallation, verifyRemoteProviderPack as verifyRemoteProviderPackIdentity } from "./remote-provider-pack-verification.js"; import { writeEnvironmentCleanup } from "../environment-cleanup-notifications.js"; import { DELIVERY_QUEUES } from "../delivery-work-notifications.js"; import { writeExecutionControl } from "../execution-control-notifications.js"; @@ -23,7 +23,7 @@ import { isCloudManagedInstance } from "../cloud-instance.js"; import { dotRunnerBroker } from "../dot-runner-broker.js"; import { CURSOR_DISTRIBUTION_PINS, QUALIFIED_ACPX_PROFILES, QUALIFIED_ACPX_VERSION } from "../../vendor/paperclip-runner/index.js"; import { isProviderMode } from "../../vendor/paperclip-runner/index.js"; -import { bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary, runnerBinaryTarget } from "../../vendor/paperclip-runner/index.js"; +import { PROVIDER_PACK_COMPATIBILITY, bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary, runnerBinaryTarget } from "../../vendor/paperclip-runner/index.js"; import { nativeRetryCancellationEligible, rethrowNativeCancellationLockConflict, assertCancellationRequest, cancellationIntentId as callerCancellationIntentId, cancellationRequestId } from "./native-cancellation-request.js"; import { readNativePlanWait } from "./native-plan-wait.js"; import { nativeProviderLifecycle } from "./provider-lifecycle.js"; @@ -133,7 +133,7 @@ import { createNativeSshCommandRunner } from "./native-ssh-command-runner.js"; import { discoverRemoteExecutable, parseRemoteExecutableCandidate } from "./codex-model-fallback.js"; export { parseRemoteExecutableCandidate } from "./codex-model-fallback.js"; import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime"; -import { createNativeRunnerExecutionDriver, resolveRemoteRunnerTransportMode } from "./native-runner-execution-driver.js"; +import { prepareNativeRemoteRunner, createNativeRunnerExecutionDriver, resolveRemoteRunnerTransportMode } from "./native-runner-execution-driver.js"; import type { Db } from "@paperclipai/db"; import { and, @@ -10316,6 +10316,7 @@ type RemoteProviderPackManifest = { schema: typeof REMOTE_PROVIDER_PACK_SCHEMA; digest: string; payload: { + compatibility?: { opencode: number; acpx: number }; pins: typeof REMOTE_PROVIDER_PACK_PINS; target: { platform: string; architecture: string }; runnerSourceRevision: string; @@ -10427,6 +10428,7 @@ function readRemoteProviderPackIdentity(packRoot: string, verifyControllerFiles: if ( manifest?.schema !== REMOTE_PROVIDER_PACK_SCHEMA || !payload || + (payload.compatibility !== undefined && canonicalJson(payload.compatibility) !== canonicalJson(PROVIDER_PACK_COMPATIBILITY)) || canonicalJson(payload.pins) !== canonicalJson(REMOTE_PROVIDER_PACK_PINS) || canonicalJson(payload.acpxProfileDigests) !== canonicalJson(REMOTE_PROVIDER_PACK_PROFILE_DIGESTS) || @@ -12141,7 +12143,9 @@ async function createRunnerdBackendWithinSessionClaim( || !lstatSync(configuredProviderPackRoot).isDirectory()) { throw new Error("runner_remote_provider_artifact_incompatible: configure PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH with the build-owned provider pack"); } - return readRemoteProviderPackManifest(configuredProviderPackRoot); + // Selection reads identity only. Content verification belongs to the + // install path; a compatible image pack needs no controller tree scan. + return readRemoteProviderPackIdentity(configuredProviderPackRoot, false); })(); return currentProviderPackManifest; }; @@ -12261,6 +12265,7 @@ async function createRunnerdBackendWithinSessionClaim( : Promise.resolve(); const verifyRemoteProviderPack = async (packRoot: string) => { + if (providerPackVerified && packRoot === activeRemoteProviderPackRoot) return; if (!remoteTarget || !remoteCommandRunner || !expectedProviderPackManifest) return; await verifyRemoteProviderPackIdentity({ runner: remoteCommandRunner, @@ -12293,6 +12298,8 @@ async function createRunnerdBackendWithinSessionClaim( model: input.execution.provider.kind === "codex" ? input.execution.provider.model : null, reuseRetainedRunner: sandboxLeaseAcquisition?.outcome === "resumed", targetPlatformArtifactVerified: remotePiCompanion !== null || useBundledRemoteImageAssets || targetMatchedCodexArtifact || remoteClaude, trace: input.trace, onLog: input.onLog, + providerPackRoots: requiresRemoteProviderPack && stagedRemoteProviderPackRoot + ? [stagedRemoteProviderPackRoot, "/opt/paperclip-runner/provider-pack"] : undefined, }) : null; const verifyRemoteRunner = async (mode: "dial_wss" | "listen_ws", executable?: string | null) => { @@ -12300,6 +12307,33 @@ async function createRunnerdBackendWithinSessionClaim( }; const verifyRemoteCodex = async () => { await remoteNativeArtifacts?.verifyRemoteCodex(); }; + // A custom operator-selected pack is an explicit runtime selection. The + // standard app-image path is the default transport source, not an upgrade request. + const explicitProviderPackSelection = Boolean(input.runnerRemoteProviderPackPath?.trim() + && input.runnerRemoteProviderPackPath.trim() !== "/opt/paperclip-runner/provider-pack"); + const useInstalledProviderPack = async () => { + const expected = expectedProviderPackManifest; + if (!expected || !remoteTarget || !remoteCommandRunner) return false; + for (const installation of remoteNativeArtifacts?.providerPackInventory() ?? []) { + try { + const manifest = await measureNativeRunnerSpan(input.trace, "provider_pack.compatibility", () => verifyRemoteProviderPackIdentity({ + runner: remoteCommandRunner, cwd: remoteTarget.remoteCwd, packRoot: installation.root, + manifest: expected, installation, + provider: input.execution.provider as { kind: "opencode" } | { kind: "acpx"; agent: string }, + requireExactIdentity: reattachProviderPack || explicitProviderPackSelection, + })); + if (!reattachProviderPack) providerPackAuthority!.select(manifest); + expectedProviderPackManifest = manifest; + activeRemoteProviderPackRoot = installation.root; + providerPackVerified = true; + return true; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") throw error; + } + } + return false; + }; + const prepareRemoteRunner = async ( requiredMode: "dial_wss" | "listen_ws", ) => { @@ -12312,7 +12346,11 @@ async function createRunnerdBackendWithinSessionClaim( return; selectedRemoteMode = requiredMode; if (input.restartRecovery?.kind === "reattach_remote_runner" || input.remoteWarmRecovery) { - await verifyRemoteRunner(requiredMode); + await remoteNativeArtifacts?.verifyRemoteRunner(requiredMode, undefined, true); + if (requiresRemoteProviderPack && await useInstalledProviderPack()) { + remotePrepared = true; + return; + } if (requiresRemoteProviderPack && stagedRemoteProviderPackRoot) { await verifyRemoteProviderPack(stagedRemoteProviderPackRoot); activeRemoteProviderPackRoot = stagedRemoteProviderPackRoot; @@ -12321,6 +12359,10 @@ async function createRunnerdBackendWithinSessionClaim( return; } await remoteNativeArtifacts?.prepare(requiredMode); + if (requiresRemoteProviderPack && await useInstalledProviderPack()) { + remotePrepared = true; + return; + } if ( requiresRemoteProviderPack && expectedProviderPackManifest && @@ -12383,8 +12425,10 @@ async function createRunnerdBackendWithinSessionClaim( "runner_remote_provider_artifact_incompatible: this remote transport cannot stage a provider pack; preinstall the exact manifest-matched pack", ); } - const archive = await measureNativeRunnerSpan(input.trace, "provider_pack.archive.verify", () => - readProviderPackArchive(configuredProviderPackRoot, expectedProviderPackManifest!.digest), + const archive = await measureNativeRunnerSpan(input.trace, "provider_pack.archive.verify", () => { + readRemoteProviderPackManifest(configuredProviderPackRoot); + return readProviderPackArchive(configuredProviderPackRoot, expectedProviderPackManifest!.digest); + }, ); const escapedPackRoot = stagedRemoteProviderPackRoot.replaceAll( "'", @@ -13365,6 +13409,11 @@ async function createRunnerdBackendWithinSessionClaim( if (!current) throw new Error("native_session_tool_authority_unavailable"); return current.execute(call); }; + if (requiresRemoteProviderPack) { + await prepareNativeRemoteRunner({ target, runnerIngressAuthorized: input.runnerIngressAuthorized === true, + runnerPublicUrl: input.runnerPublicUrl, runnerCaBundlePath: input.runnerCaBundlePath, + runId: input.execution.binding.runId, prepareRemote: prepareRemoteRunner }); + } const backend = createNativeSessionBackend(runnerExecution, { runnerInstanceId: retainedTransition?.runnerInstanceId ?? input.runnerInstanceId, @@ -13476,10 +13525,10 @@ async function createRunnerdBackendWithinSessionClaim( }, } : {}), - ...(expectedProviderPackManifest && stagedRemoteProviderPackRoot + ...(expectedProviderPackManifest && activeRemoteProviderPackRoot ? { providerNodeCommand: posix.join( - stagedRemoteProviderPackRoot, + activeRemoteProviderPackRoot, expectedProviderPackManifest.payload.artifacts.nodeCommand.path, ), providerNodeCommandSha256: @@ -13487,7 +13536,7 @@ async function createRunnerdBackendWithinSessionClaim( .sha256, providerPackAuthorityDigest: expectedProviderPackManifest.digest, opencodeCommand: posix.join( - stagedRemoteProviderPackRoot, + activeRemoteProviderPackRoot, expectedProviderPackManifest.payload.artifacts .opencodeExecutable.path, ), @@ -13495,7 +13544,7 @@ async function createRunnerdBackendWithinSessionClaim( expectedProviderPackManifest.payload.artifacts .opencodeExecutable.sha256, opencodeProxyPath: posix.join( - stagedRemoteProviderPackRoot, + activeRemoteProviderPackRoot, expectedProviderPackManifest.payload.artifacts.opencodeProxy .path, ), @@ -13503,7 +13552,7 @@ async function createRunnerdBackendWithinSessionClaim( expectedProviderPackManifest.payload.artifacts.opencodeProxy .sha256, acpxSidecarPath: posix.join( - stagedRemoteProviderPackRoot, + activeRemoteProviderPackRoot, expectedProviderPackManifest.payload.artifacts.acpxSidecar.path, ), acpxSidecarSha256: @@ -13768,6 +13817,7 @@ export function createRemoteNativeArtifactPreparation(input: { model?: string | null; reuseRetainedRunner?: boolean; targetPlatformArtifactVerified?: boolean; + providerPackRoots?: readonly string[]; trace?: NativeRunTrace; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; }) { @@ -13778,18 +13828,25 @@ export function createRemoteNativeArtifactPreparation(input: { const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null; const remoteCodexNpmSpec = input.runnerRemoteCodexNpmSpec?.trim() || null; if (explicitRemoteCodex && remoteCodexNpmSpec) throw new Error("runner_remote_codex_source_conflict"); + let providerPackInventory: ProviderPackInstallation[] = []; const verifyRemoteRunner = async ( requiredMode: "dial_wss" | "listen_ws", executable = remoteBinary, + inspectProviderPacks = false, ) => { if (!remoteTarget || !remoteCommandRunner || !executable) return; - const metadataResult = await remoteCommandRunner.execute({ + let metadataResult = await remoteCommandRunner.execute({ command: executable, - args: ["--build-metadata"], + args: ["--build-metadata", ...(inspectProviderPacks ? (input.providerPackRoots ?? []).flatMap(root => ["--provider-pack-root", root]) : [])], cwd: remoteTarget.remoteCwd, bypassSession: true, timeoutMs: 30_000, }); + if (inspectProviderPacks && input.providerPackRoots?.length && metadataResult.exitCode !== null + && metadataResult.exitCode !== 0 && !metadataResult.timedOut && !metadataResult.signal) { + metadataResult = await remoteCommandRunner.execute({ command: executable, args: ["--build-metadata"], + cwd: remoteTarget.remoteCwd, bypassSession: true, timeoutMs: 30_000 }); + } if (metadataResult.exitCode !== 0 || metadataResult.timedOut) { throw new Error("runner_remote_artifact_verification_failed"); } @@ -13802,6 +13859,12 @@ export function createRemoteNativeArtifactPreparation(input: { }); } assertRemoteRunnerBuildMetadata(metadata, requiredMode); + if (inspectProviderPacks) { + providerPackInventory = Array.isArray(metadata.providerPacks) + ? metadata.providerPacks.filter((pack): pack is ProviderPackInstallation => + typeof pack?.root === "string" && input.providerPackRoots?.includes(pack.root) === true + && typeof pack.immutable === "boolean") : []; + } }; const reportedCodexVersions = new Set(); @@ -13982,7 +14045,7 @@ export function createRemoteNativeArtifactPreparation(input: { }); } await measureNativeRunnerSpan(input.trace, "runner.artifact.verify", () => - verifyRemoteRunner(requiredMode), + verifyRemoteRunner(requiredMode, undefined, true), ); }; const prepareHarness = async () => { @@ -14097,5 +14160,5 @@ export function createRemoteNativeArtifactPreparation(input: { await prepareHarness(); } }; - return { prepare, verifyRemoteRunner, verifyRemoteCodex }; + return { prepare, verifyRemoteRunner, verifyRemoteCodex, providerPackInventory: () => providerPackInventory }; } diff --git a/server/src/services/native-runtime/remote-provider-pack-authority.test.ts b/server/src/services/native-runtime/remote-provider-pack-authority.test.ts index f5f2039c4cf..8530105addc 100644 --- a/server/src/services/native-runtime/remote-provider-pack-authority.test.ts +++ b/server/src/services/native-runtime/remote-provider-pack-authority.test.ts @@ -119,3 +119,14 @@ it("retains the original projectless lease across warm run placeholders but reje const changed = { ...binding, workspaceId: providerPackWorkspaceId("project-workspace", "run-2", "original-lease") }; expect(() => resolveRemoteProviderPackAuthority({ ...input, binding: changed, reattach: true })).toThrow("does not match this execution"); }); + +it("persists the compatible installation selected at launch, then pins it on reattachment", () => { + const { input } = fixture(); + const authority = resolveRemoteProviderPackAuthority(input); + const installed = manifest("b"); + authority.select(installed); + authority.verified(); + const attached = resolveRemoteProviderPackAuthority({ ...input, reattach: true }); + expect(attached.manifest).toEqual(installed); + expect(() => attached.select(manifest("c"))).toThrow("cannot replace an attached pack authority"); +}); diff --git a/server/src/services/native-runtime/remote-provider-pack-authority.ts b/server/src/services/native-runtime/remote-provider-pack-authority.ts index 5516ad6d959..b1c30f55407 100644 --- a/server/src/services/native-runtime/remote-provider-pack-authority.ts +++ b/server/src/services/native-runtime/remote-provider-pack-authority.ts @@ -33,7 +33,7 @@ export function resolveRemoteProviderPackAuthority T; runnerArtifact: () => RunnerArtifactIdentity; -}): { manifest: T; runnerArtifact: RunnerArtifactIdentity; verified: () => void } { +}): { manifest: T; runnerArtifact: RunnerArtifactIdentity; select: (manifest: T) => void; verified: () => void } { const root = lstatSync(input.stateDirectory); if (!root.isDirectory() || root.isSymbolicLink()) { throw new Error("runner_remote_provider_artifact_incompatible: provider pack authority directory is unsafe"); @@ -68,11 +68,22 @@ export function resolveRemoteProviderPackAuthority maximumBytes) throw new Error("runner_remote_provider_artifact_incompatible: provider pack authority exceeds its size limit"); + let selected = structuredClone(manifest); + const serialize = (manifest: T) => { + const receipt = JSON.stringify({ schema, binding: input.binding, manifest, runnerArtifact: selectedRunner }); + if (Buffer.byteLength(receipt) > maximumBytes) throw new Error("runner_remote_provider_artifact_incompatible: provider pack authority exceeds its size limit"); + return receipt; + }; + let receipt = serialize(selected); return { - manifest: selected, + get manifest() { return selected; }, + select: (manifest: T) => { + if (input.reattach) throw new Error("runner_remote_provider_artifact_incompatible: cannot replace an attached pack authority"); + assertManifest(manifest); + const next = structuredClone(manifest); + receipt = serialize(next); + selected = next; + }, runnerArtifact: selectedRunner, verified: () => { const temporary = `${path}.${randomUUID()}.tmp`; diff --git a/server/src/services/native-runtime/remote-provider-pack-verification.test.ts b/server/src/services/native-runtime/remote-provider-pack-verification.test.ts new file mode 100644 index 00000000000..e5c8f742329 --- /dev/null +++ b/server/src/services/native-runtime/remote-provider-pack-verification.test.ts @@ -0,0 +1,106 @@ +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[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"); + }); + +}); diff --git a/server/src/services/native-runtime/remote-provider-pack-verification.ts b/server/src/services/native-runtime/remote-provider-pack-verification.ts index 3f504a636e9..68170e23089 100644 --- a/server/src/services/native-runtime/remote-provider-pack-verification.ts +++ b/server/src/services/native-runtime/remote-provider-pack-verification.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { posix } from "node:path"; import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime"; import { runRemoteProviderVerification } from "./remote-provider-pack.js"; @@ -13,6 +14,10 @@ export type ProviderPackIdentity = { schema: string; digest: string; payload: { + compatibility?: { opencode: number; acpx: number }; + acpxProfileDigests?: Record; + providers?: Record; + candidateProviders?: Record; pins: { opencode: string; [key: string]: string | null }; target: { platform: string; architecture: string }; runnerSourceRevision: string; @@ -20,12 +25,66 @@ export type ProviderPackIdentity = { }; }; -export async function verifyRemoteProviderPack({ runner, cwd, packRoot, manifest }: { +export type ProviderPackInstallation = { root: string; manifest: unknown; immutable: boolean }; +export type PackProvider = { kind: "opencode" } | { kind: "acpx"; agent: string }; + +/** Admission of a build-qualified, OS-protected installation. Source revisions, + * dependency pins and unrelated provider changes are provenance, not an interface. + * The metadata comes from the trusted runner, never from a provider tool result. + */ +function compatibleInstallation( + installation: ProviderPackInstallation, expected: T, packRoot: string, + provider: PackProvider, requireExactIdentity: boolean, +): T { + const actual = installation.manifest as T | null; + const payload = actual?.payload; + const contract = expected.payload.compatibility?.[provider.kind]; + if (!installation.immutable || installation.root !== packRoot || !payload + || actual.schema !== expected.schema + || actual.digest !== `sha256:${createHash("sha256").update(canonicalProviderPackJson(payload)).digest("hex")}` + || canonicalProviderPackJson(payload.target) !== canonicalProviderPackJson(expected.payload.target) + || (requireExactIdentity ? actual.digest !== expected.digest + : !Number.isSafeInteger(contract) || contract! < 1 || payload.compatibility?.[provider.kind] !== contract) + || typeof payload.pins?.opencode !== "string") { + throw new Error("runner_remote_provider_artifact_incompatible: installed provider pack interface or installation is incompatible"); + } + // Launch profiles and recovery receipts bind the selected installation's own + // bytes, never the current controller's artifact hashes. + for (const [name, expectedArtifact] of Object.entries(expected.payload.artifacts)) { + const artifact = payload.artifacts?.[name]; + if (artifact?.path !== expectedArtifact.path || !/^sha256:[a-f0-9]{64}$/.test(artifact.sha256)) { + throw new Error("runner_remote_provider_artifact_incompatible: installed provider pack artifact identity is invalid"); + } + } + if (provider.kind === "acpx") { + const agent = provider.agent; + const currentProfile = expected.payload.acpxProfileDigests?.[agent]; + const selected = expected.payload.providers?.[agent] ?? expected.payload.candidateProviders?.[agent]; + const installed = payload.providers?.[agent] ?? payload.candidateProviders?.[agent]; + // ACPX's existing qualified launch profiles remain authoritative for the + // selected agent. Other providers in the same pack do not invalidate it. + if (currentProfile ? payload.acpxProfileDigests?.[agent] !== currentProfile + : !selected || !installed || ["version", "profileDigest", "closureDigest", "qualification", "path"].some(key => + (installed as Record)[key] !== (selected as Record)[key])) { + throw new Error("runner_remote_provider_artifact_incompatible: selected ACPX provider profile is incompatible"); + } + } + return structuredClone(actual); +} + +export async function verifyRemoteProviderPack({ runner, cwd, packRoot, manifest, installation, provider, requireExactIdentity = false }: { runner: CommandManagedRuntimeRunner; cwd: string; packRoot: string; - manifest: ProviderPackIdentity; -}): Promise { + manifest: T; + installation?: ProviderPackInstallation; + provider?: PackProvider; + requireExactIdentity?: boolean; +}): Promise { + if (installation) { + if (!provider) throw new Error("runner_remote_provider_artifact_incompatible: provider interface is required"); + return compatibleInstallation(installation, manifest, packRoot, provider, requireExactIdentity); + } const expected = Buffer.from( canonicalProviderPackJson(manifest), "utf8", @@ -82,4 +141,5 @@ export async function verifyRemoteProviderPack({ runner, cwd, packRoot, manifest "runner_remote_provider_artifact_incompatible: OpenCode version mismatch", ); } + return structuredClone(manifest); } diff --git a/server/src/vendor/paperclip-runner/index.ts b/server/src/vendor/paperclip-runner/index.ts index 571daf6a929..6790b801b36 100644 --- a/server/src/vendor/paperclip-runner/index.ts +++ b/server/src/vendor/paperclip-runner/index.ts @@ -166,6 +166,7 @@ export const completeTerminatedLocalNativeSessionCleanup = runner.completeTermin export const probeAcpxClaudeInstallation = runner.probeAcpxClaudeInstallation; export const probeAcpxGrokInstallation = runner.probeAcpxGrokInstallation; export const probeAcpxPiInstallation = runner.probeAcpxPiInstallation; +export const PROVIDER_PACK_COMPATIBILITY = runner.PROVIDER_PACK_COMPATIBILITY; export const bundledRemoteProviderPackManifestPath = runner.bundledRemoteProviderPackManifestPath; export const bundledRemoteRunnerBinary = runner.bundledRemoteRunnerBinary;