From 123a18837478d8cd0e2aff23f7e7aa86c9107de3 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 11:20:36 -0500 Subject: [PATCH] fix: separate Codex login and ARM host qualification Keep daemon ABI admission separate from CLI login and preserve restricted Docker installation checks before native host startup. Co-Authored-By: Paperclip --- doc/plans/2026-10-06-default-agent-runner.md | 31 +++++++++++++ packages/paperclip-runner/README.md | 12 ++++- .../grok-public-install-sandbox.test.mjs | 25 +++++++++- scripts/grok-public-install-sandbox.mjs | 11 +++++ scripts/verify-grok-npm-install.mjs | 33 +++++++++++-- .../services/local-ai-browser-login.test.ts | 46 +++++++++++++++++-- server/src/services/local-ai-browser-login.ts | 8 ++-- 7 files changed, 152 insertions(+), 14 deletions(-) diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index a65abc46178..9466e4fa158 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -62,6 +62,37 @@ 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 diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 4e0ec3654d2..76683bd3cf1 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -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 and uses the native Codex resolver -on supported Linux and macOS targets, including Linux ARM64. +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 @@ -203,6 +203,14 @@ 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 diff --git a/scripts/__tests__/grok-public-install-sandbox.test.mjs b/scripts/__tests__/grok-public-install-sandbox.test.mjs index de8eedd15cf..193dd4d5b37 100644 --- a/scripts/__tests__/grok-public-install-sandbox.test.mjs +++ b/scripts/__tests__/grok-public-install-sandbox.test.mjs @@ -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:/); }); diff --git a/scripts/grok-public-install-sandbox.mjs b/scripts/grok-public-install-sandbox.mjs index 7695f54dd65..afe596aee98 100644 --- a/scripts/grok-public-install-sandbox.mjs +++ b/scripts/grok-public-install-sandbox.mjs @@ -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 ` diff --git a/scripts/verify-grok-npm-install.mjs b/scripts/verify-grok-npm-install.mjs index 5286415ed90..26d7f588f1c 100644 --- a/scripts/verify-grok-npm-install.mjs +++ b/scripts/verify-grok-npm-install.mjs @@ -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)); diff --git a/server/src/services/local-ai-browser-login.test.ts b/server/src/services/local-ai-browser-login.test.ts index 97e294bff4e..eb4ad50df00 100644 --- a/server/src/services/local-ai-browser-login.test.ts +++ b/server/src/services/local-ai-browser-login.test.ts @@ -43,12 +43,13 @@ function targetPlatform(platform: string, architecture: string, glibc = true, gl } describe.skipIf(process.platform === "win32")("local browser subscription login", () => { - it.each(["native", "arm32", "musl", "glibc-2.38", "glibc-3.0"])("uses the selected Codex command with its credential home and ambient HOME/cwd (%s)", async platform => { - const legacy = ["arm32", "musl", "glibc-2.38"].includes(platform); + 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', @@ -66,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>("../../../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>("../../../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"'); diff --git a/server/src/services/local-ai-browser-login.ts b/server/src/services/local-ai-browser-login.ts index f826869c9a7..755a1e41573 100644 --- a/server/src/services/local-ai-browser-login.ts +++ b/server/src/services/local-ai-browser-login.ts @@ -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 { localPaperclipRunnerSupported } from "../lib/agent-runner-platform.js"; 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 = !localPaperclipRunnerSupported(); + // 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);