From a200495cc7488f4d59da570407e516cf702e5af8 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 12:14:38 -0500 Subject: [PATCH] fix: preserve TOML table semantics during Codex startup trust Co-Authored-By: Paperclip --- doc/plans/2026-10-06-default-agent-runner.md | 46 ++++++++++++++ .../drivers/codex/codex-startup-trust.test.ts | 62 +++++++++++++++++-- .../src/drivers/codex/codex-startup-trust.ts | 13 ++-- 3 files changed, 110 insertions(+), 11 deletions(-) diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index 70743130406..fdc17a36a60 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -129,6 +129,52 @@ 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. +Checkpoint `598e190cb308f4d7f8f43c188c2e7d88c3fc90d1` passes +[ordinary CI](https://github.com/paperclipai/paperclip/actions/runs/38279960223): +both required gates, 54 successful checks/statuses, and four conditional skips. +Fresh review is 5/5; all 15 review threads are resolved. Human code-owner approval +is still absent. The test-drive serves `2026.1005.0+350.git.598e190cb3`; all four +execution configurations and both historical native runs survive restart. +The verified Apple Silicon daemon remains unchanged, `needsBuild: false`, with +no binary override, native compilation, Docker build, or provider turn locally. +Receipts: `/private/tmp/ci-final-598e190-ci-review-receipt.json` and +`/private/tmp/pr2-final-preservation-after.json`. + +The [direct/native ARM diagnostic](https://github.com/paperclipai/paperclip/actions/runs/38280515437) +records GNU libc 2.39, AppArmor enabled with restricted unprivileged user +namespaces, an unconfined current profile, zero effective capabilities, and no +seccomp filter. Native startup still fails at bubblewrap loopback `RTM_NEWADDR`. +The direct control stopped before Codex spawn at a TOML preservation guard, +so it does not establish the sandbox failure's cause. Receipt: +`/private/tmp/ci-final-arm-direct-diagnostic-598-receipt.json`. + +Pure, integrity-bound public `smol-toml` 1.9.1 reproduction identifies that guard +failure: its null-prototype tables differ from the writer's spread-created +ordinary objects. Preserve the parser's tables while changing only the intended +trust field; keep strict comparison, original comments/bytes, private writes, +and malformed-input rejection. No parser pin, semantic bypass, or permission +change. The existing suite passes 22 controls across both table representations; +real 1.9.1 treatment repairs five valid configurations and keeps two invalid +configurations unchanged. Runner/server typechecks pass. Receipt: +`/private/tmp/codex-default-startup-trust-fix-receipt.json`. + +The [four-target performance qualifier](https://github.com/paperclipai/paperclip/actions/runs/38281631416) +uses literal `598e190` product/workflow source. Observed Linux x64/ARM64 and Apple +Silicon builds pass with actual concurrent JavaScript/native preparation. +Cache restore is exercised; observed results are cold misses, not measured +speedups. The cache writer is reachable through the existing verified master +canary push, with exact source and repository guards; no write-gate expansion +is needed. Linux daemon bytes match the measured GNU 2.39 baseline. Remaining +producer/consumer results must be recorded before qualification claims. + +Next action: freeze the narrow trust correction, repeat the bounded direct +control with explicit corrected-tooling provenance, and retain native startup +as mandatory. A private namespace negative control and bounded read-only +AppArmor journal may help distinguish host denial. Do not modify host policy, +relax Codex's sandbox, or substitute direct execution for native qualification. +Require fresh CI/review after the correction. ARM startup and managed staging +remain unresolved; no merge or production recommendation is made. + ## Current addition: Linux ARM64 The user now requires ARM Linux support as well. Treat ARM Linux as ARM64 diff --git a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts index aad5b5c737c..02d377d0451 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts @@ -5,16 +5,66 @@ import { rmSync, writeFileSync, realpathSync, + statSync, symlinkSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { execFileSync } from "node:child_process"; -import { describe, it, expect } from "vitest"; +import { beforeEach, afterEach, describe, it, expect, vi } from "vitest"; import { parse } from "smol-toml"; import { trustCodexStartupRoot } from "./codex-startup-trust.js"; -describe("isolated Codex startup trust", () => { +const parserStyle = vi.hoisted(() => ({ nullPrototypeTables: false })); +vi.mock("smol-toml", async (importOriginal) => { + const actual = await importOriginal(); + const setTablePrototypes = (value: unknown): void => { + if (value === null || typeof value !== "object" || value instanceof Date) return; + if (Array.isArray(value)) { + value.forEach(setTablePrototypes); + return; + } + Object.values(value).forEach(setTablePrototypes); + Object.setPrototypeOf(value, parserStyle.nullPrototypeTables ? null : Object.prototype); + }; + return { + ...actual, + parse: (...args: Parameters) => { + const result = actual.parse(...args); + // Exercise both table representations independently of the installed + // parser version while retaining the real TOML values. + setTablePrototypes(result); + return result; + }, + }; +}); + +describe.each([ + { name: "ordinary tables", nullPrototypeTables: false }, + { name: "null-prototype tables", nullPrototypeTables: true }, +])("isolated Codex startup trust with $name", ({ nullPrototypeTables }) => { + beforeEach(() => { parserStyle.nullPrototypeTables = nullPrototypeTables; }); + afterEach(() => { parserStyle.nullPrototypeTables = false; }); + + it.each(["missing", "empty"])("initializes %s config in an isolated home", (kind) => { + const temp = realpathSync(mkdtempSync(join(tmpdir(), "codex-trust-empty-"))); + try { + const cwd = join(temp, "project"); + const home = join(temp, "home"); + mkdirSync(cwd); + if (kind === "empty") { + mkdirSync(home); + writeFileSync(join(home, "config.toml"), ""); + } + trustCodexStartupRoot(home, cwd); + const path = join(home, "config.toml"); + const updated = readFileSync(path, "utf8"); + expect(parse(updated)).toEqual({ projects: { [cwd]: { trust_level: "trusted" } } }); + expect(statSync(path).mode & 0o777).toBe(0o600); + trustCodexStartupRoot(home, cwd); + expect(readFileSync(path, "utf8")).toBe(updated); + } finally { rmSync(temp, { recursive: true, force: true }); } + }); it.each(["missing", "table", "empty-table", "inline", "empty-inline", "inline-projects"])( "preserves managed comments and unrelated content with %s project configuration", (kind) => { const temp = realpathSync(mkdtempSync(join(tmpdir(), "codex-trust-comments-"))); @@ -60,15 +110,17 @@ describe("isolated Codex startup trust", () => { mkdirSync(home); writeFileSync( join(home, "config.toml"), - 'model = "test"\n[mcp_servers.fixture]\nurl = "http://localhost/example"\n', + 'model = "test"\nstarted = 2026-10-12T12:00:00Z\nlimits = [inf, nan, 9007199254740991]\n[mcp_servers.fixture]\nurl = "http://localhost/example"\n', ); + const original = readFileSync(join(home, "config.toml"), "utf8"); + const unrelated = parse(original); trustCodexStartupRoot(home, cwd); trustCodexStartupRoot(home, cwd); expect(parse(readFileSync(join(home, "config.toml"), "utf8"))).toEqual({ - model: "test", - mcp_servers: { fixture: { url: "http://localhost/example" } }, + ...unrelated, projects: { [cwd]: { trust_level: "trusted" } }, }); + expect(readFileSync(join(home, "config.toml"), "utf8")).toContain(original); expect(readFileSync(join(home, "config.toml"), "utf8")).not.toContain( `[projects."${temp}"]`, ); diff --git a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts index d85f002552f..488b025148d 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts @@ -92,24 +92,25 @@ export function trustCodexStartupRoot(codexHome: string, cwd: string): void { const path = join(codexHome, "config.toml"); const source = existsSync(path) ? readFileSync(path, "utf8") : ""; const config = parse(source); - const projects = config.projects ?? {}; + // Retain the parser's table prototypes so strict candidate comparison also + // works with parsers that return null-prototype TOML tables. + const projects = config.projects ?? parse(""); if ( typeof projects !== "object" || Array.isArray(projects) || projects instanceof Date ) throw new Error("codex_startup_trust_invalid_projects"); - const project = projects[root] ?? {}; + const project = projects[root] ?? parse(""); if ( typeof project !== "object" || Array.isArray(project) || project instanceof Date ) throw new Error("codex_startup_trust_invalid_project"); - config.projects = { - ...projects, - [root]: { ...project, trust_level: "trusted" }, - }; + project.trust_level = "trusted"; + projects[root] = project; + config.projects = projects; const updated = editTrust(source, root, config); if (updated === source) return; const temporary = resolve(codexHome, `config.toml.${randomUUID()}.tmp`);