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) && ( + {agent.adapterChoice === "openai_dot" ? "OpenAI Dot (experimental)" : `${getAdapterLabel(selectedType)} (${agentHarnessType(agent.adapterType, agent.adapterConfig)})`} + )} )} + {agent.availabilityWarning && selectedType === agent.adapterChoice && ( + {agent.availabilityWarning} + )} {isExpanded && ( >({}); const [adapterExpandedSlugs, setAdapterExpandedSlugs] = useState>(new Set()); const [adapterConfigValues, setAdapterConfigValues] = useState>({}); + const [adapterConfigEditedSlugs, setAdapterConfigEditedSlugs] = useState>(new Set()); // Post-import success / activation state const [pauseAutomations, setPauseAutomations] = useState(true); @@ -1038,10 +1063,8 @@ export function CompanyImport() { return ceo?.adapterType ?? "claude_local"; }, [companyAgents]); - // Fetch the destination's installed adapters so imported agents keep their - // manifest adapter whenever it is usable here. Only agents whose manifest - // adapter is missing (or disabled) fall back to the CEO's adapter — with a - // visible per-agent warning, never silently. + // Missing legacy adapters use a warned fallback. Saved native profiles keep + // their identity so availability or an experimental gate cannot change the harness. const { data: installedAdapters } = useQuery({ queryKey: queryKeys.adapters.all, queryFn: () => adaptersApi.list(), @@ -1058,8 +1081,8 @@ export function CompanyImport() { queryFn: () => instanceSettingsApi.getExperimental(), retry: false, }); - // Each Runner provider choice fails closed until its own opt-in and the - // shared adapter's availability are known. Dot does not enable other providers. + // These new provider choices remain experimental. Saved native Codex needs + // only the installed Runner; Dot does not enable other native providers. const runnerInstalled = availableAdapterTypes?.has("paperclip_runner") === true; const nativeRunnerAvailable = runnerInstalled && experimentalSettings?.enableNativeRunner === true; const dotAvailable = runnerInstalled && experimentalSettings?.enableOpenAiDot === true; @@ -1162,6 +1185,7 @@ export function CompanyImport() { setAdapterOverrides({}); setAdapterExpandedSlugs(new Set()); setAdapterConfigValues({}); + setAdapterConfigEditedSlugs(new Set()); // Check all files by default, then uncheck COMPANY.md for existing company const allFiles = new Set(Object.keys(result.files)); @@ -1546,6 +1570,11 @@ export function CompanyImport() { function handleAdapterChange(slug: string, adapterType: string) { resetMutationState(); setAdapterOverrides((prev) => ({ ...prev, [slug]: adapterType })); + setAdapterConfigEditedSlugs(prev => { + const next = new Set(prev); + next.delete(slug); + return next; + }); // Reset config values when adapter type changes setAdapterConfigValues((prev) => { const next = { ...prev }; @@ -1574,10 +1603,15 @@ export function CompanyImport() { resetMutationState(); const agent = adapterAgents.find((a) => a.slug === slug); const currentType = agent ? effectiveAdapterType(agent) : adapterOverrides[slug] ?? "claude_local"; + if (Object.keys(patch).some(key => key !== "runner")) { + setAdapterConfigEditedSlugs(prev => new Set([...prev, slug])); + } setAdapterConfigValues((prev) => ({ ...prev, [slug]: { ...(prev[slug] ?? importAdapterValues( currentType, currentType === agent?.adapterChoice ? agent.adapterConfig : undefined, + currentType === agent?.adapterChoice ? agent.adapterType : undefined, + currentType === agent?.adapterChoice ? agent.runner : undefined, )), ...patch }, })); } @@ -1622,24 +1656,23 @@ export function CompanyImport() { } } - // Build the list of agents for adapter picking. An agent whose manifest - // adapter is not installed on the destination gets a warned fallback to the - // CEO's adapter; while availability is unknown the manifest adapter stands. + // Recover Codex's harness for display while retaining its saved native profile. const adapterAgents = useMemo(() => { if (!importPreview) return []; return importPreview.manifest.agents.map((a) => { + const nativeCodex = a.adapterType === "paperclip_runner" + && (a.adapterConfig?.provider === "codex" || a.adapterConfig?.provider === undefined); const adapterChoice = a.adapterType === "paperclip_runner" && a.adapterConfig?.provider === "openai_dot" - ? "openai_dot" : a.adapterType; + ? "openai_dot" : nativeCodex ? agentHarnessType(a.adapterType, a.adapterConfig) : a.adapterType; let fallbackAdapterType: string | null = null; - if (a.adapterType === "paperclip_runner" && !(adapterChoice === "openai_dot" ? dotAvailable : nativeRunnerAvailable)) { - const firstEnabledLegacyAdapter = availableAdapterTypes - ? [...availableAdapterTypes].find((type) => type !== "paperclip_runner") ?? null - : null; - fallbackAdapterType = - ceoAdapterType !== "paperclip_runner" && - (!availableAdapterTypes || availableAdapterTypes.has(ceoAdapterType)) - ? ceoAdapterType - : firstEnabledLegacyAdapter ?? FALLBACK_IMPORT_ADAPTER_TYPE; + let availabilityWarning: string | undefined; + if (a.adapterType === "paperclip_runner") { + const available = nativeCodex ? runnerInstalled : adapterChoice === "openai_dot" ? dotAvailable : nativeRunnerAvailable; + if (!available && (availableAdapterTypes || !nativeCodex)) { + availabilityWarning = nativeCodex + ? "Paperclip Runner is unavailable here. Choose a runner or another harness explicitly." + : `Paperclip Runner provider ${String(a.adapterConfig?.provider)} requires its experimental feature to be enabled. This import keeps its saved harness until you choose another one.`; + } } else if (availableAdapterTypes && !availableAdapterTypes.has(a.adapterType)) { // The fallback must itself be installed: the CEO's adapter when it is, // else any installed adapter, else null so the manifest adapter stands @@ -1658,10 +1691,12 @@ export function CompanyImport() { adapterType: a.adapterType, adapterChoice, adapterConfig: a.adapterConfig ?? {}, + runner: a.runner, + availabilityWarning, fallbackAdapterType, }; }); - }, [importPreview, availableAdapterTypes, ceoAdapterType, nativeRunnerAvailable, dotAvailable]); + }, [importPreview, availableAdapterTypes, ceoAdapterType, runnerInstalled, nativeRunnerAvailable, dotAvailable]); /** The adapter type an imported agent will actually use: an explicit user pick, else the availability fallback, else the manifest adapter. */ function effectiveAdapterType(agent: AdapterPickerItem): string { @@ -1680,11 +1715,16 @@ export function CompanyImport() { if (selectedType === agent.adapterChoice && !configVals) continue; const apiType = selectedType === "openai_dot" ? "paperclip_runner" : selectedType; const override: CompanyPortabilityAdapterOverride = { adapterType: apiType }; - if (configVals) { + if (configVals?.runner !== undefined) override.runner = configVals.runner; + if (configVals && (selectedType !== agent.adapterChoice || adapterConfigEditedSlugs.has(agent.slug))) { + const provider = configVals.adapterSchemaValues?.provider; + if (configVals.adapterType === "paperclip_runner" && provider !== undefined && !isPaperclipRunnerProvider(provider)) { + throw new Error(`Unknown Paperclip Runner provider ${String(provider)}. Keep the package configuration or explicitly choose another harness.`); + } if (selectedType === "openai_dot" && configVals.adapterSchemaValues?.allowUnmeteredProvider !== true) { throw new Error("OpenAI Dot requires acknowledgement of external billing. Configure the Dot adapter and enable Allow externally billed provider."); } - override.adapterConfig = getUIAdapter(apiType).buildAdapterConfig(configVals); + override.adapterConfig = getUIAdapter(configVals.adapterType).buildAdapterConfig(configVals); } overrides[agent.slug] = override; }
{agent.availabilityWarning}