fix(codex-local): make auth reconciliation idempotent

This commit is contained in:
Devin Foley committed 2026-06-20 15:19:56 -07:00
1 parent da570f0fe9
commit 2f933a4dd8
7 files changed
+69 -5

No files matched your search

@@ -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 });
}
@@ -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<string, unknown>).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<boolean> {
}
}
async function codexHomeHasMatchingApiKeyAuth(home: string, apiKey: string): Promise<boolean> {
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<void> {
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 };
@@ -381,7 +381,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
) {
throw new Error(
`no Codex credentials provisioned for managed home "${effectiveCodexHome}" ` +
`(no usable ${path.basename(effectiveCodexHome)}/auth.json and OPENAI_API_KEY is empty). ` +
`(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.`,
);
@@ -179,5 +179,11 @@ describe("reconcileCodexLocalManagedHomesOnStartup", () => {
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",
});
});
});
@@ -58,6 +58,12 @@ type LogEntry = {
chunk: string;
};
async function seedSharedCodexAuth(homeRoot: string): Promise<void> {
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<string, string> = {};
@@ -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 {
@@ -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 })),
+1 -1
View File
@@ -733,7 +733,7 @@ export async function startServer(): Promise<StartedServer> {
// 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(