mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
perf(runner): reuse compatible protected provider packs (#17749)
## 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>
This commit is contained in:
1 parent
f1922d95e1
commit
9ae4c25e7e
20 files changed
+570
-72
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
+14
-3
@@ -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
|
||||
|
||||
@@ -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"; \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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<serde_json::Value> {
|
||||
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<serde_json::Value, LocalRunnerError> {
|
||||
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<String, LocalRunnerError> {
|
||||
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()]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"opencode": 1,
|
||||
"acpx": 1
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -59,6 +59,30 @@ export function resolveRemoteRunnerTransportMode(input: {
|
||||
return requiredMode;
|
||||
}
|
||||
|
||||
type RemotePreparation = Pick<NativeRunnerExecutionDriverInput,
|
||||
"target" | "runnerIngressAuthorized" | "runnerPublicUrl" | "runnerCaBundlePath" | "prepareRemote"> & { runId: string };
|
||||
|
||||
async function resolveOutboundTransport(input: RemotePreparation): Promise<PaperclipRunnerTransport> {
|
||||
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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void>;
|
||||
}) {
|
||||
@@ -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<string | null>();
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -33,7 +33,7 @@ export function resolveRemoteProviderPackAuthority<T extends ProviderPackIdentit
|
||||
reattach: boolean;
|
||||
current: () => 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<T extends ProviderPackIdentit
|
||||
assertManifest(manifest);
|
||||
runnerArtifact ??= input.runnerArtifact();
|
||||
const selectedRunner = structuredClone(runnerArtifact);
|
||||
const selected = structuredClone(manifest);
|
||||
const receipt = JSON.stringify({ schema, binding: input.binding, manifest: selected, runnerArtifact: selectedRunner });
|
||||
if (Buffer.byteLength(receipt) > 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`;
|
||||
|
||||
@@ -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<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");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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<string, string>;
|
||||
providers?: Record<string, unknown>;
|
||||
candidateProviders?: Record<string, unknown>;
|
||||
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<T extends ProviderPackIdentity>(
|
||||
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<string, unknown>)[key] !== (selected as Record<string, unknown>)[key])) {
|
||||
throw new Error("runner_remote_provider_artifact_incompatible: selected ACPX provider profile is incompatible");
|
||||
}
|
||||
}
|
||||
return structuredClone(actual);
|
||||
}
|
||||
|
||||
export async function verifyRemoteProviderPack<T extends ProviderPackIdentity>({ runner, cwd, packRoot, manifest, installation, provider, requireExactIdentity = false }: {
|
||||
runner: CommandManagedRuntimeRunner;
|
||||
cwd: string;
|
||||
packRoot: string;
|
||||
manifest: ProviderPackIdentity;
|
||||
}): Promise<void> {
|
||||
manifest: T;
|
||||
installation?: ProviderPackInstallation;
|
||||
provider?: PackProvider;
|
||||
requireExactIdentity?: boolean;
|
||||
}): Promise<T> {
|
||||
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);
|
||||
}
|
||||
+1
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user