import { randomUUID } from "node:crypto"; import { chmod, chown, lstat, mkdir, open, readFile, rename, rm, writeFile, } from "node:fs/promises"; import { dirname, join } from "node:path"; import { field, isNotFound, isObject } from "#common"; interface CodexApiKey { kind: "api-key"; token: string; } interface CodexPersistent { kind: "persistent"; } export type CodexAuthentication = CodexApiKey | CodexPersistent; export function codexAuthentication(token?: string): CodexAuthentication { return token === undefined ? { kind: "persistent" } : { kind: "api-key", token }; } // Codex logs revoked, expired, or reused ChatGPT sign-ins and rejected API keys // on its own ERROR lines; tool output and the echoed prompt must not count. const CODEX_AUTH_FAILURE = /^(?:\S+\s+)?ERROR\b.*(?:\b401\b|unauthorized|invalid_refresh_token|refresh_token_(?:expired|reused|invalidated)|token_(?:revoked|expired)|invalid_api_key|(?:log|sign) in again)/imu; export function isCodexAuthFailure(error: string): boolean { return CODEX_AUTH_FAILURE.test(error); } // Claude Code reports a rejected CLAUDE_CODE_OAUTH_TOKEN as an is_error // stream-json result, e.g. "Failed to authenticate. API Error: 401 OAuth // access token is invalid." with api_error_status 401. // The fields of Claude Code's stream-json `result` event that tell a sign-in // failure apart, as `apiErrorStatus` and `result`. export interface ClaudeFailureFields { apiErrorStatus?: number; result?: string; } export function isClaudeAuthFailure(result: ClaudeFailureFields): boolean { return ( result.apiErrorStatus === 401 || /failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/iu.test( result.result ?? "", ) ); } export interface CommandResult { code: number; error: string; } export async function retryInvalidToken( attempt: () => Promise, relogin: () => Promise, ): Promise { let result = await attempt(); if (result.code !== 0 && isCodexAuthFailure(result.error)) { await relogin(); result = await attempt(); } return result; } async function readCodexAuth(path: string): Promise { const stat = await lstat(path); if (!stat.isFile() || stat.isSymbolicLink()) { throw new Error("Codex login must be a regular file"); } if ((stat.mode & 0o077) !== 0) { throw new Error("Codex login must be private"); } const text = await readFile(path, "utf8"); let value: unknown = null; try { value = JSON.parse(text); } catch { throw new Error("invalid Codex login"); } const authMode = field(value, "auth_mode"); const tokens = field(value, "tokens"); if ( typeof authMode !== "string" || authMode === "" || !isObject(tokens) || !["access_token", "refresh_token", "id_token", "account_id"].every((name) => { const token = field(tokens, name); return typeof token === "string" && token !== ""; }) ) { throw new Error("invalid Codex login"); } return text; } export async function stageCodexAuth( canonicalPath: string, profileHome: string, uid: number, gid: number, ): Promise { const codexHome = join(profileHome, ".codex"); await mkdir(codexHome, { recursive: true, mode: 0o700 }); await chmod(codexHome, 0o700); await chown(codexHome, uid, gid); const stagedPath = join(codexHome, "auth.json"); try { await writeFile(stagedPath, await readCodexAuth(canonicalPath), { mode: 0o600 }); await chmod(stagedPath, 0o600); await chown(stagedPath, uid, gid); } catch (error) { if (!isNotFound(error)) { throw error; } } return stagedPath; } // Creates an empty private file under a fresh name. async function makeTempFile(directory: string): Promise { const path = join(directory, `.tmp-${randomUUID()}`); const handle = await open(path, "wx", 0o600); await handle.close(); return path; } export async function persistCodexAuth(stagedPath: string, canonicalPath: string): Promise { const text = await readCodexAuth(stagedPath); const directory = dirname(canonicalPath); await mkdir(directory, { recursive: true, mode: 0o700 }); const temporary = await makeTempFile(directory); try { await writeFile(temporary, text, { mode: 0o600 }); await chmod(temporary, 0o600); await rename(temporary, canonicalPath); } catch (error) { await rm(temporary).catch(() => {}); throw error; } }