063eb09750
Co-authored-by: Danny Kim <temeddix@gmail.com> Co-committed-by: Danny Kim <temeddix@gmail.com>
162 lines
4.5 KiB
TypeScript
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;
|
|
}
|
|
}
|