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 aa51c4d7a53..f2fec642408 100644 --- a/packages/adapters/codex-local/src/server/codex-home.test.ts +++ b/packages/adapters/codex-local/src/server/codex-home.test.ts @@ -468,6 +468,17 @@ describe("reconcileManagedCodexHome", () => { 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 66fd67272e5..b3f1b4eceef 100644 --- a/packages/adapters/codex-local/src/server/codex-home.ts +++ b/packages/adapters/codex-local/src/server/codex-home.ts @@ -31,6 +31,14 @@ function hasUsableAuthPayload(authPayload: unknown): boolean { 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 { @@ -96,6 +104,19 @@ export async function codexHomeHasUsableAuth(home: string): Promise { } } +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 }); } @@ -307,11 +328,15 @@ export async function reconcileManagedCodexHome( 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 }); - // Seeding always (re)writes when an API key is supplied; otherwise it only - // changes disk state when auth was missing. Report accordingly so callers can - // distinguish a real backfill from a verified no-op. + // 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.ts b/packages/adapters/codex-local/src/server/execute.ts index c95f6638b11..e3c38d4963a 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -381,7 +381,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { 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 0f78f6773a1..c987c93b0b7 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 { diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index 410f550f55d..b8808eb7bfb 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -168,6 +168,15 @@ 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, + 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 63ab47d07d5..8f3bcd3795c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -733,7 +733,7 @@ export async function startServer(): Promise { // 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 as any) + void reconcileCodexLocalManagedHomesOnStartup(db) .then((result) => { if (result.seeded > 0 || result.failed > 0) { logger.warn(