Files
bot-agents/src/agents/auth.ts
T
Danny Kim 063eb09750 Replace NodeJS dict aliases with maps and named field reads
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-10-20 05:01:49 +00:00

162 lines
4.5 KiB
TypeScript

import { randomUUID } from "node:crypto";
import {
chmod,
chown,
lstat,
mkdir,
open,
readFile,
rename,
rm,
writeFile,
} from "node:fs/promises";
import nodePath 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<T extends CommandResult>(
attempt: () => Promise<T>,
relogin: () => Promise<void>,
): Promise<T> {
let result = await attempt();
if (result.code !== 0 && isCodexAuthFailure(result.error)) {
await relogin();
result = await attempt();
}
return result;
}
async function readCodexAuth(path: string): Promise<string> {
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<string> {
const codexHome = nodePath.join(profileHome, ".codex");
await mkdir(codexHome, { recursive: true, mode: 0o700 });
await chmod(codexHome, 0o700);
await chown(codexHome, uid, gid);
const stagedPath = nodePath.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<string> {
const path = nodePath.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<void> {
const text = await readCodexAuth(stagedPath);
const directory = nodePath.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;
}
}