Merge latest Codex platform qualification into Claude default

Preserve Codex GNU libc eligibility and Linux ARM64 behavior; keep Claude automatic selection limited to Apple Silicon.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-12 11:48:10 -05:00
commit 966b3d5bc2
29 files changed
+836 -73

No files matched your search

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