From d522822a5daeb72d124360b576b82a8bb52b43f4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 08:55:54 -0500 Subject: [PATCH] fix: preserve imported runner choices and qualify Mac install fixtures Forward explicit import runner overrides, preserve native harness identity without experimental Codex gating, refresh existing host daemon outputs atomically, and retain strict public install probes with target paths and the executing Node SDK. Co-Authored-By: Paperclip --- doc/plans/2026-10-06-default-agent-runner.md | 66 +++++++-- .../scripts/stage-runner-binary.mjs | 23 +++- .../scripts/stage-runner-binary.test.mjs | 48 ++++++- .../grok-public-install-sandbox.test.mjs | 69 +++++++++- scripts/grok-public-install-sandbox.mjs | 27 ++++ scripts/pi-public-install-probe.mjs | 43 +++++- scripts/verify-grok-npm-install.mjs | 11 +- ui/src/pages/CompanyImport.test.tsx | 126 +++++++++++++++++- ui/src/pages/CompanyImport.tsx | 90 +++++++++---- 9 files changed, 446 insertions(+), 57 deletions(-) diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index a4a41f0ccc9..57285a882c3 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -15,10 +15,10 @@ 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 -`6ca302426b768791b499d2d563bbca4276f2c908`. Its prior CI has 52 passing checks, -four conditional skips, fresh 5/5 review and eight resolved threads. These do -not qualify the new macOS changes. Current master has real merge conflicts; -integrate the fetched master after checkpointing the owned macOS changes. +`dc7304d7c4db1c9c9ee2ed80a98d004371372771`; its ordinary CI is green. +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; @@ -71,11 +71,59 @@ the $250 ceiling and $248.18 combined cost/reservation total remain unchanged. Limit the Mac journey to two task turns, 180 seconds each, no automatic retry, and an independently checked useful file plus revision. -Next action: finish the confirmed master conflict resolutions, freeze the source, -and run remote qualification for the Mac artifacts. Native Apple Silicon live acceptance, new-head CI/review, managed -staging execution, code-owner approval and actual post-merge Cloud composition -remain unverified. Prior staging image/preflight evidence is retained at -[37962635959](https://github.com/paperclipai/paperclip/actions/runs/37962635959) +### Mac acceptance checkpoint + +The real [Apple Silicon task and follow-up](http://127.0.0.1:3125/COD/issues/COD-2) +passed at runtime source `f4abdc2cfcd779d5ad9b9c0ea8b63114c1a18a9b`. +The ordinary picker submitted `codex_local` without a runner override and saved +`paperclip_runner` / `codex`. Saved subscription reuse and native setup passed. +Both task runs succeeded with driver `codex_app_server`, observed model +`gpt-5.6-sol`, and the same provider session. Independent attachment downloads +verified exact 61-byte and 102-byte Markdown files, including preservation of +the first attachment. Reload retained the native configuration. + +The actual owned daemon process loaded the source-bound CI bytes: +`sha256:d0656673f7b6dfa6f2554fac18f3e0b42e2eb10f444b06f6063255335d71aa14`. +Mach-O arm64, production contract, source receipt and code signature passed. +The borrowed development daemon override was removed. Startup at this revision +preserved the old legacy agent's full execution digest. Unrelated UI title edits +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/`. + +[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 +failed because it expected the old flat daemon path. Both Mac consumers reached +deferred offline lifecycle scripts but Node-gyp tried a forbidden header download. +Narrow verifier corrections preserve the startup/network/lock/sentinel assertions +and use the host SDK. These failed attempts remain evidence; installed-package +qualification still requires a passing corrected run. + +Test-only head `dc7304d7c4db1c9c9ee2ed80a98d004371372771` is production-byte +identical to `f4abdc2`. Its [ordinary CI](https://github.com/paperclipai/paperclip/actions/runs/38253941991) +passes the required checks. Two fixture repairs enabled Dot only in its intended +experiment cases and restored owner directory permissions only inside each test +worker's scratch home for teardown. The actual asset producer/symlink control +passed. Fresh review identified import override/availability gaps and stale dev +host binaries. Their fixes now pass 38 real-component import cases (eight fail +on the old page) and 12 dev-stager/freshness controls. Unknown/gated providers +retain their identity. Rebuilding refreshes an existing host target atomically +and keeps a running old executable intact. Fresh final-head checks/review remain +required. The corrected Pi/Mac installation probes pass 19 focused controls +and independent source review; actual hosted consumers remain pending. + +The two native runs report included subscription use without a dollar amount. +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. +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) and [37965658123](https://github.com/paperclipai/paperclip-cloud/actions/runs/37965658123). ## Outcome and ownership diff --git a/packages/paperclip-runner/scripts/stage-runner-binary.mjs b/packages/paperclip-runner/scripts/stage-runner-binary.mjs index 48643ea7083..f55b3624eb5 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 } from "node:fs/promises"; +import { chmod, copyFile, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; @@ -28,11 +28,22 @@ export async function stageRunnerBinary(source, destination) { } } +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"); + 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)) { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); - const executable = process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd"; - await stageRunnerBinary( - path.join(packageRoot, "runner", "target", "release", executable), - path.join(packageRoot, "dist", "bin", executable), - ); + await stageBuiltRunnerBinary(packageRoot); } diff --git a/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs index dafb9b23f0a..e7ee9f1ce18 100644 --- a/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs +++ b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs @@ -1,13 +1,14 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { once } from "node:events"; -import { mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, utimes, writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import path from "node:path"; import { test } from "node:test"; -import { stageRunnerBinary } from "./stage-runner-binary.mjs"; +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"; 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 } }; @@ -108,6 +109,49 @@ 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 () => { + 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 => { + 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"); + const oldTime = new Date(Date.now() - 10_000); + await utimes(hostTarget, oldTime, oldTime); + const oldArtifact = await readFile(hostTarget); + const oldIdentity = await stat(hostTarget); + const old = start(hostTarget, ["30"]); + await old.spawned; + const requirement = { repoRoot: root, nativeRunnerRequired: true }; + assert.equal(paperclipRunnerBinaryNeedsBuild(requirement), true); + + await stageBuiltRunnerBinary(packageRoot); + + const published = await stat(hostTarget); + assert.notEqual(published.ino, oldIdentity.ino); + 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.equal(paperclipRunnerBinaryNeedsBuild(requirement), false); + assert.equal(old.child.exitCode, null); + assert.equal(old.child.signalCode, null); + const next = start(hostTarget, ["rebuilt"]); + await next.spawned; await next.closed; + assert.equal(next.child.exitCode, 0); + }); +}); + test("a second spawn error still retires the live old child and removes its fixture", { skip: process.platform === "win32", timeout: 15_000 }, async () => { const root = await mkdtemp(path.join(tmpdir(), "runnerd-stage-spawn-failure-")); let old; diff --git a/scripts/__tests__/grok-public-install-sandbox.test.mjs b/scripts/__tests__/grok-public-install-sandbox.test.mjs index 8b5f99001b0..24ba8e95c4f 100644 --- a/scripts/__tests__/grok-public-install-sandbox.test.mjs +++ b/scripts/__tests__/grok-public-install-sandbox.test.mjs @@ -1,16 +1,46 @@ import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; 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, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from '../grok-public-install-sandbox.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 { 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('Mac offline lifecycle uses only the matching executing Node SDK', () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'mac-node-headers-test-'))); + try { + const prefix = join(root, 'sdk'), executable = join(prefix, 'bin/node'), include = join(prefix, 'include/node'); + mkdirSync(join(prefix, 'bin'), { recursive: true }); mkdirSync(include, { recursive: true }); + writeFileSync(executable, 'fixture Node executable'); + writeFileSync(join(include, 'node.h'), '#include "node_version.h"\n'); + const header = '#define NODE_MAJOR_VERSION 24\n#define NODE_MINOR_VERSION 20\n#define NODE_PATCH_VERSION 0\n'; + writeFileSync(join(include, 'node_version.h'), header); + assert.equal(macNodeHeadersDirectory(executable, '24.20.0'), prefix); + assert.throws(() => macNodeHeadersDirectory(executable, '24.19.0'), /must match the executing Node/); + assert.throws(() => macNodeHeadersDirectory(executable, '24.20.0-custom'), /exact release version/); + writeFileSync(join(include, 'node_version.h'), header + '#define NODE_PATCH_VERSION 0\n'); + assert.throws(() => macNodeHeadersDirectory(executable, '24.20.0'), /declare one patch version/); + writeFileSync(join(include, 'node_version.h'), header); + rmSync(join(include, 'node.h')); + assert.throws(() => macNodeHeadersDirectory(executable, '24.20.0'), /ENOENT/); + writeFileSync(join(root, 'outside-node.h'), '#include "node_version.h"\n'); + symlinkSync(join(root, 'outside-node.h'), join(include, 'node.h')); + assert.throws(() => macNodeHeadersDirectory(executable, '24.20.0'), /inside the executing Node prefix/); + const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8'); + assert.ok(source.includes('macNodeHeadersDirectory(process.execPath, process.versions.node)')); + assert.ok(source.includes('consumer, { ...env, npm_config_nodedir: nodeHeaders }'), 'SDK is bound only to the deferred lifecycle environment'); + assert.equal(source.split('npm_config_nodedir:').length, 2); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + test('lifecycle execution has no network, host credentials, checkout, or elevated privileges', () => { const args = grokConsumerDockerArgs({ ...paths, command: GROK_PUBLIC_INSTALL_LIFECYCLE }); assert.deepEqual(values(args, '--network'), ['none']); @@ -399,3 +429,38 @@ test('Mac startup wiring normalizes only validated manifest digests and uses ser const closedEnvironment = verifier.match(/const env = \{ PATH: `\$\{tools\}[\s\S]*?\};/)[0]; assert.doesNotMatch(closedEnvironment, /NODE_PATH|NODE_OPTIONS|\.\.\.process.env/); }); + +test('Pi admission verifies assembled host daemon identity and preserves the flat source-package control', async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'pi-installed-daemon-'))), server = join(root, 'server'); + const output = join(server, 'dist/vendor/paperclip-runner'), target = 'linux-x64', source = 'a'.repeat(40); + const binary = join(output, 'bin', target, 'paperclip-runnerd'), manifestPath = join(output, 'bin/release-manifest.json'); + const bytes = Buffer.alloc(64); bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(62, 18); + const sha256 = value => `sha256:${createHash('sha256').update(value).digest('hex')}`; + const manifest = { schema: 'paperclip.runner.release-binaries.v1', sourceRevision: source, + platforms: { [target]: { path: `${target}/paperclip-runnerd`, sha256: sha256(bytes) } } }; + const check = () => verifyPiPackagedDaemon(server, binary, target, runnerBinaryTarget); + try { + mkdirSync(join(output, 'bin', target), { recursive: true }); + writeFileSync(binary, bytes); writeFileSync(join(server, 'dist/build-info.json'), JSON.stringify({ commit: source })); + writeFileSync(manifestPath, JSON.stringify(manifest)); + await check(); + for (const changed of [null, { ...manifest, schema: 'untrusted' }, { ...manifest, sourceRevision: 'b'.repeat(40) }, + { ...manifest, platforms: { [target]: { ...manifest.platforms[target], path: '../outside' } } }, + { ...manifest, platforms: { [target]: { ...manifest.platforms[target], sha256: 'sha256:' + '0'.repeat(64) } } }]) { + writeFileSync(manifestPath, JSON.stringify(changed)); await assert.rejects(check()); + } + const wrongHeader = Buffer.from(bytes); wrongHeader.writeUInt16LE(183, 18); + writeFileSync(binary, wrongHeader); + writeFileSync(manifestPath, JSON.stringify({ ...manifest, platforms: { [target]: { ...manifest.platforms[target], sha256: sha256(wrongHeader) } } })); + await assert.rejects(check(), /linux-arm64/); + writeFileSync(binary, bytes); writeFileSync(manifestPath, JSON.stringify(manifest)); + const outside = join(root, 'outside'); writeFileSync(outside, bytes); + rmSync(binary); symlinkSync(outside, binary); await assert.rejects(check()); + rmSync(binary); writeFileSync(binary, bytes); rmSync(manifestPath); + const flat = join(output, 'bin/paperclip-runnerd'); writeFileSync(flat, bytes); + await verifyPiPackagedDaemon(server, flat, target, runnerBinaryTarget); + await assert.rejects(check(), /strictly equal/); + writeFileSync(manifestPath, 'null'); + await assert.rejects(verifyPiPackagedDaemon(server, flat, target, runnerBinaryTarget), /manifest must be an object/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/scripts/grok-public-install-sandbox.mjs b/scripts/grok-public-install-sandbox.mjs index 4259da033f8..ea245f9b627 100644 --- a/scripts/grok-public-install-sandbox.mjs +++ b/scripts/grok-public-install-sandbox.mjs @@ -1,4 +1,6 @@ import assert from 'node:assert/strict'; +import { readFileSync, realpathSync, statSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; // Keep public-package lifecycle code off the verification host. Resolve and // cache the public npm graph without scripts, then execute it offline. @@ -9,6 +11,31 @@ export const GROK_PUBLIC_INSTALL_LIFECYCLE = [ 'npm', 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts', ]; +// setup-node provides its matching SDK alongside the executing Node binary. +// Deferred lifecycle scripts may read that SDK, but may never download headers. +export function macNodeHeadersDirectory(nodeExecutable, nodeVersion) { + assert.match(nodeVersion, /^\d+\.\d+\.\d+$/, 'Executing Node must have an exact release version'); + const executable = realpathSync(nodeExecutable); + assert.equal(basename(executable), 'node', 'Node SDK must belong to the executing Node binary'); + assert.ok(statSync(executable).isFile(), 'Executing Node must be a regular file'); + const prefix = realpathSync(join(dirname(executable), '..')); + const include = join(prefix, 'include/node'); + for (const name of ['node.h', 'node_version.h']) { + const header = join(include, name); + assert.equal(realpathSync(header), header, 'Node SDK headers must remain inside the executing Node prefix'); + assert.ok(statSync(header).isFile(), 'Node SDK headers must be regular files'); + } + assert.match(readFileSync(join(include, 'node.h'), 'utf8'), /#\s*include\s+"node_version\.h"/, 'Node SDK must include its version header'); + const versionHeader = readFileSync(join(include, 'node_version.h'), 'utf8'); + const versions = ['MAJOR', 'MINOR', 'PATCH'].map(part => { + const matches = [...versionHeader.matchAll(new RegExp('^#\\s*define\\s+NODE_' + part + '_VERSION\\s+(\\d+)\\s*$', 'gm'))]; + assert.equal(matches.length, 1, 'Node SDK must declare one ' + part.toLowerCase() + ' version'); + return matches[0][1]; + }); + assert.equal(versions.join('.'), nodeVersion, 'Node SDK version must match the executing Node'); + return prefix; +} + // Inspect the actual npm archive, independently of the staging helper. export function assertNoBundledCodexPayloads(entries) { assert.ok(Array.isArray(entries) && entries.length > 0, 'Packed package must have tar members'); diff --git a/scripts/pi-public-install-probe.mjs b/scripts/pi-public-install-probe.mjs index 663754fcbc8..5827f9ad008 100644 --- a/scripts/pi-public-install-probe.mjs +++ b/scripts/pi-public-install-probe.mjs @@ -2,10 +2,44 @@ // Run in a credential-free public npm consumer after `paperclipai runtime setup pi`. // Use the server's normal packaged daemon resolver; never submit a prompt. import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +export async function verifyPiPackagedDaemon(server, daemon, target, runnerBinaryTarget) { + assert.match(target, /^(?:linux|darwin)-(?:x64|arm64)$/); + const output = join(server, 'dist/vendor/paperclip-runner'), manifestPath = join(output, 'bin/release-manifest.json'); + let manifest; + try { manifest = JSON.parse(await readFile(manifestPath, 'utf8')); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + let expectedPath = join(output, 'bin/paperclip-runnerd'); + if (manifest !== undefined) { + assert.ok(manifest && typeof manifest === 'object' && !Array.isArray(manifest), 'Release manifest must be an object'); + assert.equal(await realpath(manifestPath), manifestPath, 'Release manifest must remain inside the installed package'); + assert.equal(manifest.schema, 'paperclip.runner.release-binaries.v1'); + assert.match(manifest.sourceRevision, /^[a-f0-9]{40}$/); + const build = JSON.parse(await readFile(join(server, 'dist/build-info.json'), 'utf8')); + assert.equal(manifest.sourceRevision, build.commit, 'Packaged daemon source must match the installed server build'); + const artifact = manifest.platforms?.[target]; + assert.equal(artifact?.path, `${target}/paperclip-runnerd`, 'Packaged daemon must use the known host target path'); + assert.match(artifact.sha256, /^sha256:[a-f0-9]{64}$/); + expectedPath = join(output, 'bin', artifact.path); + assert.equal(await realpath(daemon), expectedPath, 'Production must select the installed host daemon'); + assert.equal(await realpath(expectedPath), expectedPath, 'Packaged daemon cannot escape through a symlink'); + const bytes = await readFile(expectedPath); + assert.equal(`sha256:${createHash('sha256').update(bytes).digest('hex')}`, artifact.sha256); + assert.equal(runnerBinaryTarget(bytes), target); + } else { + // Existing host-only source packages have no multi-target release manifest. + // They must still use their exact normal flat daemon, never an override. + assert.equal(await realpath(daemon), expectedPath); + assert.equal(runnerBinaryTarget(await readFile(expectedPath)), target); + } +} + +async function main() { const [serverDirectory, ...extra] = process.argv.slice(2); assert.ok(serverDirectory && extra.length === 0, 'Usage: pi-public-install-probe.mjs INSTALLED_SERVER_ROOT'); @@ -25,7 +59,8 @@ assert.equal(QUALIFIED_ACPX_PROFILES.pi.commandDigest, 'sha256:e92078bee3c23bec4 assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'qualificationModel'), false); assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'reportedModelId'), false); const daemon = resolvePaperclipRunnerBinary(); -assert.equal(await realpath(daemon), join(server, 'dist/vendor/paperclip-runner/bin/paperclip-runnerd')); +const { runnerBinaryTarget } = await import(pathToFileURL(join(server, 'dist/vendor/paperclip-runner/live/runner-binary.js'))); +await verifyPiPackagedDaemon(server, daemon, `${process.platform}-${process.arch}`, runnerBinaryTarget); const root = await mkdtemp(join(tmpdir(), 'pi-public-install-probe-')); const evidence = []; const started = performance.now(); @@ -69,3 +104,5 @@ try { try { await bundle?.transport.close(); } finally { clearTimeout(watchdog); await rm(root, { recursive: true, force: true }); } } +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); diff --git a/scripts/verify-grok-npm-install.mjs b/scripts/verify-grok-npm-install.mjs index b88e3215de7..51ac0110fa2 100644 --- a/scripts/verify-grok-npm-install.mjs +++ b/scripts/verify-grok-npm-install.mjs @@ -12,7 +12,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, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from './grok-public-install-sandbox.mjs'; +import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, 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'; @@ -39,7 +39,7 @@ async function verifyMacConsumer(packageDirectory, sourceRevision, receiptPath) // command can hide missing installed dependencies. const env = { PATH: `${tools}:/usr/bin:/bin:/usr/sbin:/sbin`, HOME: home, TMPDIR: join(root, 'tmp'), npm_config_cache: join(root, 'cache'), npm_config_audit: 'false', npm_config_fund: 'false' }; - const run = (command, args, cwd = consumer) => execFileSync(command, args, { cwd, env, + const run = (command, args, cwd = consumer, runEnv = env) => execFileSync(command, args, { cwd, env: runEnv, timeout: 10 * 60_000, maxBuffer: 32 * 1024 * 1024, encoding: 'utf8' }); const tarballs = manifest.archives.map(entry => { assert.match(entry.file, /^[A-Za-z0-9._-]+\.tgz$/); assert.match(entry.sha256, /^[a-f0-9]{64}$/); @@ -71,7 +71,9 @@ async function verifyMacConsumer(packageDirectory, sourceRevision, receiptPath) run(process.execPath, ['--input-type=module', '-e', networkProbe, 'allowed']); run('/usr/bin/sandbox-exec', ['-f', profile, process.execPath, '--input-type=module', '-e', networkProbe, 'denied']); } finally { await new Promise(resolve => listener.close(resolve)); } - run('/usr/bin/sandbox-exec', ['-f', profile, process.execPath, npmCli, 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts', '--foreground-scripts']); + const nodeHeaders = macNodeHeadersDirectory(process.execPath, process.versions.node); + run('/usr/bin/sandbox-exec', ['-f', profile, process.execPath, npmCli, 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts', '--foreground-scripts'], + consumer, { ...env, npm_config_nodedir: nodeHeaders }); assert.equal(readFileSync(sentinel, 'utf8'), 'ok'); assert.equal(readFileSync(join(consumer, 'package-lock.json'), 'utf8'), lock); for (const entry of manifest.archives) { @@ -86,7 +88,8 @@ async function verifyMacConsumer(packageDirectory, sourceRevision, receiptPath) const startup = JSON.parse(run(process.execPath, ['--import', 'tsx', startupFile], join(repo, 'server')).trim()); const receipt = { schema: 'paperclip.runner.mac-public-install.v1', sourceRevision, platform: process.platform, architecture: process.arch, cleanNpmInstall: true, packageCount: manifest.archives.length, codexPackedPayloadsOmitted: true, - consumerLockPreserved: true, lifecycleSentinelVerified: true, lifecycleNetwork: 'sandbox-exec denied (loopback negative control)', + consumerLockPreserved: true, lifecycleSentinelVerified: true, lifecycleNodeHeadersVerified: true, lifecycleNodeVersion: process.versions.node, + lifecycleNetwork: 'sandbox-exec denied (loopback negative control)', ...daemon, ...codex, startup, providerCalls: 0 }; writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + '\n'); console.log(JSON.stringify(receipt)); diff --git a/ui/src/pages/CompanyImport.test.tsx b/ui/src/pages/CompanyImport.test.tsx index 98ae8845ccb..7a222bf7fdb 100644 --- a/ui/src/pages/CompanyImport.test.tsx +++ b/ui/src/pages/CompanyImport.test.tsx @@ -4,7 +4,7 @@ import { webcrypto } from "node:crypto"; import type { ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { CompanyPortabilityImportResult, CompanyPortabilityPreviewResult } from "@paperclipai/shared"; +import type { AgentRunnerChoice, CompanyPortabilityImportResult, CompanyPortabilityPreviewResult } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ApiError } from "../api/client"; import type { CompanyImportJobAccepted } from "../api/companies"; @@ -53,6 +53,7 @@ const mockSidebarPreferencesApi = vi.hoisted(() => ({ })); const mockPushToast = vi.hoisted(() => vi.fn()); const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn()); +const mockOptionalCompany = vi.hoisted(() => vi.fn()); const mockReadZipArchive = vi.hoisted(() => vi.fn()); vi.mock("../api/companies", () => ({ @@ -104,14 +105,16 @@ vi.mock("../context/CompanyContext", () => ({ selectedCompany: { id: "company-1", name: "Paperclip" }, setSelectedCompanyId: mockSetSelectedCompanyId, }), - useOptionalCompany: () => null, + useOptionalCompany: mockOptionalCompany, })); vi.mock("../context/ToastContext", () => ({ useToastActions: () => ({ pushToast: mockPushToast }), + useOptionalToastActions: () => ({ pushToast: mockPushToast }), })); vi.mock("@/components/ui/tooltip", () => ({ + TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}, Tooltip: ({ children }: { children: ReactNode }) => <>{children}, TooltipContent: ({ children }: { children: ReactNode }) => {children}, TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, @@ -276,6 +279,7 @@ describe("CompanyImport", () => { container = document.createElement("div"); document.body.appendChild(container); mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } }); + mockOptionalCompany.mockReturnValue(null); mockAgentsApi.list.mockResolvedValue([]); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: false }); mockInstanceSettingsApi.getGeneral.mockResolvedValue({}); @@ -1071,7 +1075,7 @@ describe("CompanyImport", () => { function lastImportMeta() { const call = mockCompaniesApi.importBundleAsync.mock.calls.at(-1); expect(call).toBeTruthy(); - return call![0] as { adapterOverrides?: Record }> }; + return call![0] as { adapterOverrides?: Record }> }; } it("keeps manifest adapters and sends no overrides when the user touches nothing", async () => { @@ -1137,7 +1141,7 @@ describe("CompanyImport", () => { } }); - it("keeps imported Dot agents and falls back other Runner providers under Dot-only opt-in", async () => { + it("keeps imported Dot and native Codex agents under Dot-only opt-in", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true }); mockAdaptersApi.list.mockResolvedValue([ { type: "claude_local", disabled: false }, @@ -1153,7 +1157,7 @@ describe("CompanyImport", () => { await enterGithubUrl(); await clickButton((text) => text === "Preview import"); - expect(findAdapterSelects().map((select) => select.value)).toEqual(["openai_dot", "claude_local"]); + expect(findAdapterSelects().map((select) => select.value)).toEqual(["openai_dot", "codex_local"]); for (const select of findAdapterSelects()) { const choices = Array.from(select.options).map((option) => option.value); expect(choices).toContain("openai_dot"); @@ -1161,7 +1165,117 @@ describe("CompanyImport", () => { } await clickButton((text) => text.startsWith("Import 3 file")); await settle(); - expect(lastImportMeta().adapterOverrides).toEqual({ researcher: { adapterType: "claude_local" } }); + expect(lastImportMeta().adapterOverrides).toBeUndefined(); + }); + + it.each([undefined, "codex"])("preserves an exported native Codex package with provider %s when experiments are disabled", async provider => { + mockAdaptersApi.list.mockResolvedValue([ + { type: "claude_local", disabled: false }, + { type: "codex_local", disabled: false }, + { type: "paperclip_runner", disabled: false }, + ]); + const preview = buildMixedAdapterPreviewResult(); + preview.manifest.agents[1]!.adapterType = "paperclip_runner"; + preview.manifest.agents[1]!.runner = "paperclip"; + preview.manifest.agents[1]!.adapterConfig = { ...(provider ? { provider } : {}), model: "gpt-5.6-sol", reasoningEffort: "high" }; + mockCompaniesApi.importPreview.mockResolvedValue(preview); + await renderPage(); + await enterGithubUrl(); + await clickButton(text => text === "Preview import"); + expect(findAdapterSelects().map(select => select.value)).toEqual(["claude_local", "codex_local"]); + expect(container.textContent).not.toContain("will use Claude Code"); + await clickButton(text => text.startsWith("Import 3 file")); + await settle(); + expect(lastImportMeta().adapterOverrides).toBeUndefined(); + }); + + it.each([ + { provider: "opencode", model: "provider/model" }, + { provider: "acpx", acpxAgent: "codex" }, + { provider: "unknown-provider" }, + ])("keeps gated native $provider identity instead of falling back to another harness", async adapterConfig => { + mockAdaptersApi.list.mockResolvedValue([ + { type: "claude_local", disabled: false }, + { type: "paperclip_runner", disabled: false }, + ]); + const preview = buildMixedAdapterPreviewResult(); + preview.manifest.agents[0]!.adapterType = "paperclip_runner"; + preview.manifest.agents[0]!.adapterConfig = adapterConfig; + mockCompaniesApi.importPreview.mockResolvedValue(preview); + await renderPage(); + await enterGithubUrl(); + await clickButton(text => text === "Preview import"); + expect(findAdapterSelects()[0]!.value).toBe("paperclip_runner"); + expect(container.textContent).toContain("requires its experimental feature to be enabled"); + expect(container.textContent).not.toContain("source adapter paperclip_runner is not installed here"); + await clickButton(text => text.startsWith("Import 3 file")); + await settle(); + expect(lastImportMeta().adapterOverrides?.coder).toBeUndefined(); + }); + + 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 }) => { + mockOptionalCompany.mockReturnValue({ selectedCompanyId: "company-1" }); + mockAdaptersApi.list.mockResolvedValue([ + { type: "claude_local", disabled: false }, + { type: "codex_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" }, + { type: "paperclip_runner", disabled: false }, + ]); + const preview = buildPreviewResult(); + preview.manifest.agents[0]!.adapterType = sourceType; + preview.manifest.agents[0]!.runner = sourceRunner; + preview.manifest.agents[0]!.adapterConfig = { + ...(sourceType === "paperclip_runner" ? { provider: "codex" } : {}), + model: "gpt-5.6-sol", modelReasoningEffort: "high", env: { CODEX_HOME: "/saved/account" }, + }; + mockCompaniesApi.importPreview.mockResolvedValue(preview); + await renderPage(); + await enterGithubUrl(); + await clickButton(text => text === "Preview import"); + await clickButton(text => text.includes("configure adapter")); + await clickButton(text => text.includes("Advanced")); + const select = container.querySelector('[aria-label="Runner"]'); + expect(select).toBeTruthy(); + await act(async () => select!.click()); + const option = document.querySelector(`[role="option"][data-value="${runner}"]`); + expect(option).toBeTruthy(); + expect(option!.disabled).toBe(false); + await act(async () => option!.click()); + await flushReact(); + await clickButton(text => text.startsWith("Import 3 file")); + await settle(); + expect(lastImportMeta().adapterOverrides).toEqual({ coder: { adapterType: "codex_local", runner } }); + }); + + it("does not reinterpret an unknown native provider as Codex when its configuration is edited", async () => { + mockAdaptersApi.list.mockResolvedValue([{ type: "paperclip_runner", disabled: false }]); + const preview = buildPreviewResult(); + preview.manifest.agents[0]!.adapterType = "paperclip_runner"; + preview.manifest.agents[0]!.adapterConfig = { provider: "unknown-provider", customSetting: "keep" }; + mockCompaniesApi.importPreview.mockResolvedValue(preview); + await renderPage(); + await enterGithubUrl(); + await clickButton(text => text === "Preview import"); + await clickButton(text => text.includes("configure adapter")); + await clickButton(text => text.includes("Advanced")); + const input = container.querySelector('input[placeholder="e.g. --verbose, --foo=bar"]'); + expect(input).toBeTruthy(); + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + setter.call(input!, "--verbose"); + input!.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flushReact(); + await act(async () => { + input!.dispatchEvent(new FocusEvent("focusout", { bubbles: true })); + }); + await clickButton(text => text.startsWith("Import 3 file")); + await settle(); + expect(mockCompaniesApi.importBundleAsync).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Unknown Paperclip Runner provider unknown-provider"); }); it("requires billing acknowledgement and serializes the standalone Dot import choice as Runner", async () => { diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index f42abd0afdb..640f4be17a9 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { + AgentRunnerChoice, CompanyPortabilityCollisionStrategy, CompanyPortabilityFileEntry, CompanyPortabilityImportResult, @@ -8,6 +9,7 @@ import type { CompanyPortabilitySource, CompanyPortabilityAdapterOverride, } from "@paperclipai/shared"; +import { agentHarnessType, agentRunner } from "@paperclipai/shared"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToastActions } from "../context/ToastContext"; @@ -40,7 +42,7 @@ 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 type { CreateConfigValues } from "@paperclipai/adapter-utils"; +import { isPaperclipRunnerProvider, type CreateConfigValues } from "@paperclipai/adapter-utils"; import { type FileTreeNode, type FrontmatterData, @@ -557,7 +559,6 @@ function ConflictResolutionList({ // ── Adapter type options for import ─────────────────────────────────── -const FALLBACK_IMPORT_ADAPTER_TYPE = "claude_local"; const IMPORT_ADAPTER_OPTIONS: { value: string; label: string }[] = [ ...listUIAdapters().map((adapter) => ({ value: adapter.type, @@ -567,10 +568,23 @@ const IMPORT_ADAPTER_OPTIONS: { value: string; label: string }[] = [ ]; // Dot is a picker choice backed by the shared Runner API adapter. -function importAdapterValues(choice: string, sourceConfig?: Record): CreateConfigValues { +function importAdapterValues( + choice: string, + sourceConfig?: Record, + sourceAdapterType?: string, + runner?: AgentRunnerChoice, +): CreateConfigValues { + const nativeCodex = sourceAdapterType === "paperclip_runner" && choice === "codex_local"; return { ...defaultCreateValues, adapterType: choice === "openai_dot" ? "paperclip_runner" : choice, + ...(runner !== undefined ? { runner } : nativeCodex ? { runner: agentRunner("paperclip_runner") } : {}), + ...(sourceConfig && choice === "codex_local" ? { + model: typeof sourceConfig.model === "string" ? sourceConfig.model : "", + thinkingEffort: String(sourceConfig.reasoningEffort ?? sourceConfig.modelReasoningEffort ?? ""), + } : {}), + ...(sourceAdapterType === "paperclip_runner" && choice !== "openai_dot" + ? { adapterSchemaValues: { ...sourceConfig } } : {}), ...(choice === "openai_dot" ? { adapterSchemaValues: { provider: "openai_dot", @@ -592,6 +606,8 @@ interface AdapterPickerItem { /** Display choice, including the separate Dot provider. */ adapterChoice: string; adapterConfig: Record; + runner?: AgentRunnerChoice; + availabilityWarning?: string; /** * Set when the manifest adapter is not installed on the destination: the * adapter type the agent falls back to unless the user picks another one. @@ -638,6 +654,8 @@ function AdapterPickerList({ const isExpanded = expandedSlugs.has(agent.slug); const vals = configValues[agent.slug] ?? importAdapterValues( selectedType, selectedType === agent.adapterChoice ? agent.adapterConfig : undefined, + selectedType === agent.adapterChoice ? agent.adapterType : undefined, + selectedType === agent.adapterChoice ? agent.runner : undefined, ); return ( @@ -663,6 +681,9 @@ function AdapterPickerList({ {opt.label} ))} + {!adapterOptions.some((option) => option.value === selectedType) && ( + + )}