mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
feat(runner): assemble target-owned Claude Linux provider packs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
18626f677f
commit
d0da37c221
11 files changed
+262
-33
No files matched your search
@@ -242,6 +242,13 @@ jobs:
|
||||
pattern: ${{ needs.qualification_daemons.outputs.runner_artifact_prefix }}-*
|
||||
path: ${{ runner.temp }}/release-runner-inputs
|
||||
merge-multiple: true
|
||||
- name: Download target-owned Linux provider-pack identities
|
||||
if: needs.qualification_daemons.outputs.provider_pack_artifact_prefix != ''
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: ${{ needs.qualification_daemons.outputs.provider_pack_artifact_prefix }}-*
|
||||
path: ${{ runner.temp }}/release-provider-pack-inputs
|
||||
merge-multiple: true
|
||||
- name: Bind the immutable Linux producer source
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.qualification_source_sha }}
|
||||
@@ -259,9 +266,14 @@ jobs:
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.qualification_source_sha }}
|
||||
run: |
|
||||
pack_args=()
|
||||
if [[ -d "$RUNNER_TEMP/release-provider-pack-inputs" ]]; then pack_args=("$RUNNER_TEMP/release-provider-pack-inputs"); fi
|
||||
node packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs \
|
||||
assemble-daemons "$RUNNER_TEMP/release-runner-inputs" "$SOURCE_SHA"
|
||||
assemble-daemons "$RUNNER_TEMP/release-runner-inputs" "$SOURCE_SHA" "${pack_args[@]}"
|
||||
cp -R packages/paperclip-runner/dist/bin/. server/dist/vendor/paperclip-runner/bin/
|
||||
if [[ -d packages/paperclip-runner/dist/remote-provider-packs ]]; then
|
||||
cp -R packages/paperclip-runner/dist/remote-provider-packs server/dist/vendor/paperclip-runner/
|
||||
fi
|
||||
- name: Verify clean public installation and retain the same package bytes
|
||||
env:
|
||||
PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR: ${{ runner.temp }}/public-runner-packs
|
||||
|
||||
@@ -15,6 +15,8 @@ on:
|
||||
outputs:
|
||||
runner_artifact_prefix:
|
||||
value: ${{ jobs.runner_binaries.outputs.artifact_prefix }}
|
||||
provider_pack_artifact_prefix:
|
||||
value: ${{ jobs.runner_binaries.outputs.provider_pack_artifact_prefix }}
|
||||
native_consumer_matrix:
|
||||
value: ${{ jobs.runner_binary_targets.outputs.native_consumers }}
|
||||
|
||||
@@ -513,6 +515,7 @@ jobs:
|
||||
matrix: ${{ fromJSON(needs.runner_binary_targets.outputs.matrix) }}
|
||||
outputs:
|
||||
artifact_prefix: ${{ steps.identity.outputs.artifact_prefix }}
|
||||
provider_pack_artifact_prefix: ${{ steps.identity.outputs.provider_pack_artifact_prefix }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -535,6 +538,9 @@ jobs:
|
||||
[[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1
|
||||
[[ "$(node -p 'process.platform + "-" + process.arch')" == "$TARGET" ]] || exit 1
|
||||
echo "artifact_prefix=runner-release-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
|
||||
if node --input-type=module -e 'const m = await import("./packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs"); process.exit(m.RELEASE_PROVIDER_PACK_TARGETS ? 0 : 1)'; then
|
||||
echo "provider_pack_artifact_prefix=runner-provider-packs-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Pin native Cargo workspace and toolchain
|
||||
id: native_rust_workspace
|
||||
working-directory: packages/paperclip-runner
|
||||
@@ -618,3 +624,29 @@ jobs:
|
||||
path: ${{ runner.temp }}/runner-release/
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Build target-owned Linux provider pack
|
||||
id: provider_pack
|
||||
if: startsWith(matrix.target, 'linux-') && steps.identity.outputs.provider_pack_artifact_prefix != ''
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.ref }}
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pack="$RUNNER_TEMP/provider-pack"
|
||||
PAPERCLIP_RUNNER_SOURCE_REVISION="$SOURCE_SHA" node packages/paperclip-runner/scripts/build-provider-pack.mjs "$pack"
|
||||
node --input-type=module - "$pack/provider-pack.json" <<'NODE'
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { verifyReleaseProviderPack } from './packages/paperclip-runner/scripts/release-provider-pack.mjs';
|
||||
verifyReleaseProviderPack(JSON.parse(readFileSync(process.argv[2], 'utf8')), process.env.SOURCE_SHA, process.env.TARGET);
|
||||
NODE
|
||||
mkdir -p "$RUNNER_TEMP/release-provider-packs/$TARGET"
|
||||
cp "$pack/provider-pack.json" "$RUNNER_TEMP/release-provider-packs/$TARGET/provider-pack.json"
|
||||
tar -C "$pack" -czf "$RUNNER_TEMP/release-provider-packs/$TARGET/provider-pack.tar.gz" .
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
if: startsWith(matrix.target, 'linux-') && steps.identity.outputs.provider_pack_artifact_prefix != ''
|
||||
with:
|
||||
name: ${{ steps.identity.outputs.provider_pack_artifact_prefix }}-${{ matrix.target }}
|
||||
path: ${{ runner.temp }}/release-provider-packs/
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -432,6 +432,16 @@ jobs:
|
||||
pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-*
|
||||
path: ${{ runner.temp }}/release-runner-inputs
|
||||
merge-multiple: true
|
||||
- name: Download target-owned Linux provider-pack identities
|
||||
if: needs.release_runner_source.outputs.required == 'true' && needs.release_runner_binaries.outputs.provider_pack_artifact_prefix != ''
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: ${{ needs.release_runner_binaries.outputs.provider_pack_artifact_prefix }}-*
|
||||
path: ${{ runner.temp }}/release-provider-pack-inputs
|
||||
merge-multiple: true
|
||||
- name: Bind provider-pack inputs for release assembly
|
||||
if: needs.release_runner_source.outputs.required == 'true' && needs.release_runner_binaries.outputs.provider_pack_artifact_prefix != ''
|
||||
run: echo "PAPERCLIP_RELEASE_PROVIDER_PACK_ARTIFACTS_DIR=$RUNNER_TEMP/release-provider-pack-inputs" >> "$GITHUB_ENV"
|
||||
- name: Bind daemon inputs for release assembly
|
||||
if: needs.release_runner_source.outputs.required == 'true'
|
||||
run: echo "PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR=$RUNNER_TEMP/release-runner-inputs" >> "$GITHUB_ENV"
|
||||
@@ -729,6 +739,16 @@ jobs:
|
||||
pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-*
|
||||
path: ${{ runner.temp }}/release-runner-inputs
|
||||
merge-multiple: true
|
||||
- name: Download target-owned Linux provider-pack identities
|
||||
if: needs.release_runner_source.outputs.required == 'true' && needs.release_runner_binaries.outputs.provider_pack_artifact_prefix != ''
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: ${{ needs.release_runner_binaries.outputs.provider_pack_artifact_prefix }}-*
|
||||
path: ${{ runner.temp }}/release-provider-pack-inputs
|
||||
merge-multiple: true
|
||||
- name: Bind provider-pack inputs for release assembly
|
||||
if: needs.release_runner_source.outputs.required == 'true' && needs.release_runner_binaries.outputs.provider_pack_artifact_prefix != ''
|
||||
run: echo "PAPERCLIP_RELEASE_PROVIDER_PACK_ARTIFACTS_DIR=$RUNNER_TEMP/release-provider-pack-inputs" >> "$GITHUB_ENV"
|
||||
- name: Bind daemon inputs for release assembly
|
||||
if: needs.release_runner_source.outputs.required == 'true'
|
||||
run: echo "PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR=$RUNNER_TEMP/release-runner-inputs" >> "$GITHUB_ENV"
|
||||
@@ -1008,6 +1028,16 @@ jobs:
|
||||
pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-*
|
||||
path: ${{ runner.temp }}/release-runner-inputs
|
||||
merge-multiple: true
|
||||
- name: Download target-owned Linux provider-pack identities
|
||||
if: needs.release_runner_source.outputs.required == 'true' && needs.release_runner_binaries.outputs.provider_pack_artifact_prefix != ''
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: ${{ needs.release_runner_binaries.outputs.provider_pack_artifact_prefix }}-*
|
||||
path: ${{ runner.temp }}/release-provider-pack-inputs
|
||||
merge-multiple: true
|
||||
- name: Bind provider-pack inputs for release assembly
|
||||
if: needs.release_runner_source.outputs.required == 'true' && needs.release_runner_binaries.outputs.provider_pack_artifact_prefix != ''
|
||||
run: echo "PAPERCLIP_RELEASE_PROVIDER_PACK_ARTIFACTS_DIR=$RUNNER_TEMP/release-provider-pack-inputs" >> "$GITHUB_ENV"
|
||||
- name: Bind daemon inputs for release assembly
|
||||
if: needs.release_runner_source.outputs.required == 'true'
|
||||
run: echo "PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR=$RUNNER_TEMP/release-runner-inputs" >> "$GITHUB_ENV"
|
||||
@@ -1389,6 +1419,16 @@ jobs:
|
||||
pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-*
|
||||
path: ${{ runner.temp }}/release-runner-inputs
|
||||
merge-multiple: true
|
||||
- name: Download target-owned Linux provider-pack identities
|
||||
if: needs.release_runner_source.outputs.required == 'true' && needs.release_runner_binaries.outputs.provider_pack_artifact_prefix != ''
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: ${{ needs.release_runner_binaries.outputs.provider_pack_artifact_prefix }}-*
|
||||
path: ${{ runner.temp }}/release-provider-pack-inputs
|
||||
merge-multiple: true
|
||||
- name: Bind provider-pack inputs for release assembly
|
||||
if: needs.release_runner_source.outputs.required == 'true' && needs.release_runner_binaries.outputs.provider_pack_artifact_prefix != ''
|
||||
run: echo "PAPERCLIP_RELEASE_PROVIDER_PACK_ARTIFACTS_DIR=$RUNNER_TEMP/release-provider-pack-inputs" >> "$GITHUB_ENV"
|
||||
- name: Bind daemon inputs for release assembly
|
||||
if: needs.release_runner_source.outputs.required == 'true'
|
||||
run: echo "PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR=$RUNNER_TEMP/release-runner-inputs" >> "$GITHUB_ENV"
|
||||
@@ -1447,6 +1487,16 @@ jobs:
|
||||
pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-*
|
||||
path: ${{ runner.temp }}/release-runner-inputs
|
||||
merge-multiple: true
|
||||
- name: Download target-owned Linux provider-pack identities
|
||||
if: needs.release_runner_source.outputs.required == 'true' && needs.release_runner_binaries.outputs.provider_pack_artifact_prefix != ''
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: ${{ needs.release_runner_binaries.outputs.provider_pack_artifact_prefix }}-*
|
||||
path: ${{ runner.temp }}/release-provider-pack-inputs
|
||||
merge-multiple: true
|
||||
- name: Bind provider-pack inputs for release assembly
|
||||
if: needs.release_runner_source.outputs.required == 'true' && needs.release_runner_binaries.outputs.provider_pack_artifact_prefix != ''
|
||||
run: echo "PAPERCLIP_RELEASE_PROVIDER_PACK_ARTIFACTS_DIR=$RUNNER_TEMP/release-provider-pack-inputs" >> "$GITHUB_ENV"
|
||||
- name: Bind daemon inputs for release assembly
|
||||
if: needs.release_runner_source.outputs.required == 'true'
|
||||
run: echo "PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR=$RUNNER_TEMP/release-runner-inputs" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -597,6 +597,15 @@ Use the matching Paperclip Daytona image: the controller verifies the image's
|
||||
pack manifest, executable hashes, bridge tree, and pinned versions before launch.
|
||||
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
|
||||
pack identity for its actual selected host. For SSH, install that exact release's
|
||||
pack at `$HOME/.local/share/paperclip-runner/provider-pack` or
|
||||
`/opt/paperclip-runner/provider-pack`; the runtime discovers and verifies it.
|
||||
The package's identities and the native producer archives share one source.
|
||||
No development pack override is needed for this installed-host path. Other
|
||||
provider qualification boundaries and the current Linux x64 image path remain.
|
||||
ARM64 cloud-image production is deferred.
|
||||
|
||||
A plain Daytona sandbox image does not contain this provider pack. Custom images
|
||||
and source-development launchers must supply a verified Linux provider pack and
|
||||
daemon through the existing remote artifact overrides; see
|
||||
|
||||
@@ -94,3 +94,30 @@ Executable identities: daemon source `5f0b898e9ecef77afefe309a230e04d7fef66cde`,
|
||||
## Approved prerequisite split and current CI
|
||||
|
||||
The behavior PR now targets the protocol branch and has 64 changed files. Protocol #15994 targets the latest Codex dependency and adds the shared v7 effort/resume contract with historical readers. Cloud Docker-context verification found that `.dockerignore` omitted the new Pi v23 and Copilot v18 identity inputs; explicit exceptions retain them without broadening the fixture context. The old 9788c05 package campaign passed all four daemon producers and Apple Silicon clean install, but the Linux ARM64 consumer failed durable-suspend settlement. Retain that failure; current-source package qualification and ARM diagnosis remain required. No local Docker or Rust compilation occurred.
|
||||
|
||||
|
||||
## Platform candidate implementation
|
||||
|
||||
The isolated platform checkout is `/private/tmp/claude-runner-platform-pr` on
|
||||
`codex/claude-runner-platforms`. It preserves behavior candidate `8d07b40907`.
|
||||
The pinned Linux ARM64 Claude SDK 0.3.286 archive passed registry SHA512
|
||||
integrity, ELF aarch64 inspection and an actual no-prompt version launch on
|
||||
the authorized cloud ARM host. Its executable SHA256 is
|
||||
`0292fa22ac2fd43e16be9d0e511ddd8347280d6e0ebaca744ef5b27e05d8d0f8`.
|
||||
Native admission retains descriptor/no-follow, package and architecture checks.
|
||||
|
||||
Release producers now retain target-owned Linux x64 and ARM64 provider-pack
|
||||
identities and relocatable archives separately from daemon receipts. Assembly
|
||||
binds both target identities to its daemon source and vendors the manifests.
|
||||
ARM inventories cannot claim additional Pi, Cursor or Copilot qualification.
|
||||
Mac controllers probe the selected Claude remote target and select its packaged
|
||||
identity without a development override. Installed SSH hosts use the standard
|
||||
pack locations. Additional automatic defaults remain gated pending evidence.
|
||||
|
||||
Focused platform checks: pack integrity11, release workflow67, native remote
|
||||
manifest/selection12, executable/profile/path86 passed (six platform skips).
|
||||
The first new ARM selection test failed from shared durable fixture identity;
|
||||
a unique workspace/session per case fixed the fixture. No product guard was
|
||||
removed. Platform TypeScript build passed. Cloud package and actual Linux task
|
||||
pairs remain required. Current behavior package campaign:
|
||||
https://github.com/paperclipai/paperclip/actions/runs/38284813521 .
|
||||
@@ -170,7 +170,7 @@ the task waits for the next instruction. See the
|
||||
|
||||
A release includes all four platform daemons and the Linux provider-pack identity
|
||||
from its matching Daytona image. Run `stage:release-binaries` with a manifest that
|
||||
binds each daemon path and SHA-256, plus `remoteProviderPack: {path, sha256}` for
|
||||
binds each daemon path and SHA-256, plus `remoteProviderPacks: {"linux-x64": {path, sha256}, "linux-arm64": {path, sha256}}` for
|
||||
the actual image's `provider-pack.json`. Assemble these assets after the normal
|
||||
build and include them in the server's vendored Runner output before npm packing.
|
||||
Assembly requires the provider pack's source revision to match `sourceRevision`
|
||||
@@ -682,3 +682,18 @@ canonical event retained for that command. The header marker `🖇️ v0.1.2`
|
||||
identifies the current console iteration.
|
||||
|
||||
`create_task` accepts an optional initial `status` of `backlog` or `todo`. Use `backlog` when the user wants a saved task or plan without execution: assignment and the initial plan are committed without scheduling a wake, even when dependencies are already complete. Omitting status preserves immediate delegation (`todo`, or `blocked` for unresolved dependencies).
|
||||
|
||||
|
||||
Linux provider-pack producers run on the corresponding native Linux target and
|
||||
retain `provider-pack.json` with a relocatable `provider-pack.tar.gz`. Release
|
||||
assembly binds both identities to the same source as its four daemons. The
|
||||
historical `remoteProviderPack` field remains the Linux x64 identity. Linux
|
||||
ARM64 packs do not qualify Pi, Cursor or Copilot on that target.
|
||||
|
||||
For an installed SSH host, install the same release producer's pack at
|
||||
`$HOME/.local/share/paperclip-runner/provider-pack` or
|
||||
`/opt/paperclip-runner/provider-pack`. A Mac controller selects its packaged
|
||||
Linux target identity after probing the selected host. The runtime verifies the
|
||||
installed pack before prompting. No development pack override is needed. A
|
||||
missing or mismatched pack fails clearly. Existing explicit overrides remain
|
||||
authoritative. ARM64 cloud-image production remains deferred.
|
||||
@@ -7,11 +7,12 @@ const canonical = value => Array.isArray(value) ? `[${value.map(canonical).join(
|
||||
: JSON.stringify(value);
|
||||
|
||||
/** Release assembly must reject a self-consistent pack built for another release. */
|
||||
export function verifyReleaseProviderPack(identity, sourceRevision) {
|
||||
export function verifyReleaseProviderPack(identity, sourceRevision, target = "linux-x64") {
|
||||
const payload = identity?.payload;
|
||||
if (identity?.schema !== "paperclip-runner/remote-provider-pack/v1" || !payload
|
||||
|| payload.target?.platform !== "linux" || payload.target?.architecture !== "x64"
|
||||
|| payload.runnerSourceRevision !== sourceRevision) throw new Error("Remote provider pack must match the release source and Linux x64 target");
|
||||
|| !["linux-x64", "linux-arm64"].includes(target)
|
||||
|| payload.target?.platform !== "linux" || `linux-${payload.target?.architecture}` !== target
|
||||
|| payload.runnerSourceRevision !== sourceRevision) throw new Error(`Remote provider pack must match the release source and ${target === "linux-x64" ? "Linux x64" : target} target`);
|
||||
if (identity.digest !== `sha256:${createHash("sha256").update(canonical(payload)).digest("hex")}`) {
|
||||
throw new Error("Remote provider-pack payload digest mismatch");
|
||||
}
|
||||
@@ -19,6 +20,12 @@ export function verifyReleaseProviderPack(identity, sourceRevision) {
|
||||
|| ["grok", "claude", "codex"].some(agent => payload.acpxProfileDigests?.[agent] !== profiles.profiles[agent].commandDigest)) {
|
||||
throw new Error("Remote provider-pack ACPX profiles do not match this release");
|
||||
}
|
||||
if (target === "linux-arm64") {
|
||||
if (Object.keys(payload.providers ?? {}).length || Object.keys(payload.candidateProviders ?? {}).length) {
|
||||
throw new Error("Linux ARM64 pack must not expand other providers qualification");
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
const current = profiles.profiles.cursor;
|
||||
for (const cursor of [payload.providers?.cursor, ...(Object.hasOwn(payload.candidateProviders ?? {}, "cursor") ? [payload.candidateProviders.cursor] : [])]) {
|
||||
if (!cursor || cursor.version !== current.agentServerVersion || cursor.profileDigest !== current.commandDigest
|
||||
|
||||
@@ -9,7 +9,7 @@ import test from "node:test";
|
||||
import profiles from "../acpx-profiles.json" with { type: "json" };
|
||||
import distributions from "../cursor-distributions.json" with { type: "json" };
|
||||
import { verifyReleaseProviderPack } from "./release-provider-pack.mjs";
|
||||
import { existingReleaseProviderPack } from "./stage-release-runner-binaries.mjs";
|
||||
import { existingReleaseProviderPack, releaseProviderPackManifest } from "./stage-release-runner-binaries.mjs";
|
||||
|
||||
const revision = "a".repeat(40);
|
||||
const digest = "sha256:" + "b".repeat(64);
|
||||
@@ -94,3 +94,37 @@ test("daemon-only assembly retains an existing same-source Linux provider-pack i
|
||||
assert.throws(() => existingReleaseProviderPack(root, revision), /Linux x64 target/);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
function armFixture() {
|
||||
const payload = fixture();
|
||||
payload.target.architecture = "arm64";
|
||||
delete payload.providers;
|
||||
return payload;
|
||||
}
|
||||
test("ARM pack binds its own target and preserves other providers qualification limits", () => {
|
||||
const pack = seal(armFixture());
|
||||
assert.equal(verifyReleaseProviderPack(pack, revision, "linux-arm64"), pack);
|
||||
assert.throws(() => verifyReleaseProviderPack(pack, revision), /Linux x64 target/);
|
||||
assert.throws(() => verifyReleaseProviderPack(seal(fixture()), revision, "linux-arm64"), /linux-arm64 target/);
|
||||
for (const inventory of ["providers", "candidateProviders"]) {
|
||||
const payload = armFixture(); payload[inventory] = { pi: {} };
|
||||
assert.throws(() => verifyReleaseProviderPack(seal(payload), revision, "linux-arm64"), /must not expand/);
|
||||
}
|
||||
});
|
||||
test("both remote pack identities bind one source and remain separated during assembly", () => {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "release-provider-targets-")));
|
||||
try {
|
||||
for (const [target, payload] of [["linux-x64", fixture()], ["linux-arm64", armFixture()]]) {
|
||||
const file = join(root, target, "provider-pack.json"); mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, JSON.stringify(seal(payload)));
|
||||
}
|
||||
const entries = releaseProviderPackManifest(root, revision);
|
||||
assert.deepEqual(Object.keys(entries), ["linux-x64", "linux-arm64"]);
|
||||
assert.notEqual(entries["linux-x64"].sha256, entries["linux-arm64"].sha256);
|
||||
assert.throws(() => releaseProviderPackManifest(root, "c".repeat(40)), /release source/);
|
||||
writeFileSync(join(root, "linux-arm64", "provider-pack.json"), JSON.stringify(seal(fixture())));
|
||||
assert.throws(() => releaseProviderPackManifest(root, revision), /linux-arm64 target/);
|
||||
rmSync(join(root, "linux-arm64"), { recursive: true });
|
||||
assert.throws(() => releaseProviderPackManifest(root, revision), /exactly both/);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { runnerBinaryTarget } from "../src/live/runner-binary.ts";
|
||||
import { verifyReleaseProviderPack } from "./release-provider-pack.mjs";
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
export const RELEASE_RUNNER_TARGETS = Object.freeze(["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"]);
|
||||
export const RELEASE_PROVIDER_PACK_TARGETS = Object.freeze(["linux-x64", "linux-arm64"]);
|
||||
const required = RELEASE_RUNNER_TARGETS;
|
||||
const digest = bytes => "sha256:" + createHash("sha256").update(bytes).digest("hex");
|
||||
function regularFile(path, maximumSize) {
|
||||
@@ -55,10 +56,23 @@ export function releaseDaemonManifest(artifactDirectory, sourceRevision, verifyM
|
||||
return { sourceRevision, platforms };
|
||||
}
|
||||
|
||||
export function existingReleaseProviderPack(packageRoot, sourceRevision) {
|
||||
const file = join(packageRoot, "dist/remote-provider-packs/linux-x64/provider-pack.json");
|
||||
export function existingReleaseProviderPack(packageRoot, sourceRevision, target = "linux-x64") {
|
||||
if (!RELEASE_PROVIDER_PACK_TARGETS.includes(target)) throw Error("Unsupported release provider pack target");
|
||||
const file = join(packageRoot, `dist/remote-provider-packs/${target}/provider-pack.json`);
|
||||
if (!existsSync(file)) return undefined;
|
||||
return verifyReleaseProviderPack(JSON.parse(regularFile(file, 512 * 1024)), sourceRevision);
|
||||
return verifyReleaseProviderPack(JSON.parse(regularFile(file, 512 * 1024)), sourceRevision, target);
|
||||
}
|
||||
|
||||
export function releaseProviderPackManifest(artifactDirectory, sourceRevision) {
|
||||
if (readdirSync(artifactDirectory).sort().join() !== [...RELEASE_PROVIDER_PACK_TARGETS].sort().join()) {
|
||||
throw Error("Release provider packs require exactly both Linux target directories");
|
||||
}
|
||||
return Object.fromEntries(RELEASE_PROVIDER_PACK_TARGETS.map(target => {
|
||||
const path = join(resolve(artifactDirectory), target, "provider-pack.json");
|
||||
const bytes = regularFile(path, 512 * 1024);
|
||||
verifyReleaseProviderPack(JSON.parse(bytes), sourceRevision, target);
|
||||
return [target, { path, sha256: digest(bytes) }];
|
||||
}));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -79,11 +93,12 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
const daemonOnly = process.argv[2] === "assemble-daemons";
|
||||
if (daemonOnly ? process.argv.length !== 5 : process.argv.length !== 3) throw Error("Usage: stage-release-runner-binaries.mjs MANIFEST.json | assemble-daemons ARTIFACT_DIRECTORY SOURCE_SHA");
|
||||
if (daemonOnly ? ![5, 6].includes(process.argv.length) : process.argv.length !== 3) throw Error("Usage: stage-release-runner-binaries.mjs MANIFEST.json | assemble-daemons ARTIFACT_DIRECTORY SOURCE_SHA");
|
||||
const manifest = daemonOnly
|
||||
? releaseDaemonManifest(process.argv[3], process.argv[4], await metadataContract())
|
||||
: JSON.parse(regularFile(resolve(process.argv[2]), 512 * 1024));
|
||||
if (!/^[a-f0-9]{40}$/.test(manifest.sourceRevision) || !manifest.platforms || Object.keys(manifest.platforms).sort().join() !== [...required].sort().join()) throw Error("Release daemon manifest must bind one source and all four supported targets");
|
||||
if (daemonOnly && process.argv[5]) manifest.remoteProviderPacks = releaseProviderPackManifest(process.argv[5], manifest.sourceRevision);
|
||||
const verified = required.map(target => {
|
||||
const artifact = manifest.platforms[target];
|
||||
if (!artifact || !isAbsolute(artifact.path) || !/^sha256:[a-f0-9]{64}$/.test(artifact.sha256)) throw Error(`Invalid ${target} release artifact`);
|
||||
@@ -91,18 +106,26 @@ async function main() {
|
||||
if (sha256 !== artifact.sha256 || runnerBinaryTarget(bytes) !== target) throw Error(`Release daemon ${target} digest or architecture mismatch`);
|
||||
return { target, source: artifact.path, sha256 };
|
||||
});
|
||||
if (!manifest.remoteProviderPack && !daemonOnly) throw Error("Release assembly requires the matching Linux remote provider pack");
|
||||
let packBytes, packIdentity;
|
||||
if (manifest.remoteProviderPack) {
|
||||
const entry = manifest.remoteProviderPack;
|
||||
if (!isAbsolute(entry.path) || !/^sha256:[a-f0-9]{64}$/.test(entry.sha256)) throw Error("Invalid remote provider-pack release artifact");
|
||||
packBytes = regularFile(entry.path, 512 * 1024);
|
||||
if (packBytes.length > 512 * 1024 || "sha256:" + createHash("sha256").update(packBytes).digest("hex") !== entry.sha256) throw Error("Remote provider-pack release manifest digest mismatch");
|
||||
packIdentity = verifyReleaseProviderPack(JSON.parse(packBytes), manifest.sourceRevision);
|
||||
} else if (daemonOnly) {
|
||||
// Preserve a qualified same-source Linux pack already materialized by the
|
||||
// normal producer. It never becomes a Mac provider-pack qualification.
|
||||
packIdentity = existingReleaseProviderPack(root, manifest.sourceRevision);
|
||||
if (!manifest.remoteProviderPack && !manifest.remoteProviderPacks && !daemonOnly) throw Error("Release assembly requires the matching Linux remote provider pack");
|
||||
if (manifest.remoteProviderPacks && Object.keys(manifest.remoteProviderPacks).sort().join() !== [...RELEASE_PROVIDER_PACK_TARGETS].sort().join()) {
|
||||
throw Error("Release provider packs require exactly both Linux targets");
|
||||
}
|
||||
const packs = {};
|
||||
for (const target of RELEASE_PROVIDER_PACK_TARGETS) {
|
||||
const entry = manifest.remoteProviderPacks?.[target] ?? (target === "linux-x64" ? manifest.remoteProviderPack : undefined);
|
||||
if (entry) {
|
||||
if (!isAbsolute(entry.path) || !/^sha256:[a-f0-9]{64}$/.test(entry.sha256)) throw Error("Invalid remote provider-pack release artifact");
|
||||
const bytes = regularFile(entry.path, 512 * 1024);
|
||||
if (digest(bytes) !== entry.sha256) throw Error("Remote provider-pack release manifest digest mismatch");
|
||||
packs[target] = { bytes, identity: verifyReleaseProviderPack(JSON.parse(bytes), manifest.sourceRevision, target) };
|
||||
if (target === "linux-x64" && manifest.remoteProviderPack && manifest.remoteProviderPacks &&
|
||||
(manifest.remoteProviderPack.path !== entry.path || manifest.remoteProviderPack.sha256 !== entry.sha256)) {
|
||||
throw Error("Conflicting legacy Linux x64 provider-pack identity");
|
||||
}
|
||||
} else if (daemonOnly) {
|
||||
const identity = existingReleaseProviderPack(root, manifest.sourceRevision, target);
|
||||
if (identity) packs[target] = { identity };
|
||||
}
|
||||
}
|
||||
const platforms = {};
|
||||
for (const artifact of verified) {
|
||||
@@ -116,20 +139,22 @@ async function main() {
|
||||
const sha256 = "sha256:" + createHash("sha256").update(readFileSync(destination)).digest("hex");
|
||||
platforms[artifact.target] = { path: relative, sha256, sourceSha256: artifact.sha256 };
|
||||
}
|
||||
if (packBytes) {
|
||||
const destination = join(root, "dist", "remote-provider-packs", "linux-x64", "provider-pack.json");
|
||||
for (const [target, pack] of Object.entries(packs)) {
|
||||
if (!pack.bytes) continue;
|
||||
const destination = join(root, "dist", "remote-provider-packs", target, "provider-pack.json");
|
||||
mkdirSync(dirname(destination), { recursive: true });
|
||||
// Replace the inode so repeat assembly can preserve a read-only manifest.
|
||||
const staged = `${destination}.staging-${process.pid}`;
|
||||
try {
|
||||
writeFileSync(staged, packBytes, { mode: 0o444, flag: "wx" });
|
||||
writeFileSync(staged, pack.bytes, { mode: 0o444, flag: "wx" });
|
||||
renameSync(staged, destination);
|
||||
} finally {
|
||||
rmSync(staged, { force: true });
|
||||
}
|
||||
}
|
||||
writeFileSync(join(root, "dist", "bin", "release-manifest.json"), JSON.stringify({schema:"paperclip.runner.release-binaries.v1",sourceRevision:manifest.sourceRevision,platforms,
|
||||
...(packIdentity ? { remoteProviderPack: { target: "linux-x64", digest: packIdentity.digest, sourceRevision: packIdentity.payload.runnerSourceRevision } } : {})},null,2)+"\n");
|
||||
...(packs["linux-x64"] ? { remoteProviderPack: { target: "linux-x64", digest: packs["linux-x64"].identity.digest, sourceRevision: packs["linux-x64"].identity.payload.runnerSourceRevision } } : {}),
|
||||
...(Object.keys(packs).length ? { remoteProviderPacks: Object.fromEntries(Object.entries(packs).map(([target, pack]) => [target, { target, digest: pack.identity.digest, sourceRevision: pack.identity.payload.runnerSourceRevision }])) } : {})},null,2)+"\n");
|
||||
console.log("Staged verified release daemons for " + required.join(", "));
|
||||
}
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
|
||||
+8
-1
@@ -288,9 +288,16 @@ pnpm build
|
||||
# receipts. Assemble after the build (which stages its host binary), before
|
||||
# public packages are packed. The server vendors this same compiled runner.
|
||||
if [ -n "${PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR:-}" ]; then
|
||||
pack_args=()
|
||||
if [ -n "${PAPERCLIP_RELEASE_PROVIDER_PACK_ARTIFACTS_DIR:-}" ]; then
|
||||
pack_args=("$PAPERCLIP_RELEASE_PROVIDER_PACK_ARTIFACTS_DIR")
|
||||
fi
|
||||
node "$REPO_ROOT/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs" \
|
||||
assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR" "$(git rev-parse HEAD)"
|
||||
assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR" "$(git rev-parse HEAD)" "${pack_args[@]}"
|
||||
cp -R "$REPO_ROOT/packages/paperclip-runner/dist/bin/." "$REPO_ROOT/server/dist/vendor/paperclip-runner/bin/"
|
||||
if [ -d "$REPO_ROOT/packages/paperclip-runner/dist/remote-provider-packs" ]; then
|
||||
cp -R "$REPO_ROOT/packages/paperclip-runner/dist/remote-provider-packs" "$REPO_ROOT/server/dist/vendor/paperclip-runner/"
|
||||
fi
|
||||
elif [ "$dry_run" = false ]; then
|
||||
release_fail "Public releases require the four hosted runner daemon artifacts; use the supported Release workflow."
|
||||
fi
|
||||
|
||||
@@ -234,7 +234,7 @@ vi.mock("../../vendor/paperclip-runner/index.js", async (importOriginal) => {
|
||||
>();
|
||||
return {
|
||||
...original,
|
||||
bundledRemoteProviderPackManifestPath: () => state.bundledPackManifestPath ?? original.bundledRemoteProviderPackManifestPath(),
|
||||
bundledRemoteProviderPackManifestPath: vi.fn((moduleUrl?: string, target?: "linux-x64" | "linux-arm64") => state.bundledPackManifestPath ?? original.bundledRemoteProviderPackManifestPath(moduleUrl, target)),
|
||||
bundledRemoteRunnerBinary: () => state.bundledRunnerBinary ?? original.bundledRemoteRunnerBinary(),
|
||||
createNativeSessionBackend: state.createBackend,
|
||||
createRunnerdCodexTransport: state.createTransport,
|
||||
@@ -1136,7 +1136,9 @@ describe("remote provider pack manifest", () => {
|
||||
return JSON.stringify(value);
|
||||
};
|
||||
|
||||
it.each(["opencode", "cursor"] as const)("uses the bundled Linux image authority for remote %s without controller overrides", async provider => {
|
||||
it.each(["opencode", "cursor", "claude-x64", "claude-arm64"] as const)("uses the bundled Linux image authority for remote %s without controller overrides", async provider => {
|
||||
const architecture = provider === "claude-arm64" ? "arm64" : "x64";
|
||||
const claude = provider.startsWith("claude-");
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-bundled-image-"));
|
||||
const digest = "sha256:" + "a".repeat(64);
|
||||
const artifacts = Object.fromEntries(Object.entries({
|
||||
@@ -1146,7 +1148,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 }]));
|
||||
const payload = {
|
||||
target: { platform: "linux", architecture: "x64" }, runnerSourceRevision: "a".repeat(40),
|
||||
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,
|
||||
claudeAcp: QUALIFIED_ACPX_PROFILES.claude.agentServerVersion, codexAcp: QUALIFIED_ACPX_PROFILES.codex.agentServerVersion },
|
||||
@@ -1160,10 +1162,14 @@ describe("remote provider pack manifest", () => {
|
||||
state.bundledPackManifestPath = join(root, "provider-pack.json");
|
||||
state.bundledRunnerBinary = join(root, "linux-paperclip-runnerd");
|
||||
await writeFile(state.bundledPackManifestPath, JSON.stringify(manifest));
|
||||
await writeFile(state.bundledRunnerBinary, "verified Linux daemon fixture");
|
||||
const daemon = Buffer.alloc(64); daemon.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]);
|
||||
daemon.writeUInt16LE(architecture === "arm64" ? 183 : 62, 18);
|
||||
await writeFile(state.bundledRunnerBinary, daemon);
|
||||
if (claude) state.resolveRemoteRunnerBinary.mockResolvedValue(state.bundledRunnerBinary);
|
||||
const remoteExecution = { ...execution, provider: provider === "opencode"
|
||||
? { kind: "opencode", model: null } : { kind: "acpx", agent: "cursor", model: null },
|
||||
session: { ...execution.session, driverKind: provider === "opencode" ? "opencode_server" : "acpx_runtime" },
|
||||
? { kind: "opencode", model: null } : { kind: "acpx", agent: claude ? "claude" : "cursor", model: null },
|
||||
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[] }) => {
|
||||
@@ -1182,6 +1188,11 @@ describe("remote provider pack manifest", () => {
|
||||
controlPlaneRegistration: (authority: unknown) => Promise<unknown>;
|
||||
};
|
||||
expect(transport.runnerBinary).toBe(state.bundledRunnerBinary);
|
||||
if (claude) {
|
||||
expect(state.resolveRemoteRunnerBinary).toHaveBeenCalledWith(expect.objectContaining({ remoteCwd: "/workspace", configuredPath: undefined }));
|
||||
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",
|
||||
|
||||
Reference in new issue
Block a user