3565988249
Codex re-runs device login on revoked, expired, or reused sessions and token_revoked/401 errors, not only invalid_refresh_token. Claude and OpenCode credential rejections are classified too. Sign-in failures fall through to the next profile, and the failure comment names each tried profile with a fixed reason label.
114 lines
3.9 KiB
TypeScript
114 lines
3.9 KiB
TypeScript
import { dirname, join } from "jsr:@std/path@1";
|
|
|
|
export type CodexAuthentication =
|
|
| { kind: "api-key"; token: string }
|
|
| { kind: "persistent" };
|
|
|
|
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)/im;
|
|
|
|
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.
|
|
export function isClaudeAuthFailure(
|
|
result: { api_error_status?: number | null; result?: string },
|
|
): boolean {
|
|
return result.api_error_status === 401 ||
|
|
/failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/i
|
|
.test(result.result ?? "");
|
|
}
|
|
|
|
export async function retryInvalidToken<
|
|
T extends { status: { success: boolean }; error: string },
|
|
>(attempt: () => Promise<T>, relogin: () => Promise<void>): Promise<T> {
|
|
let result = await attempt();
|
|
if (!result.status.success && isCodexAuthFailure(result.error)) {
|
|
await relogin();
|
|
result = await attempt();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function readCodexAuth(path: string): Promise<string> {
|
|
const stat = await Deno.lstat(path);
|
|
if (!stat.isFile || stat.isSymlink) {
|
|
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 Deno.readTextFile(path);
|
|
let value: unknown;
|
|
try {
|
|
value = JSON.parse(text);
|
|
} catch {
|
|
throw new Error("invalid Codex login");
|
|
}
|
|
const record = value as Record<string, unknown> | null;
|
|
const tokens = record?.tokens as Record<string, unknown> | null;
|
|
if (
|
|
record === null || typeof record !== "object" ||
|
|
typeof record.auth_mode !== "string" || record.auth_mode === "" ||
|
|
tokens === null || typeof tokens !== "object" ||
|
|
!["access_token", "refresh_token", "id_token", "account_id"].every(
|
|
(name) => typeof tokens[name] === "string" && tokens[name] !== "",
|
|
)
|
|
) {
|
|
throw new Error("invalid Codex login");
|
|
}
|
|
return text;
|
|
}
|
|
|
|
export async function stageCodexAuth(
|
|
canonicalPath: string,
|
|
profileHome: string,
|
|
uid: number,
|
|
gid: number,
|
|
): Promise<string> {
|
|
const codexHome = join(profileHome, ".codex");
|
|
await Deno.mkdir(codexHome, { recursive: true, mode: 0o700 });
|
|
await Deno.chmod(codexHome, 0o700);
|
|
await Deno.chown(codexHome, uid, gid);
|
|
const stagedPath = join(codexHome, "auth.json");
|
|
try {
|
|
await Deno.writeTextFile(stagedPath, await readCodexAuth(canonicalPath), {
|
|
mode: 0o600,
|
|
});
|
|
await Deno.chmod(stagedPath, 0o600);
|
|
await Deno.chown(stagedPath, uid, gid);
|
|
} catch (error) {
|
|
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
|
}
|
|
return stagedPath;
|
|
}
|
|
|
|
export async function persistCodexAuth(
|
|
stagedPath: string,
|
|
canonicalPath: string,
|
|
): Promise<void> {
|
|
const text = await readCodexAuth(stagedPath);
|
|
const directory = dirname(canonicalPath);
|
|
await Deno.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
const temporary = await Deno.makeTempFile({ dir: directory });
|
|
try {
|
|
await Deno.writeTextFile(temporary, text, { mode: 0o600 });
|
|
await Deno.chmod(temporary, 0o600);
|
|
await Deno.rename(temporary, canonicalPath);
|
|
} catch (error) {
|
|
await Deno.remove(temporary).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
}
|