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 } } : {}),