From dcdf09a0f33536fa2b6daaf6e731d921d5711282 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 10:13:34 -0500 Subject: [PATCH] feat: include Linux ARM64 in native Codex runner targets Ship a source-bound ARM64 daemon alongside Linux x64 and both Mac targets. Use native hosted builds and the existing clean npm/native startup verifier. Read target requirements from the immutable source to preserve historical three-target promotions. Keep Codex payloads as official npm dependencies. Resolve ARM64 creation and login through the shared platform contract. Share the existing legacy CLI permission calculation with import previews without changing execution behavior. Record targeted checks and live Mac evidence. Co-Authored-By: Paperclip --- .github/workflows/release-smoke.yml | 19 +++-- .github/workflows/release-verify.yml | 51 ++++++++++--- .github/workflows/release.yml | 2 +- doc/plans/2026-10-06-default-agent-runner.md | 59 +++++++++++++++ packages/adapters/codex-local/src/index.ts | 24 +++++++ .../codex-local/src/server/codex-args.test.ts | 15 ++++ .../codex-local/src/server/codex-args.ts | 25 +------ .../scripts/release-provider-pack.test.mjs | 6 +- .../scripts/stage-release-runner-binaries.mjs | 8 +-- .../scripts/stage-runner-binary.test.mjs | 21 ++++-- packages/shared/src/agent-runner.ts | 4 +- .../grok-public-install-sandbox.test.mjs | 48 ++++++++++--- .../release-verify-workflow.test.mjs | 50 +++++++++++-- scripts/grok-public-install-sandbox.mjs | 7 +- scripts/release.sh | 2 +- scripts/verify-grok-npm-install.mjs | 71 ++++++++++++++++++- server/src/__tests__/adapter-routes.test.ts | 3 +- server/src/__tests__/built-in-agents.test.ts | 2 +- .../__tests__/plugin-managed-agents.test.ts | 2 +- .../services/agent-runner-selection.test.ts | 12 ++-- .../services/local-ai-browser-login.test.ts | 10 +-- server/src/services/local-ai-browser-login.ts | 8 +-- ui/src/pages/CompanyImport.test.tsx | 8 +++ ui/src/pages/CompanyImport.tsx | 4 +- 24 files changed, 363 insertions(+), 98 deletions(-) diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index eeb7d71928d..7bda5e7ac36 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -24,7 +24,7 @@ on: default: release-smoke type: string qualification_source_sha: - description: Optional immutable source SHA for provider-free three-target package qualification + description: Optional immutable source SHA for provider-free native package qualification required: false default: "" type: string @@ -282,7 +282,7 @@ jobs: if-no-files-found: error retention-days: 14 - qualification_mac: + qualification_native: if: ${{ !cancelled() && inputs.qualification_source_sha != '' && needs.qualification_daemons.result == 'success' }} needs: [qualification_daemons, qualification_pack] name: Clean installed package (${{ matrix.target }}) @@ -290,12 +290,7 @@ jobs: timeout-minutes: 30 strategy: fail-fast: false - matrix: - include: - - target: darwin-arm64 - host: macos-15 - - target: darwin-x64 - host: macos-15-intel + matrix: ${{ fromJSON(needs.qualification_daemons.outputs.native_consumer_matrix) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -321,12 +316,14 @@ jobs: set -euo pipefail [[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1 [[ "$(node -p 'process.platform + "-" + process.arch')" == "$TARGET" ]] || exit 1 - node scripts/verify-grok-npm-install.mjs --mac-consumer \ - "$RUNNER_TEMP/public-runner-packs" "$SOURCE_SHA" "$RUNNER_TEMP/mac-public-install.json" + mode=--mac-consumer + if [[ "$TARGET" == linux-arm64 ]]; then mode=--linux-arm64-consumer; fi + node scripts/verify-grok-npm-install.mjs "$mode" \ + "$RUNNER_TEMP/public-runner-packs" "$SOURCE_SHA" "$RUNNER_TEMP/native-public-install.json" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() with: name: ${{ inputs.artifact_name }}-${{ matrix.target }} - path: ${{ runner.temp }}/mac-public-install.json + path: ${{ runner.temp }}/native-public-install.json if-no-files-found: warn retention-days: 14 diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index 34d768d47c3..a8a490a9a5e 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -15,6 +15,8 @@ on: outputs: runner_artifact_prefix: value: ${{ jobs.runner_binaries.outputs.artifact_prefix }} + native_consumer_matrix: + value: ${{ jobs.runner_binary_targets.outputs.native_consumers }} # Caller-provided refs may name unmerged PR code. AWS is eligible only when # the caller runs on canonical master and verifies that event's exact SHA. @@ -459,8 +461,48 @@ jobs: - name: Build run: pnpm build + runner_binary_targets: + if: inputs.runner_binaries_only + name: Read exact-source daemon targets + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + matrix: ${{ steps.targets.outputs.matrix }} + native_consumers: ${{ steps.targets.outputs.native_consumers }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + - name: Preserve the immutable source's platform contract + id: targets + env: + SOURCE_SHA: ${{ inputs.ref }} + run: | + set -euo pipefail + [[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]] || exit 1 + [[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1 + node --input-type=module <<'NODE' + import assert from 'node:assert/strict'; + import { appendFileSync } from 'node:fs'; + import { RELEASE_RUNNER_TARGETS } from './packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs'; + const historical = ['darwin-arm64', 'darwin-x64', 'linux-x64']; + const current = [...historical, 'linux-arm64']; + const targets = [...RELEASE_RUNNER_TARGETS].sort(); + assert.ok(JSON.stringify(targets) === JSON.stringify(historical.sort()) || JSON.stringify(targets) === JSON.stringify(current.sort()), 'Unknown exact-source daemon platform contract'); + const hosts = { 'linux-x64': 'ubuntu-latest', 'linux-arm64': 'ubuntu-24.04-arm', 'darwin-arm64': 'macos-15', 'darwin-x64': 'macos-15-intel' }; + const include = RELEASE_RUNNER_TARGETS.map(target => ({ target, host: hosts[target] })); + appendFileSync(process.env.GITHUB_OUTPUT, `matrix=${JSON.stringify({ include })}\nnative_consumers=${JSON.stringify({ include: include.filter(row => row.target !== 'linux-x64') })}\n`); + NODE + runner_binaries: if: inputs.runner_binaries_only + needs: runner_binary_targets name: Release daemon (${{ matrix.target }}) runs-on: ${{ matrix.host }} timeout-minutes: 30 @@ -468,14 +510,7 @@ jobs: contents: read strategy: fail-fast: false - matrix: - include: - - target: linux-x64 - host: ubuntu-latest - - target: darwin-arm64 - host: macos-15 - - target: darwin-x64 - host: macos-15-intel + matrix: ${{ fromJSON(needs.runner_binary_targets.outputs.matrix) }} outputs: artifact_prefix: ${{ steps.identity.outputs.artifact_prefix }} steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3632c7449bc..a676cf55a28 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -342,7 +342,7 @@ jobs: release_runner_source: # Old promotions retain their already-shipped packaging contract. New - # sources opt into mandatory three-target assembly in their release.sh. + # sources opt into source-declared native target assembly in their release.sh. needs: [verify_canary, select_nightly, select_beta, preflight_stable] if: >- !cancelled() && ( diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index b468079814c..31c34d2641b 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -2,6 +2,65 @@ Updated 2026-10-12. +## Current addition: Linux ARM64 + +The user now requires ARM Linux support as well. Treat ARM Linux as ARM64 +(`aarch64`), alongside Linux x64 and both macOS architectures. The earlier +Linux ARM deferral below is superseded for ARM64; 32-bit ARM and Windows retain +legacy defaults. Existing agents keep their saved execution. + +Extend the same native Codex creation/discovery contract and existing selection, +login, built-in and plugin fixtures. Extend the current release assembler and +remote workflow matrix to the fourth target, with an actual ARM64 hosted build +and clean installation. Reuse the installed no-turn Codex startup check and +the same public npm tarballs. Read required targets from the selected source +so historical three-target promotions still work. Keep the Linux x64 managed +provider pack distinct from the local ARM64 daemon. + +No local Rust/Docker builds or new paid Linux campaign. Apple Silicon task and +follow-up acceptance remains valid at the explicitly recorded source; Linux +ARM64 build/install/startup must acquire its own evidence. The existing +[three-target qualifier](https://github.com/paperclipai/paperclip/actions/runs/38259364638) +completed successfully at `d522822`, including both clean Mac consumers. It does +not qualify ARM64. Candidate `058af8f1b69c5fa3d9bc974b9eab73c141b52823` passed +ordinary CI and fresh 5/5 review. Its remaining import display finding is fixed +by sharing the existing legacy argument-policy calculation with the UI without +changing execution permissions. Ninety-five focused controls pass; all 45 exact +argument cases also pass against the original builder. Four component cases fail +on the original page. A no-provider protected preview shows Legacy with bypass +off, matching `--sandbox read-only`; it does not apply another import. +Receipt: `/private/tmp/codex-default-dc-import-live/legacy-policy-p2-receipt.json`. + +ARM64 selection/discovery controls pass 46 and 29 cases. Fourteen isolated +browser-login tests pass after replacing the hardcoded ARM64 legacy login branch +with the shared platform contract. This uses native execution's owned Codex +resolver on ARM64 and retains PATH lookup for unqualified platforms. Shared +typecheck passes. Built-in and plugin fixtures now include ARM64; their database +checks remain in normal Linux CI. Receipt: +`/private/tmp/pr2-linux-arm64-selection-checkpoint.json`. + +The packaging patch passes 97 existing controls: 15 assembler/provider-pack, +20 consumer-boundary and 62 workflow tests. The first full workflow attempt was +blocked by sandbox localhost permissions; the same full suite passes with its +temporary listener access. Three workflow YAML files and MJS/shell syntax pass. +Independent review confirms historical source compatibility and the unprivileged +offline consumer boundaries. Official registry metadata confirms the pinned +Node image has a native ARM64 manifest and the installed Codex dependency has +an ARM64 package. No image or binary was pulled to this Mac. Receipt: +`/private/tmp/pr2-linux-arm-packaging-owned-receipt.json`. + +Actual ARM64 daemon build and clean npm/native startup remain unexecuted at this +source freeze. The test-drive stays on its existing verified Mac daemon; its +last restart at `058af8f` preserved both full execution digests, both recorded +native runs and both paused imports, with the deprecated flag false. Receipt: +`/private/tmp/codex-default-dc-import-live/final-restart-receipt.json`. + +Next action: integrate the narrow policy display and ARM64 source/package fixes, +run targeted controls, freeze/push one candidate, then run the existing hosted +qualification once with all source-declared targets and require fresh CI/review. +Keep the same branch, PR, preview and evidence index below. No merge or +production deployment is authorized. + ## Current amendment: include macOS in this PR The user requires macOS support in the same Codex-default PR. The earlier diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index 49c9d1e69b6..c9d54904d7d 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -12,6 +12,30 @@ export const SANDBOX_INSTALL_COMMAND = "npm install -g @openai/codex"; // Codex CLI warn ("Model metadata for `gpt-5.6` not found") and fall back to generic context limits. export const DEFAULT_CODEX_LOCAL_MODEL = PAPERCLIP_RUNNER_DEFAULT_MODELS.codex; export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true; + +/** Shared by CLI execution and configuration previews; explicit arguments keep their policy. */ +export function resolveCodexLocalCliPolicy(config: unknown, options: { networkAccess?: boolean } = {}) { + const record = typeof config === "object" && config !== null && !Array.isArray(config) + ? config as Record : {}; + const stringArgs = (value: unknown): string[] => Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") : []; + const configuredExtraArgs = stringArgs(record.extraArgs); + const extraArgs = configuredExtraArgs.length > 0 ? configuredExtraArgs : stringArgs(record.args); + const explicitSandbox = extraArgs.some((arg) => + /^(--sandbox(?:=|$)|-s|--profile(?:=|$)|-p|--full-auto$|--yolo$|--dangerously-bypass-approvals-and-sandbox$)/.test(arg) + || /^(?:(?:--config=|-c=?)\s*)?(?:sandbox_mode|profile)\s*=/.test(arg), + ); + const explicitPermissionRestriction = extraArgs.some((arg) => + /^(?:(?:--config=|-c=?)\s*)?(?:approval_policy\s*=|sandbox_workspace_write\.network_access\s*=\s*false)/.test(arg), + ); + const defaultBypass = !explicitSandbox && !explicitPermissionRestriction + && options.networkAccess !== false && DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; + const bypass = typeof record.dangerouslyBypassApprovalsAndSandbox === "boolean" + ? record.dangerouslyBypassApprovalsAndSandbox + : typeof record.dangerouslyBypassSandbox === "boolean" ? record.dangerouslyBypassSandbox : defaultBypass; + return { extraArgs, explicitSandbox, bypass }; +} + export const CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS = [ "gpt-6-astra", "gpt-6.1-sol", diff --git a/packages/adapters/codex-local/src/server/codex-args.test.ts b/packages/adapters/codex-local/src/server/codex-args.test.ts index e75a17ebee8..1157018a661 100644 --- a/packages/adapters/codex-local/src/server/codex-args.test.ts +++ b/packages/adapters/codex-local/src/server/codex-args.test.ts @@ -1,7 +1,22 @@ import { describe, expect, it } from "vitest"; import { buildCodexExecArgs } from "./codex-args.js"; +import { resolveCodexLocalCliPolicy } from "../index.js"; describe("buildCodexExecArgs", () => { + it.each([ + { config: { extraArgs: ["--sandbox", "read-only"] }, bypass: false, args: ["exec", "--json", "--sandbox", "read-only", "-"] }, + { config: { args: ["-c", "approval_policy=on-request"] }, bypass: false, args: ["exec", "--json", "-c", 'sandbox_mode="workspace-write"', "-c", "sandbox_workspace_write.network_access=true", "-c", "approval_policy=on-request", "-"] }, + { config: { extraArgs: [], args: ["--profile", "restricted"] }, bypass: false, args: ["exec", "--json", "--profile", "restricted", "-"] }, + { config: { extraArgs: [0, null], args: ["--sandbox", "read-only"] }, bypass: false, args: ["exec", "--json", "--sandbox", "read-only", "-"] }, + { config: { extraArgs: ["--color", "never"], args: ["--sandbox", "read-only"] }, bypass: true, args: ["exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "--color", "never", "-"] }, + { config: { dangerouslyBypassApprovalsAndSandbox: true, extraArgs: ["--sandbox", "read-only"] }, bypass: true, args: ["exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "--sandbox", "read-only", "-"] }, + { config: { dangerouslyBypassSandbox: true, args: ["--sandbox", "read-only"] }, bypass: true, args: ["exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "--sandbox", "read-only", "-"] }, + { config: { dangerouslyBypassApprovalsAndSandbox: false, dangerouslyBypassSandbox: true, extraArgs: ["--color", "never"] }, bypass: false, args: ["exec", "--json", "-c", 'sandbox_mode="workspace-write"', "-c", "sandbox_workspace_write.network_access=true", "--color", "never", "-"] }, + ])("keeps the browser policy and exact execution arguments consistent for $config", ({ config, bypass, args }) => { + expect(resolveCodexLocalCliPolicy(config).bypass).toBe(bypass); + expect(buildCodexExecArgs(config).args).toEqual(args); + }); + it.each([null, "existing-session"])("defaults direct and resumed launches to full bypass (%s)", (resumeSessionId) => { const { args } = buildCodexExecArgs({}, { resumeSessionId }); expect(args).toContain("--dangerously-bypass-approvals-and-sandbox"); diff --git a/packages/adapters/codex-local/src/server/codex-args.ts b/packages/adapters/codex-local/src/server/codex-args.ts index 6ebf8381b44..5cb6b217642 100644 --- a/packages/adapters/codex-local/src/server/codex-args.ts +++ b/packages/adapters/codex-local/src/server/codex-args.ts @@ -1,7 +1,7 @@ -import { asBoolean, asString, asStringArray } from "@paperclipai/adapter-utils/server-utils"; +import { asBoolean, asString } from "@paperclipai/adapter-utils/server-utils"; import { CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS, - DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, + resolveCodexLocalCliPolicy, isCodexLocalFastModeSupported, normalizeCodexModel, } from "../index.js"; @@ -16,12 +16,6 @@ export type BuildCodexExecArgsResult = { fastModeIgnoredReason: string | null; }; -function readExtraArgs(config: unknown): string[] { - const fromExtraArgs = asStringArray(asRecord(config).extraArgs); - if (fromExtraArgs.length > 0) return fromExtraArgs; - return asStringArray(asRecord(config).args); -} - function asRecord(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) @@ -49,20 +43,7 @@ export function buildCodexExecArgs( const search = asBoolean(record.search, false); const fastModeRequested = asBoolean(record.fastMode, false); const fastModeApplied = fastModeRequested && isCodexLocalFastModeSupported(model); - const extraArgs = readExtraArgs(record); - // Explicit CLI modes/profiles remain deliberate overrides. An omitted - // setting uses the same full-auto default as agent creation and onboarding. - const explicitSandbox = extraArgs.some((arg) => - /^(--sandbox(?:=|$)|-s|--profile(?:=|$)|-p|--full-auto$|--yolo$|--dangerously-bypass-approvals-and-sandbox$)/.test(arg) - || /^(?:(?:--config=|-c=?)\s*)?(?:sandbox_mode|profile)\s*=/.test(arg), - ); - const explicitPermissionRestriction = extraArgs.some((arg) => - /^(?:(?:--config=|-c=?)\s*)?(?:approval_policy\s*=|sandbox_workspace_write\.network_access\s*=\s*false)/.test(arg), - ); - const bypass = asBoolean( - record.dangerouslyBypassApprovalsAndSandbox, - asBoolean(record.dangerouslyBypassSandbox, !explicitSandbox && !explicitPermissionRestriction && options.networkAccess !== false && DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX), - ); + const { extraArgs, explicitSandbox, bypass } = resolveCodexLocalCliPolicy(record, options); const args = ["exec", "--json"]; if (!bypass && !explicitSandbox) { args.push("-c", 'sandbox_mode="workspace-write"'); diff --git a/packages/paperclip-runner/scripts/release-provider-pack.test.mjs b/packages/paperclip-runner/scripts/release-provider-pack.test.mjs index f1015a2ffcf..5dded2a32fd 100644 --- a/packages/paperclip-runner/scripts/release-provider-pack.test.mjs +++ b/packages/paperclip-runner/scripts/release-provider-pack.test.mjs @@ -56,10 +56,10 @@ test("release assembly rejects rehashed stale or missing packs before staging an mkdirSync(dirname(join(root, file)), { recursive: true }); cpSync(join(source, file), join(root, file)); } const platforms = {}; - for (const target of ["darwin-arm64", "darwin-x64", "linux-x64"]) { + for (const target of ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"]) { const bytes = Buffer.alloc(64); - if (target === "linux-x64") { - bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(62, 18); + if (target.startsWith("linux-")) { + bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(target === "linux-x64" ? 62 : 183, 18); } else { bytes.writeUInt32LE(0xfeedfacf, 0); bytes.writeUInt32LE(target === "darwin-arm64" ? 0x0100000c : 0x01000007, 4); } diff --git a/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs b/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs index 447603e38a7..c36e79e2aa7 100644 --- a/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs +++ b/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs @@ -1,4 +1,4 @@ -// Assemble the three independently built release daemons before npm packing. +// Assemble the four independently built release daemons before npm packing. // Usage: node packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs MANIFEST.json // Manifest: {sourceRevision, platforms: {"darwin-arm64": {path, sha256}, ...}, remoteProviderPack: {path, sha256}}. import { createHash } from "node:crypto"; @@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url"; 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"]); +export const RELEASE_RUNNER_TARGETS = Object.freeze(["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"]); const required = RELEASE_RUNNER_TARGETS; const digest = bytes => "sha256:" + createHash("sha256").update(bytes).digest("hex"); function regularFile(path, maximumSize) { @@ -38,7 +38,7 @@ export function verifyReleaseRunnerMetadata(metadata, expected) { export function releaseDaemonManifest(artifactDirectory, sourceRevision, verifyMetadata) { if (!/^[a-f0-9]{40}$/.test(sourceRevision)) throw Error("Release daemons require an immutable source revision"); - if (readdirSync(artifactDirectory).sort().join() !== [...required].sort().join()) throw Error("Release daemons require exactly the three supported target directories"); + if (readdirSync(artifactDirectory).sort().join() !== [...required].sort().join()) throw Error("Release daemons require exactly the four supported target directories"); const platforms = {}; for (const target of required) { const directory = join(resolve(artifactDirectory), target); @@ -83,7 +83,7 @@ async function main() { 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 three supported targets"); + 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"); 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`); diff --git a/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs index b041a244f6e..3efc5bcf7e9 100644 --- a/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs +++ b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs @@ -16,7 +16,7 @@ const contract = { package: { name: "@paperclipai/paperclip-runner", version: "0 const metadata = { binaryContractVersion: 2, nativeExecutionVersion: 1, harnessDriverVersion: 1, packageName: "@paperclipai/paperclip-runner", packageVersion: "0.0.0", prp: { name: "paperclip.runner", minimumVersion: 1, maximumVersion: 2 } }; -test("release assembly binds all three actual targets, source, hashes and compatible metadata", async () => { +test("release assembly binds all four actual targets, source, hashes and compatible metadata", async () => { const root = await realpath(await mkdtemp(path.join(tmpdir(), "runner-release-inputs-"))), source = "a".repeat(40); const verify = value => verifyReleaseRunnerMetadata(value, contract); const receiptPath = target => path.join(root, target, "receipt.json"); @@ -24,8 +24,8 @@ test("release assembly binds all three actual targets, source, hashes and compat for (const target of RELEASE_RUNNER_TARGETS) { await mkdir(path.join(root, target)); const bytes = Buffer.alloc(64); - if (target === "linux-x64") { - bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(62, 18); + if (target.startsWith("linux-")) { + bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(target === "linux-x64" ? 62 : 183, 18); } else { bytes.writeUInt32LE(0xfeedfacf); bytes.writeUInt32LE(target === "darwin-x64" ? 0x01000007 : 0x0100000c, 4); } await writeFile(path.join(root, target, "paperclip-runnerd"), bytes); await writeFile(receiptPath(target), JSON.stringify({ schema: "paperclip.runner.release-artifact.v1", sourceRevision: source, @@ -48,10 +48,17 @@ test("release assembly binds all three actual targets, source, hashes and compat await rm(binary); await symlink(path.join(root, "darwin-x64/paperclip-runnerd"), binary); assert.throws(() => releaseDaemonManifest(root, source, verify), /regular file/); await rm(binary); await writeFile(binary, bytes); + const armBinary = path.join(root, "linux-arm64/paperclip-runnerd"), armBytes = await readFile(armBinary); + const armReceipt = JSON.parse(await readFile(receiptPath("linux-arm64"), "utf8")); + const wrongArm = await readFile(path.join(root, "linux-x64/paperclip-runnerd")); + await writeFile(armBinary, wrongArm); + await writeFile(receiptPath("linux-arm64"), JSON.stringify({ ...armReceipt, sha256: "sha256:" + createHash("sha256").update(wrongArm).digest("hex") })); + assert.throws(() => releaseDaemonManifest(root, source, verify), /architecture mismatch/); + await writeFile(armBinary, armBytes); await writeFile(receiptPath("linux-arm64"), JSON.stringify(armReceipt)); await mkdir(path.join(root, "win32-x64")); - assert.throws(() => releaseDaemonManifest(root, source, verify), /exactly the three/); + assert.throws(() => releaseDaemonManifest(root, source, verify), /exactly the four/); await rm(path.join(root, "win32-x64"), { recursive: true }); - await rm(receiptPath("linux-x64")); + await rm(receiptPath("linux-arm64")); assert.throws(() => releaseDaemonManifest(root, source, verify), /only its daemon and receipt/); } finally { await rm(root, { recursive: true, force: true }); } }); @@ -142,8 +149,8 @@ test("a source rebuild selects fresh dev bytes while preserving release binaries const artifact = path.join(binaryRoot, target, "paperclip-runnerd"); if (artifact !== hostTarget) { const header = Buffer.alloc(64); - if (target === "linux-x64") { - header.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); header.writeUInt16LE(62, 18); + if (target.startsWith("linux-")) { + header.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); header.writeUInt16LE(target === "linux-x64" ? 62 : 183, 18); } else { header.writeUInt32LE(0xfeedfacf); header.writeUInt32LE(target === "darwin-x64" ? 0x01000007 : 0x0100000c, 4); } await mkdir(path.dirname(artifact), { recursive: true }); await writeFile(artifact, header); diff --git a/packages/shared/src/agent-runner.ts b/packages/shared/src/agent-runner.ts index 62f633d70c0..1f228502ff2 100644 --- a/packages/shared/src/agent-runner.ts +++ b/packages/shared/src/agent-runner.ts @@ -12,10 +12,10 @@ export function paperclipRunnerProfileForHarness(harness: string): { provider: " return harness === "codex_local" ? { provider: "codex" } : undefined; } -/** Creation defaults follow the shipped Linux x64 and macOS release artifacts. */ +/** Creation defaults follow the shipped 64-bit Linux and macOS release artifacts. */ export function paperclipRunnerSupportsPlatform(harness: string, platform: string, architecture: string): boolean { return !!paperclipRunnerProfileForHarness(harness) - && ((platform === "linux" && architecture === "x64") + && ((platform === "linux" && (architecture === "x64" || architecture === "arm64")) || (platform === "darwin" && (architecture === "arm64" || architecture === "x64"))); } diff --git a/scripts/__tests__/grok-public-install-sandbox.test.mjs b/scripts/__tests__/grok-public-install-sandbox.test.mjs index 24ba8e95c4f..de8eedd15cf 100644 --- a/scripts/__tests__/grok-public-install-sandbox.test.mjs +++ b/scripts/__tests__/grok-public-install-sandbox.test.mjs @@ -169,7 +169,8 @@ test('the bundled-dependency installs run together while the other packages are const checks = position("for (const tarball of tarballs) assertNoBundledCodexPayloads(run('tar', ['-tzf', tarball])"); const copied = position('for (const tarball of tarballs) {'); assert.ok(bundledSources < start && start < registered && registered < otherSources && otherSources < join && join < packs && packs < reordered && reordered < checks && checks < copied); - assert.equal(source.match(/run\('npm', \['pack'/g).length, 1, 'only the lifecycle sentinel is packed in-process'); + const packagingLane = source.slice(position("} else {\nassert.equal(process.platform, 'linux'")); + assert.equal(packagingLane.match(/run\('npm', \['pack'/g).length, 1, 'only the lifecycle sentinel is packed in-process in the packaging lane'); assert.ok(source.includes("destination: join(root, `pack-${index}`)"), 'each pack writes into its own directory'); // The timing line reports the split staging phase, each install, and the // pack step with what the packs would have cost one after another. @@ -352,6 +353,7 @@ test('actual tar member checks allow Codex JavaScript but reject nested platform assert.doesNotThrow(() => assertNoBundledCodexPayloads(javascript)); for (const payload of [ 'package/node_modules/@openai/codex-linux-x64/package.json', + 'package/node_modules/@openai/codex-linux-arm64/package.json', 'package/node_modules/bridge/node_modules/@openai/codex-darwin-arm64/package.json', 'package/node_modules/@openai/codex-win32-x64/package.json', 'package/node_modules/@openai/codex/vendor/x86_64-unknown-linux-musl/codex/codex', @@ -364,26 +366,28 @@ test('actual tar member checks allow Codex JavaScript but reject nested platform }); test('consumer provenance rejects bundled, foreign, inconsistent and non-official Codex payloads', () => { - const path = 'node_modules/@openai/codex-linux-x64'; - const manifest = { name: '@openai/codex', version: '0.160.0-linux-x64', os: ['linux'], cpu: ['x64'] }; + for (const target of ['linux-x64', 'linux-arm64']) { + const path = `node_modules/@openai/codex-${target}`; + const manifest = { name: '@openai/codex', version: `0.160.0-${target}`, os: ['linux'], cpu: [target.slice('linux-'.length)] }; const entry = { version: manifest.version, resolved: `https://registry.npmjs.org/@openai/codex/-/codex-${manifest.version}.tgz`, integrity: `sha512-${Buffer.alloc(64, 1).toString('base64')}` }; - const check = (value = entry, installed = [{ path, manifest }]) => assertInstalledCodexPlatformProvenance({ packages: { [path]: value } }, installed, 'linux-x64'); + const check = (value = entry, installed = [{ path, manifest }]) => assertInstalledCodexPlatformProvenance({ packages: { [path]: value } }, installed, target); assert.equal(check().codexConsumerHostOnly, true); - const legacyManifest = { ...manifest, version: '0.156.1-linux-x64' }; + const legacyManifest = { ...manifest, version: `0.156.1-${target}` }; assert.equal(check({ ...entry, version: legacyManifest.version, - resolved: 'https://registry.npmjs.org/@openai/codex/-/codex-0.156.1-linux-x64.tgz' }, [{ path, manifest: legacyManifest }]).codexConsumerHostOnly, true); - assert.throws(() => check(entry, [{ path, manifest: { ...manifest, version: '^0.160.0-linux-x64' } }]), /exact host version/); + resolved: `https://registry.npmjs.org/@openai/codex/-/codex-0.156.1-${target}.tgz` }, [{ path, manifest: legacyManifest }]).codexConsumerHostOnly, true); + assert.throws(() => check(entry, [{ path, manifest: { ...manifest, version: `^0.160.0-${target}` } }]), /exact host version/); for (const change of [ - { inBundle: true }, { link: true }, { version: '0.156.1-linux-x64' }, - { resolved: 'https://example.com/@openai/codex/-/codex-0.160.0-linux-x64.tgz' }, - { resolved: 'https://registry.npmjs.org/@openai/codex/-/codex-0.156.1-linux-x64.tgz' }, - { resolved: 'https://user@registry.npmjs.org/@openai/codex/-/codex-0.160.0-linux-x64.tgz' }, + { inBundle: true }, { link: true }, { version: `0.156.1-${target}` }, + { resolved: `https://example.com/@openai/codex/-/codex-0.160.0-${target}.tgz` }, + { resolved: `https://registry.npmjs.org/@openai/codex/-/codex-0.156.1-${target}.tgz` }, + { resolved: `https://user@registry.npmjs.org/@openai/codex/-/codex-0.160.0-${target}.tgz` }, { integrity: undefined }, { integrity: 'sha512-AA==' }, ]) assert.throws(() => check({ ...entry, ...change })); assert.throws(() => check(entry, []), /must install its host/); assert.throws(() => check(entry, [{ path: 'node_modules/@openai/codex-darwin-arm64', manifest }]), /only the host/); assert.throws(() => check(entry, [{ path, manifest: { ...manifest, name: 'untrusted' } }]), /identity/); + } }); @@ -413,9 +417,31 @@ test('installed daemon proof uses the production host resolver and complete sour assert.match(source, /runnerBinaryTarget\(bytes\), target/); assert.match(source, /createHash\('sha256'\)/); assert.match(source, /metadata.binaryContractVersion, expected.contracts.runnerdArtifact/); + assert.match(source, /'linux-arm64'/); assert.doesNotMatch(source, /PAPERCLIP_RUNNERD_BINARY|runner\/target\/debug|execFileSync\('paperclip-runnerd'/); }); +test('native ARM clean consumers keep the existing container and installed-runtime boundaries', () => { + const args = grokConsumerDockerArgs({ ...paths, platform: 'linux/arm64', command: GROK_PUBLIC_INSTALL_LIFECYCLE }); + assert.deepEqual(values(args, '--platform'), ['linux/arm64']); + assert.deepEqual(values(args, '--network'), ['none']); + assert.deepEqual(values(args, '--user'), ['1001:1001']); + assert.ok(args.includes('--read-only')); + assert.deepEqual(values(args, '--cap-drop'), ['ALL']); + assert.deepEqual(values(args, '--security-opt'), ['no-new-privileges']); + assert.ok(values(args, '--mount').some(value => value.endsWith('dst=/packages,readonly'))); + assert.throws(() => grokConsumerDockerArgs({ ...paths, platform: 'linux/arm/v7', command: [] }), /supported native Linux platform/); + const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8'); + const arm = source.split('async function verifyLinuxArmConsumer(')[1].split("if (process.argv[2] === '--linux-arm64-consumer')")[0]; + assert.match(arm, /process.platform \+ '-' \+ process.arch, 'linux-arm64'/); + assert.match(arm, /const platform = 'linux\/arm64'/); + assert.match(arm, /isolated\(GROK_PUBLIC_INSTALL_LIFECYCLE\)/); + assert.match(arm, /installedCodexProbeSource\(installed \+ '\/index.js', '\/consumer', undefined, \{ nativeCodex: true \}\)/); + assert.match(arm, /tests\/runner-e2e\/installed-cli.ts/); + assert.match(arm, /temporarySizeMiB: 2048, temporaryExecutable: true/); + assert.doesNotMatch(arm, /stage-runner|runner\/target|PAPERCLIP_RUNNER_BINARY|prerequisite:/); +}); + test('Mac startup wiring normalizes only validated manifest digests and uses server-scoped tooling', () => { const source = macInstalledStartupProbeSource('/owned/consumer', '/owned/consumer/server', 'sha256:' + 'a'.repeat(64), '/source/tests/runner-e2e/installed-cli.ts'); diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs index 4f0c15cbad2..c8f54484fb7 100644 --- a/scripts/__tests__/release-verify-workflow.test.mjs +++ b/scripts/__tests__/release-verify-workflow.test.mjs @@ -746,9 +746,14 @@ test("direct protocol concurrency override only lowers the configured ceiling", test("release daemon production uses exact-source native hosts and no publisher or provider permissions", () => { const workflow = readWorkflow("release-verify.yml"); const producer = workflow.split(" runner_binaries:\n")[1]; - for (const [target, host] of [["linux-x64", "ubuntu-latest"], ["darwin-arm64", "macos-15"], ["darwin-x64", "macos-15-intel"]]) { - assert.ok(producer.includes(`- target: ${target}\n host: ${host}`)); + const targets = workflow.split(" runner_binary_targets:\n")[1].split(" runner_binaries:\n")[0]; + for (const [target, host] of [["linux-x64", "ubuntu-latest"], ["linux-arm64", "ubuntu-24.04-arm"], ["darwin-arm64", "macos-15"], ["darwin-x64", "macos-15-intel"]]) { + assert.ok(targets.includes(`'${target}': '${host}'`)); } + assert.match(producer, /matrix: \$\{\{ fromJSON\(needs.runner_binary_targets.outputs.matrix\) \}\}/); + assert.match(targets, /Unknown exact-source daemon platform contract/); + assert.match(targets, /RELEASE_RUNNER_TARGETS/); + assert.doesNotMatch(targets, /secrets\.|id-token: write|contents: write|npm publish|environment:/); assert.match(producer, /persist-credentials: false/); assert.match(producer, /contents: read/); assert.doesNotMatch(producer, /secrets\.|id-token: write|contents: write|npm publish|environment:/); @@ -773,7 +778,7 @@ test("release daemon production uses exact-source native hosts and no publisher assert.match(script, /elif \[ "\$dry_run" = false \]; then[\s\S]*?release_fail "Public releases require/); }); -test("premerge Mac qualification consumes the same Linux-packed npm bytes without publishing", () => { +test("premerge native qualification consumes the same Linux-packed npm bytes without publishing", () => { const smoke = readWorkflow("release-smoke.yml"); assert.match(smoke, /smoke_service:\n\s+if: inputs.qualification_source_sha == ''/); assert.match(smoke, /smoke:\n\s+if: inputs.qualification_source_sha == ''/); @@ -784,11 +789,46 @@ test("premerge Mac qualification consumes the same Linux-packed npm bytes withou assert.match(qualification, /if: \$\{\{ !cancelled\(\) && steps.pack_identity.outputs.available == 'true' \}\}/); assert.match(qualification, /name: \$\{\{ inputs.artifact_name \}\}-public-packages/); assert.match(qualification, /--mac-consumer/); - assert.match(qualification, /host: macos-15\n/); - assert.match(qualification, /host: macos-15-intel\n/); + assert.match(qualification, /--linux-arm64-consumer/); + assert.match(qualification, /fromJSON\(needs.qualification_daemons.outputs.native_consumer_matrix\)/); assert.match(qualification, /pnpm install --ignore-scripts --no-frozen-lockfile/); }); +test("exact-source daemon matrices preserve historical three-target sources and add native ARM only for new sources", () => { + const workflow = readWorkflow("release-verify.yml"); + const step = workflow.split(' - name: Preserve the immutable source\'s platform contract')[1].split('\n runner_binaries:')[0]; + const script = step.split(' run: |\n')[1].split('\n').map(line => line.startsWith(' ') ? line.slice(10) : line).join('\n'); + const root = mkdtempSync(path.join(tmpdir(), 'release-target-contract-')); + try { + const tools = path.join(root, 'tools'); mkdirSync(tools); + writeFileSync(path.join(tools, 'git'), '#!/bin/sh\nprintf "%s\\n" "$FIXTURE_HEAD"\n', { mode: 0o755 }); + const source = path.join(root, 'packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs'); + mkdirSync(path.dirname(source), { recursive: true }); + const output = path.join(root, 'outputs'); + const historical = ['darwin-arm64', 'darwin-x64', 'linux-x64']; + for (const targets of [historical, [...historical, 'linux-arm64']]) { + writeFileSync(source, `export const RELEASE_RUNNER_TARGETS = ${JSON.stringify(targets)};`); + writeFileSync(output, ''); + const result = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { cwd: root, encoding: 'utf8', env: { + PATH: `${tools}:${path.dirname(process.execPath)}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'a'.repeat(40), GITHUB_OUTPUT: output, + } }); + assert.equal(result.status, 0, result.stderr); + const data = Object.fromEntries(readFileSync(output, 'utf8').trim().split('\n').map(line => { + const index = line.indexOf('='); return [line.slice(0, index), JSON.parse(line.slice(index + 1))]; + })); + assert.deepEqual(data.matrix.include.map(row => row.target), targets); + assert.deepEqual(data.native_consumers.include.map(row => row.target), targets.filter(target => target !== 'linux-x64')); + assert.ok(data.matrix.include.every(row => row.target !== 'linux-arm64' || row.host === 'ubuntu-24.04-arm')); + } + writeFileSync(source, 'export const RELEASE_RUNNER_TARGETS = ["linux-arm64"];'); writeFileSync(output, ''); + const result = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { cwd: root, encoding: 'utf8', env: { + PATH: `${tools}:${path.dirname(process.execPath)}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'a'.repeat(40), GITHUB_OUTPUT: output, + } }); + assert.equal(result.status, 1); assert.match(result.stderr, /Unknown exact-source daemon platform contract/); + assert.equal(readFileSync(output, 'utf8'), ''); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + test("historical promotion sources keep their packaging path while new sources require daemon assembly", () => { const workflow = readWorkflow("release.yml"); diff --git a/scripts/grok-public-install-sandbox.mjs b/scripts/grok-public-install-sandbox.mjs index ea245f9b627..7695f54dd65 100644 --- a/scripts/grok-public-install-sandbox.mjs +++ b/scripts/grok-public-install-sandbox.mjs @@ -198,7 +198,7 @@ export function installedDaemonProbeSource(outputRoot, consumerRoot, sourceRevis const { parsePaperclipRunnerdBuildMetadata } = await import(pathToFileURL(join(output, 'evals/runnerd-artifact.js')).href); const { PAPERCLIP_RUNNER_BUILD_METADATA: expected } = await import(pathToFileURL(join(output, 'evals/build-metadata.js')).href); const manifest = JSON.parse(readFileSync(join(output, 'bin/release-manifest.json'), 'utf8')); - const targets = ['darwin-arm64', 'darwin-x64', 'linux-x64']; + const targets = ['darwin-arm64', 'darwin-x64', 'linux-x64', 'linux-arm64']; assert.equal(manifest.schema, 'paperclip.runner.release-binaries.v1'); assert.equal(manifest.sourceRevision, ${JSON.stringify(sourceRevision)}); assert.deepEqual(Object.keys(manifest.platforms).sort(), targets.sort()); @@ -240,7 +240,8 @@ export function macInstalledStartupProbeSource(consumerRoot, serverRoot, runnerD serverRoot:${JSON.stringify(serverRoot)},expectedRunnerSha256:${JSON.stringify(runnerDigest.slice(7))}})));`; } -export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, gid, download = false, prerequisite, temporarySizeMiB = 256, temporaryExecutable = false }) { +export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, gid, download = false, prerequisite, temporarySizeMiB = 256, temporaryExecutable = false, platform = 'linux/amd64' }) { + if (!['linux/amd64', 'linux/arm64'].includes(platform)) throw new Error('Public-install verification requires a supported native Linux platform'); if (!Number.isSafeInteger(uid) || uid <= 0 || !Number.isSafeInteger(gid) || gid <= 0) { throw new Error('Public-install verification requires an unprivileged host user'); } @@ -251,7 +252,7 @@ export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, throw new Error('Executable runtime snapshots require an offline verification sandbox'); } return [ - 'run', '--rm', '--platform', 'linux/amd64', + 'run', '--rm', '--platform', platform, '--user', `${uid}:${gid}`, '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--pids-limit', '256', '--memory', '3g', diff --git a/scripts/release.sh b/scripts/release.sh index a75d4e827ac..a4a0073550f 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -292,7 +292,7 @@ if [ -n "${PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR:-}" ]; then assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR" "$(git rev-parse HEAD)" cp -R "$REPO_ROOT/packages/paperclip-runner/dist/bin/." "$REPO_ROOT/server/dist/vendor/paperclip-runner/bin/" elif [ "$dry_run" = false ]; then - release_fail "Public releases require the three hosted runner daemon artifacts; use the supported Release workflow." + release_fail "Public releases require the four hosted runner daemon artifacts; use the supported Release workflow." fi node "$REPO_ROOT/scripts/build-standalone-public-packages.mjs" bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh" diff --git a/scripts/verify-grok-npm-install.mjs b/scripts/verify-grok-npm-install.mjs index 51ac0110fa2..5286415ed90 100644 --- a/scripts/verify-grok-npm-install.mjs +++ b/scripts/verify-grok-npm-install.mjs @@ -8,6 +8,7 @@ import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, re import { createHash } from 'node:crypto'; import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; +import { stripTypeScriptTypes } from 'node:module'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { packConcurrency, packStagedPackages, packTimings } from './pack-staged-packages.mjs'; @@ -95,7 +96,75 @@ async function verifyMacConsumer(packageDirectory, sourceRevision, receiptPath) console.log(JSON.stringify(receipt)); } finally { rmSync(root, { recursive: true, force: true }); } } -if (process.argv[2] === '--mac-consumer') { +async function verifyLinuxArmConsumer(packageDirectory, sourceRevision, receiptPath) { + assert.equal(process.env.GITHUB_ACTIONS, 'true', 'Native clean consumers run only on disposable hosted CI'); + assert.equal(process.platform + '-' + process.arch, 'linux-arm64', 'ARM qualification requires a native ARM host'); + assert.match(sourceRevision, /^[a-f0-9]{40}$/); + const packages = realpathSync(packageDirectory); + const manifest = JSON.parse(readFileSync(join(packages, 'manifest.json'), 'utf8')); + assert.equal(manifest.schema, 'paperclip.runner.public-packages/v1'); + assert.equal(manifest.sourceRevision, sourceRevision); + assert.ok(manifest.archives.some(entry => entry.name === '@paperclipai/server')); + assert.ok(manifest.archives.some(entry => entry.name === 'paperclipai')); + assert.equal(new Set(manifest.archives.map(entry => entry.name)).size, manifest.archives.length); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'paperclip-arm-public-install-'))); + try { + const assets = join(root, 'assets'), consumer = join(root, 'consumer'), cache = join(root, 'cache'); + for (const directory of [assets, consumer, cache]) mkdirSync(directory); + const run = (command, args, cwd = root) => execFileSync(command, args, { cwd, + env: { PATH: process.env.PATH, HOME: root, TMPDIR: root }, timeout: 10 * 60_000, + maxBuffer: 32 * 1024 * 1024, encoding: 'utf8' }); + const platform = 'linux/arm64'; + run('docker', ['pull', '--platform', platform, GROK_PUBLIC_INSTALL_IMAGE]); + const isolated = (command, options = {}) => run('docker', grokConsumerDockerArgs({ assets, consumer, cache, + uid: process.getuid(), gid: process.getgid(), platform, command, ...options })); + for (const entry of manifest.archives) { + assert.match(entry.file, /^[A-Za-z0-9._-]+\.tgz$/); assert.match(entry.sha256, /^[a-f0-9]{64}$/); + const tarball = join(packages, entry.file); + assert.equal(realpathSync(tarball), tarball); + assert.equal(createHash('sha256').update(readFileSync(tarball)).digest('hex'), entry.sha256); + assertNoBundledCodexPayloads(run('tar', ['-tzf', tarball]).trim().split('\n')); + cpSync(tarball, join(assets, entry.file)); chmodSync(join(assets, entry.file), 0o644); + } + writeFileSync(join(consumer, 'package.json'), JSON.stringify({ private: true, type: 'module' })); + const sentinelSource = join(root, 'lifecycle-sentinel'); mkdirSync(sentinelSource); + writeFileSync(join(sentinelSource, 'package.json'), JSON.stringify({ name: 'paperclip-verification-lifecycle-sentinel', version: '1.0.0', + scripts: { postinstall: 'node -e "require(\'node:fs\').writeFileSync(\'lifecycle-ran\', \'ok\')"' } })); + run('npm', ['pack', '--ignore-scripts', '--pack-destination', assets], sentinelSource); + isolated(['npm', 'install', '--ignore-scripts', '--omit=dev', ...readdirSync(assets).filter(file => file.endsWith('.tgz')).map(file => `/packages/${file}`)], { download: true }); + const sentinel = join(consumer, 'node_modules/paperclip-verification-lifecycle-sentinel/lifecycle-ran'); + assert.equal(existsSync(sentinel), false); + const lock = readFileSync(join(consumer, 'package-lock.json'), 'utf8'); + isolated(GROK_PUBLIC_INSTALL_LIFECYCLE); + assert.equal(readFileSync(sentinel, 'utf8'), 'ok'); + assert.equal(readFileSync(join(consumer, 'package-lock.json'), 'utf8'), lock); + for (const entry of manifest.archives) { + assert.equal(JSON.parse(readFileSync(join(consumer, 'node_modules', entry.name, 'package.json'))).version, manifest.releaseVersion); + } + const server = '/consumer/node_modules/@paperclipai/server', installed = server + '/dist/vendor/paperclip-runner'; + const probe = (name, source, options = {}) => { writeFileSync(join(assets, name), source); return JSON.parse(isolated(['node', '/packages/' + name], options).trim()); }; + const daemon = probe('daemon-probe.mjs', installedDaemonProbeSource(installed, '/consumer', sourceRevision)); + assert.equal(daemon.target, 'linux-arm64'); + const codex = probe('codex-probe.mjs', installedCodexProbeSource(installed + '/index.js', '/consumer', undefined, { nativeCodex: true })); + // Reuse the existing zero-turn hook as read-only verifier source. All + // production imports still resolve from the installed consumer graph. + cpSync(join(repo, 'tests/runner-e2e/installed-cli.ts'), join(assets, 'installed-cli.ts')); + writeFileSync(join(assets, 'types.js'), stripTypeScriptTypes(readFileSync(join(repo, 'tests/runner-e2e/types.ts'), 'utf8'))); + const startup = probe('startup-probe.mjs', macInstalledStartupProbeSource('/consumer', server, daemon.sha256, '/packages/installed-cli.ts'), + { temporarySizeMiB: 2048, temporaryExecutable: true }); + const receipt = { schema: 'paperclip.runner.linux-arm64-public-install.v1', sourceRevision, platform: process.platform, architecture: process.arch, + cleanNpmInstall: true, packageCount: manifest.archives.length, codexPackedPayloadsOmitted: true, + consumerImage: GROK_PUBLIC_INSTALL_IMAGE, consumerImagePlatform: platform, + consumerLockPreserved: true, lifecycleSentinelVerified: true, lifecycleNetwork: 'none', + ...daemon, ...codex, startup, providerCalls: 0 }; + writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + '\n'); + console.log(JSON.stringify(receipt)); + } finally { rmSync(root, { recursive: true, force: true }); } +} +if (process.argv[2] === '--linux-arm64-consumer') { + assert.equal(process.argv.length, 6, 'Usage: --linux-arm64-consumer PACKAGE_DIRECTORY SOURCE_SHA RECEIPT_PATH'); + await verifyLinuxArmConsumer(process.argv[3], process.argv[4], process.argv[5]); +} else if (process.argv[2] === '--mac-consumer') { assert.equal(process.argv.length, 6, 'Usage: --mac-consumer PACKAGE_DIRECTORY SOURCE_SHA RECEIPT_PATH'); await verifyMacConsumer(process.argv[3], process.argv[4], process.argv[5]); } else { diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index 4585683fe74..b042705a9b9 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -170,7 +170,8 @@ describe("adapter routes", () => { [{ driver: "ssh", platform: "linux", architecture: "x64" }, "paperclip"], [{ driver: "ssh", platform: "darwin", architecture: "arm64" }, "paperclip"], [{ driver: "ssh", platform: "darwin", architecture: "x64" }, "paperclip"], - [{ driver: "ssh", platform: "linux", architecture: "arm64" }, "legacy"], + [{ driver: "ssh", platform: "linux", architecture: "arm64" }, "paperclip"], + [{ driver: "ssh", platform: "linux", architecture: "arm" }, "legacy"], [{ driver: "local", platform: "win32", architecture: "x64" }, "legacy"], ])("projects the selected environment once for the whole adapter inventory (%s)", async (target, defaultRunner) => { const resolveRunnerTarget = vi.fn(async () => target); diff --git a/server/src/__tests__/built-in-agents.test.ts b/server/src/__tests__/built-in-agents.test.ts index 20f411b6f3f..3ac52cff830 100644 --- a/server/src/__tests__/built-in-agents.test.ts +++ b/server/src/__tests__/built-in-agents.test.ts @@ -267,7 +267,7 @@ describeEmbeddedPostgres("built-in agents", () => { ])).toThrow("defaultAdapterType must be allowed"); }); - it.each([{ platform: "linux", architecture: "x64" }, { platform: "darwin", architecture: "arm64" }, { platform: "darwin", architecture: "x64" }])("lazily provisions one agent per company/key and updates the same row on setup ($platform/$architecture)", async ({ platform, architecture }) => { + it.each([{ platform: "linux", architecture: "x64" }, { platform: "linux", architecture: "arm64" }, { platform: "darwin", architecture: "arm64" }, { platform: "darwin", architecture: "x64" }])("lazily provisions one agent per company/key and updates the same row on setup ($platform/$architecture)", async ({ platform, architecture }) => { runnerTarget.platform = platform; runnerTarget.architecture = architecture; const companyId = await seedCompany(); diff --git a/server/src/__tests__/plugin-managed-agents.test.ts b/server/src/__tests__/plugin-managed-agents.test.ts index c306e28afeb..e51c992fff5 100644 --- a/server/src/__tests__/plugin-managed-agents.test.ts +++ b/server/src/__tests__/plugin-managed-agents.test.ts @@ -284,7 +284,7 @@ describeEmbeddedPostgres("plugin-managed agents", () => { }); }); - it.each([{ platform: "linux", architecture: "x64" }, { platform: "darwin", architecture: "arm64" }, { platform: "darwin", architecture: "x64" }])("creates managed agents with the most-used compatible company adapter ($platform/$architecture)", async ({ platform, architecture }) => { + it.each([{ platform: "linux", architecture: "x64" }, { platform: "linux", architecture: "arm64" }, { platform: "darwin", architecture: "arm64" }, { platform: "darwin", architecture: "x64" }])("creates managed agents with the most-used compatible company adapter ($platform/$architecture)", async ({ platform, architecture }) => { runnerTarget.platform = platform; runnerTarget.architecture = architecture; const pluginManifest = manifest(); diff --git a/server/src/services/agent-runner-selection.test.ts b/server/src/services/agent-runner-selection.test.ts index 9d972ecebc1..38e819d1f7c 100644 --- a/server/src/services/agent-runner-selection.test.ts +++ b/server/src/services/agent-runner-selection.test.ts @@ -9,7 +9,7 @@ vi.mock("./environment-config.js", () => ({ resolveEnvironmentDriverConfigForRun vi.mock("@paperclipai/adapter-utils/ssh", () => ({ runSshCommand: state.ssh })); import { agentRunnerAvailability, resolveAgentRunnerTargetForCompany, resolveNewAgentRunner, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js"; const target = { driver: "local", platform: "linux", architecture: "x64" }; -const qualifiedTargets = [target, { driver: "local", platform: "darwin", architecture: "arm64" }, { driver: "local", platform: "darwin", architecture: "x64" }]; +const qualifiedTargets = [target, { driver: "local", platform: "linux", architecture: "arm64" }, { driver: "local", platform: "darwin", architecture: "arm64" }, { driver: "local", platform: "darwin", architecture: "x64" }]; describe("server-owned Codex runner selection", () => { beforeEach(() => { state.disabled = []; state.overridden.clear(); state.settings = {}; state.getEnvironment.mockReset(); state.bindings.mockReset().mockResolvedValue([]); state.managedEnvironment.mockReset(); state.ssh.mockReset().mockResolvedValue({ stdout: "Linux\nx86_64\n" }); state.resolveEnvironment.mockReset().mockResolvedValue({ driver: "ssh", config: {} }); }); @@ -34,7 +34,7 @@ describe("server-owned Codex runner selection", () => { expect(resolveNewAgentRunner({ adapterType: "paperclip_runner", runner: "legacy", adapterConfig: { provider: "codex", codexPermissionMode: "never", lifecycleMode: "per_turn", dangerouslyBypassApprovalsAndSandbox: false } })) .toEqual({ adapterType: "codex_local", adapterConfig: { dangerouslyBypassApprovalsAndSandbox: false } }); }); - it.each([{ platform: "win32", architecture: "x64" }, { platform: "linux", architecture: "arm64" }, { platform: "darwin", architecture: "ia32" }])("keeps unqualified $platform/$architecture targets legacy while preserving explicit native profiles", ({ platform, architecture }) => { + it.each([{ platform: "win32", architecture: "x64" }, { platform: "linux", architecture: "arm" }, { platform: "darwin", architecture: "ia32" }])("keeps unqualified $platform/$architecture targets legacy while preserving explicit native profiles", ({ platform, architecture }) => { const input = { adapterType: "codex_local", target: { driver: "local", platform, architecture } }; expect(resolveNewAgentRunner(input).adapterType).toBe("codex_local"); expect(() => resolveNewAgentRunner({ ...input, runner: "paperclip" })).toThrow("unavailable"); @@ -80,13 +80,15 @@ describe("server-owned Codex runner selection", () => { state.ssh.mockResolvedValue({ stdout: "Linux\naarch64\n" }); const result = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: null }); expect(state.getEnvironment).toHaveBeenCalledWith("default"); expect(state.bindings).toHaveBeenCalledWith("default"); - expect(result.adapterType).toBe("codex_local"); + expect(result.adapterType).toBe("paperclip_runner"); }); it.each([ ["Linux\nx86_64\n", "paperclip", "paperclip_runner"], ["Darwin\narm64\n", "paperclip", "paperclip_runner"], ["Darwin\nx86_64\n", "paperclip", "paperclip_runner"], - ["Linux\naarch64\n", "legacy", "codex_local"], + ["Linux\naarch64\n", "paperclip", "paperclip_runner"], + ["Linux\narm64\n", "paperclip", "paperclip_runner"], + ["Linux\narmv7l\n", "legacy", "codex_local"], ])("uses the same selected SSH target for discovery and saving (%s)", async (stdout, expectedRunner, expectedAdapter) => { state.getEnvironment.mockResolvedValue({ id: "selected", driver: "ssh", status: "active", config: {} }); state.ssh.mockResolvedValue({ stdout }); @@ -121,7 +123,7 @@ describe("Codex creation runner contract", () => { }); it("qualifies only the Codex daemon targets shipped in public server packages", () => { for (const { platform, architecture } of qualifiedTargets) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(true); - for (const [platform, architecture] of [["darwin", "ia32"], ["linux", "arm64"], ["win32", "x64"], ["win32", "arm64"], ["freebsd", "x64"]]) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(false); + for (const [platform, architecture] of [["darwin", "ia32"], ["linux", "arm"], ["win32", "x64"], ["win32", "arm64"], ["freebsd", "x64"]]) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(false); expect(paperclipRunnerSupportsPlatform("claude_local", "darwin", "arm64")).toBe(false); }); it("keeps auto out of saved update defaults and validates request intent", () => { diff --git a/server/src/services/local-ai-browser-login.test.ts b/server/src/services/local-ai-browser-login.test.ts index 6bfc0d36365..d1067100c50 100644 --- a/server/src/services/local-ai-browser-login.test.ts +++ b/server/src/services/local-ai-browser-login.test.ts @@ -41,8 +41,8 @@ function targetPlatform(platform: string, architecture: string) { } describe.skipIf(process.platform === "win32")("local browser subscription login", () => { - it.each([false, true])("uses the selected Codex command with its credential home and ambient HOME/cwd (legacy Linux ARM64: %s)", async (legacy) => { - if (legacy) targetPlatform("linux", "arm64"); + it.each([false, true])("uses the selected Codex command with its credential home and ambient HOME/cwd (legacy Linux ARM32: %s)", async (legacy) => { + if (legacy) targetPlatform("linux", "arm"); const home = await fakeCli("codex", [ '[ "$1 $2" = "login --device-auth" ] || exit 11', '[ "$HOME" = "$CLAUDE_CONFIG_DIR" ] && [ "$HOME" != "$CODEX_HOME" ] && [ "$CODEX_HOME" = "$PAPERCLIP_TEST_LOGIN_CODEX_HOME" ] || exit 12', @@ -66,7 +66,7 @@ describe.skipIf(process.platform === "win32")("local browser subscription login" expect(login.code).toBe("ABCD-EFGHJ"); }); - it.each([["linux", "x64"], ["darwin", "arm64"], ["darwin", "x64"]] as const)("reports an unavailable Codex CLI on %s/%s without revealing private details", async (platform, architecture) => { + it.each([["linux", "x64"], ["linux", "arm64"], ["darwin", "arm64"], ["darwin", "x64"]] as const)("reports an unavailable Codex CLI on %s/%s without revealing private details", async (platform, architecture) => { targetPlatform(platform, architecture); const home = await fakeCli("codex", 'echo invoked > "$CODEX_HOME/unreviewed-cli"'); const failure = new Error("private-installation-path-or-provider-output"); @@ -79,8 +79,8 @@ describe.skipIf(process.platform === "win32")("local browser subscription login" expect(login.authorizationUrl).toBeUndefined(); }); - it("reports a missing Linux ARM64 Codex CLI without revealing the host PATH", async () => { - targetPlatform("linux", "arm64"); + it("reports a missing Linux ARM32 Codex CLI without revealing the host PATH", async () => { + targetPlatform("linux", "arm"); const home = await fakeCli("codex", "exit 0"); process.env.PATH = "/usr/bin:/bin"; const login = startLocalBrowserLogin("openai", home); diff --git a/server/src/services/local-ai-browser-login.ts b/server/src/services/local-ai-browser-login.ts index ad176b8618f..1643e0e0d53 100644 --- a/server/src/services/local-ai-browser-login.ts +++ b/server/src/services/local-ai-browser-login.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { runSetupTokenLogin } from "@paperclipai/adapter-claude-local/server"; import { runDeviceLogin } from "@paperclipai/adapter-codex-local/server"; import { ensureCommandResolvable, resolveCommandForLogs } from "@paperclipai/adapter-utils/server-utils"; +import { paperclipRunnerSupportsPlatform } from "@paperclipai/shared"; import { resolveCodexCommand } from "../vendor/paperclip-runner/index.js"; export type LocalBrowserLoginState = { @@ -56,10 +57,9 @@ export function startLocalBrowserLogin(provider: "anthropic" | "openai", home: s async start(_command: string, onData: (chunk: string) => void): Promise<{ exitCode: number | null }> { let executable = "claude"; if (provider === "openai") { - // Linux ARM64 retains its supported legacy CLI path; it is not a - // qualified native runner target. Other hosts prefer the installed - // dependency, then an existing CLI when the dependency is absent. - const legacyPlatform = process.platform === "linux" && process.arch === "arm64"; + // Native targets use the same owned Codex resolution as execution. + // Other platforms retain the legacy CLI lookup. + const legacyPlatform = !paperclipRunnerSupportsPlatform("codex_local", process.platform, process.arch); try { if (legacyPlatform) { await ensureCommandResolvable("codex", home, process.env); diff --git a/ui/src/pages/CompanyImport.test.tsx b/ui/src/pages/CompanyImport.test.tsx index 3eafca01b1f..8ac4808f7a3 100644 --- a/ui/src/pages/CompanyImport.test.tsx +++ b/ui/src/pages/CompanyImport.test.tsx @@ -1221,6 +1221,14 @@ describe("CompanyImport", () => { { sourceType: "paperclip_runner", sourceRunner: "paperclip" as const, runner: "legacy" as const, sourcePolicy: { codexPermissionMode: "never", dangerouslyBypassApprovalsAndSandbox: false }, expectedBypass: false }, { sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { dangerouslyBypassSandbox: false }, expectedBypass: false }, { sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { dangerouslyBypassApprovalsAndSandbox: true }, expectedBypass: true }, + { sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { extraArgs: ["--sandbox", "read-only"] }, expectedBypass: false }, + { sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { args: ["-c", "approval_policy=on-request"] }, expectedBypass: false }, + { sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { extraArgs: [], args: ["--profile", "restricted"] }, expectedBypass: false }, + { sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { extraArgs: ["--color", "never"], args: ["--sandbox", "read-only"] }, expectedBypass: true }, + { sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { extraArgs: ["-c", "sandbox_workspace_write.network_access=false"] }, expectedBypass: false }, + { sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { dangerouslyBypassApprovalsAndSandbox: true, extraArgs: ["--sandbox", "read-only"] }, expectedBypass: true }, + { sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { dangerouslyBypassSandbox: true, args: ["--sandbox", "read-only"] }, expectedBypass: true }, + { sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { dangerouslyBypassApprovalsAndSandbox: false, dangerouslyBypassSandbox: true, extraArgs: ["--color", "never"] }, expectedBypass: false }, ])("submits $runner from $sourceType with visible bypass=$expectedBypass while preserving saved policy", async ({ sourceType, sourceRunner, runner, sourcePolicy, expectedBypass }) => { mockOptionalCompany.mockReturnValue({ selectedCompanyId: "company-1" }); mockAdaptersApi.list.mockResolvedValue([ diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index dbc3c307813..089af848b6b 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -43,7 +43,7 @@ import { getAdapterLabel } from "../adapters/adapter-display-registry"; import { defaultCreateValues } from "../components/agent-config-defaults"; import { getUIAdapter, listUIAdapters } from "../adapters"; import { isPaperclipRunnerProvider, PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES, type CreateConfigValues } from "@paperclipai/adapter-utils"; -import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/adapter-codex-local"; +import { resolveCodexLocalCliPolicy } from "@paperclipai/adapter-codex-local"; import { type FileTreeNode, type FrontmatterData, @@ -589,7 +589,7 @@ function importAdapterValues( dangerouslyBypassSandbox: typeof savedBypass === "boolean" ? savedBypass : nativeCodex ? (sourceConfig.codexPermissionMode ?? PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.codex.defaultMode) === "never" - : DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, + : resolveCodexLocalCliPolicy(sourceConfig).bypass, } : {}), ...(sourceAdapterType === "paperclip_runner" && choice !== "openai_dot" ? { adapterSchemaValues: { ...sourceConfig } } : {}),