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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-12 08:55:54 -05:00
1 parent dc7304d7c4
commit d522822a5d
9 files changed
+446 -57

No files matched your search

+57 -9
View File
@@ -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
@@ -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);
}
@@ -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;
@@ -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 }); }
});
+27
View File
@@ -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');
+40 -3
View File
@@ -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();
+7 -4
View File
@@ -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));
+120 -6
View File
@@ -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 }) => <span>{children}</span>,
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<string, { adapterType: string; adapterConfig?: Record<string, unknown> }> };
return call![0] as { adapterOverrides?: Record<string, { adapterType: string; runner?: AgentRunnerChoice; adapterConfig?: Record<string, unknown> }> };
}
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<HTMLButtonElement>('[aria-label="Runner"]');
expect(select).toBeTruthy();
await act(async () => select!.click());
const option = document.querySelector<HTMLButtonElement>(`[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<HTMLInputElement>('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 () => {
+65 -25
View File
@@ -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<string, unknown>): CreateConfigValues {
function importAdapterValues(
choice: string,
sourceConfig?: Record<string, unknown>,
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<string, unknown>;
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}
</option>
))}
{!adapterOptions.some((option) => option.value === selectedType) && (
<option value={selectedType}>{agent.adapterChoice === "openai_dot" ? "OpenAI Dot (experimental)" : `${getAdapterLabel(selectedType)} (${agentHarnessType(agent.adapterType, agent.adapterConfig)})`}</option>
)}
</select>
<button
type="button"
@@ -686,6 +707,9 @@ function AdapterPickerList({
</p>
</div>
)}
{agent.availabilityWarning && selectedType === agent.adapterChoice && (
<p role="alert" className="mx-4 mb-2.5 text-xs text-destructive">{agent.availabilityWarning}</p>
)}
{isExpanded && (
<div className="border-t border-border bg-accent/10 px-4 py-3 space-y-3">
<AgentConfigForm
@@ -917,6 +941,7 @@ export function CompanyImport() {
const [adapterOverrides, setAdapterOverrides] = useState<Record<string, string>>({});
const [adapterExpandedSlugs, setAdapterExpandedSlugs] = useState<Set<string>>(new Set());
const [adapterConfigValues, setAdapterConfigValues] = useState<Record<string, CreateConfigValues>>({});
const [adapterConfigEditedSlugs, setAdapterConfigEditedSlugs] = useState<Set<string>>(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<AdapterPickerItem[]>(() => {
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;
}