From 67ca8287e8214faa80399f507c9ab965f62882b9 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 20 Jun 2026 16:17:54 -0700 Subject: [PATCH] fix(codex-local): seed managed auth into isolated homes (#8403) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The `codex_local` adapter isolates local Codex runs by assigning managed `CODEX_HOME` state per company and per agent > - PR #8272 tightened that isolation, but it left a gap: once `CODEX_HOME` became explicit, the adapter treated it like a user-managed override and skipped auth seeding > - That meant newly isolated agents could launch with no usable `auth.json`, hit OpenAI unauthenticated, and fail with `401 Missing bearer` > - Users who had already persisted one of those broken managed homes could remain stranded even after config changes unless Paperclip repaired the home itself > - This pull request teaches Paperclip to seed managed homes correctly, backfill already-stranded managed homes on startup, and reject credential-less managed homes before they reach the provider > - The benefit is that affected managed `codex_local` agents recover automatically after upgrade and restart, without manual `CODEX_HOME` surgery ## Linked Issues or Issue Description - Fixes #497 - Refs #5028 - Related PR: #8272 - Related PR: #8399 ## What Changed - Distinguished Paperclip-managed `CODEX_HOME` paths from genuine external overrides and always seeded auth into managed homes, even when `CODEX_HOME` is explicit in config. - Wrote API-key-backed `auth.json` files for managed homes when `OPENAI_API_KEY` is configured, otherwise symlinked the shared Codex auth for subscription/OAuth flows. - Added a startup reconciliation pass that backfills already-isolated managed homes created by the broken release so upgrade plus restart repairs stranded agents automatically. - Preserved previously resolved API-key auth when the stored `OPENAI_API_KEY` binding is secret-backed and startup cannot resolve the secret value directly. - Hardened the managed-home preflight to require a credential-bearing `auth.json`, not just file presence, and documented the recovery behavior. - Added regression tests covering managed-home seeding, fail-fast behavior, and server-side startup reconciliation. ## Verification ```bash pnpm exec vitest run packages/adapters/codex-local/src/server/codex-home.test.ts packages/adapters/codex-local/src/server/execute.auth.test.ts server/src/__tests__/codex-auth-reconciliation.test.ts server/src/__tests__/codex-local-execute.test.ts server/src/__tests__/server-startup-feedback-export.test.ts pnpm --filter @paperclipai/adapter-codex-local typecheck pnpm --filter @paperclipai/server typecheck pnpm check:tokens pnpm exec vitest run server/src/__tests__/server-startup-feedback-export.test.ts ``` GitHub Actions `PR` workflow is green on latest head `e5b1e08d4`, including policy, typecheck, test shards, build, e2e, serialized server suites, and canary dry run. Greptile Review is green on latest head with 0 comments added. No UI changes. ## Risks - Startup reconciliation now mutates persisted managed Codex homes at boot. Risk is low because it only touches Paperclip-managed company/agent home paths and no-ops when a home already has usable auth. - Genuine external `CODEX_HOME` overrides remain intentionally self-managed, so those users still own repair steps inside their custom home. - Hosts with neither shared Codex auth nor an explicit per-agent API key now fail earlier with a clearer adapter error instead of surfacing a downstream `401`, which changes timing but not capability. ## Model Used - OpenAI Codex, GPT-5-based coding agent in a local Codex session; exact served model ID/context window were not exposed to the session. Tool use and code execution were enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- docs/adapters/codex-local.md | 10 + .../codex-local/src/server/codex-home.test.ts | 310 +++++++++++++++++- .../codex-local/src/server/codex-home.ts | 183 ++++++++++- .../src/server/execute.auth.test.ts | 77 +++++ .../codex-local/src/server/execute.ts | 53 ++- .../adapters/codex-local/src/server/index.ts | 7 + .../codex-auth-reconciliation.test.ts | 206 ++++++++++++ .../src/__tests__/codex-local-execute.test.ts | 16 + .../server-startup-feedback-export.test.ts | 10 + server/src/index.ts | 25 +- .../src/services/codex-auth-reconciliation.ts | 147 +++++++++ server/src/services/index.ts | 4 + 12 files changed, 1034 insertions(+), 14 deletions(-) create mode 100644 packages/adapters/codex-local/src/server/execute.auth.test.ts create mode 100644 server/src/__tests__/codex-auth-reconciliation.test.ts create mode 100644 server/src/services/codex-auth-reconciliation.ts diff --git a/docs/adapters/codex-local.md b/docs/adapters/codex-local.md index 927e7cf3..fa4877b0 100644 --- a/docs/adapters/codex-local.md +++ b/docs/adapters/codex-local.md @@ -45,6 +45,16 @@ Paperclip currently applies that only when the selected model is `gpt-5.4`. On o When Paperclip is running inside a managed worktree instance (`PAPERCLIP_IN_WORKTREE=true`), the adapter instead uses a worktree-isolated `CODEX_HOME` under the Paperclip instance so Codex skills, sessions, logs, and other runtime state do not leak across checkouts. It seeds that isolated home from the user's main Codex home for shared auth/config continuity. +### Per-agent isolation and auth seeding + +For `codex_local` agents the server isolation guard pins each agent to a per-agent home (`/companies//agents//codex-home`) and sets `OPENAI_API_KEY=""` so an agent can never spend against the host API key or share another agent's Codex state. + +A managed home is created empty, so the adapter must provision auth into it before launching Codex — otherwise the agent runs with zero credentials and the provider returns `401 Missing bearer`. The seeding contract: + +- **Managed homes** (the default home and any configured `CODEX_HOME` under the company tree) are always seeded: the ChatGPT-subscription `auth.json` is symlinked from the host Codex home, or, when a per-agent `OPENAI_API_KEY` is configured, an API-key `auth.json` is written instead. +- **Genuine external overrides** (a `CODEX_HOME` outside the Paperclip-managed company tree) are treated as self-managed and are never seeded or overwritten. +- **Fail-fast guard:** if a managed home ends up with no usable `auth.json` and no configured API key, the run fails with an explicit `adapter_failed` ("no Codex credentials provisioned for managed home …") rather than emitting an unauthenticated request. + ## Manual Local CLI For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use: diff --git a/packages/adapters/codex-local/src/server/codex-home.test.ts b/packages/adapters/codex-local/src/server/codex-home.test.ts index a6e6aa86..44db5168 100644 --- a/packages/adapters/codex-local/src/server/codex-home.test.ts +++ b/packages/adapters/codex-local/src/server/codex-home.test.ts @@ -2,7 +2,14 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { ensureSymlink, prepareManagedCodexHome } from "./codex-home.js"; +import { + codexHomeHasUsableAuth, + ensureSymlink, + isManagedCodexHomePath, + prepareManagedCodexHome, + reconcileManagedCodexHome, + seedManagedCodexHome, +} from "./codex-home.js"; describe("codex managed home", () => { afterEach(() => { @@ -194,3 +201,304 @@ describe("codex managed home", () => { }); }); + +describe("isManagedCodexHomePath", () => { + const env = { + PAPERCLIP_HOME: "/srv/paperclip", + PAPERCLIP_INSTANCE_ID: "default", + } satisfies NodeJS.ProcessEnv; + const companyRoot = path.resolve( + "/srv/paperclip/instances/default/companies/company-1", + ); + + it("treats the per-agent managed home as managed", () => { + expect( + isManagedCodexHomePath( + env, + "company-1", + path.join(companyRoot, "agents", "agent-7", "codex-home"), + ), + ).toBe(true); + }); + + it("treats the shared company home as managed", () => { + expect( + isManagedCodexHomePath(env, "company-1", path.join(companyRoot, "codex-home")), + ).toBe(true); + }); + + it("treats a path outside the company tree as an external override", () => { + expect(isManagedCodexHomePath(env, "company-1", "/home/dev/.codex")).toBe(false); + expect( + isManagedCodexHomePath( + env, + "company-1", + path.resolve("/srv/paperclip/instances/default/companies/company-2/codex-home"), + ), + ).toBe(false); + }); + + it("returns false without a companyId", () => { + expect(isManagedCodexHomePath(env, undefined, path.join(companyRoot, "codex-home"))).toBe( + false, + ); + }); +}); + +describe("codexHomeHasUsableAuth", () => { + it("is true for credential-bearing auth.json and false when missing", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-auth-")); + try { + expect(await codexHomeHasUsableAuth(root)).toBe(false); + await fs.writeFile(path.join(root, "auth.json"), "{}", "utf8"); + expect(await codexHomeHasUsableAuth(root)).toBe(false); + await fs.writeFile(path.join(root, "auth.json"), '{"foo":"bar"}', "utf8"); + expect(await codexHomeHasUsableAuth(root)).toBe(false); + await fs.writeFile(path.join(root, "auth.json"), '{"token":"shared"}', "utf8"); + expect(await codexHomeHasUsableAuth(root)).toBe(true); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("is false for a dangling auth.json symlink", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-auth-dangling-")); + try { + await fs.symlink(path.join(root, "missing-source.json"), path.join(root, "auth.json")); + expect(await codexHomeHasUsableAuth(root)).toBe(false); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); + +describe("seedManagedCodexHome", () => { + it("symlinks auth.json from the shared source into an explicit per-agent home", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-")); + try { + const sharedCodexHome = path.join(root, "shared-codex-home"); + const agentHome = path.join( + root, + "instances", + "default", + "companies", + "company-1", + "agents", + "agent-7", + "codex-home", + ); + const sharedAuth = path.join(sharedCodexHome, "auth.json"); + const agentAuth = path.join(agentHome, "auth.json"); + + await fs.mkdir(sharedCodexHome, { recursive: true }); + await fs.writeFile(sharedAuth, '{"token":"shared"}', "utf8"); + + await seedManagedCodexHome(agentHome, { CODEX_HOME: sharedCodexHome }, async () => {}); + + expect((await fs.lstat(agentAuth)).isSymbolicLink()).toBe(true); + expect(await fs.realpath(agentAuth)).toBe(await fs.realpath(sharedAuth)); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("writes an API-key auth.json into the home when an apiKey is supplied", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-apikey-")); + try { + const agentHome = path.join(root, "agent-home"); + const emptyShared = path.join(root, "empty-shared"); + await fs.mkdir(emptyShared, { recursive: true }); + await seedManagedCodexHome(agentHome, { CODEX_HOME: emptyShared }, async () => {}, { + apiKey: "sk-test-123", + }); + + const written = JSON.parse(await fs.readFile(path.join(agentHome, "auth.json"), "utf8")); + expect(written).toEqual({ OPENAI_API_KEY: "sk-test-123" }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); + +// Startup backfill for already-isolated managed homes. +describe("reconcileManagedCodexHome", () => { + async function makeFixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-reconcile-")); + const sharedCodexHome = path.join(root, "shared-codex-home"); + const paperclipHome = path.join(root, "paperclip-home"); + const agentHome = path.join( + paperclipHome, + "instances", + "default", + "companies", + "company-1", + "agents", + "agent-7", + "codex-home", + ); + const sharedAuth = path.join(sharedCodexHome, "auth.json"); + const agentAuth = path.join(agentHome, "auth.json"); + await fs.mkdir(sharedCodexHome, { recursive: true }); + await fs.writeFile(sharedAuth, '{"token":"shared"}', "utf8"); + const env = { + CODEX_HOME: sharedCodexHome, + PAPERCLIP_HOME: paperclipHome, + PAPERCLIP_INSTANCE_ID: "default", + } satisfies NodeJS.ProcessEnv; + return { root, sharedCodexHome, sharedAuth, agentHome, agentAuth, env }; + } + + it("seeds a previously-stranded managed home and is a no-op on re-run", async () => { + const fx = await makeFixture(); + try { + // The isolation guard created the per-agent home with no auth.json. + expect(await codexHomeHasUsableAuth(fx.agentHome)).toBe(false); + + const first = await reconcileManagedCodexHome({ + companyId: "company-1", + configuredCodexHome: fx.agentHome, + env: fx.env, + }); + expect(first.status).toBe("seeded"); + expect(first.home).toBe(fx.agentHome); + expect((await fs.lstat(fx.agentAuth)).isSymbolicLink()).toBe(true); + expect(await fs.realpath(fx.agentAuth)).toBe(await fs.realpath(fx.sharedAuth)); + + const second = await reconcileManagedCodexHome({ + companyId: "company-1", + configuredCodexHome: fx.agentHome, + env: fx.env, + }); + expect(second.status).toBe("already_seeded"); + expect((await fs.lstat(fx.agentAuth)).isSymbolicLink()).toBe(true); + expect(await fs.realpath(fx.agentAuth)).toBe(await fs.realpath(fx.sharedAuth)); + } finally { + await fs.rm(fx.root, { recursive: true, force: true }); + } + }); + + it("reports source_auth_missing when shared auth is unavailable", async () => { + const fx = await makeFixture(); + try { + await fs.rm(fx.sharedAuth, { force: true }); + + const result = await reconcileManagedCodexHome({ + companyId: "company-1", + configuredCodexHome: fx.agentHome, + env: fx.env, + }); + + expect(result.status).toBe("source_auth_missing"); + await expect(fs.lstat(fx.agentAuth)).rejects.toThrow(); + } finally { + await fs.rm(fx.root, { recursive: true, force: true }); + } + }); + + it("leaves a genuine external override untouched", async () => { + const fx = await makeFixture(); + try { + const external = path.join(fx.root, "user-codex"); + await fs.mkdir(external, { recursive: true }); + + const result = await reconcileManagedCodexHome({ + companyId: "company-1", + configuredCodexHome: external, + env: fx.env, + }); + expect(result.status).toBe("external_override"); + expect(await codexHomeHasUsableAuth(external)).toBe(false); + } finally { + await fs.rm(fx.root, { recursive: true, force: true }); + } + }); + + it("reports no_managed_home when no CODEX_HOME is configured", async () => { + const fx = await makeFixture(); + try { + const result = await reconcileManagedCodexHome({ + companyId: "company-1", + configuredCodexHome: null, + env: fx.env, + }); + expect(result).toEqual({ status: "no_managed_home", home: null }); + } finally { + await fs.rm(fx.root, { recursive: true, force: true }); + } + }); + + it("preserves an existing API-key auth.json when the key is secret-bound", async () => { + const fx = await makeFixture(); + try { + // A prior execute-time run resolved the secret and wrote a regular-file + // auth.json containing the key. + await fs.mkdir(fx.agentHome, { recursive: true }); + await fs.writeFile( + fx.agentAuth, + JSON.stringify({ OPENAI_API_KEY: "sk-secret-resolved" }), + { mode: 0o600 }, + ); + + const result = await reconcileManagedCodexHome({ + companyId: "company-1", + configuredCodexHome: fx.agentHome, + apiKeySecretBound: true, + env: fx.env, + }); + + expect(result.status).toBe("already_seeded"); + expect((await fs.lstat(fx.agentAuth)).isSymbolicLink()).toBe(false); + expect(JSON.parse(await fs.readFile(fx.agentAuth, "utf8"))).toEqual({ + OPENAI_API_KEY: "sk-secret-resolved", + }); + } finally { + await fs.rm(fx.root, { recursive: true, force: true }); + } + }); + + it("seeds the shared symlink for a secret-bound key when no auth exists yet", async () => { + const fx = await makeFixture(); + try { + const result = await reconcileManagedCodexHome({ + companyId: "company-1", + configuredCodexHome: fx.agentHome, + apiKeySecretBound: true, + env: fx.env, + }); + + expect(result.status).toBe("seeded"); + expect((await fs.lstat(fx.agentAuth)).isSymbolicLink()).toBe(true); + expect(await fs.realpath(fx.agentAuth)).toBe(await fs.realpath(fx.sharedAuth)); + } finally { + await fs.rm(fx.root, { recursive: true, force: true }); + } + }); + + it("writes an API-key auth.json into a managed home when an apiKey is supplied", async () => { + const fx = await makeFixture(); + try { + const result = await reconcileManagedCodexHome({ + companyId: "company-1", + configuredCodexHome: fx.agentHome, + apiKey: "sk-reconcile-1", + env: fx.env, + }); + expect(result.status).toBe("seeded"); + const written = JSON.parse(await fs.readFile(fx.agentAuth, "utf8")); + expect(written).toEqual({ OPENAI_API_KEY: "sk-reconcile-1" }); + + const second = await reconcileManagedCodexHome({ + companyId: "company-1", + configuredCodexHome: fx.agentHome, + apiKey: "sk-reconcile-1", + env: fx.env, + }); + expect(second.status).toBe("already_seeded"); + expect(JSON.parse(await fs.readFile(fx.agentAuth, "utf8"))).toEqual({ + OPENAI_API_KEY: "sk-reconcile-1", + }); + } finally { + await fs.rm(fx.root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/adapters/codex-local/src/server/codex-home.ts b/packages/adapters/codex-local/src/server/codex-home.ts index bd9d4c02..d20b5fed 100644 --- a/packages/adapters/codex-local/src/server/codex-home.ts +++ b/packages/adapters/codex-local/src/server/codex-home.ts @@ -7,6 +7,7 @@ import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-uti const TRUTHY_ENV_RE = /^(1|true|yes|on)$/i; const COPIED_SHARED_FILES = ["config.json", "config.toml", "instructions.md"] as const; const SYMLINKED_SHARED_FILES = ["auth.json"] as const; +const AUTH_CREDENTIAL_KEYS = /(?:openai[_-]?key|api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|session|auth)/i; function nonEmpty(value: string | undefined): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; @@ -16,6 +17,28 @@ export async function pathExists(candidate: string): Promise { return fs.access(candidate).then(() => true).catch(() => false); } +function hasUsableAuthPayload(authPayload: unknown): boolean { + if (authPayload === null || typeof authPayload !== "object" || Array.isArray(authPayload)) { + return false; + } + + for (const [key, value] of Object.entries(authPayload as Record)) { + if (!AUTH_CREDENTIAL_KEYS.test(key)) continue; + if (key.toLowerCase() === "token_type") continue; + if (typeof value === "string" && value.trim().length > 0) return true; + } + + return false; +} + +function readApiKeyFromAuthPayload(authPayload: unknown): string | null { + if (authPayload === null || typeof authPayload !== "object" || Array.isArray(authPayload)) { + return null; + } + const raw = (authPayload as Record).OPENAI_API_KEY; + return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : null; +} + export function resolveSharedCodexHomeDir( env: NodeJS.ProcessEnv = process.env, ): string { @@ -41,6 +64,59 @@ export function resolveManagedCodexHomeDir( : path.resolve(instanceRoot, "codex-home"); } +/** + * True when `homePath` lives under the Paperclip-managed company tree + * (`/companies//...`). This covers both the shared + * company `codex-home` and the per-agent `agents//codex-home` set by + * the server-side isolation guard. A path outside that tree is a genuine + * external/user-supplied override that Paperclip must not seed or overwrite. + */ +export function isManagedCodexHomePath( + env: NodeJS.ProcessEnv, + companyId: string | undefined, + homePath: string, +): boolean { + if (!companyId) return false; + const instanceRoot = resolvePaperclipInstanceRootForAdapter({ + homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined, + instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined, + env, + }); + const companyRoot = path.resolve(instanceRoot, "companies", companyId); + const resolved = path.resolve(homePath); + return resolved === companyRoot || resolved.startsWith(companyRoot + path.sep); +} + +/** + * True when the Codex home has a usable `auth.json`. Uses `fs.access` (follows + * symlinks), so a dangling auth symlink whose source has been removed counts as + * no usable credentials. + */ +export async function codexHomeHasUsableAuth(home: string): Promise { + const authPath = path.join(home, "auth.json"); + if (!(await pathExists(authPath))) return false; + try { + const raw = await fs.readFile(authPath, "utf8"); + const parsed = JSON.parse(raw); + return hasUsableAuthPayload(parsed); + } catch { + return false; + } +} + +async function codexHomeHasMatchingApiKeyAuth(home: string, apiKey: string): Promise { + const authPath = path.join(home, "auth.json"); + const existing = await fs.lstat(authPath).catch(() => null); + if (!existing || existing.isSymbolicLink()) return false; + try { + const raw = await fs.readFile(authPath, "utf8"); + const parsed = JSON.parse(raw); + return readApiKeyFromAuthPayload(parsed) === apiKey.trim(); + } catch { + return false; + } +} + async function ensureParentDir(target: string): Promise { await fs.mkdir(path.dirname(target), { recursive: true }); } @@ -116,13 +192,20 @@ export async function writeApiKeyAuthJson(home: string, apiKey: string): Promise await fs.writeFile(target, JSON.stringify({ OPENAI_API_KEY: apiKey }), { mode: 0o600 }); } -export async function prepareManagedCodexHome( +/** + * Seeds auth/config into an explicit Paperclip-managed `targetHome`. Symlinks + * `auth.json` from the shared source home (so ChatGPT-subscription credentials + * stay live and single-use refresh tokens are not copied), copies the static + * shared config files, and — when an API key is supplied — writes an API-key + * `auth.json` instead. Used both for the default company home and for the + * per-agent home set by the server isolation guard. + */ +export async function seedManagedCodexHome( + targetHome: string, env: NodeJS.ProcessEnv, onLog: AdapterExecutionContext["onLog"], - companyId?: string, options: { apiKey?: string | null } = {}, -): Promise { - const targetHome = resolveManagedCodexHomeDir(env, companyId); +): Promise { const apiKey = nonEmpty(options.apiKey ?? undefined); const sourceHome = resolveSharedCodexHomeDir(env); @@ -168,6 +251,98 @@ export async function prepareManagedCodexHome( `[paperclip] Wrote API-key auth.json into Codex home "${targetHome}" from configured OPENAI_API_KEY.\n`, ); } +} +export async function prepareManagedCodexHome( + env: NodeJS.ProcessEnv, + onLog: AdapterExecutionContext["onLog"], + companyId?: string, + options: { apiKey?: string | null } = {}, +): Promise { + const targetHome = resolveManagedCodexHomeDir(env, companyId); + await seedManagedCodexHome(targetHome, env, onLog, options); return targetHome; } + +export type ReconcileManagedCodexHomeStatus = + | "no_managed_home" + | "external_override" + | "already_seeded" + | "source_auth_missing" + | "seeded"; + +export interface ReconcileManagedCodexHomeInput { + companyId: string | undefined; + configuredCodexHome: string | null | undefined; + apiKey?: string | null; + /** + * Set when the agent's persisted `OPENAI_API_KEY` is a secret binding that + * could not be resolved in this context (e.g. startup reconciliation, which + * never resolves secrets). When true and the home already has usable auth, + * reconciliation preserves that auth instead of downgrading it to the shared + * subscription symlink. + */ + apiKeySecretBound?: boolean; + env?: NodeJS.ProcessEnv; + onLog?: AdapterExecutionContext["onLog"]; +} + +export interface ReconcileManagedCodexHomeResult { + status: ReconcileManagedCodexHomeStatus; + home: string | null; +} + +const noopOnLog: AdapterExecutionContext["onLog"] = async () => {}; + +/** + * Idempotently reconciles a persisted `codex_local` agent home. Phase 1 seeds + * managed homes at execute time; this is the backfill for agents that already + * carry a persisted (but unseeded) per-agent `CODEX_HOME` and have not run + * since the seeding fix landed. Shares the managed-home detection + * (`isManagedCodexHomePath`) and seeding (`seedManagedCodexHome`) logic so a + * genuine external/user override is never touched. Safe to re-run: when a valid + * `auth.json` is already present (and no API-key rewrite is requested) it is a + * no-op and reports `already_seeded`. + */ +export async function reconcileManagedCodexHome( + input: ReconcileManagedCodexHomeInput, +): Promise { + const env = input.env ?? process.env; + const configured = nonEmpty(input.configuredCodexHome ?? undefined); + if (!configured) return { status: "no_managed_home", home: null }; + + const resolved = path.resolve(configured); + if (!isManagedCodexHomePath(env, input.companyId, resolved)) { + return { status: "external_override", home: resolved }; + } + + const apiKey = nonEmpty(input.apiKey ?? undefined); + const hadUsableAuth = await codexHomeHasUsableAuth(resolved); + + // A secret-bound OPENAI_API_KEY cannot be resolved here, so we cannot rewrite + // it into auth.json. If the home already has usable auth — typically an + // API-key auth.json written at execute time when the secret WAS resolved — + // preserve it. Re-seeding without the key would delete that file and restore + // the shared subscription symlink, silently changing the agent's credentials + // on every boot while the persisted config still says "use the secret key". + if (input.apiKeySecretBound && hadUsableAuth) { + return { status: "already_seeded", home: resolved }; + } + + if (apiKey && await codexHomeHasMatchingApiKeyAuth(resolved, apiKey)) { + return { status: "already_seeded", home: resolved }; + } + + await seedManagedCodexHome(resolved, env, input.onLog ?? noopOnLog, { apiKey }); + + if (!apiKey && !(await codexHomeHasUsableAuth(resolved))) { + return { status: "source_auth_missing", home: resolved }; + } + + // Without an API key, seeding only changes disk state when auth was missing. + // With an API key, the matching-file short-circuit above filters out the + // already-seeded case before this write path. + const status: ReconcileManagedCodexHomeStatus = + !apiKey && hadUsableAuth ? "already_seeded" : "seeded"; + return { status, home: resolved }; +} diff --git a/packages/adapters/codex-local/src/server/execute.auth.test.ts b/packages/adapters/codex-local/src/server/execute.auth.test.ts new file mode 100644 index 00000000..10ec5dc3 --- /dev/null +++ b/packages/adapters/codex-local/src/server/execute.auth.test.ts @@ -0,0 +1,77 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { execute } from "./execute.js"; + +describe("codex managed-home auth fail-fast", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + vi.unstubAllEnvs(); + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it("fails fast when a managed CODEX_HOME has no auth.json and OPENAI_API_KEY is empty", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-failfast-")); + cleanupDirs.push(root); + + const paperclipHome = path.join(root, "paperclip-home"); + const emptySharedHome = path.join(root, "shared-codex-home"); + const workspaceDir = path.join(root, "workspace"); + // A managed per-agent home with no credentials seeded into it. + const managedAgentHome = path.join( + paperclipHome, + "instances", + "default", + "companies", + "company-1", + "agents", + "agent-1", + "codex-home", + ); + await fs.mkdir(emptySharedHome, { recursive: true }); + await fs.mkdir(workspaceDir, { recursive: true }); + + // Source home has no auth.json, so nothing is symlinked into the managed home. + vi.stubEnv("PAPERCLIP_HOME", paperclipHome); + vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default"); + vi.stubEnv("CODEX_HOME", emptySharedHome); + + await expect( + execute({ + runId: "run-failfast", + agent: { + id: "agent-1", + companyId: "company-1", + name: "CodexCoder", + adapterType: "codex_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: "codex", + cwd: workspaceDir, + env: { + CODEX_HOME: managedAgentHome, + OPENAI_API_KEY: "", + }, + }, + context: {}, + onLog: async () => {}, + }), + ).rejects.toThrow(/no Codex credentials provisioned for managed home/); + + // The managed home must not have been left with a usable auth.json. + await expect(fs.access(path.join(managedAgentHome, "auth.json"))).rejects.toBeTruthy(); + }); +}); diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index ce99fda3..e3c38d49 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -44,7 +44,15 @@ import { isCodexTransientUpstreamError, isCodexUnknownSessionError, } from "./parse.js"; -import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js"; +import { + codexHomeHasUsableAuth, + isManagedCodexHomePath, + pathExists, + prepareManagedCodexHome, + resolveManagedCodexHomeDir, + resolveSharedCodexHomeDir, + seedManagedCodexHome, +} from "./codex-home.js"; import { prepareCodexRuntimeConfig } from "./runtime-config.js"; import { resolveCodexDesiredSkillNames } from "./skills.js"; import { buildCodexExecArgs } from "./codex-args.js"; @@ -340,15 +348,44 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? envConfig.OPENAI_API_KEY.trim() : null; - const preparedManagedCodexHome = - configuredCodexHome - ? null - : await prepareManagedCodexHome(process.env, onLog, agent.companyId, { - apiKey: configuredOpenAiApiKey, - }); + // A configured CODEX_HOME that lives under the Paperclip-managed company tree + // (the per-agent home set by the server isolation guard) still needs auth + // seeded — it ships with no credentials and OPENAI_API_KEY="" by default. + // Only a genuine external/user-supplied override is treated as self-managed + // and left untouched. + const configuredHomeIsManaged = + configuredCodexHome != null && + isManagedCodexHomePath(process.env, agent.companyId, configuredCodexHome); + if (configuredCodexHome == null) { + await prepareManagedCodexHome(process.env, onLog, agent.companyId, { + apiKey: configuredOpenAiApiKey, + }); + } else if (configuredHomeIsManaged) { + await seedManagedCodexHome(configuredCodexHome, process.env, onLog, { + apiKey: configuredOpenAiApiKey, + }); + } const defaultCodexHome = resolveManagedCodexHomeDir(process.env, agent.companyId); - const effectiveCodexHome = configuredCodexHome ?? preparedManagedCodexHome ?? defaultCodexHome; + const effectiveCodexHome = configuredCodexHome ?? defaultCodexHome; await fs.mkdir(effectiveCodexHome, { recursive: true }); + + // Never launch a managed CODEX_HOME with no credentials. Without auth.json and + // with OPENAI_API_KEY="" the provider rejects every request with + // "401 Missing bearer"; fail fast with a clear adapter error instead of + // emitting unauthenticated calls. External overrides manage their own auth. + const effectiveHomeIsManaged = configuredCodexHome == null || configuredHomeIsManaged; + if ( + effectiveHomeIsManaged && + !configuredOpenAiApiKey && + !(await codexHomeHasUsableAuth(effectiveCodexHome)) + ) { + throw new Error( + `no Codex credentials provisioned for managed home "${effectiveCodexHome}" ` + + `(no usable auth.json and OPENAI_API_KEY is empty). ` + + `Sign in to Codex on the host with a ChatGPT subscription, or configure a per-agent ` + + `OPENAI_API_KEY.`, + ); + } // Merge custom model providers (PAPERCLIP_CODEX_PROVIDERS) into the managed // CODEX_HOME's config.toml BEFORE the home is shipped to a remote execution // target, so both local and sandboxed Codex processes pick up the routing. diff --git a/packages/adapters/codex-local/src/server/index.ts b/packages/adapters/codex-local/src/server/index.ts index 047fccb1..273bc3d7 100644 --- a/packages/adapters/codex-local/src/server/index.ts +++ b/packages/adapters/codex-local/src/server/index.ts @@ -1,4 +1,11 @@ export { execute, ensureCodexSkillsInjected } from "./execute.js"; +export { + reconcileManagedCodexHome, + isManagedCodexHomePath, + type ReconcileManagedCodexHomeInput, + type ReconcileManagedCodexHomeResult, + type ReconcileManagedCodexHomeStatus, +} from "./codex-home.js"; export { listCodexSkills, syncCodexSkills } from "./skills.js"; export { testEnvironment } from "./test.js"; export { parseCodexJsonl, isCodexTransientUpstreamError, isCodexUnknownSessionError } from "./parse.js"; diff --git a/server/src/__tests__/codex-auth-reconciliation.test.ts b/server/src/__tests__/codex-auth-reconciliation.test.ts new file mode 100644 index 00000000..5ab8ab76 --- /dev/null +++ b/server/src/__tests__/codex-auth-reconciliation.test.ts @@ -0,0 +1,206 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { Db } from "@paperclipai/db"; +import { reconcileCodexLocalManagedHomesOnStartup } from "../services/codex-auth-reconciliation.js"; + +type AgentRow = { + id: string; + companyId: string; + adapterConfig: Record; +}; + +function makeDb(rows: AgentRow[]): Db { + return { + select: () => ({ + from: () => ({ + where: async () => rows, + }), + }), + } as unknown as Db; +} + +describe("reconcileCodexLocalManagedHomesOnStartup", () => { + let root: string; + let paperclipHome: string; + let sharedCodexHome: string; + const savedEnv: Record = {}; + + function managedAgentHome(companyId: string, agentId: string): string { + return path.join( + paperclipHome, + "instances", + "default", + "companies", + companyId, + "agents", + agentId, + "codex-home", + ); + } + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-startup-")); + paperclipHome = path.join(root, "paperclip-home"); + sharedCodexHome = path.join(root, "shared-codex-home"); + await fs.mkdir(sharedCodexHome, { recursive: true }); + await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}', "utf8"); + + for (const key of ["PAPERCLIP_HOME", "PAPERCLIP_INSTANCE_ID", "CODEX_HOME"]) { + savedEnv[key] = process.env[key]; + } + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "default"; + process.env.CODEX_HOME = sharedCodexHome; + }); + + afterEach(async () => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + await fs.rm(root, { recursive: true, force: true }); + }); + + it("seeds a stranded managed home and is a no-op on the next boot", async () => { + const agentHome = managedAgentHome("company-1", "agent-7"); + const rows: AgentRow[] = [ + { id: "agent-7", companyId: "company-1", adapterConfig: { env: { CODEX_HOME: agentHome, OPENAI_API_KEY: "" } } }, + ]; + + const first = await reconcileCodexLocalManagedHomesOnStartup(makeDb(rows)); + expect(first).toMatchObject({ scanned: 1, seeded: 1, alreadySeeded: 0, failed: 0 }); + expect(first.seededAgentIds).toEqual(["agent-7"]); + const agentAuth = path.join(agentHome, "auth.json"); + expect((await fs.lstat(agentAuth)).isSymbolicLink()).toBe(true); + expect(await fs.realpath(agentAuth)).toBe( + await fs.realpath(path.join(sharedCodexHome, "auth.json")), + ); + + const second = await reconcileCodexLocalManagedHomesOnStartup(makeDb(rows)); + expect(second).toMatchObject({ scanned: 1, seeded: 0, alreadySeeded: 1, failed: 0 }); + }); + + it("does not report a backfill when shared Codex auth is missing", async () => { + await fs.rm(path.join(sharedCodexHome, "auth.json"), { force: true }); + const agentHome = managedAgentHome("company-1", "agent-missing-source"); + const rows: AgentRow[] = [ + { id: "agent-missing-source", companyId: "company-1", adapterConfig: { env: { CODEX_HOME: agentHome } } }, + ]; + + const summary = await reconcileCodexLocalManagedHomesOnStartup(makeDb(rows)); + expect(summary).toMatchObject({ + scanned: 1, + seeded: 0, + sourceAuthMissing: 1, + failed: 0, + }); + await expect(fs.lstat(path.join(agentHome, "auth.json"))).rejects.toThrow(); + }); + + it("classifies external overrides and unconfigured homes without seeding", async () => { + const external = path.join(root, "user-codex"); + await fs.mkdir(external, { recursive: true }); + const rows: AgentRow[] = [ + { id: "ext", companyId: "company-1", adapterConfig: { env: { CODEX_HOME: external } } }, + { id: "none", companyId: "company-1", adapterConfig: { env: {} } }, + ]; + + const summary = await reconcileCodexLocalManagedHomesOnStartup(makeDb(rows)); + expect(summary).toMatchObject({ + scanned: 2, + seeded: 0, + externalOverride: 1, + noManagedHome: 1, + failed: 0, + }); + expect(await fs.access(path.join(external, "auth.json")).then(() => true).catch(() => false)).toBe( + false, + ); + }); + + it("does not write a secret-bound OPENAI_API_KEY placeholder as the API key", async () => { + const agentHome = managedAgentHome("company-2", "agent-9"); + // A secret binding must never be written verbatim into auth.json; the home + // should fall back to the seeded subscription symlink instead. + const rows: AgentRow[] = [ + { + id: "agent-9", + companyId: "company-2", + adapterConfig: { + env: { + CODEX_HOME: agentHome, + OPENAI_API_KEY: { type: "secret", secretId: "sec-123" }, + }, + }, + }, + ]; + + const summary = await reconcileCodexLocalManagedHomesOnStartup(makeDb(rows)); + expect(summary).toMatchObject({ scanned: 1, seeded: 1, failed: 0 }); + const agentAuth = path.join(agentHome, "auth.json"); + expect((await fs.lstat(agentAuth)).isSymbolicLink()).toBe(true); + expect(await fs.realpath(agentAuth)).toBe( + await fs.realpath(path.join(sharedCodexHome, "auth.json")), + ); + }); + + it("preserves a preexisting API-key auth.json when the key is secret-bound", async () => { + const agentHome = managedAgentHome("company-4", "agent-secret"); + // Simulate an agent that previously ran with a RESOLVED secret API key: + // execute-time seeding wrote a regular-file auth.json containing the key. + await fs.mkdir(agentHome, { recursive: true }); + await fs.writeFile( + path.join(agentHome, "auth.json"), + JSON.stringify({ OPENAI_API_KEY: "sk-secret-resolved" }), + { mode: 0o600 }, + ); + const rows: AgentRow[] = [ + { + id: "agent-secret", + companyId: "company-4", + adapterConfig: { + env: { + CODEX_HOME: agentHome, + // Canonical secret binding shape; unresolvable at startup. + OPENAI_API_KEY: { type: "secret_ref", secretId: "11111111-1111-1111-1111-111111111111" }, + }, + }, + }, + ]; + + const summary = await reconcileCodexLocalManagedHomesOnStartup(makeDb(rows)); + expect(summary).toMatchObject({ scanned: 1, seeded: 0, alreadySeeded: 1, failed: 0 }); + + const agentAuth = path.join(agentHome, "auth.json"); + // The resolved-key file must remain a regular file, not be downgraded to the + // shared subscription symlink. + expect((await fs.lstat(agentAuth)).isSymbolicLink()).toBe(false); + expect(JSON.parse(await fs.readFile(agentAuth, "utf8"))).toEqual({ + OPENAI_API_KEY: "sk-secret-resolved", + }); + }); + + it("writes a plain OPENAI_API_KEY into the managed home", async () => { + const agentHome = managedAgentHome("company-3", "agent-5"); + const rows: AgentRow[] = [ + { + id: "agent-5", + companyId: "company-3", + adapterConfig: { env: { CODEX_HOME: agentHome, OPENAI_API_KEY: "sk-plain-1" } }, + }, + ]; + + const summary = await reconcileCodexLocalManagedHomesOnStartup(makeDb(rows)); + expect(summary).toMatchObject({ scanned: 1, seeded: 1, failed: 0 }); + const written = JSON.parse(await fs.readFile(path.join(agentHome, "auth.json"), "utf8")); + expect(written).toEqual({ OPENAI_API_KEY: "sk-plain-1" }); + + const second = await reconcileCodexLocalManagedHomesOnStartup(makeDb(rows)); + expect(second).toMatchObject({ scanned: 1, seeded: 0, alreadySeeded: 1, failed: 0 }); + expect(JSON.parse(await fs.readFile(path.join(agentHome, "auth.json"), "utf8"))).toEqual({ + OPENAI_API_KEY: "sk-plain-1", + }); + }); +}); diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index 0f78f677..80c11f0a 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -58,6 +58,12 @@ type LogEntry = { chunk: string; }; +async function seedSharedCodexAuth(homeRoot: string): Promise { + const sharedCodexHome = path.join(homeRoot, ".codex"); + await fs.mkdir(sharedCodexHome, { recursive: true }); + await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8"); +} + function createLocalSandboxRunner() { let counter = 0; return { @@ -201,6 +207,7 @@ describe("codex execute", () => { const previousHome = process.env.HOME; process.env.HOME = root; + await seedSharedCodexAuth(root); let commandNotes: string[] = []; try { @@ -261,6 +268,7 @@ describe("codex execute", () => { const previousPath = process.env.PATH; process.env.HOME = root; process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`; + await seedSharedCodexAuth(root); let loggedCommand: string | null = null; let loggedEnv: Record = {}; @@ -328,6 +336,7 @@ describe("codex execute", () => { process.env.HOME = root; process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`; + await seedSharedCodexAuth(root); try { const result = await execute({ @@ -395,6 +404,7 @@ describe("codex execute", () => { const previousHome = process.env.HOME; process.env.HOME = root; + await seedSharedCodexAuth(root); try { const result = await execute({ @@ -505,6 +515,7 @@ describe("codex execute", () => { const previousHome = process.env.HOME; process.env.HOME = root; + await seedSharedCodexAuth(root); try { const result = await execute({ @@ -555,6 +566,7 @@ describe("codex execute", () => { const previousHome = process.env.HOME; process.env.HOME = root; + await seedSharedCodexAuth(root); vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 3, 22, 22, 29, 0)); @@ -615,6 +627,7 @@ describe("codex execute", () => { const previousHome = process.env.HOME; process.env.HOME = root; + await seedSharedCodexAuth(root); let commandNotes: string[] = []; try { @@ -691,6 +704,7 @@ describe("codex execute", () => { const previousHome = process.env.HOME; process.env.HOME = root; + await seedSharedCodexAuth(root); try { const result = await execute({ @@ -845,6 +859,7 @@ describe("codex execute", () => { const previousHome = process.env.HOME; process.env.HOME = root; + await seedSharedCodexAuth(root); try { const result = await execute({ @@ -943,6 +958,7 @@ describe("codex execute", () => { const previousHome = process.env.HOME; process.env.HOME = root; + await seedSharedCodexAuth(root); let invocationPrompt = ""; let invocationNotes: string[] = []; diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index 410f550f..37e59eb9 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -168,6 +168,16 @@ vi.mock("../services/index.js", () => ({ })), })), reconcileCloudUpstreamRunsOnStartup: vi.fn(async () => ({ reconciled: 0 })), + reconcileCodexLocalManagedHomesOnStartup: vi.fn(async () => ({ + scanned: 0, + seeded: 0, + alreadySeeded: 0, + externalOverride: 0, + noManagedHome: 0, + sourceAuthMissing: 0, + failed: 0, + seededAgentIds: [], + })), reconcilePersistedRuntimeServicesOnStartup: vi.fn(async () => ({ reconciled: 0 })), routineService: vi.fn(() => ({ tickScheduledTriggers: vi.fn(async () => ({ triggered: 0 })), diff --git a/server/src/index.ts b/server/src/index.ts index 71d00336..1e6ac16b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -41,6 +41,7 @@ import { heartbeatService, instanceSettingsService, reconcileCloudUpstreamRunsOnStartup, + reconcileCodexLocalManagedHomesOnStartup, reconcilePersistedRuntimeServicesOnStartup, routineService, } from "./services/index.js"; @@ -728,7 +729,29 @@ export async function startServer(): Promise { .catch((err) => { logger.error({ err }, "startup reconciliation of cloud upstream runs failed"); }); - + + // Backfill auth.json into any already-isolated codex_local managed home that + // was created by the #8272 isolation guard before the Phase 1 seeding fix. + // Idempotent; the Phase 1 execute-time seeding covers new strandings. + void reconcileCodexLocalManagedHomesOnStartup(db) + .then((result) => { + if (result.seeded > 0 || result.failed > 0) { + logger.warn( + { seeded: result.seeded, failed: result.failed, scanned: result.scanned }, + "reconciled codex_local managed homes (backfilled missing auth)", + ); + } + if (result.sourceAuthMissing > 0) { + logger.warn( + { sourceAuthMissing: result.sourceAuthMissing, scanned: result.scanned }, + "could not backfill codex_local managed homes because shared Codex auth is missing", + ); + } + }) + .catch((err) => { + logger.error({ err }, "startup reconciliation of codex_local managed homes failed"); + }); + // Force the instance onto the Kubernetes sandbox provider when configured via // env (PAPERCLIP_EXECUTION_MODE=kubernetes). Runs BEFORE the heartbeat resumes // queued runs so the policy + managed k8s environments are in place. A bad diff --git a/server/src/services/codex-auth-reconciliation.ts b/server/src/services/codex-auth-reconciliation.ts new file mode 100644 index 00000000..3e286823 --- /dev/null +++ b/server/src/services/codex-auth-reconciliation.ts @@ -0,0 +1,147 @@ +import type { Db } from "@paperclipai/db"; +import { agents } from "@paperclipai/db"; +import { reconcileManagedCodexHome } from "@paperclipai/adapter-codex-local/server"; +import { eq } from "drizzle-orm"; +import { logger } from "../middleware/logger.js"; + +export interface CodexAuthReconciliationSummary { + scanned: number; + seeded: number; + alreadySeeded: number; + externalOverride: number; + noManagedHome: number; + sourceAuthMissing: number; + failed: number; + seededAgentIds: string[]; +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** + * Extracts a literal (non-secret) env value. Adapter env bindings persist either + * as a bare string or as a `{ type: "plain", value }` object; secret bindings + * are intentionally NOT resolved here (we never write an unresolved secret + * placeholder into auth.json). Mirrors the server-side env-binding extraction in + * routes/agents.ts. + */ +function readPlainEnvValue(value: unknown): string | null { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + const record = asRecord(value); + if (record?.type !== "plain") return null; + return readPlainEnvValue(record.value); +} + +type ApiKeyBinding = + | { kind: "plain"; value: string } + | { kind: "secret" } + | { kind: "none" }; + +/** + * Classifies an `OPENAI_API_KEY` env binding so reconciliation can tell the + * difference between three states it must treat differently: + * - `plain`: a literal value we can write into auth.json. + * - `secret`: a secret binding (e.g. `{ type: "secret_ref", ... }`) we cannot + * resolve at startup; the resolved value may already exist on disk from a + * prior execute-time run, so reconciliation must not clobber it. + * - `none`: no key configured (chatgpt-subscription mode); seed the shared + * auth symlink. + */ +function classifyApiKeyBinding(value: unknown): ApiKeyBinding { + const plain = readPlainEnvValue(value); + if (plain) return { kind: "plain", value: plain }; + const record = asRecord(value); + if (record && typeof record.type === "string" && record.type !== "plain") { + return { kind: "secret" }; + } + return { kind: "none" }; +} + +/** + * Startup backfill: seed `auth.json` into any already- + * isolated `codex_local` managed home that was created (by the #8272 isolation + * guard) before the Phase 1 seeding fix landed. Phase 1 seeds at execute time; + * this repairs persisted homes proactively so a stranded agent recovers without + * waiting to run and without a manual symlink. Idempotent and safe to re-run on + * every boot: a home that already has valid auth is a no-op. + */ +export async function reconcileCodexLocalManagedHomesOnStartup( + db: Db, +): Promise { + const summary: CodexAuthReconciliationSummary = { + scanned: 0, + seeded: 0, + alreadySeeded: 0, + externalOverride: 0, + noManagedHome: 0, + sourceAuthMissing: 0, + failed: 0, + seededAgentIds: [], + }; + + const rows = await db + .select({ + id: agents.id, + companyId: agents.companyId, + adapterConfig: agents.adapterConfig, + }) + .from(agents) + .where(eq(agents.adapterType, "codex_local")); + + for (const row of rows) { + summary.scanned += 1; + const env = asRecord(asRecord(row.adapterConfig)?.env); + const configuredCodexHome = env ? readPlainEnvValue(env.CODEX_HOME) : null; + const apiKeyBinding = classifyApiKeyBinding(env?.OPENAI_API_KEY); + + try { + const result = await reconcileManagedCodexHome({ + companyId: row.companyId, + configuredCodexHome, + apiKey: apiKeyBinding.kind === "plain" ? apiKeyBinding.value : null, + apiKeySecretBound: apiKeyBinding.kind === "secret", + }); + switch (result.status) { + case "seeded": + summary.seeded += 1; + summary.seededAgentIds.push(row.id); + logger.info( + { agentId: row.id, companyId: row.companyId, home: result.home }, + "seeded auth into already-isolated codex_local managed home", + ); + break; + case "already_seeded": + summary.alreadySeeded += 1; + break; + case "external_override": + summary.externalOverride += 1; + break; + case "no_managed_home": + summary.noManagedHome += 1; + break; + case "source_auth_missing": + summary.sourceAuthMissing += 1; + break; + } + } catch (err) { + summary.failed += 1; + logger.warn( + { + agentId: row.id, + companyId: row.companyId, + home: configuredCodexHome, + err: err instanceof Error ? err.message : String(err), + }, + "failed to reconcile codex_local managed home on startup", + ); + } + } + + return summary; +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index bfb082d2..209e1c8d 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -80,5 +80,9 @@ export { workProductService } from "./work-products.js"; export { logActivity, type LogActivityInput } from "./activity-log.js"; export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js"; export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js"; +export { + reconcileCodexLocalManagedHomesOnStartup, + type CodexAuthReconciliationSummary, +} from "./codex-auth-reconciliation.js"; export { reconcilePersistedRuntimeServicesOnStartup, restartDesiredRuntimeServicesOnStartup } from "./workspace-runtime.js"; export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js";