diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index 9466e4fa158..70743130406 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -93,6 +93,42 @@ 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. +The [host treatment](https://github.com/paperclipai/paperclip/actions/runs/38276826878) +at tooling `123a18837478d8cd0e2aff23f7e7aa86c9107de3` also fails native startup. +The actual ARM host reaches the installed daemon and Codex, then bubblewrap +fails loopback `RTM_NEWADDR` with `Operation not permitted`. No successful +native session or after-start continuity is claimed. Docker/host installation +identity passed. Exact upstream Codex 0.160 source places namespace creation +before its own seccomp filter and always restricts networking for filesystem +helpers. A Paperclip network-mode change would not solve this. Prepare one +temporary same-policy direct Codex control plus read-only host namespace facts +to distinguish host enforcement from native mediation. Do not alter kernel, +Docker, or product permission policy. Receipts: +`/private/tmp/ci-final-arm-host-diagnostic-123-receipt.json` and +`/private/tmp/codex-default-arm-sandbox-source-diagnosis.json`. + +Fresh `123a188` review is 5/5 with one payload-continuity finding. The existing +consumer now hashes the actual official Codex native executable separately +from its launcher, checks containment/header, and compares both across Docker, +host, and before/after. Twenty-one controls pass, including native-only mutation +and an older supported package layout. Receipt: +`/private/tmp/pr2-native-codex-payload-and-control-preparation-receipt.json`. + +Ordinary `123a188` CI found two test gaps. Nine new login fixture controls were +shadowed by Vitest's global module bridge. The existing bounded plain-Node +resolver seam now proves private package selection and containment before only +fake login; all 27 login cases and typecheck pass. A repository test reloaded one +millisecond after Save, before any update request reached the server. It now +awaits the real saved-value render, then retains the persistence assertion after +reload. No product repository code changes. Receipts: +`/private/tmp/codex-default-login-resolver-isolation-receipt.json` and +`/private/tmp/ci-final-123a188-repository-e2e-analysis.json`. + +Next action: freeze these review/test fixes, run the bounded direct ARM control, +and require fresh Linux CI. Keep ARM startup sign-off unresolved until proven +on a compatible target; package installation and direct execution alone do not +qualify the native runner. All Docker/native builds remain remote. + ## Current addition: Linux ARM64 The user now requires ARM Linux support as well. Treat ARM Linux as ARM64 diff --git a/scripts/__tests__/grok-public-install-sandbox.test.mjs b/scripts/__tests__/grok-public-install-sandbox.test.mjs index 193dd4d5b37..5b731c17a3f 100644 --- a/scripts/__tests__/grok-public-install-sandbox.test.mjs +++ b/scripts/__tests__/grok-public-install-sandbox.test.mjs @@ -15,7 +15,7 @@ const paths = { assets: '/private/staging/assets', consumer: '/private/staging/c 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) }; + const before = { lockSha256: 'a'.repeat(64), runnerSha256: 'b'.repeat(64), codexCommandSha256: 'c'.repeat(64), codexNativeExecutableSha256: 'e'.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)); @@ -252,6 +252,14 @@ test('the installed Codex probe exercises the public export and rejects incomple const platformPath = `node_modules/@openai/codex-${target}`; const platform = join(consumer, platformPath); mkdirSync(platform, { recursive: true }); const nativeVendor = join(platform, 'vendor'); mkdirSync(nativeVendor); + const triple = { 'linux-x64': 'x86_64-unknown-linux-musl', 'linux-arm64': 'aarch64-unknown-linux-musl', + 'darwin-x64': 'x86_64-apple-darwin', 'darwin-arm64': 'aarch64-apple-darwin', 'win32-x64': 'x86_64-pc-windows-msvc', 'win32-arm64': 'aarch64-pc-windows-msvc' }[target]; + const nativeBytes = Buffer.alloc(64); + if (process.platform === 'darwin') { nativeBytes.writeUInt32LE(0xfeedfacf); nativeBytes.writeUInt32LE(process.arch === 'arm64' ? 0x0100000c : 0x01000007, 4); } + else if (process.platform === 'win32') { nativeBytes.set([0x4d, 0x5a]); nativeBytes.writeUInt32LE(40, 60); nativeBytes.writeUInt32LE(0x00004550, 40); nativeBytes.writeUInt16LE(process.arch === 'arm64' ? 0xaa64 : 0x8664, 44); } + else { nativeBytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); nativeBytes.writeUInt16LE(process.arch === 'arm64' ? 183 : 62, 18); } + const nativeFile = join(nativeVendor, triple, 'bin', process.platform === 'win32' ? 'codex.exe' : 'codex'); + mkdirSync(join(nativeVendor, triple, 'bin'), { recursive: true }); writeFileSync(nativeFile, nativeBytes, { mode: 0o755 }); const manifest = { name: '@openai/codex', version: `0.160.0-${target}`, os: [process.platform], cpu: [process.arch] }; writeFileSync(join(platform, 'package.json'), JSON.stringify(manifest)); const entry = { name: manifest.name, version: manifest.version, @@ -260,6 +268,8 @@ test('the installed Codex probe exercises the public export and rejects incomple writeFileSync(join(consumer, 'package-lock.json'), JSON.stringify(lock)); const server = join(consumer, 'node_modules/@paperclipai/server'); const vendor = join(server, 'dist/vendor/paperclip-runner'), drivers = join(vendor, 'drivers/acpx'); mkdirSync(drivers, { recursive: true }); + mkdirSync(join(vendor, 'live')); + writeFileSync(join(vendor, 'live/runner-binary.js'), `export const runnerBinaryTarget = ${runnerBinaryTarget.toString()};`); writeFileSync(join(server, 'package.json'), JSON.stringify({ name: '@paperclipai/server', type: 'module' })); const integrity = join(drivers, 'installation-integrity.js'); const security = join(vendor, 'drivers/codex/codex-security-config.js'); mkdirSync(join(vendor, 'drivers/codex')); @@ -285,6 +295,23 @@ test('the installed Codex probe exercises the public export and rejects incomple 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).codexNativeExecutableSha256, createHash('sha256').update(nativeBytes).digest('hex')); + const original = JSON.parse(result.stdout); + nativeBytes[63] = 1; writeFileSync(nativeFile, nativeBytes); + result = run(); assert.equal(result.status, 0, result.stderr); + const changed = JSON.parse(result.stdout); + assert.equal(changed.codexCommandSha256, original.codexCommandSha256, 'native mutation does not change the npm launcher'); + assert.notEqual(changed.codexNativeExecutableSha256, original.codexNativeExecutableSha256); + assert.throws(() => assertInstalledNativeConsumerContinuity({ lockSha256: 'a'.repeat(64), runnerSha256: 'b'.repeat(64), ...original }, + { lockSha256: 'a'.repeat(64), runnerSha256: 'b'.repeat(64), ...changed }), /codexNativeExecutableSha256/); + nativeBytes[63] = 0; writeFileSync(nativeFile, nativeBytes); + rmSync(nativeFile); + result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /one native executable/); + const foreignNative = join(root, 'foreign-codex'); writeFileSync(foreignNative, nativeBytes, { mode: 0o755 }); + symlinkSync(foreignNative, nativeFile); + result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /bounded regular file in its selected platform package/); + rmSync(nativeFile); writeFileSync(nativeFile, nativeBytes, { mode: 0o755 }); + result = run(); assert.equal(result.status, 0, result.stderr); assert.equal(JSON.parse(result.stdout).codexQualifiedInstallationVerified, true); assert.equal(JSON.parse(result.stdout).codexCommandLeaseVerified, true); assert.equal(JSON.parse(result.stdout).codexNativeSandboxResourcesVerified, true); @@ -308,6 +335,8 @@ test('the installed Codex probe exercises the public export and rejects incomple // Native Codex uses protocol compatibility, not the ACPX version allowlist. // Keep exact official-npm host provenance and the selected resource boundary. mkdirSync(join(legacy, 'vendor')); + const legacyNative = join(legacy, 'vendor', triple, 'codex', process.platform === 'win32' ? 'codex.exe' : 'codex'); + mkdirSync(join(legacy, 'vendor', triple, 'codex'), { recursive: true }); writeFileSync(legacyNative, nativeBytes, { mode: 0o755 }); writeFileSync(legacyCommand, '#!/bin/sh\nprintf "codex-cli 0.156.1\\n"\n', { mode: 0o755 }); writeFileSync(security, securitySource.replaceAll(JSON.stringify(nativeVendor), JSON.stringify(join(legacy, 'vendor')))); writeFileSync(probe, installedCodexProbeSource(index, consumer, undefined, { nativeCodex: true })); diff --git a/scripts/grok-public-install-sandbox.mjs b/scripts/grok-public-install-sandbox.mjs index afe596aee98..3e674c1017b 100644 --- a/scripts/grok-public-install-sandbox.mjs +++ b/scripts/grok-public-install-sandbox.mjs @@ -160,6 +160,21 @@ export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersi const platformRoot = realpathSync(dirname(selectedPlatformManifest)); const nativeVendor = realpathSync(join(platformRoot, 'vendor')); assert.ok(contained(nativeVendor) && nativeVendor.startsWith(platformRoot + sep), 'Codex vendor resources must remain inside their installed platform package'); + const triple = { 'linux-x64': 'x86_64-unknown-linux-musl', 'linux-arm64': 'aarch64-unknown-linux-musl', + 'darwin-x64': 'x86_64-apple-darwin', 'darwin-arm64': 'aarch64-apple-darwin', + 'win32-x64': 'x86_64-pc-windows-msvc', 'win32-arm64': 'aarch64-pc-windows-msvc' }[process.platform + '-' + process.arch]; + assert.ok(triple, 'Codex native payload requires a known host target'); + const nativeName = process.platform === 'win32' ? 'codex.exe' : 'codex'; + const nativeCandidates = ['bin', 'codex'].map(directory => join(nativeVendor, triple, directory, nativeName)).filter(existsSync); + assert.equal(nativeCandidates.length, 1, 'Selected official Codex platform must contain one native executable'); + const nativeExecutable = realpathSync(nativeCandidates[0]); + const nativeStat = lstatSync(nativeCandidates[0]); + assert.ok(nativeExecutable.startsWith(nativeVendor + sep) && nativeStat.isFile() && !nativeStat.isSymbolicLink() + && nativeStat.size >= 20 && nativeStat.size <= 512 * 1024 * 1024, 'Codex native executable must be a bounded regular file in its selected platform package'); + accessSync(nativeExecutable, constants.X_OK); + const nativeBytes = readFileSync(nativeExecutable); + const { runnerBinaryTarget } = await import(pathToFileURL(join(vendor, 'live/runner-binary.js')).href); + assert.equal(runnerBinaryTarget(nativeBytes), process.platform + '-' + process.arch, 'Codex native executable must match the actual host architecture'); const sandboxSource = { HOME: '/private-provider-home', PATH: '/usr/local/bin:/usr/bin:/bin' }; const readRoots = codexExecutableReadOnlyRoots(sandboxSource, command); assert.ok(readRoots.includes(nativeVendor), 'Selected native Codex vendor directory must be readable inside its sandbox'); @@ -176,6 +191,8 @@ export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersi await lease.close();`} console.log(JSON.stringify({ pinnedCodexCommandVerified: true, installedCodexVersion: version, codexCommandSha256: createHash('sha256').update(readFileSync(command)).digest('hex'), + codexNativeExecutableSha256: createHash('sha256').update(nativeBytes).digest('hex'), + codexNativeExecutablePath: relative(root, nativeExecutable).split(sep).join('/'), ...provenance, codexSelectedPlatformPath: selectedPlatform.path, codexSelectedPlatformVersion: selectedMetadata.version, codexNativeSandboxResourcesVerified: true, ${nativeCodex ? '' : `pinnedCodexVersion: ${JSON.stringify(expectedVersion)}, codexQualifiedInstallationVerified: true, codexCommandLeaseVerified: true,`} @@ -185,7 +202,7 @@ 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']) { + for (const field of ['lockSha256', 'runnerSha256', 'codexCommandSha256', 'codexNativeExecutableSha256']) { if (!/^[a-f0-9]{64}$/.test(before[field]) || before[field] !== after[field]) { throw new Error(`Installed native host startup changed ${field}`); } diff --git a/scripts/verify-grok-npm-install.mjs b/scripts/verify-grok-npm-install.mjs index 26d7f588f1c..6bd8754baf4 100644 --- a/scripts/verify-grok-npm-install.mjs +++ b/scripts/verify-grok-npm-install.mjs @@ -166,13 +166,16 @@ async function verifyLinuxArmConsumer(packageDirectory, sourceRevision, receiptP 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 }; + const beforeCodex = hostCodex(); + const before = { lockSha256: digest(lock), runnerSha256: hostDaemon().sha256.slice(7), codexCommandSha256: beforeCodex.codexCommandSha256, codexNativeExecutableSha256: beforeCodex.codexNativeExecutableSha256 }; 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'); + assert.equal(before.codexNativeExecutableSha256, codex.codexNativeExecutableSha256, 'Docker and host must select the same official native Codex payload 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 }; + const afterCodex = hostCodex(); + const after = { lockSha256: digest(readFileSync(join(consumer, 'package-lock.json'))), runnerSha256: hostDaemon().sha256.slice(7), codexCommandSha256: afterCodex.codexCommandSha256, codexNativeExecutableSha256: afterCodex.codexNativeExecutableSha256 }; assertInstalledNativeConsumerContinuity(before, after); assert.equal(startup.runnerSha256, before.runnerSha256); assert.equal(startup.codexSha256, before.codexCommandSha256); diff --git a/server/src/services/local-ai-browser-login.test.ts b/server/src/services/local-ai-browser-login.test.ts index eb4ad50df00..505b0bee18c 100644 --- a/server/src/services/local-ai-browser-login.test.ts +++ b/server/src/services/local-ai-browser-login.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdtemp, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { createRequire } from "node:module"; import { startLocalBrowserLogin } from "./local-ai-browser-login.js"; import { resolveCodexCommand } from "../vendor/paperclip-runner/index.js"; @@ -42,6 +44,24 @@ function targetPlatform(platform: string, architecture: string, glibc = true, gl if (platform === "linux") vi.spyOn(process.report, "getReport").mockReturnValue({ header: glibc ? { glibcVersionRuntime } : {} } as never); } +function resolveFixtureCodexCommand(home: string): string { + // Vitest's global dependency paths can supply a foreign bridge before the + // fixture wrapper. Reuse the existing Codex security test's closed Node seam. + const source = ` + import { resolveCodexCommand } from ${JSON.stringify(new URL("../../../packages/paperclip-runner/src/drivers/codex/codex-command.ts", import.meta.url).href)}; + try { console.log(JSON.stringify({ command: resolveCodexCommand(${JSON.stringify(path.join(root!, "issuer.js"))}, { PATH: "/missing-codex" }, ${JSON.stringify(home)}) })); } + catch (error) { console.log(JSON.stringify({ error: error.message })); } + `; + const result = JSON.parse(execFileSync(process.execPath, [ + "--no-global-search-paths", "--import", createRequire(import.meta.url).resolve("tsx"), + "--input-type=module", "--eval", source, + ], { cwd: root, env: { PATH: "/usr/bin:/bin", NODE_PATH: "", NODE_OPTIONS: "" }, + encoding: "utf8", timeout: 10000, maxBuffer: 16384 })); + if (typeof result.error === "string") throw new Error(result.error); + if (typeof result.command !== "string") throw new Error("Fixture resolver returned no executable"); + return result.command; +} + describe.skipIf(process.platform === "win32")("local browser subscription login", () => { 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"; @@ -84,15 +104,18 @@ describe.skipIf(process.platform === "win32")("local browser subscription login" 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)); + const command = resolveFixtureCodexCommand(home); + expect(command).toBe(await realpath(path.join(dependency, "bin/codex"))); + vi.mocked(resolveCodexCommand).mockReturnValue(command); 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(); + try { + 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(); + } finally { login.abort(); } }); it.each(["2.38", "musl", "unknown"])("reports a real owned-dependency containment failure on Linux/%s without falling back to another CLI", async libc => { @@ -101,15 +124,20 @@ describe.skipIf(process.platform === "win32")("local browser subscription login" 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)); + let resolutionError: unknown; + try { resolveFixtureCodexCommand(home); } catch (error) { resolutionError = error; } + expect(resolutionError).toBeInstanceOf(Error); + expect((resolutionError as Error).message).toContain("executable escapes its package"); + vi.mocked(resolveCodexCommand).mockImplementation(() => { throw resolutionError; }); 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(); + try { + 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(); + } finally { login.abort(); } }); 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) => { diff --git a/tests/e2e/project-repositories.spec.ts b/tests/e2e/project-repositories.spec.ts index 54b25a52141..e13640dc834 100644 --- a/tests/e2e/project-repositories.spec.ts +++ b/tests/e2e/project-repositories.spec.ts @@ -48,6 +48,7 @@ test("project creation and repository configuration persist on a short mobile vi await section.getByRole("button", { name: "Edit", exact: true }).click(); await section.getByRole("textbox", { name: "Existing repo URL" }).fill("https://git.example/org/updated"); await section.getByRole("button", { name: "Save URL" }).click(); + await expect(section.getByText("https://git.example/org/updated", { exact: true })).toBeVisible(); await page.reload(); await expect(section.getByText("https://git.example/org/updated", { exact: true })).toBeVisible(); await expect(page.getByText("Set the KEY to the env var name", { exact: false })).toHaveCount(0);