diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index 57285a882c3..b468079814c 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -14,20 +14,24 @@ an attended Intel provider journey is not required. Reuse the healthy test drive and the existing Product E2E helpers rather than create another test system. Keep the current PR and branch: [#15683](https://github.com/paperclipai/paperclip/pull/15683), -`codex/codex-runner-default`. The last pushed source is -`dc7304d7c4db1c9c9ee2ed80a98d004371372771`; its ordinary CI is green. +`codex/codex-runner-default`. The Mac qualification source is +`d522822a5daeb72d124360b576b82a8bb52b43f4`; its +[ordinary CI](https://github.com/paperclipai/paperclip/actions/runs/38259205327) +is green. Fresh automated review is 5/5. The development release-manifest +finding is fixed with focused tests and awaits fresh final-head review. Earlier Linux-only source `6ca302426b768791b499d2d563bbca4276f2c908` had fresh 5/5 review and eight resolved threads. That historical review does not qualify the macOS amendment. Master integration conflicts are resolved. The [test-drive server](http://127.0.0.1:3125/COD/agents/all) is running through -the normal dev supervisor at the pushed source. Health and bootstrap are ready; -the actual listener belongs to this worktree. The preserved Codex QA agent is -idle on `codex_local`, with its saved unrelated title edit intact. Dependencies +the normal dev supervisor at the Mac qualification source. Health and bootstrap +are ready; the actual listener belongs to this worktree. The preserved legacy +and new native Codex QA agents are idle, with unrelated title edits intact. Dependencies and ignored configuration had been cleared, so startup restored them and the normal CLI signing keys while retaining the database and encrypted account key. -The existing cached development daemon is from a different Rust tree. It avoids -a local Rust build and is not macOS qualification evidence for this candidate. +The initial borrowed development daemon was removed before paid testing. +The production resolver selects the source-bound CI arm64 daemon. Both native +task runs and restart preservation use the verified runtime below. Implementation ownership: @@ -91,6 +95,36 @@ and reload preserved complete legacy/native execution digests; the shared runner picker passed light/dark, mobile and keyboard checks. Screenshots and sanitized receipts are under `/private/tmp/pr2-f4-mac-ui/`. +The corrected qualifier's exact `d522822` Apple Silicon artifact independently +matches those same bytes and metadata. Artifact `11752770004` passed native +header, source receipt and strict ad-hoc signature checks. Rust tree +`6ca713d5f1f0a059c7f01a37d21f268817e0c93c` and runner TypeScript tree +`ba9744db7fe79301cb0ab442e9546de3282a4bf4` are identical to the paid-task source. +The receipt is `/private/tmp/pr2-d522-mac-remote-daemon/sanitized-verification-receipt.json`. +Restarting the test-drive at `d522822` preserved both complete execution digests, +titles and both native run records, with no additional provider calls. + +The deprecated `enableNativeRunner` field is now false on this QA instance. +The native export/import UI journey passes with that field disabled. Import +submission omitted adapter overrides, preserved native Codex/model identity, +and left the new agent paused with its import hold. The explicit Legacy round +trip also passes: the request sends `runner: legacy` without replacing source +configuration, and the saved agent is `codex_local` with the import hold intact. +Both survive reload; the source agent remains unchanged. These imports do not +run setup probes or provider turns. +Evidence is under `/private/tmp/codex-default-dc-import-live/`. + +The live import journey found one incorrect policy display. Native Codex's +preserved `never` policy translated to Legacy bypass true, while its checkbox +showed false. A narrow hydration fix uses the existing policy defaults and +canonical booleans; it does not change server permission translation. Against +the pre-fix page, three display cases fail and four safe controls pass. The +fixed page passes 42 component cases and 40 selection controls. The no-provider +preview check passes: the corrected control matches the already-saved paused +agent. Exactly two paused imports were submitted, with no third Apply. The +combined receipt is `/private/tmp/codex-default-dc-import-live/receipt.json`; +it separates `d522822` persistence proof from the corrected UI's content hashes. + [Three-target qualification](https://github.com/paperclipai/paperclip/actions/runs/38252137381) built all three daemons, including actual Intel Mac. Its clean Linux consumer passed the 18-package installation and Codex ownership controls, then a Pi probe @@ -118,9 +152,35 @@ The complete $3 setup/task hold remains reserved as unpriced. No historical unknown or cleanup hold was released, no automatic retry ran, and no additional paid Linux campaign started. The aggregate ceiling remains $250. -Next action: commit the verified import, dev-stager and installation-probe fixes -together, then run the existing three-target qualifier once for the frozen head. -Verify restart preservation and update the same PR with its final evidence. +The import, dev-stager and installation-probe fixes are pushed at `d522822`. +The [corrected three-target qualifier](https://github.com/paperclipai/paperclip/actions/runs/38259364638) +has passed all three daemon producers and the real Linux clean installation, +including the corrected Pi probe. Both Mac consumers are checking the same +18 tarballs. Ordinary CI reports 53 successful check runs, Snyk success and four +conditional skips. Fresh review reports 5/5, with one new development-only +manifest issue: rebuilding a host daemon must not corrupt released artifact +identity. Its fix separates local development selection from release assets; +it does not alter the Rust runner, production selector or release assembler. +Fourteen focused controls prove preserved release targets/hash manifest, running +old inodes, correct Mac host selection and future source freshness. Independent +review verifies that the atomic flat publisher and qualified release components +remain unchanged. The actual Mac still selects the verified release target and +does not require Cargo. Receipts are +`/private/tmp/pr2-dev-release-preservation-checkpoint.json` and +`/private/tmp/ci-final-d522-dev-equivalence-audit.json`. + +Next action: commit the verified development/import display fixes and this state +record together. Run fresh final-head CI/review in parallel with the remaining +clean package consumers, then record their exact conclusions in the PR evidence +index. Keep `d522822` as the qualification source; subsequent UI/development +checks have their own source identity. Keep exact sources in every receipt. +Do not repeat unchanged paid task turns or qualification components merely for +the development-only fix. + +This is the 2026-10-12 source-freeze snapshot. The PR verification section is +the linked evidence index for final CI, review and consumer conclusions. Current +qualification links above retain their literal tested source rather than claim +that an earlier workflow ran on a later development/UI commit. Managed staging execution/access renewal, code-owner approval and actual post-merge Cloud composition remain unverified. Prior staging preflight evidence is retained at [37962635959](https://github.com/paperclipai/paperclip/actions/runs/37962635959) diff --git a/packages/paperclip-runner/scripts/stage-runner-binary.mjs b/packages/paperclip-runner/scripts/stage-runner-binary.mjs index f55b3624eb5..581ee2867e8 100644 --- a/packages/paperclip-runner/scripts/stage-runner-binary.mjs +++ b/packages/paperclip-runner/scripts/stage-runner-binary.mjs @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { constants } from "node:fs"; -import { chmod, copyFile, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"; +import { chmod, copyFile, mkdir, mkdtemp, rename, rm } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; @@ -32,15 +32,9 @@ export async function stageBuiltRunnerBinary(packageRoot) { const executable = process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd"; const source = path.join(packageRoot, "runner", "target", "release", executable); const binaryRoot = path.join(packageRoot, "dist", "bin"); + // Source builds are separate from the source-bound release targets and their + // manifest. The dev supervisor selects this fresh output for its server. await stageRunnerBinary(source, path.join(binaryRoot, executable)); - const hostTarget = path.join(binaryRoot, `${process.platform}-${process.arch}`, executable); - const targetStat = await stat(hostTarget).catch(error => { - if (error.code === "ENOENT") return null; - throw error; - }); - // The production resolver prefers an existing platform target. A source - // rebuild must refresh it too, using the same atomic, signed publication. - if (targetStat?.isFile()) await stageRunnerBinary(source, hostTarget); } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { diff --git a/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs index e7ee9f1ce18..b041a244f6e 100644 --- a/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs +++ b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { once } from "node:events"; import { copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, utimes, writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; @@ -8,7 +8,8 @@ import path from "node:path"; import { test } from "node:test"; import { stageBuiltRunnerBinary, stageRunnerBinary } from "./stage-runner-binary.mjs"; import { RELEASE_RUNNER_TARGETS, releaseDaemonManifest, verifyReleaseRunnerMetadata } from "./stage-release-runner-binaries.mjs"; -import { paperclipRunnerBinaryNeedsBuild } from "../../../scripts/dev-runner-native-binary.mjs"; +import { paperclipRunnerBinaryNeedsBuild, resolveNativeRunnerDevBinary } from "../../../scripts/dev-runner-native-binary.mjs"; +import { resolvePackagedRunnerBinary } from "../src/live/runner-binary.ts"; const contract = { package: { name: "@paperclipai/paperclip-runner", version: "0.0.0" }, contracts: { runnerdArtifact: 2, nativeExecution: 1, harnessDriver: 1 }, prp: { name: "paperclip.runner", minimumVersion: 1, maximumVersion: 3 } }; @@ -109,27 +110,56 @@ test("atomically publishes while the previous executable is running", { skip: pr }); }); -test("a source rebuild refreshes the preferred host target without replacing another architecture", { skip: process.platform === "win32", timeout: 15_000 }, async () => { +test("a source rebuild selects fresh dev bytes while preserving release binaries and their manifest", { skip: process.platform === "win32", timeout: 15_000 }, async () => { const root = await mkdtemp(path.join(tmpdir(), "runnerd-stage-rebuild-")); const packageRoot = path.join(root, "packages", "paperclip-runner"); const binaryRoot = path.join(packageRoot, "dist", "bin"); const hostTarget = path.join(binaryRoot, `${process.platform}-${process.arch}`, "paperclip-runnerd"); - const otherTarget = path.join(binaryRoot, `${process.platform}-${process.arch === "x64" ? "arm64" : "x64"}`, "paperclip-runnerd"); const source = path.join(packageRoot, "runner", "crates", "runner-core", "src", "main.rs"); const built = path.join(packageRoot, "runner", "target", "release", "paperclip-runnerd"); await withOwnedChildren(root, async start => { + const copyHostFixture = async (input, output) => { + await copyFile(input, output); + if (process.platform === "darwin") { + // System fixtures can be universal; Cargo release daemons are thin. + // Extract only this host slice before exercising the native header gate. + const slices = execFileSync("/usr/bin/lipo", ["-archs", input], { encoding: "utf8" }).trim().split(/\s+/); + const architecture = slices.find(value => process.arch === "arm64" ? value.startsWith("arm64") : value === "x86_64"); + assert.ok(architecture); + if (slices.length > 1) execFileSync("/usr/bin/lipo", [input, "-thin", architecture, "-output", output]); + } + }; await mkdir(path.dirname(source), { recursive: true }); await writeFile(source, "fn main() {}\n"); await mkdir(path.dirname(built), { recursive: true }); - await copyFile("/bin/echo", built); - await stageRunnerBinary("/bin/sleep", hostTarget); - await stageRunnerBinary("/bin/echo", path.join(binaryRoot, "paperclip-runnerd")); - await mkdir(path.dirname(otherTarget), { recursive: true }); - await writeFile(otherTarget, "other architecture artifact"); + await copyHostFixture("/bin/echo", built); + const previousBuild = path.join(root, "previous-build"); + await copyHostFixture("/bin/sleep", previousBuild); + await stageRunnerBinary(previousBuild, hostTarget); + await stageRunnerBinary(built, path.join(binaryRoot, "paperclip-runnerd")); + const platforms = {}, releaseBytes = new Map(); + for (const target of RELEASE_RUNNER_TARGETS) { + const artifact = path.join(binaryRoot, target, "paperclip-runnerd"); + if (artifact !== hostTarget) { + const header = Buffer.alloc(64); + if (target === "linux-x64") { + header.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); header.writeUInt16LE(62, 18); + } else { header.writeUInt32LE(0xfeedfacf); header.writeUInt32LE(target === "darwin-x64" ? 0x01000007 : 0x0100000c, 4); } + await mkdir(path.dirname(artifact), { recursive: true }); + await writeFile(artifact, header); + } + const bytes = await readFile(artifact); + releaseBytes.set(artifact, bytes); + platforms[target] = { path: `${target}/paperclip-runnerd`, sha256: "sha256:" + createHash("sha256").update(bytes).digest("hex") }; + } const oldTime = new Date(Date.now() - 10_000); await utimes(hostTarget, oldTime, oldTime); + await utimes(path.join(binaryRoot, "paperclip-runnerd"), oldTime, oldTime); const oldArtifact = await readFile(hostTarget); const oldIdentity = await stat(hostTarget); + const manifestPath = path.join(binaryRoot, "release-manifest.json"); + const manifest = JSON.stringify({ schema: "paperclip.runner.release-binaries.v1", sourceRevision: "a".repeat(40), platforms }); + await writeFile(manifestPath, manifest); const old = start(hostTarget, ["30"]); await old.spawned; const requirement = { repoRoot: root, nativeRunnerRequired: true }; @@ -137,16 +167,20 @@ test("a source rebuild refreshes the preferred host target without replacing ano await stageBuiltRunnerBinary(packageRoot); - const published = await stat(hostTarget); - assert.notEqual(published.ino, oldIdentity.ino); + const selected = resolveNativeRunnerDevBinary(requirement); + assert.deepEqual(selected, { binary: path.join(binaryRoot, "paperclip-runnerd"), sourceBuild: true }); + const published = await stat(selected.binary); assert.equal(published.mode & 0o777, 0o755); - assert.notDeepEqual(await readFile(hostTarget), oldArtifact); - assert.deepEqual(await readFile(hostTarget), await readFile(path.join(binaryRoot, "paperclip-runnerd"))); - assert.equal(await readFile(otherTarget, "utf8"), "other architecture artifact"); + assert.notDeepEqual(await readFile(selected.binary), oldArtifact); + assert.equal((await stat(hostTarget)).ino, oldIdentity.ino); + assert.deepEqual(await readFile(hostTarget), oldArtifact); + assert.equal(await readFile(manifestPath, "utf8"), manifest); + for (const [artifact, bytes] of releaseBytes) assert.deepEqual(await readFile(artifact), bytes); + assert.equal(resolvePackagedRunnerBinary(path.join(packageRoot, "dist")), hostTarget); assert.equal(paperclipRunnerBinaryNeedsBuild(requirement), false); assert.equal(old.child.exitCode, null); assert.equal(old.child.signalCode, null); - const next = start(hostTarget, ["rebuilt"]); + const next = start(selected.binary, ["rebuilt"]); await next.spawned; await next.closed; assert.equal(next.child.exitCode, 0); }); diff --git a/scripts/dev-runner-native-binary.mjs b/scripts/dev-runner-native-binary.mjs index 6ad6d2d31e6..d49eb01aaed 100644 --- a/scripts/dev-runner-native-binary.mjs +++ b/scripts/dev-runner-native-binary.mjs @@ -1,5 +1,6 @@ -import { readdirSync, statSync } from "node:fs"; +import { readFileSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; +import { runnerBinaryTarget } from "../packages/paperclip-runner/src/live/runner-binary.ts"; export function resolveNativeRunnerRequirement({ exitCode, stdout }) { if (exitCode !== 0) { @@ -38,6 +39,27 @@ function newestMtimeMs(target) { return newest; } +export function resolveNativeRunnerDevBinary({ + repoRoot, + platform = process.platform, + architecture = process.arch, +}) { + const executable = platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd"; + const packageRoot = path.join(repoRoot, "packages", "paperclip-runner"); + const target = path.join(packageRoot, "dist", "bin", `${platform}-${architecture}`, executable); + const flat = path.join(packageRoot, "dist", "bin", executable); + const targetStat = statSync(target, { throwIfNoEntry: false }); + const flatStat = statSync(flat, { throwIfNoEntry: false }); + // Release targets remain authoritative unless a newer source build matches + // this host. In particular, a build-host flat artifact cannot mask a remotely + // staged Mac target or force a local Cargo build. + if (flatStat?.isFile() && (!targetStat?.isFile() || flatStat.mtimeMs > targetStat.mtimeMs) + && runnerBinaryTarget(readFileSync(flat)) === `${platform}-${architecture}`) { + return { binary: flat, sourceBuild: true }; + } + return targetStat?.isFile() ? { binary: target, sourceBuild: false } : null; +} + export function paperclipRunnerBinaryNeedsBuild({ repoRoot, nativeRunnerRequired, @@ -48,20 +70,10 @@ export function paperclipRunnerBinaryNeedsBuild({ if (!nativeRunnerRequired) return false; if (configuredBinary?.trim()) return false; - const executable = platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd"; + const selected = resolveNativeRunnerDevBinary({ repoRoot, platform, architecture }); + if (!selected) return true; + const binaryStat = statSync(selected.binary); const packageRoot = path.join(repoRoot, "packages", "paperclip-runner"); - // Match production resolution: a staged release target takes precedence over - // the flat source-build output. Remote Mac builds must not trigger Cargo just - // because only the normal darwin- release executable is present. - let binaryStat; - for (const binary of [ - path.join(packageRoot, "dist", "bin", `${platform}-${architecture}`, executable), - path.join(packageRoot, "dist", "bin", executable), - ]) { - const candidate = statSync(binary, { throwIfNoEntry: false }); - if (candidate?.isFile()) { binaryStat = candidate; break; } - } - if (!binaryStat) return true; const runnerRoot = path.join(packageRoot, "runner"); const buildInputs = [ diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 6bd777f3333..748df95ab72 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -8,6 +8,7 @@ import { stdin, stdout } from "node:process"; import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.ts"; import { paperclipRunnerBinaryNeedsBuild, + resolveNativeRunnerDevBinary, resolveNativeRunnerRequirement, } from "./dev-runner-native-binary.mjs"; import { applyDevRunnerOptions } from "./dev-runner-options.ts"; @@ -171,6 +172,9 @@ const env: NodeJS.ProcessEnv = { ...process.env, PAPERCLIP_UI_DEV_MIDDLEWARE: explicitUiDevMiddleware ?? (serveBuiltUiForManagedRuntime ? "false" : "true"), }; +// An automatic source-build override must not hide subsequent Rust edits from +// the watcher. Keep the operator's original choice separate from server env. +const configuredRunnerBinary = env.PAPERCLIP_RUNNER_BINARY; if (mode === "dev") { env.PAPERCLIP_DEV_SERVER_STATUS_FILE = devServerStatusFilePath; @@ -573,28 +577,32 @@ async function buildPaperclipRunner() { process.exit(typescriptResult.code); } + const nativeRunnerRequired = await getNativeRunnerRequired(); if ( - !paperclipRunnerBinaryNeedsBuild({ + paperclipRunnerBinaryNeedsBuild({ repoRoot, - nativeRunnerRequired: await getNativeRunnerRequired(), - configuredBinary: env.PAPERCLIP_RUNNER_BINARY, + nativeRunnerRequired, + configuredBinary: configuredRunnerBinary, }) ) { - return; + console.log("[paperclip] building paperclip runner native binary..."); + const binaryResult = await runPnpm( + ["--filter", "@paperclipai/paperclip-runner", "build:binary"], + { stdio: "inherit" }, + ); + if (binaryResult.signal) { + exitForSignal(binaryResult.signal); + return; + } + if (binaryResult.code !== 0) { + console.error("[paperclip] paperclip runner native binary build failed"); + process.exit(binaryResult.code); + } } - - console.log("[paperclip] building paperclip runner native binary..."); - const binaryResult = await runPnpm( - ["--filter", "@paperclipai/paperclip-runner", "build:binary"], - { stdio: "inherit" }, - ); - if (binaryResult.signal) { - exitForSignal(binaryResult.signal); - return; - } - if (binaryResult.code !== 0) { - console.error("[paperclip] paperclip runner native binary build failed"); - process.exit(binaryResult.code); + if (!configuredRunnerBinary?.trim()) { + const selected = nativeRunnerRequired ? resolveNativeRunnerDevBinary({ repoRoot }) : null; + if (selected?.sourceBuild) env.PAPERCLIP_RUNNER_BINARY = selected.binary; + else delete env.PAPERCLIP_RUNNER_BINARY; } } diff --git a/server/src/__tests__/dev-runner-native-binary.test.ts b/server/src/__tests__/dev-runner-native-binary.test.ts index b0f41862b70..368f14a3e4d 100644 --- a/server/src/__tests__/dev-runner-native-binary.test.ts +++ b/server/src/__tests__/dev-runner-native-binary.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { paperclipRunnerBinaryNeedsBuild, + resolveNativeRunnerDevBinary, resolveNativeRunnerRequirement, } from "../../../scripts/dev-runner-native-binary.mjs"; @@ -36,10 +37,24 @@ function createRunnerCheckout(): { root: string; source: string; binary: string fs.writeFileSync(path.join(runnerRoot, "Cargo.toml"), "[workspace]\n", "utf8"); fs.writeFileSync(path.join(runnerRoot, "Cargo.lock"), "", "utf8"); fs.writeFileSync(source, "fn main() {}\n", "utf8"); - fs.writeFileSync(binary, "runnerd", "utf8"); + fs.writeFileSync(binary, nativeHeader(process.platform, process.arch)); return { root, source, binary }; } +function nativeHeader(platform: string, architecture: string): Buffer { + const bytes = Buffer.alloc(128); + if (platform === "darwin") { + bytes.writeUInt32LE(0xfeedfacf); + bytes.writeUInt32LE(architecture === "x64" ? 0x01000007 : 0x0100000c, 4); + } else if (platform === "win32") { + bytes.set([0x4d, 0x5a]); bytes.writeUInt32LE(64, 60); + bytes.writeUInt32LE(0x00004550, 64); bytes.writeUInt16LE(architecture === "x64" ? 0x8664 : 0xaa64, 68); + } else { + bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(architecture === "x64" ? 62 : 183, 18); + } + return bytes; +} + describe("paperclip runner native dev prerequisite", () => { it("uses an explicit status response and fails safe when status is unknown", () => { expect( @@ -114,12 +129,18 @@ describe("paperclip runner native dev prerequisite", () => { const checkout = createRunnerCheckout(); const target = path.join(path.dirname(checkout.binary), `darwin-${architecture}`, "paperclip-runnerd"); fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.writeFileSync(target, "target-runnerd"); + fs.writeFileSync(target, nativeHeader("darwin", architecture)); const old = new Date(Date.now() - 2000), current = new Date(Date.now() + 2000); fs.utimesSync(checkout.binary, old, old); fs.utimesSync(target, current, current); const input = { repoRoot: checkout.root, nativeRunnerRequired: true, platform: "darwin", architecture }; expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(false); + // A newer Linux build-host output cannot mask the remote Mac artifact. + fs.writeFileSync(checkout.binary, nativeHeader("linux", "x64")); + const wrongHostNewer = new Date(Date.now() + 4000); + fs.utimesSync(checkout.binary, wrongHostNewer, wrongHostNewer); + expect(resolveNativeRunnerDevBinary(input)).toEqual({ binary: target, sourceBuild: false }); + expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(false); fs.rmSync(checkout.binary); expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(false); fs.utimesSync(target, old, old); @@ -128,6 +149,26 @@ describe("paperclip runner native dev prerequisite", () => { expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(true); }); + it.each(["arm64", "x64"])("uses only a newer matching Mac %s source build without changing release selection", architecture => { + const checkout = createRunnerCheckout(); + const target = path.join(path.dirname(checkout.binary), `darwin-${architecture}`, "paperclip-runnerd"); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, nativeHeader("darwin", architecture)); + fs.writeFileSync(checkout.binary, nativeHeader("darwin", architecture)); + const old = new Date(Date.now() - 2000), fresh = new Date(Date.now() + 2000), changed = new Date(Date.now() + 4000); + fs.utimesSync(target, old, old); fs.utimesSync(checkout.binary, fresh, fresh); + const input = { repoRoot: checkout.root, nativeRunnerRequired: true, platform: "darwin", architecture }; + expect(resolveNativeRunnerDevBinary(input)).toEqual({ binary: checkout.binary, sourceBuild: true }); + expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(false); + fs.utimesSync(checkout.source, changed, changed); + expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(true); + fs.writeFileSync(checkout.binary, nativeHeader("darwin", architecture === "x64" ? "arm64" : "x64")); + expect(resolveNativeRunnerDevBinary(input)).toEqual({ binary: target, sourceBuild: false }); + fs.rmSync(target); + expect(resolveNativeRunnerDevBinary(input)).toBeNull(); + expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(true); + }); + it("does not build a workspace binary when an explicit binary is configured", () => { expect( paperclipRunnerBinaryNeedsBuild({ diff --git a/ui/src/pages/CompanyImport.test.tsx b/ui/src/pages/CompanyImport.test.tsx index 7a222bf7fdb..3eafca01b1f 100644 --- a/ui/src/pages/CompanyImport.test.tsx +++ b/ui/src/pages/CompanyImport.test.tsx @@ -1214,10 +1214,14 @@ describe("CompanyImport", () => { }); it.each([ - { sourceType: "codex_local", sourceRunner: "legacy" as const, runner: "paperclip" as const }, - { sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const }, - { sourceType: "paperclip_runner", sourceRunner: "paperclip" as const, runner: "legacy" as const }, - ])("submits the explicit $runner import override from $sourceType without replacing saved settings", async ({ sourceType, sourceRunner, runner }) => { + { sourceType: "codex_local", sourceRunner: "legacy" as const, runner: "paperclip" as const, sourcePolicy: {}, expectedBypass: undefined }, + { sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { dangerouslyBypassApprovalsAndSandbox: false }, expectedBypass: false }, + { sourceType: "paperclip_runner", sourceRunner: "paperclip" as const, runner: "legacy" as const, sourcePolicy: { codexPermissionMode: "never" }, expectedBypass: true }, + { sourceType: "paperclip_runner", sourceRunner: "paperclip" as const, runner: "legacy" as const, sourcePolicy: {}, expectedBypass: true }, + { sourceType: "paperclip_runner", sourceRunner: "paperclip" as const, runner: "legacy" as const, sourcePolicy: { codexPermissionMode: "never", dangerouslyBypassApprovalsAndSandbox: false }, expectedBypass: false }, + { sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { dangerouslyBypassSandbox: false }, expectedBypass: false }, + { sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { dangerouslyBypassApprovalsAndSandbox: true }, expectedBypass: true }, + ])("submits $runner from $sourceType with visible bypass=$expectedBypass while preserving saved policy", async ({ sourceType, sourceRunner, runner, sourcePolicy, expectedBypass }) => { mockOptionalCompany.mockReturnValue({ selectedCompanyId: "company-1" }); mockAdaptersApi.list.mockResolvedValue([ { type: "claude_local", disabled: false }, @@ -1230,7 +1234,9 @@ describe("CompanyImport", () => { preview.manifest.agents[0]!.adapterConfig = { ...(sourceType === "paperclip_runner" ? { provider: "codex" } : {}), model: "gpt-5.6-sol", modelReasoningEffort: "high", env: { CODEX_HOME: "/saved/account" }, + ...sourcePolicy, }; + const savedSourceConfig = structuredClone(preview.manifest.agents[0]!.adapterConfig); mockCompaniesApi.importPreview.mockResolvedValue(preview); await renderPage(); await enterGithubUrl(); @@ -1245,9 +1251,14 @@ describe("CompanyImport", () => { expect(option!.disabled).toBe(false); await act(async () => option!.click()); await flushReact(); + if (expectedBypass !== undefined) { + expect(container.querySelector('[role="switch"][aria-label="Bypass sandbox"]')?.getAttribute("aria-checked")) + .toBe(String(expectedBypass)); + } await clickButton(text => text.startsWith("Import 3 file")); await settle(); expect(lastImportMeta().adapterOverrides).toEqual({ coder: { adapterType: "codex_local", runner } }); + expect(preview.manifest.agents[0]!.adapterConfig).toEqual(savedSourceConfig); }); it("does not reinterpret an unknown native provider as Codex when its configuration is edited", async () => { diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index 640f4be17a9..dbc3c307813 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -42,7 +42,8 @@ import { Field, adapterLabels } from "../components/agent-config-primitives"; import { getAdapterLabel } from "../adapters/adapter-display-registry"; import { defaultCreateValues } from "../components/agent-config-defaults"; import { getUIAdapter, listUIAdapters } from "../adapters"; -import { isPaperclipRunnerProvider, type CreateConfigValues } from "@paperclipai/adapter-utils"; +import { isPaperclipRunnerProvider, PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES, type CreateConfigValues } from "@paperclipai/adapter-utils"; +import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/adapter-codex-local"; import { type FileTreeNode, type FrontmatterData, @@ -575,6 +576,8 @@ function importAdapterValues( runner?: AgentRunnerChoice, ): CreateConfigValues { const nativeCodex = sourceAdapterType === "paperclip_runner" && choice === "codex_local"; + const savedBypass = sourceConfig?.dangerouslyBypassApprovalsAndSandbox + ?? (nativeCodex ? undefined : sourceConfig?.dangerouslyBypassSandbox); return { ...defaultCreateValues, adapterType: choice === "openai_dot" ? "paperclip_runner" : choice, @@ -582,6 +585,11 @@ function importAdapterValues( ...(sourceConfig && choice === "codex_local" ? { model: typeof sourceConfig.model === "string" ? sourceConfig.model : "", thinkingEffort: String(sourceConfig.reasoningEffort ?? sourceConfig.modelReasoningEffort ?? ""), + // Runner-only imports preserve server-translated policy; display that policy too. + dangerouslyBypassSandbox: typeof savedBypass === "boolean" ? savedBypass + : nativeCodex + ? (sourceConfig.codexPermissionMode ?? PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.codex.defaultMode) === "never" + : DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, } : {}), ...(sourceAdapterType === "paperclip_runner" && choice !== "openai_dot" ? { adapterSchemaValues: { ...sourceConfig } } : {}),