From 695506ad3912fa63604ca784fda50416fba983d6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 10:53:24 -0500 Subject: [PATCH 1/2] perf: cache native builds and verify Linux runtime targets Preserve actual target selection, record the GNU libc baseline, and retain primary installed-startup failures for ARM qualification. Co-Authored-By: Paperclip --- .github/workflows/release-verify.yml | 77 +++++++- doc/plans/2026-10-06-default-agent-runner.md | 60 ++++++ packages/paperclip-runner/README.md | 40 +++- packages/paperclip-runner/src/index.ts | 1 + .../src/live/runner-binary.test.ts | 14 +- .../src/live/runnerd-codex-transport.ts | 9 +- packages/shared/src/agent-runner.ts | 22 ++- packages/shared/src/index.ts | 2 +- .../release-verify-workflow.test.mjs | 177 +++++++++++++++++- .../agent-permissions-routes.test.ts | 3 +- .../dev-runner-native-binary.test.ts | 16 +- server/src/dev-native-runner-status.ts | 4 +- server/src/lib/agent-runner-platform.ts | 21 +++ .../services/agent-runner-selection.test.ts | 74 +++++++- server/src/services/agent-runner-selection.ts | 16 +- .../services/local-ai-browser-login.test.ts | 12 +- server/src/services/local-ai-browser-login.ts | 4 +- .../native-codex-runner.test.ts | 54 ++++++ .../native-runtime/native-codex-runner.ts | 28 +++ .../native-session-executor.test.ts | 12 +- .../native-runtime/native-session-executor.ts | 9 +- .../native-runtime/setup-readiness.test.ts | 22 ++- .../native-runtime/setup-readiness.ts | 7 +- server/src/vendor/paperclip-runner/index.ts | 1 + tests/runner-e2e/installed-cli.test.ts | 36 +++- tests/runner-e2e/installed-cli.ts | 32 +++- 26 files changed, 689 insertions(+), 64 deletions(-) create mode 100644 server/src/lib/agent-runner-platform.ts diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index a8a490a9a5e..980e25334b5 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -495,7 +495,7 @@ jobs: 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 hosts = { 'linux-x64': targets.length === current.length ? 'ubuntu-24.04' : '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 @@ -535,13 +535,76 @@ jobs: [[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1 [[ "$(node -p 'process.platform + "-" + process.arch')" == "$TARGET" ]] || exit 1 echo "artifact_prefix=runner-release-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" - - name: Install dependencies and build production TypeScript contract - run: | - pnpm install --no-frozen-lockfile - pnpm --filter @paperclipai/paperclip-runner build:typescript - - name: Build the host daemon with the pinned toolchain + - name: Pin native Cargo workspace and toolchain + id: native_rust_workspace working-directory: packages/paperclip-runner - run: pnpm build:binary + run: | + set -euo pipefail + pinned="$HOME/paperclip-runner-rust" + if [[ -e "$pinned" && ! -L "$pinned" ]]; then + echo '::error::Native Cargo cache path must not replace a real directory' + exit 1 + fi + rm -f "$pinned" + ln -s "$GITHUB_WORKSPACE/packages/paperclip-runner/runner" "$pinned" + echo "path=$pinned" >> "$GITHUB_OUTPUT" + rustup show + toolchain="$(rustup show active-toolchain | awk '{print $1}')" + echo "RUSTUP_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV" + extra_toolchains="$(rustup toolchain list | awk '{print $1}' | grep -vx "$toolchain" || true)" + if [[ -n "$extra_toolchains" ]]; then + echo "$extra_toolchains" | xargs -n1 rustup toolchain uninstall \ + || echo '::notice title=Native Rust cache::could not remove an extra toolchain; the cache key may not match' + fi + rustup toolchain list + - name: Cache native Rust dependencies + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: ${{ steps.native_rust_workspace.outputs.path }} -> target + # Compiler/environment and Cargo lock hashes are added by the action. + # Also bind generated contracts outside the Rust workspace, while + # always recompiling workspace code and recording actual new bytes. + shared-key: release-native-v2-${{ matrix.host }}-${{ matrix.target }}-${{ hashFiles('packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/src/**', 'packages/paperclip-runner/*.json') }} + cache-workspace-crates: false + cache-bin: false + cache-on-failure: false + # A master workflow can verify an unmerged preview ref. Those jobs + # restore only; they never write master's trusted dependency cache. + save-if: ${{ github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }} + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + - name: Build TypeScript contract and host daemon concurrently + run: | + node --input-type=module <<'NODE' + import { mkdirSync, readFileSync } from 'node:fs'; + import { join } from 'node:path'; + import { startPrefetch, stopPrefetches } from './scripts/prefetch-child.mjs'; + const root = process.cwd(), logs = join(process.env.RUNNER_TEMP, 'native-build-preparation'); + mkdirSync(logs, { recursive: true }); + const builds = []; + let failure; + try { + for (const [name, args, cwd] of [ + ['typescript', ['--filter', '@paperclipai/paperclip-runner', 'build:typescript'], root], + ['native', ['build:binary'], join(root, 'packages/paperclip-runner')], + ]) { + builds.push(startPrefetch({ name, cmd: 'pnpm', args, cwd, env: process.env, logPath: join(logs, name + '.log') })); + } + await Promise.all(builds.map(build => build.done)); + } catch (error) { failure = error; } + finally { + await stopPrefetches(builds); + const results = await Promise.allSettled(builds.map(build => build.done)); + builds.forEach((build, index) => { + console.log('::group::' + build.name + ' build'); + process.stdout.write(readFileSync(join(logs, build.name + '.log'), 'utf8')); + console.log('::endgroup::'); + console.log(JSON.stringify({ build: build.name, status: results[index].status, + exitCode: build.child.exitCode, signal: build.child.signalCode, durationMs: build.durationMs })); + }); + } + if (failure) throw failure; + NODE - name: Record actual daemon metadata and immutable bytes env: SOURCE_SHA: ${{ inputs.ref }} diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index 31c34d2641b..a65abc46178 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -2,6 +2,66 @@ Updated 2026-10-12. +## Current amendment: remote build performance and Linux baseline + +The user requires Cargo dependency caching and overlapping JavaScript/native +preparation in the existing hosted jobs. Dockerfile and native builds must stay +off this Mac. Reuse the current child supervisor for concurrent commands and +failure/cancellation cleanup; join both outputs before source, header, signature, +and hash recording. Cache dependency crates only, keyed by target, OS baseline, +compiler, lockfile, and external contract inputs. Only canonical exact-source +master pushes may save trusted caches. Preview jobs restore only. Seventy-nine +workflow/process controls pass; actual speed or cache hits remain unproven until +the final hosted run. Receipt: +`/private/tmp/pr2-native-cache-parallel-owned-receipt.json`. + +The exact `dcdf09a0f33536fa2b6daaf6e731d921d5711282` four-target qualifier +[38268767165](https://github.com/paperclipai/paperclip/actions/runs/38268767165) +has built all four actual daemons. Linux package installation passes, as does +the clean Apple Silicon consumer. ARM64 installs successfully but native session +startup fails; cleanup masked the original cause. Ordinary CI passes at that +source. Fresh review found a target-daemon +selection mismatch for cross-architecture SSH: an x64 controller could select +its own daemon for an ARM64 environment. The existing resolver/setup/execution +seams now select and verify the actual target header. One hundred four affected +controls and both typechecks pass. Setup and execution share the fix; missing or +wrong artifacts fail explicitly. Receipt: +`/private/tmp/pr2-native-ubuntu-ssh-owned-receipt.json`. + +The existing installed startup fixture now preserves the primary error and +separately records cleanup failure. Fourteen controls and E2E typecheck pass. +Docker isolation, no-network policy, and zero provider turns stay unchanged. +Use the exact failed package artifact `11757308120` from run `38268767165` in +one disposable hosted ARM diagnostic job. Keep corrected tooling provenance +separate from its literal `dcdf09a` product source. Verify registry ZIP digest, +bounded archive members, source, and every package hash before using the +existing consumer. This temporary diagnostic ref adds no permanent test suite +or workflow inputs. The primary cause and final ARM startup remain unresolved. +Receipts: `/private/tmp/pr2-installed-startup-error-preservation-receipt.json` +and `/private/tmp/ci-final-dcdf09a-arm-startup-failure-receipt.json`. + +Independent inert ELF inspection of both Linux artifacts at this revision +confirms required `GLIBC_2.39`, GNU loaders, `libc.so.6`, `libm.so.6`, and +`libgcc_s.so.1`. Neither binary was executed locally. The published Linux +baseline is therefore GNU libc >=2.39 on x64/AArch64. Pin current producers to +Ubuntu 24.04; clean consumers use the pinned Debian 13 Node image. Do not claim +older distribution/kernel qualification. Automatic new-agent selection rejects +measured older, musl, or unknown Linux libc; existing native configurations keep +their execution. Windows and Alpine/musl remain deferred. Receipts: +`/private/tmp/ci-final-dcdf09a-linux-arm64-data/elf-dependencies.json` and +`/private/tmp/ci-final-dcdf09a-linux-x64-data/elf-dependencies.json`. + +The libc guard passes 209 focused controls and both typechecks. Seven actual +POSIX probe/version controls pass. Forty-one built-in database cases did not +execute because embedded PostgreSQL timed out on this Mac; require Linux CI. +Token gates pass with the pinned pnpm launcher. Receipt: +`/private/tmp/codex-default-glibc-floor-receipt.json`. + +Next action: freeze corrected diagnostic tooling, identify and fix the actual +ARM startup cause, then require fresh hosted qualification, CI, and review. Keep +the current branch, PR, test-drive, budget holds, and no-Codex-bundling boundary. +No merge or production deployment is authorized. + ## Current addition: Linux ARM64 The user now requires ARM Linux support as well. Treat ARM Linux as ARM64 diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index a7f0647a39f..4e0ec3654d2 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -66,8 +66,8 @@ are allowed. Relative PATH entries use the selected process working directory. Compatibility is established by the actual protocol or login attempt. Missing executables and real protocol failures still return actionable errors. Package identity and executable containment remain checked. Browser login keeps -its existing provider-specific credential home. Linux ARM64 retains its existing -legacy login path because native execution is not qualified there. +its existing provider-specific credential home and uses the native Codex resolver +on supported Linux and macOS targets, including Linux ARM64. Exact dependency pins remain release and ACPX artifact-qualification checks. They make published builds reproducible and verify sandbox artifacts; they do not @@ -168,7 +168,7 @@ are unavailable. Accepting a native plan ends the planning run successfully whil the task waits for the next instruction. See the [Cursor release report](../../doc/plans/2026-10-03-cursor-production-readiness.md). -A release includes all three platform daemons and the Linux provider-pack identity +A release includes all four platform daemons and the Linux provider-pack identity from its matching Daytona image. Run `stage:release-binaries` with a manifest that binds each daemon path and SHA-256, plus `remoteProviderPack: {path, sha256}` for the actual image's `provider-pack.json`. Assemble these assets after the normal @@ -183,6 +183,40 @@ Ordinary remote Cursor startup uses the packaged Linux daemon and verifies every image asset against that manifest. A mismatched image fails before the provider starts; install the matching package and image together. +### Native binary platform baseline + +Release packages contain Paperclip daemons for `linux-x64`, `linux-arm64`, +`darwin-arm64`, and `darwin-x64`. Codex native binaries are installed separately +through the official optional npm platform dependencies described above. + +The shipped Linux daemons target **GNU libc 2.39 or newer**, on x86-64 or AArch64. +Their ELF version requirements include `GLIBC_2.39`; both depend on `libc.so.6`, +`libm.so.6`, `libgcc_s.so.1`, and the matching GNU dynamic loader. The ARM64 +loader is `/lib/ld-linux-aarch64.so.1`; x64 uses +`/lib64/ld-linux-x86-64.so.2`. This is a binary compatibility baseline, not a +promise that every Linux distribution or kernel has been qualified. + +Hosted native producers use Ubuntu 24.04 for both Linux architectures. Clean npm +install checks use the pinned Debian 13 Node image; the cloud Dockerfile uses +Debian 13 (`node:24-trixie-slim`). macOS binaries build on the existing macOS 15 +Apple Silicon and Intel hosts. Actual task and follow-up acceptance is performed +on Apple Silicon; Intel qualification covers build, installation, and protocol +startup. + +Automatic creation selects legacy execution when a Linux host has musl, unknown +libc, or a measured GNU libc version below the baseline. Existing agents retain +their stored runner. Windows, 32-bit ARM, and Alpine/musl native qualification +remain deferred. + +Native release jobs cache Cargo dependencies per platform and OS baseline, +including compiler, lockfile, and external generated-contract inputs. Workspace +crates and released binaries are rebuilt and verified every time. Only canonical +exact-source master pushes may save trusted caches; preview qualification can +restore them. JavaScript preparation and native compilation run concurrently, +and both must succeed before artifact metadata and source-bound hashes are +recorded. Run Docker and native qualification on hosted CI rather than a local +developer machine. + Pi cold provider admission has an absolute 60-second budget. Warm run attachment checkpoints the old ACPX sidecar and starts a new one, so it uses that same budget while retaining the existing Runner authority. Live adoption and ordinary diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index fb127318572..32103f88d94 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -46,6 +46,7 @@ export type { DurableRecoveryIdentity } from "./control-plane/prp-transport-type export * from "./drivers/codex/app-server-transport.js"; export * from "./drivers/codex/codex-app-server-driver.js"; export { resolveCodexCommand, resolvePinnedCodexCommand } from "./drivers/codex/codex-command.js"; +export { runnerBinaryTarget } from "./live/runner-binary.js"; export * from "./drivers/opencode/opencode-server-driver.js"; export * from "./drivers/opencode/mcp-bridge.js"; export * from "./drivers/acpx/qualified-profiles.js"; diff --git a/packages/paperclip-runner/src/live/runner-binary.test.ts b/packages/paperclip-runner/src/live/runner-binary.test.ts index 914f4439989..6f27822fc6b 100644 --- a/packages/paperclip-runner/src/live/runner-binary.test.ts +++ b/packages/paperclip-runner/src/live/runner-binary.test.ts @@ -17,9 +17,9 @@ function binary(root: string, target: string | null, bytes: Buffer) { const file = join(folder, "paperclip-runnerd"); writeFileSync(file, bytes); return file; } describe("packaged native daemon target", () => { - it.each(["darwin-arm64", "darwin-x64", "linux-x64"])("selects %s from a universal package rather than the build-host legacy binary", target => { + it.each(["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"])("selects %s from a universal package rather than the build-host legacy binary", target => { const root = fixture(); binary(root, null, header("darwin-arm64")); - for (const t of ["darwin-arm64", "darwin-x64", "linux-x64"]) binary(root, t, header(t)); + for (const t of ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"]) binary(root, t, header(t)); const [platform, architecture] = target.split("-"); expect(runnerBinaryTarget(header(target))).toBe(target); expect(resolvePackagedRunnerBinary(join(root, "dist"), platform as NodeJS.Platform, architecture as typeof process.arch)).toBe(join(root, "dist", "bin", target, "paperclip-runnerd")); @@ -32,6 +32,16 @@ describe("packaged native daemon target", () => { const root = fixture(); binary(root, null, header("linux-x64")); binary(root, "linux-x64", header("darwin-arm64")); expect(() => resolvePackagedRunnerBinary(join(root, "dist"), "linux", "x64")).toThrow("runner_runtime_incompatible"); }); + it.each([["linux-x64", "arm64"], ["linux-arm64", "x64"]] as const)("selects remote bytes beside a %s controller for linux-%s", (controller, remoteArch) => { + const root = fixture(); + binary(root, null, header(controller)); + const remote = binary(root, `linux-${remoteArch}`, header(`linux-${remoteArch}`)); + expect(resolvePackagedRunnerBinary(join(root, "dist"), "linux", remoteArch)).toBe(remote); + }); + it.each([["linux-x64", "arm64"], ["linux-arm64", "x64"]] as const)("rejects a missing remote target instead of falling back to the %s controller for linux-%s", (controller, remoteArch) => { + const root = fixture(); binary(root, null, header(controller)); + expect(() => resolvePackagedRunnerBinary(join(root, "dist"), "linux", remoteArch)).toThrow("runner_runtime_incompatible"); + }); it("retains a matching local legacy build and permits the source-only debug fallback", () => { const root = fixture(); expect(resolvePackagedRunnerBinary(join(root, "dist"), "linux", "x64")).toBeNull(); const legacy = binary(root, null, header("linux-x64")); expect(resolvePackagedRunnerBinary(join(root, "dist"), "linux", "x64")).toBe(legacy); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index e4c7929690d..ff7dc3e8d56 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -7036,13 +7036,16 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { } } -export function defaultCapabilityRunnerdBinary(): string { +export function defaultCapabilityRunnerdBinary(platform = process.platform, architecture = process.arch): string { // Standalone builds use dist/live; the server vendors that compiled tree // directly under vendor/paperclip-runner. Resolve beside our own live module. const outputRoot = resolve(fileURLToPath(new URL("../", import.meta.url))); - const staged = resolvePackagedRunnerBinary(outputRoot) - ?? resolvePackagedRunnerBinary(resolve(packageRoot, "dist")); + const staged = resolvePackagedRunnerBinary(outputRoot, platform, architecture) + ?? resolvePackagedRunnerBinary(resolve(packageRoot, "dist"), platform, architecture); if (staged) return staged; + if (platform !== process.platform || architecture !== process.arch) { + throw new Error(`runner_remote_artifact_unavailable: install the Paperclip release containing the ${platform}-${architecture} daemon or configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH`); + } return resolve( packageRoot, `runner/target/debug/paperclip-runnerd${executableSuffix}`, diff --git a/packages/shared/src/agent-runner.ts b/packages/shared/src/agent-runner.ts index 1f228502ff2..51c0f97b6fe 100644 --- a/packages/shared/src/agent-runner.ts +++ b/packages/shared/src/agent-runner.ts @@ -12,10 +12,26 @@ export function paperclipRunnerProfileForHarness(harness: string): { provider: " return harness === "codex_local" ? { provider: "codex" } : undefined; } -/** Creation defaults follow the shipped 64-bit Linux and macOS release artifacts. */ -export function paperclipRunnerSupportsPlatform(harness: string, platform: string, architecture: string): boolean { +/** Highest required GLIBC version in the shipped GNU/Linux daemon's ELF version needs. */ +export const PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION = "2.39"; + +function supportsLinuxLibc(libc?: "glibc" | "musl" | "unknown", version?: string): boolean { + if (libc !== undefined && libc !== "glibc") return false; + // Source-only/platform callers do not assert a measured host libc version. + if (version === undefined) return true; + const match = typeof version === "string" ? version.trim().match(/^(?:glibc\s+)?(\d+)\.(\d+)(?:\.\d+)?$/) : null; + if (!match) return false; + const major = Number(match[1]), minor = Number(match[2]); + if (!Number.isSafeInteger(major) || !Number.isSafeInteger(minor)) return false; + const [requiredMajor, requiredMinor] = PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION.split(".").map(Number); + return major > requiredMajor! || (major === requiredMajor && minor >= requiredMinor!); +} + +/** Creation defaults follow GNU/Linux and macOS artifacts; Alpine/musl is deferred. */ +export function paperclipRunnerSupportsPlatform(harness: string, platform: string, architecture: string, libc?: "glibc" | "musl" | "unknown", glibcVersion?: string): boolean { return !!paperclipRunnerProfileForHarness(harness) - && ((platform === "linux" && (architecture === "x64" || architecture === "arm64")) + // Omitted libc preserves platform-only callers; detected unknown Linux is not qualified. + && ((platform === "linux" && supportsLinuxLibc(libc, glibcVersion) && (architecture === "x64" || architecture === "arm64")) || (platform === "darwin" && (architecture === "arm64" || architecture === "x64"))); } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e6cea364768..b08cbf8ae17 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2867,7 +2867,7 @@ export { updatePrimaryAgentSchema, type UpdatePrimaryAgent, type PrimaryAgentPre export * from "./agent-avatar-upload.js"; export type { DotBinding, DotInvitation, DotConnection, DotPairing } from "./dot-invitations.js"; -export { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, type AgentRunnerChoice, type AgentRunner, type AgentRunnerAvailability } from "./agent-runner.js"; +export { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION, type AgentRunnerChoice, type AgentRunner, type AgentRunnerAvailability } from "./agent-runner.js"; export { EXTERNAL_AGENT_TOOL_GUIDANCE, DOT_AGENT_TOOL_GUIDANCE } from "./external-agent-guidance.js"; export { AGENT_LIFECYCLE_STATES, type AgentLifecycleState, type AgentLifecycleOperation } from "./types/agent-lifecycle.js"; diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs index c8f54484fb7..b1b1c10291d 100644 --- a/scripts/__tests__/release-verify-workflow.test.mjs +++ b/scripts/__tests__/release-verify-workflow.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawn, spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { tmpdir } from "node:os"; @@ -747,7 +747,7 @@ test("release daemon production uses exact-source native hosts and no publisher const workflow = readWorkflow("release-verify.yml"); const producer = workflow.split(" runner_binaries:\n")[1]; 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"]]) { + for (const [target, host] of [["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\) \}\}/); @@ -760,7 +760,10 @@ test("release daemon production uses exact-source native hosts and no publisher assert.match(producer, /\[\[ "\$SOURCE_SHA" =~ \^\[a-f0-9\]\{40\}\$ \]\]/); assert.match(producer, /git rev-parse HEAD/); assert.match(producer, /process.platform \+ "-" \+ process.arch/); - assert.match(producer, /pnpm build:binary/); + assert.match(producer, /\['native', \['build:binary'\]/); + const runner = JSON.parse(readFileSync(path.join(repoRoot, 'packages/paperclip-runner/package.json'), 'utf8')); + assert.match(runner.scripts['build:binary'], /cargo build --release --manifest-path runner\/Cargo.toml --locked/); + assert.match(runner.scripts['build:binary'], /&& node scripts\/stage-runner-binary.mjs$/); assert.match(producer, /stage-release-runner-binaries.mjs record/); for (const job of ["runner_chaos_evals", "typecheck", "general_tests", "serialized_tests", "runner_workflow_evals", "verify_paperclip_runner", "build"]) { assert.ok(workflow.includes(`${job}:\n if: \${{ !inputs.runner_binaries_only }}`)); @@ -778,6 +781,170 @@ 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/); }); +function nativeProducer() { + return readWorkflow('release-verify.yml').split(' runner_binaries:\n')[1]; +} + +test('native dependency caches are target-bound and unmerged master callers cannot save them', () => { + const producer = nativeProducer(); + const cache = producer.split(' - name: Cache native Rust dependencies\n')[1].split('\n - ')[0]; + assert.match(cache, /Swatinem\/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6/); + assert.match(cache, /workspaces: \$\{\{ steps.native_rust_workspace.outputs.path \}\} -> target/); + assert.match(cache, /shared-key: release-native-v2-\$\{\{ matrix.host \}\}-\$\{\{ matrix.target \}\}-\$\{\{ hashFiles\('packages\/paperclip-runner\/protocol\/\*\*', 'packages\/paperclip-runner\/src\/\*\*', 'packages\/paperclip-runner\/\*.json'\) \}\}/); + for (const option of ['cache-workspace-crates', 'cache-bin', 'cache-on-failure']) { + assert.match(cache, new RegExp(option + ': false')); + } + const expression = cache.match(/save-if: \$\{\{ (.+) \}\}/)?.[1]; + assert.ok(expression, 'cache writes need a separate source trust gate'); + const canSave = new Function('github', 'inputs', `return (${expression});`); + const github = { repository: 'paperclipai/paperclip', repository_id: '1170821064', event_name: 'push', ref: 'refs/heads/master', sha: 'a'.repeat(40) }; + assert.equal(canSave(github, { ref: github.sha }), true); + for (const [overrides, input] of [ + [{}, 'b'.repeat(40)], [{}, ''], + [{ event_name: 'workflow_dispatch' }, github.sha], + [{ event_name: 'workflow_dispatch' }, 'b'.repeat(40)], + [{ event_name: 'pull_request', ref: 'refs/pull/123/merge' }, github.sha], + [{ ref: 'refs/heads/topic' }, github.sha], + [{ repository: 'fork/paperclip' }, github.sha], + [{ repository_id: '1' }, github.sha], + ]) assert.equal(canSave({ ...github, ...overrides }, { ref: input }), false); + assert.match(producer, /pinned="\$HOME\/paperclip-runner-rust"/); + assert.match(producer, /ln -s "\$GITHUB_WORKSPACE\/packages\/paperclip-runner\/runner" "\$pinned"/); + assert.match(producer, /echo "RUSTUP_TOOLCHAIN=\$toolchain" >> "\$GITHUB_ENV"/); + assert.doesNotMatch(producer, /cache-hit|continue-on-error/); + assert.ok(producer.indexOf('Bind immutable producer source and target') < producer.indexOf('Cache native Rust dependencies')); + assert.ok(producer.indexOf('Build TypeScript contract and host daemon concurrently') < producer.indexOf('Record actual daemon metadata and immutable bytes')); +}); + +function nativePreparationScript() { + const step = nativeProducer().split(' - name: Build TypeScript contract and host daemon concurrently\n')[1].split('\n - ')[0]; + const script = step.match(/node --input-type=module <<'NODE'\n([\s\S]*?)\n NODE/)?.[1]; + assert.ok(script); + return script.replace(/^ {10}/gm, ''); +} + +const fixtureProcessGone = pid => { + try { process.kill(pid, 0); } catch (error) { return error.code === 'ESRCH'; } + try { return /^\d+ \(.*\) Z /.test(readFileSync(`/proc/${pid}/stat`, 'utf8')); } catch { return false; } +}; +const waitForFixture = async (predicate, label) => { + for (let attempt = 0; attempt < 200; attempt++) { + if (predicate()) return; + await new Promise(resolve => setTimeout(resolve, 25)); + } + assert.fail(label); +}; + +// Execute the workflow's actual coordinator with two tiny local Node commands. +// Both must cross the start barrier before either can finish; no pnpm, Cargo, +// provider, registry, or Docker build runs in these fixtures. +async function withNativePreparationFixture(mode, check) { + const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'native-preparation-'))); + let controller; + try { + for (const directory of ['scripts', 'tools', 'packages/paperclip-runner', 'logs']) { + mkdirSync(path.join(root, directory), { recursive: true }); + } + for (const helper of ['prefetch-child.mjs', 'prefetch-supervisor.mjs']) { + copyFileSync(path.join(repoRoot, 'scripts', helper), path.join(root, 'scripts', helper)); + } + writeFileSync(path.join(root, 'prepare.mjs'), nativePreparationScript()); + writeFileSync(path.join(root, 'tools/pnpm'), `#!${process.execPath} +const { spawn } = require('node:child_process'); +const { existsSync, writeFileSync } = require('node:fs'); +const { join } = require('node:path'); +const root = process.env.FIXTURE_ROOT; +const name = process.argv.includes('build:binary') ? 'native' : 'typescript'; +writeFileSync(join(root, name + '.started'), JSON.stringify({ pid: process.pid, cwd: process.cwd(), args: process.argv.slice(2) })); +const wait = setInterval(() => { + if (!existsSync(join(root, 'native.started')) || !existsSync(join(root, 'typescript.started'))) return; + clearInterval(wait); + if (process.env.FIXTURE_MODE === 'success') { + setTimeout(() => { writeFileSync(join(root, name + '.finished'), 'joined'); console.log(name + ' complete'); }, 150); + return; + } + const child = spawn(process.execPath, ['-e', \ + "const fs=require('node:fs'); fs.writeFileSync(process.argv[1], String(process.pid)); setInterval(() => fs.writeFileSync(process.argv[2], String(Date.now())), 25)",\ + join(root, name + '.descendant'), join(root, name + '.writing')], { stdio: 'inherit' }); + child.on('exit', () => process.exit(0)); + const barrier = setInterval(() => { + if (!existsSync(join(root, 'native.descendant')) || !existsSync(join(root, 'typescript.descendant'))) return; + clearInterval(barrier); + if (process.env.FIXTURE_MODE === name) { console.error(name + ' failed'); process.exit(23); } + }, 10); +}, 10); +`, { mode: 0o755 }); + controller = spawn(process.execPath, ['prepare.mjs'], { cwd: root, env: { + ...process.env, PATH: `${path.join(root, 'tools')}:${process.env.PATH}`, RUNNER_TEMP: path.join(root, 'logs'), FIXTURE_ROOT: root, FIXTURE_MODE: mode, + }, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = '', stderr = ''; + controller.stdout.on('data', chunk => { stdout += chunk; }); + controller.stderr.on('data', chunk => { stderr += chunk; }); + const completed = new Promise((resolve, reject) => { + controller.once('error', reject); + controller.once('close', (status, signal) => resolve({ status, signal, stdout, stderr })); + }); + await check({ root, controller, completed }); + } finally { + if (controller?.exitCode === null && controller.signalCode === null) controller.kill('SIGKILL'); + for (const name of ['native', 'typescript']) { + for (const filename of [name + '.started', name + '.descendant']) { + const file = path.join(root, filename); + if (!existsSync(file)) continue; + const data = readFileSync(file, 'utf8'); + const pid = filename.endsWith('.started') ? JSON.parse(data).pid : Number(data); + if (!fixtureProcessGone(pid)) { + try { process.kill(pid, 'SIGKILL'); } catch (error) { if (error.code !== 'ESRCH') throw error; } + } + } + } + rmSync(root, { recursive: true, force: true }); + } +} + +test('native preparation overlaps independent outputs and joins both before recording', async () => { + await withNativePreparationFixture('success', async ({ root, completed }) => { + const result = await completed; + assert.equal(result.status, 0, result.stderr); + for (const name of ['native', 'typescript']) assert.equal(readFileSync(path.join(root, name + '.finished'), 'utf8'), 'joined'); + assert.equal(JSON.parse(readFileSync(path.join(root, 'native.started'), 'utf8')).cwd, path.join(root, 'packages/paperclip-runner')); + assert.deepEqual(JSON.parse(readFileSync(path.join(root, 'native.started'), 'utf8')).args, ['build:binary']); + assert.deepEqual(JSON.parse(readFileSync(path.join(root, 'typescript.started'), 'utf8')).args, ['--filter', '@paperclipai/paperclip-runner', 'build:typescript']); + const results = result.stdout.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line)); + assert.deepEqual(results.map(item => [item.build, item.status, item.exitCode]), [['typescript', 'fulfilled', 0], ['native', 'fulfilled', 0]]); + }); +}); + +for (const name of ['typescript', 'native']) { + test(`${name} preparation failure fails the join and retires both owned process trees`, async () => { + await withNativePreparationFixture(name, async ({ root, completed }) => { + const result = await completed; + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, new RegExp(name + ' prefetch failed \\(exit 23\\)')); + const results = result.stdout.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line)); + assert.equal(results.length, 2); + assert.ok(results.every(item => item.status === 'rejected')); + for (const build of ['native', 'typescript']) { + const pid = Number(readFileSync(path.join(root, build + '.descendant'), 'utf8')); + await waitForFixture(() => fixtureProcessGone(pid), build + ' descendant outlived failed join'); + } + }); + }); +} + +test('canceling native preparation retires both supervisors and their descendants', { skip: process.platform === 'win32' }, async () => { + await withNativePreparationFixture('cancel', async ({ root, controller, completed }) => { + await waitForFixture(() => ['native', 'typescript'].every(name => existsSync(path.join(root, name + '.descendant'))), 'both native build trees must start'); + controller.kill('SIGTERM'); + const result = await completed; + assert.equal(result.signal, 'SIGTERM'); + for (const name of ['native', 'typescript']) { + const pid = Number(readFileSync(path.join(root, name + '.descendant'), 'utf8')); + await waitForFixture(() => fixtureProcessGone(pid), name + ' descendant outlived cancellation'); + } + }); +}); + 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 == ''/); @@ -819,6 +986,8 @@ test("exact-source daemon matrices preserve historical three-target sources and 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')); + assert.equal(data.matrix.include.find(row => row.target === 'linux-x64').host, targets.length === 4 ? 'ubuntu-24.04' : 'ubuntu-latest', + 'new GNU libc baseline stays pinned while historical qualification keeps its producer mode'); } writeFileSync(source, 'export const RELEASE_RUNNER_TARGETS = ["linux-arm64"];'); writeFileSync(output, ''); const result = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { cwd: root, encoding: 'utf8', env: { diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index c42c167e222..446baf0cbb7 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1855,7 +1855,8 @@ describe("agent permission routes", () => { ); if (adapterCase.adapterType === "codex_local") { expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "codex" }); - expect(mockRunSshCommand).toHaveBeenCalledWith(expect.objectContaining({ host: "qa.example.invalid" }), "uname -s; uname -m", { timeoutMs: 10_000 }); + const { AGENT_RUNNER_SSH_PLATFORM_COMMAND } = await import("../services/agent-runner-selection.js"); + expect(mockRunSshCommand).toHaveBeenCalledWith(expect.objectContaining({ host: "qa.example.invalid" }), AGENT_RUNNER_SSH_PLATFORM_COMMAND, { timeoutMs: 10_000 }); } else { expect(mockRunSshCommand).not.toHaveBeenCalled(); } diff --git a/server/src/__tests__/dev-runner-native-binary.test.ts b/server/src/__tests__/dev-runner-native-binary.test.ts index 368f14a3e4d..1b3bfbf24f3 100644 --- a/server/src/__tests__/dev-runner-native-binary.test.ts +++ b/server/src/__tests__/dev-runner-native-binary.test.ts @@ -1,7 +1,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { localPaperclipRunnerSupported } from "../lib/agent-runner-platform.js"; import { paperclipRunnerBinaryNeedsBuild, @@ -10,8 +11,13 @@ import { } from "../../../scripts/dev-runner-native-binary.mjs"; const tempRoots = new Set(); +const initialPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; +const initialArchitecture = Object.getOwnPropertyDescriptor(process, "arch")!; afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(process, "platform", initialPlatform); + Object.defineProperty(process, "arch", initialArchitecture); for (const root of tempRoots) { fs.rmSync(root, { recursive: true, force: true }); } @@ -56,6 +62,14 @@ function nativeHeader(platform: string, architecture: string): Buffer { } describe("paperclip runner native dev prerequisite", () => { + it.each(["x64", "arm64"].flatMap(architecture => [[undefined, false], ["2.38", false], ["2.39", true], ["3.0", true]].map(([glibcVersionRuntime, native]) => ({ architecture, glibcVersionRuntime, native }))))("uses the measured libc prerequisite on Linux $architecture/$glibcVersionRuntime when no native run or flag requires it", ({ architecture, glibcVersionRuntime, native }) => { + Object.defineProperty(process, "platform", { ...initialPlatform, value: "linux" }); + Object.defineProperty(process, "arch", { ...initialArchitecture, value: architecture }); + vi.spyOn(process.report, "getReport").mockReturnValue({ header: { glibcVersionRuntime } } as never); + const nativeRunnerRequired = localPaperclipRunnerSupported(); + expect(nativeRunnerRequired).toBe(native); + expect(paperclipRunnerBinaryNeedsBuild({ repoRoot: "/checkout/without/a/staged/binary", nativeRunnerRequired })).toBe(native); + }); it("uses an explicit status response and fails safe when status is unknown", () => { expect( resolveNativeRunnerRequirement({ diff --git a/server/src/dev-native-runner-status.ts b/server/src/dev-native-runner-status.ts index 85fb2e9254b..0423f09fb57 100644 --- a/server/src/dev-native-runner-status.ts +++ b/server/src/dev-native-runner-status.ts @@ -6,7 +6,7 @@ import { nativeRunFinalizations, } from "@paperclipai/db"; import { resolveMigrationConnection } from "@paperclipai/db/migration-runtime"; -import { paperclipRunnerSupportsPlatform } from "@paperclipai/shared"; +import { localPaperclipRunnerSupported } from "./lib/agent-runner-platform.js"; import { instanceSettingsService } from "./services/instance-settings.js"; @@ -51,7 +51,7 @@ async function main(): Promise { JSON.stringify({ // New Codex agents can use runnerd immediately, including on instances // that have disabled the experimental providers. - nativeRunnerRequired: paperclipRunnerSupportsPlatform("codex_local", process.platform, process.arch) + nativeRunnerRequired: localPaperclipRunnerSupported() || experimental.enableNativeRunner === true || experimental.enableOpenAiDot === true || persistedNativeRun, diff --git a/server/src/lib/agent-runner-platform.ts b/server/src/lib/agent-runner-platform.ts new file mode 100644 index 00000000000..f8936bf5b8b --- /dev/null +++ b/server/src/lib/agent-runner-platform.ts @@ -0,0 +1,21 @@ +import { paperclipRunnerSupportsPlatform } from "@paperclipai/shared"; + +/** Inspect only libc identity; never retain or log Node's diagnostic report. */ +export function localAgentRunnerTarget(): { driver: "local"; platform: NodeJS.Platform; architecture: string; libc?: "glibc" | "unknown"; glibcVersion?: string } { + if (process.platform !== "linux") return { driver: "local", platform: process.platform, architecture: process.arch }; + let libc: "glibc" | "unknown" = "unknown"; + let glibcVersion: string | undefined; + try { + const { header } = process.report.getReport() as { header?: { glibcVersionRuntime?: unknown } }; + if (typeof header?.glibcVersionRuntime === "string" && header.glibcVersionRuntime.trim()) { + libc = "glibc"; + glibcVersion = header.glibcVersionRuntime.trim(); + } + } catch { /* An unidentified libc must not select a GNU/Linux binary. */ } + return { driver: "local", platform: process.platform, architecture: process.arch, libc, ...(glibcVersion === undefined ? {} : { glibcVersion }) }; +} + +export function localPaperclipRunnerSupported(): boolean { + const target = localAgentRunnerTarget(); + return paperclipRunnerSupportsPlatform("codex_local", target.platform, target.architecture, target.libc, target.glibcVersion); +} diff --git a/server/src/services/agent-runner-selection.test.ts b/server/src/services/agent-runner-selection.test.ts index 38e819d1f7c..a8ab7bdfdcc 100644 --- a/server/src/services/agent-runner-selection.test.ts +++ b/server/src/services/agent-runner-selection.test.ts @@ -1,5 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, createAgentSchema, updateAgentSchema, testAdapterEnvironmentSchema } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION, createAgentSchema, updateAgentSchema, testAdapterEnvironmentSchema } from "@paperclipai/shared"; const state = vi.hoisted(() => ({ disabled: [] as string[], overridden: new Set(), settings: {} as Record, getEnvironment: vi.fn(), bindings: vi.fn(), managedEnvironment: vi.fn(), ssh: vi.fn(), resolveEnvironment: vi.fn() })); vi.mock("../adapters/registry.js", () => ({ findActiveServerAdapter: (type: string) => ({ type }), hasActiveAdapterOverride: (type: string) => state.overridden.has(type), waitForExternalAdapters: async () => {} })); vi.mock("./adapter-plugin-store.js", () => ({ getDisabledAdapterTypes: () => state.disabled })); @@ -7,12 +7,63 @@ vi.mock("./instance-settings.js", () => ({ instanceSettingsService: () => ({ get vi.mock("./environments.js", () => ({ environmentService: () => ({ getById: state.getEnvironment, listBoundCompanyIds: state.bindings, findManagedSandboxEnvironment: state.managedEnvironment, findKubernetesEnvironment: state.managedEnvironment }) })); vi.mock("./environment-config.js", () => ({ resolveEnvironmentDriverConfigForRuntime: state.resolveEnvironment })); vi.mock("@paperclipai/adapter-utils/ssh", () => ({ runSshCommand: state.ssh })); -import { agentRunnerAvailability, resolveAgentRunnerTargetForCompany, resolveNewAgentRunner, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js"; +import { AGENT_RUNNER_SSH_PLATFORM_COMMAND, agentRunnerAvailability, resolveAgentRunnerTargetForCompany, resolveNewAgentRunner, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js"; +import { localAgentRunnerTarget } from "../lib/agent-runner-platform.js"; const target = { driver: "local", platform: "linux", architecture: "x64" }; const qualifiedTargets = [target, { driver: "local", platform: "linux", architecture: "arm64" }, { driver: "local", platform: "darwin", architecture: "arm64" }, { driver: "local", platform: "darwin", architecture: "x64" }]; +const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; +const originalArchitecture = Object.getOwnPropertyDescriptor(process, "arch")!; +afterEach(() => { vi.restoreAllMocks(); Object.defineProperty(process, "platform", originalPlatform); Object.defineProperty(process, "arch", originalArchitecture); }); 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: {} }); }); + it.each(["x64", "arm64"])("keeps detected musl/unknown Linux %s legacy and preserves saved execution", architecture => { + for (const libc of ["musl", "unknown"] as const) { + const target = { driver: "local", platform: "linux", architecture, libc }; + expect(agentRunnerAvailability("codex_local", target)).toEqual({ supportedRunners: ["legacy"], defaultRunner: "legacy" }); + const input = { adapterType: "codex_local", adapterConfig: { extraArgs: ["--sandbox", "read-only"] }, target }; + expect(resolveNewAgentRunner(input)).toEqual({ adapterType: input.adapterType, adapterConfig: input.adapterConfig }); + expect(() => resolveNewAgentRunner({ ...input, runner: "paperclip" })).toThrow("unavailable"); + const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, target }; + expect(resolveNewAgentRunner(saved)).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig }); + expect(resolveNewAgentRunner({ ...saved, runner: "paperclip" })).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig }); + expect(agentRunnerAvailability("codex_local", { ...target, libc: "glibc" }).defaultRunner).toBe("paperclip"); + } + }); + it.each([[undefined, false], ["2.38", false], ["2.39", true], ["3.0", true], [" 2.39 ", true], ["malformed", false], ["", false]] as const)("uses the measured local Node libc version (%s) for creation", async (glibcVersionRuntime, native) => { + Object.defineProperty(process, "platform", { ...originalPlatform, value: "linux" }); + Object.defineProperty(process, "arch", { ...originalArchitecture, value: "arm64" }); + vi.spyOn(process.report, "getReport").mockReturnValue({ header: { glibcVersionRuntime }, environmentVariables: { PRIVATE_SENTINEL: "do-not-retain" } } as never); + const localTarget = localAgentRunnerTarget(); + expect(localTarget).toEqual({ driver: "local", platform: "linux", architecture: "arm64", libc: glibcVersionRuntime ? "glibc" : "unknown", ...(glibcVersionRuntime ? { glibcVersion: glibcVersionRuntime.trim() } : {}) }); + expect(JSON.stringify(localTarget)).not.toContain("do-not-retain"); + expect(agentRunnerAvailability("codex_local").defaultRunner).toBe(native ? "paperclip" : "legacy"); + const saved = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local" }); + expect(saved.adapterType).toBe(native ? "paperclip_runner" : "codex_local"); + }); + it.each([ + ["2.38", false], ["2.39", true], ["2.40", true], ["3.0", true], ["2.9", false], + ["glibc 2.39", true], [" 2.39 ", true], ["2.39.1", true], ["02.039", true], + ["2.39ubuntu1", false], ["unknown", false], ["", false], ["2", false], ["2.-39", false], + ["999999999999999999999999.0", false], + ])("qualifies the measured GNU/Linux daemon ABI for glibc %s (%s)", (glibcVersion, native) => { + expect(PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION).toBe("2.39"); + for (const architecture of ["x64", "arm64"]) { + const target = { driver: "local", platform: "linux", architecture, libc: "glibc" as const, glibcVersion: String(glibcVersion) }; + expect(paperclipRunnerSupportsPlatform("codex_local", target.platform, architecture, target.libc, target.glibcVersion)).toBe(native); + expect(agentRunnerAvailability("codex_local", target).defaultRunner).toBe(native ? "paperclip" : "legacy"); + expect(resolveNewAgentRunner({ adapterType: "codex_local", target }).adapterType).toBe(native ? "paperclip_runner" : "codex_local"); + if (!native) expect(() => resolveNewAgentRunner({ adapterType: "codex_local", runner: "paperclip", target })).toThrow("unavailable"); + const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, target, runner: "paperclip" as const }; + expect(resolveNewAgentRunner(saved)).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig }); + } + }); + it("keeps an unidentified local Linux libc legacy if Node's report is unavailable", () => { + Object.defineProperty(process, "platform", { ...originalPlatform, value: "linux" }); + vi.spyOn(process.report, "getReport").mockImplementation(() => { throw new Error("private-report-details"); }); + expect(localAgentRunnerTarget()).toMatchObject({ libc: "unknown" }); + expect(agentRunnerAvailability("codex_local").defaultRunner).toBe("legacy"); + }); it.each(qualifiedTargets)("uses native Codex automatically with compatible configuration and is idempotent on $platform/$architecture", target => { const input = { adapterType: "codex_local", target, adapterConfig: { model: "gpt-5.6-sol", modelReasoningEffort: "high", env: { OPENAI_API_KEY: { type: "secret_ref", secretId: "secret" } }, cwd: "/workspace", instructionsFilePath: "/workspace/AGENTS.md", workspaceStrategy: { type: "git_worktree" }, timeoutSec: 60, search: false, fastMode: false, dangerouslyBypassApprovalsAndSandbox: true } }; const resolved = resolveNewAgentRunner(input); @@ -88,6 +139,16 @@ describe("server-owned Codex runner selection", () => { ["Darwin\nx86_64\n", "paperclip", "paperclip_runner"], ["Linux\naarch64\n", "paperclip", "paperclip_runner"], ["Linux\narm64\n", "paperclip", "paperclip_runner"], + ["Linux\nx86_64\nglibc\n", "paperclip", "paperclip_runner"], + ["Linux\naarch64\nglibc\n", "paperclip", "paperclip_runner"], + ["Linux\nx86_64\nglibc\nglibc 2.38\n", "legacy", "codex_local"], + ["Linux\naarch64\nglibc\nglibc 2.38\n", "legacy", "codex_local"], + ["Linux\nx86_64\nglibc\nglibc 2.39\n", "paperclip", "paperclip_runner"], + ["Linux\naarch64\nglibc\nglibc 3.0\n", "paperclip", "paperclip_runner"], + ["Linux\nx86_64\nglibc\nmalformed\n", "legacy", "codex_local"], + ["Linux\nx86_64\nmusl\n", "legacy", "codex_local"], + ["Linux\naarch64\nmusl\n", "legacy", "codex_local"], + ["Linux\nx86_64\nunknown\n", "legacy", "codex_local"], ["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: {} }); @@ -96,6 +157,8 @@ describe("server-owned Codex runner selection", () => { expect(agentRunnerAvailability("codex_local", discoveredTarget).defaultRunner).toBe(expectedRunner); const saved = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", defaultEnvironmentId: "selected" }); expect(saved.adapterType).toBe(expectedAdapter); + expect(state.ssh).toHaveBeenCalledTimes(2); + expect(state.ssh).toHaveBeenCalledWith({}, AGENT_RUNNER_SSH_PLATFORM_COMMAND, { timeoutMs: 10_000 }); }); it("preserves recorded explicit native execution without rechecking creation defaults", async () => { const recorded = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, runner: "paperclip" as const }; @@ -123,6 +186,11 @@ 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 architecture of ["x64", "arm64"]) { + expect(paperclipRunnerSupportsPlatform("codex_local", "linux", architecture, "glibc")).toBe(true); + expect(paperclipRunnerSupportsPlatform("codex_local", "linux", architecture, "musl")).toBe(false); + expect(paperclipRunnerSupportsPlatform("codex_local", "linux", architecture, "unknown")).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); }); diff --git a/server/src/services/agent-runner-selection.ts b/server/src/services/agent-runner-selection.ts index b4d20f6b41d..f2963c16188 100644 --- a/server/src/services/agent-runner-selection.ts +++ b/server/src/services/agent-runner-selection.ts @@ -7,8 +7,10 @@ import { PaperclipRunnerProviderProfileError, resolvePaperclipRunnerNativeProvid import { forbidden, unprocessable } from "../errors.js"; import { runSshCommand } from "@paperclipai/adapter-utils/ssh"; import { resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js"; +import { localAgentRunnerTarget } from "../lib/agent-runner-platform.js"; -export type RunnerTarget = { driver: string; platform?: string; architecture?: string }; +export type RunnerTarget = { driver: string; platform?: string; architecture?: string; libc?: "glibc" | "musl" | "unknown"; glibcVersion?: string }; +export const AGENT_RUNNER_SSH_PLATFORM_COMMAND = "uname -s; uname -m; if paperclip_glibc_version=$(getconf GNU_LIBC_VERSION 2>/dev/null) && [ -n \"$paperclip_glibc_version\" ]; then printf 'glibc\\n%s\\n' \"$paperclip_glibc_version\"; else case \"$(ldd --version 2>&1)\" in *musl*) printf 'musl\\n' ;; *) printf 'unknown\\n' ;; esac; fi"; export interface AgentRunnerSelection { adapterType?: string | null; adapterConfig?: Record; @@ -16,14 +18,14 @@ export interface AgentRunnerSelection { target?: RunnerTarget; } -export function agentRunnerAvailability(harness: string, target: RunnerTarget = { driver: "local", platform: process.platform, architecture: process.arch }): AgentRunnerAvailability { +export function agentRunnerAvailability(harness: string, target: RunnerTarget = localAgentRunnerTarget()): AgentRunnerAvailability { const disabled = new Set(getDisabledAdapterTypes()); const supported = harness === "codex_local" && !disabled.has(harness) && !disabled.has("paperclip_runner") && !!findActiveServerAdapter(harness) && !!findActiveServerAdapter("paperclip_runner") && !hasActiveAdapterOverride(harness) && !hasActiveAdapterOverride("paperclip_runner") && isEnvironmentDriverSupportedForAdapter("paperclip_runner", target.driver) - && (!target.platform || paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "")); + && (!target.platform || paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "", target.libc, target.glibcVersion)); return { supportedRunners: supported ? ["paperclip", "legacy"] : ["legacy"], defaultRunner: supported ? "paperclip" : "legacy" }; } @@ -109,16 +111,16 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri if (environment.driver === "sandbox" && environment.config?.provider === "fake") throw unprocessable("The fake sandbox provider cannot execute runs."); } const driver = environment?.driver ?? "local"; - let target: RunnerTarget = driver === "local" ? { driver, platform: process.platform, architecture: process.arch } + let target: RunnerTarget = driver === "local" ? localAgentRunnerTarget() : { driver }; // Sandbox platform/artifact identity is verified by selected-runtime setup; do not guess an image's architecture. if (driver === "ssh" && environment && agentRunnerAvailability("codex_local", target).defaultRunner === "paperclip") { const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment); if (parsed.driver !== "ssh") throw unprocessable("The selected SSH environment is unavailable."); let output: string; - try { output = (await runSshCommand(parsed.config, "uname -s; uname -m", { timeoutMs: 10_000 })).stdout; } + try { output = (await runSshCommand(parsed.config, AGENT_RUNNER_SSH_PLATFORM_COMMAND, { timeoutMs: 10_000 })).stdout; } catch { throw unprocessable("Could not check the SSH host's platform. Check its connection and retry, or select Legacy runner in Advanced.", { code: "agent_runner_platform_probe_failed" }); } - const [os = "", arch = ""] = output.trim().split(/\r?\n/); - target = { driver, platform: os === "Linux" ? "linux" : os === "Darwin" ? "darwin" : "unknown", architecture: arch === "x86_64" ? "x64" : ["aarch64", "arm64"].includes(arch) ? "arm64" : arch }; + const [os = "", arch = "", libc, glibcVersion] = output.trim().split(/\r?\n/); + target = { driver, platform: os === "Linux" ? "linux" : os === "Darwin" ? "darwin" : "unknown", architecture: arch === "x86_64" ? "x64" : ["aarch64", "arm64"].includes(arch) ? "arm64" : arch, ...(os === "Linux" && libc !== undefined ? { libc: libc === "glibc" || libc === "musl" ? libc : "unknown", ...(libc === "glibc" && glibcVersion !== undefined ? { glibcVersion } : {}) } : {}) }; } return target; } diff --git a/server/src/services/local-ai-browser-login.test.ts b/server/src/services/local-ai-browser-login.test.ts index d1067100c50..97e294bff4e 100644 --- a/server/src/services/local-ai-browser-login.test.ts +++ b/server/src/services/local-ai-browser-login.test.ts @@ -18,6 +18,7 @@ afterEach(async () => { Object.defineProperty(process, "arch", initialArchitecture); process.env.PATH = initialPath; vi.unstubAllEnvs(); + vi.restoreAllMocks(); vi.clearAllMocks(); if (root) await rm(root, { recursive: true, force: true }); root = undefined; @@ -35,14 +36,19 @@ async function fakeCli(name: string, source: string) { return realpath(home); } -function targetPlatform(platform: string, architecture: string) { +function targetPlatform(platform: string, architecture: string, glibc = true, glibcVersionRuntime = "2.39") { Object.defineProperty(process, "platform", { ...initialPlatform, value: platform }); Object.defineProperty(process, "arch", { ...initialArchitecture, value: architecture }); + if (platform === "linux") vi.spyOn(process.report, "getReport").mockReturnValue({ header: glibc ? { glibcVersionRuntime } : {} } as never); } 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 ARM32: %s)", async (legacy) => { - if (legacy) targetPlatform("linux", "arm"); + it.each(["native", "arm32", "musl", "glibc-2.38", "glibc-3.0"])("uses the selected Codex command with its credential home and ambient HOME/cwd (%s)", async platform => { + const legacy = ["arm32", "musl", "glibc-2.38"].includes(platform); + if (platform === "arm32") targetPlatform("linux", "arm"); + if (platform === "musl") targetPlatform("linux", "arm64", false); + if (platform === "glibc-2.38") targetPlatform("linux", "arm64", true, "2.38"); + if (platform === "glibc-3.0") targetPlatform("linux", "arm64", true, "3.0"); 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', diff --git a/server/src/services/local-ai-browser-login.ts b/server/src/services/local-ai-browser-login.ts index 1643e0e0d53..f826869c9a7 100644 --- a/server/src/services/local-ai-browser-login.ts +++ b/server/src/services/local-ai-browser-login.ts @@ -5,7 +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 { localPaperclipRunnerSupported } from "../lib/agent-runner-platform.js"; import { resolveCodexCommand } from "../vendor/paperclip-runner/index.js"; export type LocalBrowserLoginState = { @@ -59,7 +59,7 @@ export function startLocalBrowserLogin(provider: "anthropic" | "openai", home: s if (provider === "openai") { // 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); + const legacyPlatform = !localPaperclipRunnerSupported(); try { if (legacyPlatform) { await ensureCommandResolvable("codex", home, process.env); diff --git a/server/src/services/native-runtime/native-codex-runner.test.ts b/server/src/services/native-runtime/native-codex-runner.test.ts index 1d1cc710203..c93d1b739e9 100644 --- a/server/src/services/native-runtime/native-codex-runner.test.ts +++ b/server/src/services/native-runtime/native-codex-runner.test.ts @@ -9,6 +9,7 @@ import { buildNativeRunnerArguments, buildNativeRunnerPreparePayload, resolvePaperclipRunnerBinary, + resolveRemotePaperclipRunnerBinary, } from "./native-codex-runner.js"; afterEach(() => vi.restoreAllMocks()); @@ -45,6 +46,59 @@ describe("installed runner selection", () => { }); }); +describe("selected SSH daemon target", () => { + function header(target: string) { + const bytes = Buffer.alloc(64); + if (target.startsWith("darwin")) { + bytes.writeUInt32LE(0xfeedfacf); bytes.writeUInt32LE(target.endsWith("arm64") ? 0x0100000c : 0x01000007, 4); + } else { + bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(target.endsWith("arm64") ? 183 : 62, 18); + } + return bytes; + } + const remote = (stdout: string, overrides = {}) => ({ execute: vi.fn(async () => ({ exitCode: 0, signal: null, timedOut: false, stdout, stderr: "", pid: null, startedAt: null, ...overrides })) }); + it.each([ + ["Linux", "aarch64", "linux", "arm64"], ["Linux", "x86_64", "linux", "x64"], + ["Darwin", "arm64", "darwin", "arm64"], ["Darwin", "x86_64", "darwin", "x64"], + ] as const)("uses the packaged %s/%s daemon independent of the controller", async (os, arch, platform, architecture) => { + const root = await mkdtemp(join(tmpdir(), "paperclip-ssh-daemon-")); + const binary = join(root, "paperclip-runnerd"); + try { + await writeFile(binary, header(`${platform}-${architecture}`)); + const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary").mockReturnValue(binary); + const target = remote(`${os}\n${arch}\n`); + expect(await resolveRemotePaperclipRunnerBinary({ runner: target, remoteCwd: "/selected-workspace" })).toBe(binary); + expect(select).toHaveBeenCalledExactlyOnceWith(platform, architecture); + expect(target.execute).toHaveBeenCalledExactlyOnceWith({ command: "sh", args: ["-c", "uname -s; uname -m"], cwd: "/selected-workspace", bypassSession: true, timeoutMs: 10_000 }); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it.each(["matching", "wrong-architecture", "malformed", "missing"])("verifies an explicit controller-owned remote artifact (%s)", async scenario => { + const root = await mkdtemp(join(tmpdir(), "paperclip-ssh-daemon-")); + const binary = join(root, "configured-runnerd"); + try { + if (scenario !== "missing") await writeFile(binary, scenario === "malformed" ? Buffer.alloc(64) : header(scenario === "matching" ? "linux-arm64" : "linux-x64")); + const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary"); + const request = resolveRemotePaperclipRunnerBinary({ runner: remote("Linux\naarch64\n"), remoteCwd: "/workspace", configuredPath: binary }); + if (scenario === "matching") expect(await request).toBe(binary); + else await expect(request).rejects.toThrow(scenario === "missing" ? "runner_remote_artifact_unavailable" : "runner_remote_artifact_platform_mismatch"); + expect(select).not.toHaveBeenCalled(); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it("keeps missing package targets actionable without a controller or legacy fallback", async () => { + const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary").mockImplementation(() => { throw new Error("runner_remote_artifact_unavailable: install the matching release"); }); + await expect(resolveRemotePaperclipRunnerBinary({ runner: remote("Linux\naarch64\n"), remoteCwd: "/workspace" })).rejects.toThrow("runner_remote_artifact_unavailable"); + expect(select).toHaveBeenCalledExactlyOnceWith("linux", "arm64"); + }); + it.each([ + ["Linux\naarch64\n", { exitCode: 1 }], ["Linux\naarch64\n", { timedOut: true }], + ["Linux\nriscv64\n", {}], ["FreeBSD\nx86_64\n", {}], ["", {}], + ])("fails a missing, failed or unsupported platform probe (%s)", async (stdout, overrides) => { + const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary"); + await expect(resolveRemotePaperclipRunnerBinary({ runner: remote(stdout as string, overrides), remoteCwd: "/workspace" })).rejects.toThrow("runner_remote_artifact_platform_mismatch"); + expect(select).not.toHaveBeenCalled(); + }); +}); + describe("buildNativeRunnerArguments", () => { it("binds every durable identity without exposing the bootstrap ticket", () => { const args = buildNativeRunnerArguments({ diff --git a/server/src/services/native-runtime/native-codex-runner.ts b/server/src/services/native-runtime/native-codex-runner.ts index 14d25ed9a40..01edfbbe3c8 100644 --- a/server/src/services/native-runtime/native-codex-runner.ts +++ b/server/src/services/native-runtime/native-codex-runner.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { and, eq } from "drizzle-orm"; import type { AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime"; import type { Db } from "@paperclipai/db"; import { agentSessionGoalActions, agentTaskSessions } from "@paperclipai/db"; @@ -14,6 +15,7 @@ import { failRunnerGoalAction } from "../runner-goals.js"; import { createPaperclipRunnerAuthorizedToolSet, defaultCapabilityRunnerdBinary, + runnerBinaryTarget, type PaperclipSemanticToolDefinition, } from "../../vendor/paperclip-runner/index.js"; import { runnerPrpCoordinator } from "./runner-prp-coordinator.js"; @@ -206,6 +208,32 @@ export function resolvePaperclipRunnerBinary( ); } +/** Select controller-owned bytes for the selected SSH host, never its controller. */ +export async function resolveRemotePaperclipRunnerBinary(input: { + runner: CommandManagedRuntimeRunner; + remoteCwd: string; + configuredPath?: string | null; +}): Promise { + const probe = await input.runner.execute({ command: "sh", args: ["-c", "uname -s; uname -m"], + cwd: input.remoteCwd, bypassSession: true, timeoutMs: 10_000 }); + const [os, arch] = probe.stdout.trim().split(/\r?\n/); + const platform = os === "Linux" ? "linux" : os === "Darwin" ? "darwin" : null; + const architecture = arch === "x86_64" ? "x64" : arch === "aarch64" || arch === "arm64" ? "arm64" : null; + if (probe.exitCode !== 0 || probe.timedOut || !platform || !architecture) { + throw new Error("runner_remote_artifact_platform_mismatch: could not identify a supported remote OS and architecture; check the SSH host and retry"); + } + const configured = input.configuredPath?.trim(); + if (configured && !isAbsolute(configured)) throw new Error("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH must be an absolute path"); + const binary = configured || defaultCapabilityRunnerdBinary(platform, architecture); + let bytes: Buffer; + try { bytes = readFileSync(binary); } + catch (cause) { throw new Error(`runner_remote_artifact_unavailable: install the Paperclip release containing the ${platform}-${architecture} daemon or configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH`, { cause }); } + if (runnerBinaryTarget(bytes) !== `${platform}-${architecture}`) { + throw new Error(`runner_remote_artifact_platform_mismatch: the selected daemon does not match ${platform}-${architecture}; install the matching Paperclip release or configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH`); + } + return binary; +} + export function buildNativeRunnerArguments(input: { connectUrl: string; stateDirectory: string; diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 0627e8089f3..49d728017a5 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -206,6 +206,7 @@ const state = vi.hoisted(() => ({ bundledPackManifestPath: null as string | null, bundledRunnerBinary: null as string | null, resolveRunnerBinary: vi.fn(() => "/tmp/paperclip-runnerd"), + resolveRemoteRunnerBinary: vi.fn(async (input: { configuredPath?: string | null }) => input.configuredPath || "/tmp/paperclip-runnerd"), release: null as null | (() => void), })); @@ -300,6 +301,7 @@ vi.mock("../issue-recovery-actions.js", () => ({ vi.mock("./native-codex-runner.js", () => ({ resolvePaperclipRunnerBinary: state.resolveRunnerBinary, + resolveRemotePaperclipRunnerBinary: state.resolveRemoteRunnerBinary, })); import { @@ -373,6 +375,7 @@ beforeEach(() => { state.bundledRunnerBinary = null; state.createAssignedMcpTools.mockReset(); state.resolveRunnerBinary.mockReset().mockReturnValue("/tmp/paperclip-runnerd"); + state.resolveRemoteRunnerBinary.mockReset().mockImplementation(async (input: { configuredPath?: string | null }) => input.configuredPath || "/tmp/paperclip-runnerd"); state.resolveCurrentWakeCommentsBinding.mockReset().mockResolvedValue(null); state.assertCurrentWakeCommentsRead.mockReset().mockResolvedValue(undefined); }); @@ -12926,9 +12929,10 @@ describe("runnerd provider runtime wiring", () => { }); - it("binds a remote launch to the configured controller-owned runner artifact", async () => { + it.each(["explicit", "linux-arm64", "linux-x64"])("binds an SSH launch to the selected controller-owned runner artifact (%s)", async mode => { const remoteCwd = "/home/daytona/paperclip-workspace"; - const controllerArtifact = "/controller/artifacts/paperclip-runnerd"; + const controllerArtifact = `/controller/artifacts/${mode}/paperclip-runnerd`; + state.resolveRemoteRunnerBinary.mockResolvedValue(controllerArtifact); const remoteExecution = { ...execution, binding: { ...execution.binding, runId: "run-remote-runner-artifact" }, @@ -12954,7 +12958,7 @@ describe("runnerd provider runtime wiring", () => { strictHostKeyChecking: true, }, }, - runnerRemoteBinaryPath: controllerArtifact, + ...(mode === "explicit" ? { runnerRemoteBinaryPath: controllerArtifact } : {}), }); state.createTransport.mockClear(); @@ -12962,6 +12966,8 @@ describe("runnerd provider runtime wiring", () => { expect(state.createTransport).toHaveBeenCalledWith( expect.objectContaining({ runnerBinary: controllerArtifact }), ); + expect(state.resolveRemoteRunnerBinary).toHaveBeenCalledWith(expect.objectContaining({ remoteCwd, configuredPath: mode === "explicit" ? controllerArtifact : undefined })); + expect(state.resolveRunnerBinary).not.toHaveBeenCalled(); }); it.each([ diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 83071be55bf..0dedc9812d8 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -172,7 +172,7 @@ import { } from "./status-arbiter.js"; import { conflict, HttpError } from "../../errors.js"; import { redactSensitiveText } from "../../redaction.js"; -import { resolvePaperclipRunnerBinary } from "./native-codex-runner.js"; +import { resolvePaperclipRunnerBinary, resolveRemotePaperclipRunnerBinary } from "./native-codex-runner.js"; import { createNativeRunTrace, isNativeRunRootHistoricalSpan, @@ -11551,7 +11551,10 @@ async function createRunnerdBackendWithinSessionClaim( // filesystem; the remote launcher separately owns the sandbox command path. // When an explicit remote artifact is configured, prepareRemoteRunner stages // these exact bytes at remoteBinary before launch. - const controllerRunnerBinary = remoteTarget + const targetMatchedCodexArtifact = remoteTarget?.transport === "ssh" && input.execution.provider.kind === "codex"; + const controllerRunnerBinary = targetMatchedCodexArtifact + ? await resolveRemotePaperclipRunnerBinary({ runner: remoteCommandRunner!, remoteCwd: remoteTarget.remoteCwd, configuredPath: input.runnerRemoteBinaryPath }) + : remoteTarget ? input.runnerRemoteBinaryPath?.trim() || remotePiCompanion?.runnerBinary || (useBundledRemoteImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary()) : resolvePaperclipRunnerBinary(); const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null; @@ -11732,7 +11735,7 @@ async function createRunnerdBackendWithinSessionClaim( remoteRuntimeRoot, remoteCodexBinary, runnerRemoteBinaryPath: input.runnerRemoteBinaryPath, runnerRemoteCodexPath: input.runnerRemoteCodexPath, runnerRemoteCodexNpmSpec: input.runnerRemoteCodexNpmSpec, model: input.execution.provider.kind === "codex" ? input.execution.provider.model : null, - reuseRetainedRunner: sandboxLeaseAcquisition?.outcome === "resumed", targetPlatformArtifactVerified: remotePiCompanion !== null || useBundledRemoteImageAssets, + reuseRetainedRunner: sandboxLeaseAcquisition?.outcome === "resumed", targetPlatformArtifactVerified: remotePiCompanion !== null || useBundledRemoteImageAssets || targetMatchedCodexArtifact, trace: input.trace, onLog: input.onLog, }) : null; diff --git a/server/src/services/native-runtime/setup-readiness.test.ts b/server/src/services/native-runtime/setup-readiness.test.ts index beb09c64d3a..97275712784 100644 --- a/server/src/services/native-runtime/setup-readiness.test.ts +++ b/server/src/services/native-runtime/setup-readiness.test.ts @@ -3,12 +3,12 @@ import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from import { tmpdir } from "node:os"; import { join, posix } from "node:path"; import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared"; -const { execute, nativeProbe, selectedRunner, sshExecute, sshRunner, nativeArtifacts, remoteLauncher, registerPrp, ingressConnect } = vi.hoisted(() => ({ execute: vi.fn(), nativeProbe: vi.fn(), selectedRunner: vi.fn(), sshExecute: vi.fn(), sshRunner: vi.fn(), nativeArtifacts: vi.fn(), remoteLauncher: vi.fn(), registerPrp: vi.fn(), ingressConnect: vi.fn() })); +const { execute, nativeProbe, selectedRunner, selectedRemoteRunner, sshExecute, sshRunner, nativeArtifacts, remoteLauncher, registerPrp, ingressConnect } = vi.hoisted(() => ({ execute: vi.fn(), nativeProbe: vi.fn(), selectedRunner: vi.fn(), selectedRemoteRunner: vi.fn(), sshExecute: vi.fn(), sshRunner: vi.fn(), nativeArtifacts: vi.fn(), remoteLauncher: vi.fn(), registerPrp: vi.fn(), ingressConnect: vi.fn() })); vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ runAdapterExecutionTargetShellCommand: execute })); vi.mock("./native-ssh-command-runner.js", () => ({ createNativeSshCommandRunner: sshRunner })); vi.mock("../../realtime/runner-prp-ws.js", () => ({ registerRunnerPrpAuthority: registerPrp })); vi.mock("../../realtime/runner-prp-outbound.js", () => ({ connectRunnerPrpIngress: ingressConnect })); -vi.mock("./native-codex-runner.js", () => ({ resolvePaperclipRunnerBinary: selectedRunner })); +vi.mock("./native-codex-runner.js", () => ({ resolvePaperclipRunnerBinary: selectedRunner, resolveRemotePaperclipRunnerBinary: selectedRemoteRunner })); vi.mock("../../vendor/paperclip-runner/index.js", async original => ({ ...await original(), probeNativeRunnerEnvironment: nativeProbe })); vi.mock("./native-session-executor.js", async original => ({ ...await original(), createRemoteNativeArtifactPreparation: nativeArtifacts, createRemoteRunnerProcessLauncher: remoteLauncher })); import { testNativeRunnerAuthentication } from "./setup-readiness.js"; @@ -30,6 +30,7 @@ describe("Codex selected native account verification", () => { vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_CODEX_PATH", ""); vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC", ""); selectedRunner.mockReset().mockReturnValue("/fixture/bin/paperclip-runnerd"); + selectedRemoteRunner.mockReset().mockResolvedValue("/fixture/bin/paperclip-runnerd"); vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH", ""); vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH", ""); }); @@ -97,7 +98,9 @@ describe("Codex selected native account verification", () => { const sshContext = { companyId: "company", adapterType: "paperclip_runner", config: {}, executionTarget: { kind: "remote" as const, transport: "ssh" as const, remoteCwd: "/qa-workspace", spec: { host: "qa-host", username: "qa-user", port: 22, remoteCwd: "/qa-workspace", remoteWorkspacePath: "/qa-workspace", privateKey: null, knownHosts: null, strictHostKeyChecking: true }, } }; - it("uses task staging and the company-scoped native SSH transport without a provider pack", async () => { + it.each(["linux-arm64", "linux-x64"])("uses task staging and the selected %s SSH daemon without a provider pack", async target => { + const selectedBinary = `/fixture/bin/${target}/paperclip-runnerd`; + selectedRemoteRunner.mockResolvedValue(selectedBinary); let released = false; registerPrp.mockResolvedValue({ release: async () => { released = true; } }); nativeProbe.mockImplementation(async input => { @@ -110,8 +113,10 @@ describe("Codex selected native account verification", () => { expect((await testNativeRunnerAuthentication({ ...sshContext, config: { env: { OPENAI_API_KEY: "company-account" }, modelReasoningEffort: "low" } }, "codex", "gpt-6.1-sol")).status).toBe("pass"); const input = nativeProbe.mock.calls[0][0]; expect(input).toMatchObject({ reasoningEffort: "low", environment: { OPENAI_API_KEY: "company-account" }, workingDirectory: expect.stringMatching(/^\/qa-workspace\/.paperclip-runtime\/paperclip-native-setup-.*\/filesystem\/workspace$/) }); - expect(input.transportOptions).toMatchObject({ runnerBinary: "/fixture/bin/paperclip-runnerd", codexCommand: expect.stringMatching(/\/bin\/codex$/), runnerFilesystemRoot: expect.stringMatching(/\/filesystem$/) }); - expect(nativeArtifacts).toHaveBeenCalledWith(expect.objectContaining({ controllerRunnerBinary: "/fixture/bin/paperclip-runnerd", model: "gpt-6.1-sol" })); + expect(input.transportOptions).toMatchObject({ runnerBinary: selectedBinary, codexCommand: expect.stringMatching(/\/bin\/codex$/), runnerFilesystemRoot: expect.stringMatching(/\/filesystem$/) }); + expect(nativeArtifacts).toHaveBeenCalledWith(expect.objectContaining({ controllerRunnerBinary: selectedBinary, model: "gpt-6.1-sol", targetPlatformArtifactVerified: true })); + expect(selectedRemoteRunner).toHaveBeenCalledExactlyOnceWith({ runner: { execute: sshExecute }, remoteCwd: "/qa-workspace", configuredPath: null }); + expect(selectedRunner).not.toHaveBeenCalled(); expect(nativeArtifacts.mock.results[0]!.value.prepare).toHaveBeenCalledWith("dial_wss"); expect(registerPrp).toHaveBeenCalledWith({ companyId: "company", runId: input.transportOptions.prpIdentity.runId, authority: { marker: "native-authority" } }); expect(remoteLauncher).toHaveBeenCalledWith(expect.objectContaining({ runnerInstanceId: input.transportOptions.prpIdentity.runnerInstanceId })); @@ -133,6 +138,13 @@ describe("Codex selected native account verification", () => { expect((await testNativeRunnerAuthentication(sshContext, "codex", null)).status).toBe("fail"); expect(sshExecute).not.toHaveBeenCalled(); expect(nativeArtifacts).not.toHaveBeenCalled(); expect(nativeProbe).not.toHaveBeenCalled(); }); + it("reports an unavailable SSH target daemon before probing credentials or switching runtimes", async () => { + selectedRemoteRunner.mockRejectedValue(new Error("runner_remote_artifact_unavailable: install the matching linux-arm64 Paperclip release")); + const result = await testNativeRunnerAuthentication(sshContext, "codex", null); + expect(result.status).toBe("fail"); expect(result.checks[0].message).toContain("matching linux-arm64 Paperclip release"); + expect(nativeArtifacts).not.toHaveBeenCalled(); expect(nativeProbe).not.toHaveBeenCalled(); + expect(selectedRunner).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled(); + }); it.each([true, false])("removes SSH probe state only after confirmed teardown (%s)", async confirmed => { nativeProbe.mockImplementation(async input => { const registration = await input.transportOptions.controlPlaneRegistration({}); diff --git a/server/src/services/native-runtime/setup-readiness.ts b/server/src/services/native-runtime/setup-readiness.ts index c03f325099d..c7afbc45abc 100644 --- a/server/src/services/native-runtime/setup-readiness.ts +++ b/server/src/services/native-runtime/setup-readiness.ts @@ -5,7 +5,7 @@ import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server"; import type { AdapterEnvironmentTestContext, AdapterEnvironmentTestResult } from "@paperclipai/adapter-utils"; import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared"; import { probeNativeRunnerEnvironment, resolveSourceCodexHome } from "../../vendor/paperclip-runner/index.js"; -import { resolvePaperclipRunnerBinary } from "./native-codex-runner.js"; +import { resolvePaperclipRunnerBinary, resolveRemotePaperclipRunnerBinary } from "./native-codex-runner.js"; import { readLocalAiCredentialFile } from "../local-ai-credential-file.js"; import { createNativeSshCommandRunner } from "./native-ssh-command-runner.js"; import { resolvePaperclipRunnerTransport } from "@paperclipai/adapter-utils/runner-connectivity"; @@ -63,8 +63,11 @@ async function probeRemoteNativeCodex(options: { const remoteCodexNpmSpec = process.env.PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC?.trim() || null; const remoteCodexBinary = remoteCodexNpmSpec ? posix.join(remoteRoot, "harnesses", "codex", "node_modules", ".bin", "codex") : posix.join(remoteRoot, "bin", "codex"); const explicitRunner = process.env.PAPERCLIP_RUNNER_REMOTE_BINARY_PATH?.trim() || null; - const controllerRunnerBinary = explicitRunner || resolvePaperclipRunnerBinary(); + const controllerRunnerBinary = target.transport === "ssh" + ? await resolveRemotePaperclipRunnerBinary({ runner, remoteCwd: target.remoteCwd, configuredPath: explicitRunner }) + : explicitRunner || resolvePaperclipRunnerBinary(); const artifacts = createRemoteNativeArtifactPreparation({ target, runner, remoteBinary, controllerRunnerBinary, remoteRuntimeRoot: remoteRoot, remoteCodexBinary, + targetPlatformArtifactVerified: target.transport === "ssh", runnerRemoteBinaryPath: explicitRunner, runnerRemoteCodexPath: process.env.PAPERCLIP_RUNNER_REMOTE_CODEX_PATH?.trim(), runnerRemoteCodexNpmSpec: remoteCodexNpmSpec, model: options.model }); const requireSuccess = (result: { timedOut: boolean; exitCode: number | null }, message: string) => { if (result.timedOut || result.exitCode !== 0) throw new Error(message); diff --git a/server/src/vendor/paperclip-runner/index.ts b/server/src/vendor/paperclip-runner/index.ts index a1fbb1226f1..dd40545d7e5 100644 --- a/server/src/vendor/paperclip-runner/index.ts +++ b/server/src/vendor/paperclip-runner/index.ts @@ -110,6 +110,7 @@ export const createRunnerdCodexTransport: ( runner.createRunnerdCodexTransport; export const defaultCapabilityRunnerdBinary = runner.defaultCapabilityRunnerdBinary; +export const runnerBinaryTarget = runner.runnerBinaryTarget; export const executeNativeSession = runner.executeNativeSession; export const applyNativeSessionGoalControl = runner.applyNativeSessionGoalControl; diff --git a/tests/runner-e2e/installed-cli.test.ts b/tests/runner-e2e/installed-cli.test.ts index 868898c6f7e..64c4d5d9b3b 100644 --- a/tests/runner-e2e/installed-cli.test.ts +++ b/tests/runner-e2e/installed-cli.test.ts @@ -1,6 +1,6 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { chmod, mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { chmod, lstat, mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, expect, it } from "vitest"; @@ -107,7 +107,7 @@ it("startup-only accepts only explicit uncredentialed local hello and zero retri expect(()=>assertInstalledStartupOnly({},[cell],options)).toThrow("startup-only"); }); -async function startupFixture(mode: "normal" | "wrong-model" | "ambient-codex" = "normal") { +async function startupFixture(mode: "normal" | "wrong-model" | "ambient-codex" | "startup-and-cleanup-failed" | "cleanup-failed" = "normal") { const f = await fixture(); const vendor = join(f.server, "dist/vendor/paperclip-runner"); const daemon = join(vendor, "bin", `${process.platform}-${process.arch}`, "paperclip-runnerd"); @@ -129,15 +129,18 @@ async function startupFixture(mode: "normal" | "wrong-model" | "ambient-codex" = export function createRunnerdCodexTransport(options){ state.environmentKeys=Object.keys(options.environment).sort(); state.fileAuthPinned=options.codexArgs.includes('cli_auth_credentials_store="file"'); - save();return {transport:{async close(){retired=true;state.calls.push("transport-close");save();}}, - evidence:()=>({runnerPid:111,codexPid:222,providerDriver:"codex_app_server",runnerExited:retired})};} + state.privateStateDirectory=options.stateDirectory; + save();return {transport:{async close(){retired=true;state.calls.push("transport-close");save();${mode === "startup-and-cleanup-failed" ? 'throw new Error("fixture secondary transport cleanup failure");' : ''}}}, + evidence:()=>({runnerPid:111,codexPid:222,providerDriver:"codex_app_server",runnerExited:retired,diagnostics:["fixture provider bootstrap failed"],providerSessionId:"PRIVATE-PROVIDER-ID"})};} export class CodexAppServerDriver { constructor(options){this.options=options;} - async openSession(){state.calls.push("open");save();const model=${JSON.stringify(mode === "wrong-model" ? "wrong-model" : "gpt-5.6-sol")}; + async openSession(){state.calls.push("open");save(); + ${mode === "startup-and-cleanup-failed" ? 'throw new Error("fixture original provider startup failure");' : ''} + const model=${JSON.stringify(mode === "wrong-model" ? "wrong-model" : "gpt-5.6-sol")}; return {ids:()=>({driverSessionId:"PRIVATE-DRIVER-ID",providerSessionId:"PRIVATE-PROVIDER-ID"}), events:()=>({async *[Symbol.asyncIterator](){yield {eventType:"session.started",payload:{context:{model,sandbox:{permissionProfile:{id:"paperclip-runner-workspace-read-only"}}}}};}}), startTurn(){state.calls.push("FORBIDDEN-TURN");save();throw new Error("No provider turn is permitted");}, - async close(){retired=true;state.calls.push("session-close");save();}};} + async close(){retired=true;state.calls.push("session-close");save();${mode === "cleanup-failed" ? 'throw new Error("fixture session cleanup failure");' : ''}}};} } `); return { ...f, observations, expectedRunnerSha256: hash("fixture-daemon") }; @@ -153,6 +156,27 @@ it.skipIf(process.platform === "win32")("installed native startup uses closed ho expect(JSON.stringify(receipt)).not.toMatch(/PRIVATE-(?:DRIVER|PROVIDER)-ID/); }); +it.skipIf(process.platform === "win32")("installed startup preserves the primary cause, records bounded diagnostics and attempts independent cleanup", async () => { + const f = await startupFixture("startup-and-cleanup-failed"); + const error = await probeInstalledCodexStartup({ consumerRoot: f.root, serverRoot: f.server, expectedRunnerSha256: f.expectedRunnerSha256 }).catch(error => error); + expect(error).toBeInstanceOf(AggregateError); + expect(error.message).toContain("fixture original provider startup failure"); + expect(error.message).toContain("fixture secondary transport cleanup failure"); + expect(error.message).toContain("fixture provider bootstrap failed"); + expect(error.cause.message).toBe("fixture original provider startup failure"); + expect(error.errors.map((error: Error) => error.message)).toEqual(["fixture original provider startup failure", "fixture secondary transport cleanup failure"]); + expect(error.message).not.toContain("PRIVATE-PROVIDER-ID"); + const observations = JSON.parse(await readFile(f.observations, "utf8")); + expect(observations.calls).toEqual(["open", "transport-close"]); + await expect(lstat(observations.privateStateDirectory)).rejects.toThrow(); +}); + +it.skipIf(process.platform === "win32")("installed startup cannot qualify when session cleanup fails after successful open", async () => { + const f = await startupFixture("cleanup-failed"); + await expect(probeInstalledCodexStartup({ consumerRoot: f.root, serverRoot: f.server, expectedRunnerSha256: f.expectedRunnerSha256 })).rejects.toThrow("fixture session cleanup failure"); + expect(JSON.parse(await readFile(f.observations, "utf8")).calls).toEqual(["open", "session-close", "session-close"]); +}); + it.skipIf(process.platform === "win32")("installed startup rejects stale daemon/ambient Codex and cleans a mismatched native session", async () => { const f = await startupFixture(); await expect(probeInstalledCodexStartup({ consumerRoot: f.root, serverRoot: f.server, expectedRunnerSha256: "0".repeat(64) })).rejects.toThrow("reviewed pin"); diff --git a/tests/runner-e2e/installed-cli.ts b/tests/runner-e2e/installed-cli.ts index 7f764af6873..6bcfc6f005d 100644 --- a/tests/runner-e2e/installed-cli.ts +++ b/tests/runner-e2e/installed-cli.ts @@ -100,6 +100,8 @@ export async function probeInstalledCodexStartup(input: { let session: any; let bundle: any; let cleanupConfirmed = false; + let startupFailed = false; + let startupFailure: unknown; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(new Error("Installed Codex startup timed out")), Math.max(1, Math.min(input.timeoutMs ?? 45000, 120000))); try { @@ -138,11 +140,35 @@ export async function probeInstalledCodexStartup(input: { runnerSha256, codexSha256, codexVersion, providerDriver: "codex_app_server", model: "gpt-5.6-sol", nativeSessionStarted: true, cleanupConfirmed: true, providerTurns: 0, credentialsUsed: false, defaultRuntimeResolution: true, reasoningEffortExercised: false }; + } catch (error) { + startupFailed = true; + startupFailure = error; + throw error; } finally { clearTimeout(timer); - if (session) await session.close({ reason: "installed startup cleanup" }); - else if (bundle && !cleanupConfirmed) await bundle.transport.close("installed startup cleanup"); - if (!bundle || bundle.evidence().runnerExited) await rm(directory, { recursive: true, force: true }); + const cleanupFailures: unknown[] = []; + try { + if (session) await session.close({ reason: "installed startup cleanup" }); + else if (bundle && !cleanupConfirmed) await bundle.transport.close("installed startup cleanup"); + } catch (error) { cleanupFailures.push(error); } + try { + if (!bundle || bundle.evidence().runnerExited) await rm(directory, { recursive: true, force: true }); + } catch (error) { cleanupFailures.push(error); } + if (startupFailed) { + const evidence = bundle?.evidence(); + // This zero-turn fixture has empty homes and no provider credentials. + // Report only bounded failure text and lifecycle state, never identities + // or raw durable state. A close failure cannot hide the startup cause. + const bounded = (value: unknown) => String(value).replaceAll(directory, "[private-startup]").slice(0, 2000); + const primaryMessage = bounded(startupFailure instanceof Error ? startupFailure.message : startupFailure); + const diagnostic = { schema: "paperclip.e2e.installed-codex-startup-failure/v1", platform: `${process.platform}-${process.arch}`, + runnerExited: evidence?.runnerExited ?? null, runnerExitCode: evidence?.runnerExitCode ?? null, + runnerSignal: evidence?.runnerSignal ?? null, + diagnostics: (Array.isArray(evidence?.diagnostics) ? evidence.diagnostics : []).slice(-16).map(bounded), + cleanupFailures: cleanupFailures.map(error => bounded(error instanceof Error ? error.message : error)) }; + throw new AggregateError([startupFailure, ...cleanupFailures], `Installed Codex startup failed: ${primaryMessage}\n${JSON.stringify(diagnostic)}`, { cause: startupFailure }); + } + if (cleanupFailures.length) throw new AggregateError(cleanupFailures, "Installed Codex startup cleanup failed", { cause: cleanupFailures[0] }); } } From 123a18837478d8cd0e2aff23f7e7aa86c9107de3 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 11:20:36 -0500 Subject: [PATCH 2/2] fix: separate Codex login and ARM host qualification Keep daemon ABI admission separate from CLI login and preserve restricted Docker installation checks before native host startup. Co-Authored-By: Paperclip --- doc/plans/2026-10-06-default-agent-runner.md | 31 +++++++++++++ packages/paperclip-runner/README.md | 12 ++++- .../grok-public-install-sandbox.test.mjs | 25 +++++++++- scripts/grok-public-install-sandbox.mjs | 11 +++++ scripts/verify-grok-npm-install.mjs | 33 +++++++++++-- .../services/local-ai-browser-login.test.ts | 46 +++++++++++++++++-- server/src/services/local-ai-browser-login.ts | 8 ++-- 7 files changed, 152 insertions(+), 14 deletions(-) diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index a65abc46178..9466e4fa158 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -62,6 +62,37 @@ ARM startup cause, then require fresh hosted qualification, CI, and review. Keep the current branch, PR, test-drive, budget holds, and no-Codex-bundling boundary. No merge or production deployment is authorized. +Checkpoint `695506ad3912fa63604ca784fda50416fba983d6` is pushed and passes +ordinary CI (53 successful checks, four conditional skips). The restarted +test-drive serves `2026.1005.0+348.git.695506ad39`; four complete agent execution +configurations and both historical native runs survive unchanged, with the old +experimental flag false. Normal staging selects the verified Mac target and +reports `needsBuild: false`; there is no binary override or local Cargo build. +Receipt: `/private/tmp/pr2-final-preservation-after.json`. + +The hosted [ARM diagnostic](https://github.com/paperclipai/paperclip/actions/runs/38273898742) +used tooling `695506ad` and the literal `dcdf09a` package archives. It recovered +the primary failure: Codex's bubblewrap cannot create a namespace inside the +restricted Docker fixture. Daemon/CLI installation and ABI were not the cause. +The existing consumer now retains that Docker install/lifecycle proof and +uses the same installed graph on the actual ARM host for unchanged read-only +startup. Lock, daemon, and official Codex command hashes must match across +Docker/host and before/after. Twenty-one controls and independent review pass. +No external-sandbox substitution, sudo, sysctl, namespace flags, or product +permission policy changes are used. Actual host capability still needs proof. +Receipt: `/private/tmp/pr2-arm-host-split-owned-receipt.json`. + +Fresh review also found that the daemon's new libc gate had accidentally sent +older Linux browser login to PATH-only lookup. A two-file fix restores owned +Codex CLI resolution independently of daemon eligibility. One hundred twenty-four +focused cases and server typecheck pass; nine regressions fail before the fix. +Creation's libc floor remains unchanged. Receipt: +`/private/tmp/codex-default-login-libc-decoupling-receipt.json`. + +Next action: freeze this corrected tooling and run one remote ARM host treatment +against the same verified archives. Require actual host startup before the final +four-target qualification; retain literal product/tool source identities. + ## Current addition: Linux ARM64 The user now requires ARM Linux support as well. Treat ARM Linux as ARM64 diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 4e0ec3654d2..76683bd3cf1 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -66,8 +66,8 @@ are allowed. Relative PATH entries use the selected process working directory. Compatibility is established by the actual protocol or login attempt. Missing executables and real protocol failures still return actionable errors. Package identity and executable containment remain checked. Browser login keeps -its existing provider-specific credential home and uses the native Codex resolver -on supported Linux and macOS targets, including Linux ARM64. +its provider-specific credential home and resolves the installed Codex CLI on +64-bit Linux and macOS independently of the daemon's libc requirements. Exact dependency pins remain release and ACPX artifact-qualification checks. They make published builds reproducible and verify sandbox artifacts; they do not @@ -203,6 +203,14 @@ Apple Silicon and Intel hosts. Actual task and follow-up acceptance is performed on Apple Silicon; Intel qualification covers build, installation, and protocol startup. +On Linux, Codex's sandboxed filesystem modes also require the host to permit its +unprivileged namespaces. A no-privilege Docker container can prohibit nested +namespace creation even when installation and binary compatibility pass. +Qualification therefore retains the restricted Docker install/lifecycle checks +and exercises the same installed graph's read-only native startup on the actual +ARM host. This does not qualify arbitrary container or managed setup targets; +their selected runtime must pass setup in that environment. + Automatic creation selects legacy execution when a Linux host has musl, unknown libc, or a measured GNU libc version below the baseline. Existing agents retain their stored runner. Windows, 32-bit ARM, and Alpine/musl native qualification diff --git a/scripts/__tests__/grok-public-install-sandbox.test.mjs b/scripts/__tests__/grok-public-install-sandbox.test.mjs index de8eedd15cf..193dd4d5b37 100644 --- a/scripts/__tests__/grok-public-install-sandbox.test.mjs +++ b/scripts/__tests__/grok-public-install-sandbox.test.mjs @@ -7,13 +7,31 @@ import { join } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { isWorkspacePackage, parseWorkspaceEntries } from '../build-standalone-public-packages.mjs'; -import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledCodexPlatformProvenance, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from '../grok-public-install-sandbox.mjs'; +import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledCodexPlatformProvenance, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from '../grok-public-install-sandbox.mjs'; import { verifyPiPackagedDaemon } from '../pi-public-install-probe.mjs'; import { runnerBinaryTarget } from '../../packages/paperclip-runner/src/live/runner-binary.ts'; const paths = { assets: '/private/staging/assets', consumer: '/private/staging/consumer', cache: '/private/staging/cache', uid: 1001, gid: 1001 }; const values = (args, flag) => args.flatMap((value, index) => value === flag ? [args[index + 1]] : []); +test('native host startup preserves the consumer lock and both executable byte identities', () => { + const before = { lockSha256: 'a'.repeat(64), runnerSha256: 'b'.repeat(64), codexCommandSha256: 'c'.repeat(64) }; + assert.doesNotThrow(() => assertInstalledNativeConsumerContinuity(before, { ...before })); + for (const field of Object.keys(before)) { + assert.throws(() => assertInstalledNativeConsumerContinuity(before, { ...before, [field]: 'd'.repeat(64) }), new RegExp(field)); + assert.throws(() => assertInstalledNativeConsumerContinuity({ ...before, [field]: '' }, { ...before, [field]: '' }), new RegExp(field)); + } + 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, /const hostEnv = \{ PATH: .*HOME: home, TMPDIR: hostTmp, LC_ALL: 'C', TZ: 'UTC' \}/); + assert.match(arm, /execFileSync\(process.execPath, \[file\], \{ cwd: consumer, env: hostEnv/); + assert.ok(arm.indexOf('isolated(GROK_PUBLIC_INSTALL_LIFECYCLE)') < arm.indexOf("hostProbe('host-startup-probe.mjs'")); + assert.ok(arm.indexOf('const before =') < arm.indexOf("hostProbe('host-startup-probe.mjs'")); + assert.ok(arm.indexOf("hostProbe('host-startup-probe.mjs'") < arm.indexOf('const after =')); + assert.match(arm, /assertInstalledNativeConsumerContinuity\(before, after\)/); + assert.doesNotMatch(arm, /externallySandboxed|externalSandbox|sudo|sysctl|--privileged|seccomp=unconfined|unshare/); +}); + test('Mac offline lifecycle uses only the matching executing Node SDK', () => { const root = realpathSync(mkdtempSync(join(tmpdir(), 'mac-node-headers-test-'))); try { @@ -266,6 +284,7 @@ test('the installed Codex probe exercises the public export and rejects incomple assert.equal(JSON.parse(result.stdout).pinnedCodexCommandVerified, true); assert.equal(JSON.parse(result.stdout).codexPlatformPackageSource, 'official npm'); assert.equal(JSON.parse(result.stdout).codexConsumerPlatform, target); + assert.equal(JSON.parse(result.stdout).codexCommandSha256, createHash('sha256').update(readFileSync(command)).digest('hex')); assert.equal(JSON.parse(result.stdout).codexQualifiedInstallationVerified, true); assert.equal(JSON.parse(result.stdout).codexCommandLeaseVerified, true); assert.equal(JSON.parse(result.stdout).codexNativeSandboxResourcesVerified, true); @@ -438,7 +457,9 @@ test('native ARM clean consumers keep the existing container and installed-runti 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.match(arm, /process.versions.node.split\('\.'\)\[0\], '24'/); + assert.match(arm, /macInstalledStartupProbeSource\(consumer, hostServer, daemon.sha256, join\(assets, 'installed-cli.ts'\)\)/); + assert.match(arm, /startupEnvironment: 'native host, same installed graph, isolated empty homes'/); assert.doesNotMatch(arm, /stage-runner|runner\/target|PAPERCLIP_RUNNER_BINARY|prerequisite:/); }); diff --git a/scripts/grok-public-install-sandbox.mjs b/scripts/grok-public-install-sandbox.mjs index 7695f54dd65..afe596aee98 100644 --- a/scripts/grok-public-install-sandbox.mjs +++ b/scripts/grok-public-install-sandbox.mjs @@ -91,6 +91,7 @@ export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersi return ` import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; + import { createHash } from 'node:crypto'; import { createRequire } from 'node:module'; import { accessSync, constants, existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; @@ -174,6 +175,7 @@ export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersi const lease = await installation.openCommand(); await lease.close();`} console.log(JSON.stringify({ pinnedCodexCommandVerified: true, installedCodexVersion: version, + codexCommandSha256: createHash('sha256').update(readFileSync(command)).digest('hex'), ...provenance, codexSelectedPlatformPath: selectedPlatform.path, codexSelectedPlatformVersion: selectedMetadata.version, codexNativeSandboxResourcesVerified: true, ${nativeCodex ? '' : `pinnedCodexVersion: ${JSON.stringify(expectedVersion)}, codexQualifiedInstallationVerified: true, codexCommandLeaseVerified: true,`} @@ -181,6 +183,15 @@ export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersi `; } +/** Installed bytes and consumer resolution must survive host protocol startup. */ +export function assertInstalledNativeConsumerContinuity(before, after) { + for (const field of ['lockSha256', 'runnerSha256', 'codexCommandSha256']) { + if (!/^[a-f0-9]{64}$/.test(before[field]) || before[field] !== after[field]) { + throw new Error(`Installed native host startup changed ${field}`); + } + } +} + /** The installed consumer must select its packaged host daemon, never PATH. */ export function installedDaemonProbeSource(outputRoot, consumerRoot, sourceRevision) { return ` diff --git a/scripts/verify-grok-npm-install.mjs b/scripts/verify-grok-npm-install.mjs index 5286415ed90..26d7f588f1c 100644 --- a/scripts/verify-grok-npm-install.mjs +++ b/scripts/verify-grok-npm-install.mjs @@ -13,7 +13,7 @@ import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { packConcurrency, packStagedPackages, packTimings } from './pack-staged-packages.mjs'; import { materializePublishManifest } from './prepare-bundled-package.mjs'; -import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from './grok-public-install-sandbox.mjs'; +import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from './grok-public-install-sandbox.mjs'; import { startPrefetch, stopPrefetches } from './prefetch-child.mjs'; import { retainRunnerQualificationPackages } from './retain-runner-qualification-packages.mjs'; import { bundledInstallTimings, startBundledInstalls } from './stage-bundled-packages.mjs'; @@ -99,6 +99,7 @@ async function verifyMacConsumer(packageDirectory, sourceRevision, receiptPath) 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.equal(process.versions.node.split('.')[0], '24', 'ARM host startup requires the qualified Node24 runtime'); assert.match(sourceRevision, /^[a-f0-9]{40}$/); const packages = realpathSync(packageDirectory); const manifest = JSON.parse(readFileSync(join(packages, 'manifest.json'), 'utf8')); @@ -150,12 +151,38 @@ async function verifyLinuxArmConsumer(packageDirectory, sourceRevision, receiptP // 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 }); + // The restricted install container deliberately forbids nested namespaces. + // Keep its lifecycle/no-network proof, then test the exact same bind-mounted + // graph on the native host using Codex's normal read-only namespace policy. + const home = join(root, 'host-home'), hostTmp = join(root, 'host-tmp'); + for (const directory of [home, hostTmp]) mkdirSync(directory, { mode: 0o700 }); + const hostEnv = { PATH: `${dirname(process.execPath)}:/usr/bin:/bin:/usr/sbin:/sbin`, HOME: home, TMPDIR: hostTmp, LC_ALL: 'C', TZ: 'UTC' }; + const hostProbe = (name, source) => { + const file = join(assets, name); writeFileSync(file, source); + return JSON.parse(execFileSync(process.execPath, [file], { cwd: consumer, env: hostEnv, + timeout: 120_000, maxBuffer: 1024 * 1024, encoding: 'utf8' }).trim()); + }; + const hostServer = join(consumer, 'node_modules/@paperclipai/server'), hostInstalled = join(hostServer, 'dist/vendor/paperclip-runner'); + const hostDaemon = () => hostProbe('host-daemon-probe.mjs', installedDaemonProbeSource(hostInstalled, consumer, sourceRevision)); + const hostCodex = () => hostProbe('host-codex-probe.mjs', installedCodexProbeSource(join(hostInstalled, 'index.js'), consumer, undefined, { nativeCodex: true })); + const digest = value => createHash('sha256').update(value).digest('hex'); + const before = { lockSha256: digest(lock), runnerSha256: hostDaemon().sha256.slice(7), codexCommandSha256: hostCodex().codexCommandSha256 }; + assert.equal(before.runnerSha256, daemon.sha256.slice(7), 'Docker and host must select the same packaged daemon bytes'); + assert.equal(before.codexCommandSha256, codex.codexCommandSha256, 'Docker and host must select the same official Codex command bytes'); + const hostHeader = process.report.getReport().header; + assert.match(hostHeader.glibcVersionRuntime || '', /^\d+\.\d+$/, 'Native host startup must report its actual GNU libc ABI'); + const startup = hostProbe('host-startup-probe.mjs', macInstalledStartupProbeSource(consumer, hostServer, daemon.sha256, join(assets, 'installed-cli.ts'))); + const after = { lockSha256: digest(readFileSync(join(consumer, 'package-lock.json'))), runnerSha256: hostDaemon().sha256.slice(7), codexCommandSha256: hostCodex().codexCommandSha256 }; + assertInstalledNativeConsumerContinuity(before, after); + assert.equal(startup.runnerSha256, before.runnerSha256); + assert.equal(startup.codexSha256, before.codexCommandSha256); 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', + startupEnvironment: 'native host, same installed graph, isolated empty homes', + startupHost: { nodeVersion: process.versions.node, nodeModulesAbi: process.versions.modules, glibcVersionRuntime: hostHeader.glibcVersionRuntime, kernelRelease: hostHeader.osRelease }, + installedIdentityBefore: before, installedIdentityAfter: after, ...daemon, ...codex, startup, providerCalls: 0 }; writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + '\n'); console.log(JSON.stringify(receipt)); diff --git a/server/src/services/local-ai-browser-login.test.ts b/server/src/services/local-ai-browser-login.test.ts index 97e294bff4e..eb4ad50df00 100644 --- a/server/src/services/local-ai-browser-login.test.ts +++ b/server/src/services/local-ai-browser-login.test.ts @@ -43,12 +43,13 @@ function targetPlatform(platform: string, architecture: string, glibc = true, gl } describe.skipIf(process.platform === "win32")("local browser subscription login", () => { - it.each(["native", "arm32", "musl", "glibc-2.38", "glibc-3.0"])("uses the selected Codex command with its credential home and ambient HOME/cwd (%s)", async platform => { - const legacy = ["arm32", "musl", "glibc-2.38"].includes(platform); + it.each(["native", "arm32", "musl", "glibc-2.38", "glibc-3.0", "unknown"])("uses the selected Codex command with its credential home and ambient HOME/cwd (%s)", async platform => { + const legacy = platform === "arm32"; if (platform === "arm32") targetPlatform("linux", "arm"); if (platform === "musl") targetPlatform("linux", "arm64", false); if (platform === "glibc-2.38") targetPlatform("linux", "arm64", true, "2.38"); if (platform === "glibc-3.0") targetPlatform("linux", "arm64", true, "3.0"); + if (platform === "unknown") targetPlatform("linux", "x64", true, "unknown"); 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,12 +67,51 @@ describe.skipIf(process.platform === "win32")("local browser subscription login" for (const key of ["OPENAI_API_KEY", "CODEX_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"]) vi.stubEnv(key, "unrelated-host-value"); const login = startLocalBrowserLogin("openai", home); - await vi.waitFor(() => expect(login.outcome).toBe("success"), { timeout: 5000 }); + await vi.waitFor(() => expect(login.outcome).toBeDefined(), { timeout: 5000 }); + expect(login.outcome).toBe("success"); if (legacy) expect(resolveCodexCommand).not.toHaveBeenCalled(); else expect(resolveCodexCommand).toHaveBeenCalledOnce(); expect(login.code).toBe("ABCD-EFGHJ"); }); + it.each(["x64", "arm64"].flatMap(architecture => ["2.38", "musl", "unknown"].map(libc => ({ architecture, libc }))))("uses the installed Codex dependency for login on Linux $architecture/$libc without a global CLI", async ({ architecture, libc }) => { + targetPlatform("linux", architecture, libc !== "musl", libc); + const home = await fakeCli("codex", [ + 'echo owned > "$CODEX_HOME/owned-cli"', + 'printf "1. Open this link in your browser and sign in to your account\\nhttps://auth.openai.com/codex/device\\n2. Enter this one-time code (expires in 15 minutes)\\nABCD-EFGHJ\\n"', + ].join("\n")); + const dependency = path.join(root!, "node_modules/@openai/codex"); + await mkdir(path.join(dependency, "bin"), { recursive: true }); + await writeFile(path.join(dependency, "package.json"), JSON.stringify({ name: "@openai/codex", version: "0.1.0", bin: { codex: "bin/codex" } })); + await writeFile(path.join(dependency, "bin/codex"), await readFile(path.join(root!, "bin/codex")), { mode: 0o700 }); + const actual = await vi.importActual>("../../../packages/paperclip-runner/src/drivers/codex/codex-command.ts"); + vi.mocked(resolveCodexCommand).mockImplementation(() => actual.resolveCodexCommand(path.join(root!, "issuer.js"), process.env, home)); + process.env.PATH = "/usr/bin:/bin"; + const login = startLocalBrowserLogin("openai", home); + await vi.waitFor(() => expect(login.outcome).toBeDefined(), { timeout: 5000 }); + expect(login.outcome).toBe("success"); + expect(await readFile(path.join(home, "owned-cli"), "utf8")).toBe("owned\n"); + expect(resolveCodexCommand).toHaveBeenCalledOnce(); + expect(process.report.getReport).not.toHaveBeenCalled(); + }); + + it.each(["2.38", "musl", "unknown"])("reports a real owned-dependency containment failure on Linux/%s without falling back to another CLI", async libc => { + targetPlatform("linux", "arm64", libc !== "musl", libc); + const home = await fakeCli("codex", 'echo invoked > "$CODEX_HOME/unreviewed-cli"'); + const dependency = path.join(root!, "node_modules/@openai/codex"); + await mkdir(dependency, { recursive: true }); + await writeFile(path.join(dependency, "package.json"), JSON.stringify({ name: "@openai/codex", version: "0.1.0", bin: { codex: "../outside-codex" } })); + const actual = await vi.importActual>("../../../packages/paperclip-runner/src/drivers/codex/codex-command.ts"); + vi.mocked(resolveCodexCommand).mockImplementation(() => actual.resolveCodexCommand(path.join(root!, "issuer.js"), process.env, home)); + const login = startLocalBrowserLogin("openai", home); + await vi.waitFor(() => expect(login.outcome).toBeDefined(), { timeout: 5000 }); + expect(login.outcome).toBe("failure"); + expect(login.error).toMatch(/requires an installed Codex CLI.*Install Codex/); + expect(JSON.stringify(login)).not.toContain(root!); + await expect(readFile(path.join(home, "unreviewed-cli"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(resolveCodexCommand).toHaveBeenCalledOnce(); + }); + 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"'); diff --git a/server/src/services/local-ai-browser-login.ts b/server/src/services/local-ai-browser-login.ts index f826869c9a7..755a1e41573 100644 --- a/server/src/services/local-ai-browser-login.ts +++ b/server/src/services/local-ai-browser-login.ts @@ -5,7 +5,6 @@ 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 { localPaperclipRunnerSupported } from "../lib/agent-runner-platform.js"; import { resolveCodexCommand } from "../vendor/paperclip-runner/index.js"; export type LocalBrowserLoginState = { @@ -57,9 +56,10 @@ 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") { - // Native targets use the same owned Codex resolution as execution. - // Other platforms retain the legacy CLI lookup. - const legacyPlatform = !localPaperclipRunnerSupported(); + // The installed Codex CLI is independent of the runner daemon's glibc floor. + // Supported CLI hosts use owned resolution; other platforms keep PATH lookup. + const legacyPlatform = !((process.platform === "linux" || process.platform === "darwin") + && (process.arch === "x64" || process.arch === "arm64")); try { if (legacyPlatform) { await ensureCommandResolvable("codex", home, process.env);