Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
478 lines
15 KiB
JavaScript
478 lines
15 KiB
JavaScript
/**
|
|
* Codex-related helper functions and state.
|
|
*
|
|
* Manages Codex login sessions, auth validation cache, binary resolution,
|
|
* integration state normalization, and error / fingerprint utilities.
|
|
*/
|
|
"use strict";
|
|
|
|
const { createHash } = require("node:crypto");
|
|
const { existsSync, readFileSync } = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { StringDecoder } = require("node:string_decoder");
|
|
|
|
const { stripAnsi, extractFirstNonLocalhostUrl, toUnpackedAsarPath } = require("./shellUtils.cjs");
|
|
|
|
// ── Module-level state ──
|
|
|
|
const codexLoginSessions = new Map();
|
|
let codexValidationCache = null;
|
|
const MAX_CODEX_LOGIN_OUTPUT_BYTES = 64 * 1024;
|
|
const MAX_CODEX_LOGIN_OUTPUT_CHARS = MAX_CODEX_LOGIN_OUTPUT_BYTES;
|
|
const MAX_CODEX_LOGIN_TERMINAL_SESSIONS = 8;
|
|
const CODEX_LOGIN_KILL_GRACE_MS = 750;
|
|
|
|
const CODEX_AUTH_HINTS = [
|
|
"not logged in",
|
|
"authentication required",
|
|
"auth required",
|
|
"login required",
|
|
"missing credentials",
|
|
"no credentials",
|
|
"unauthorized",
|
|
"forbidden",
|
|
"codex login",
|
|
"401",
|
|
"403",
|
|
"invalid_grant",
|
|
"invalid_token",
|
|
"credentials",
|
|
];
|
|
|
|
// ── Login session helpers ──
|
|
|
|
function appendCodexLoginOutput(session, chunk) {
|
|
const cleanChunk = stripAnsi(chunk);
|
|
if (!cleanChunk) return;
|
|
|
|
const combined = `${session.output || ""}${cleanChunk}`;
|
|
if (!session.url) {
|
|
session.url = extractFirstNonLocalhostUrl(combined);
|
|
}
|
|
session.output = retainUtf8Tail(combined, MAX_CODEX_LOGIN_OUTPUT_BYTES);
|
|
}
|
|
|
|
function retainUtf8Tail(value, maxBytes) {
|
|
const buffer = Buffer.from(String(value || ""), "utf8");
|
|
if (buffer.length <= maxBytes) return String(value || "");
|
|
let start = buffer.length - maxBytes;
|
|
// Never begin inside a UTF-8 continuation sequence.
|
|
while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) start += 1;
|
|
return buffer.subarray(start).toString("utf8");
|
|
}
|
|
|
|
function createCodexLoginOutputDecoder(session) {
|
|
const decoder = new StringDecoder("utf8");
|
|
let ended = false;
|
|
return {
|
|
write(chunk) {
|
|
if (ended) return;
|
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
appendCodexLoginOutput(session, decoder.write(buffer));
|
|
},
|
|
end() {
|
|
if (ended) return;
|
|
ended = true;
|
|
appendCodexLoginOutput(session, decoder.end());
|
|
},
|
|
};
|
|
}
|
|
|
|
function pruneCodexLoginSessions() {
|
|
const terminalSessionIds = [];
|
|
for (const [sessionId, session] of codexLoginSessions) {
|
|
if (session?.state !== "running" && !session?.process) terminalSessionIds.push(sessionId);
|
|
}
|
|
const excess = terminalSessionIds.length - MAX_CODEX_LOGIN_TERMINAL_SESSIONS;
|
|
for (let index = 0; index < excess; index += 1) {
|
|
codexLoginSessions.delete(terminalSessionIds[index]);
|
|
}
|
|
}
|
|
|
|
function clearCodexLoginKillTimer(session, clearTimeoutFn = clearTimeout) {
|
|
if (!session?.killTimer) return;
|
|
clearTimeoutFn(session.killTimer);
|
|
session.killTimer = null;
|
|
}
|
|
|
|
function stopCodexLoginProcess(session, {
|
|
setTimeoutFn = setTimeout,
|
|
clearTimeoutFn = clearTimeout,
|
|
} = {}) {
|
|
const child = session?.process;
|
|
if (!child) return false;
|
|
clearCodexLoginKillTimer(session, clearTimeoutFn);
|
|
session.killTimer = setTimeoutFn(() => {
|
|
session.killTimer = null;
|
|
if (session.process !== child) return;
|
|
try { child.kill("SIGKILL"); } catch {}
|
|
}, CODEX_LOGIN_KILL_GRACE_MS);
|
|
session.killTimer?.unref?.();
|
|
try { child.kill("SIGTERM"); } catch {}
|
|
return true;
|
|
}
|
|
|
|
function recordCodexLoginSession(session) {
|
|
if (!session?.id) return;
|
|
codexLoginSessions.delete(session.id);
|
|
codexLoginSessions.set(session.id, session);
|
|
pruneCodexLoginSessions();
|
|
}
|
|
|
|
function toCodexLoginSessionResponse(session) {
|
|
return {
|
|
sessionId: session.id,
|
|
state: session.state,
|
|
url: session.url,
|
|
output: session.output,
|
|
error: session.error,
|
|
exitCode: session.exitCode,
|
|
codexPath: session.codexPath || null,
|
|
};
|
|
}
|
|
|
|
function getActiveCodexLoginSession() {
|
|
for (const session of codexLoginSessions.values()) {
|
|
if (session.state === "running" && session.process && !session.process.killed) {
|
|
return session;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ── Codex config.toml probing ──
|
|
//
|
|
// Users who hand-configure `~/.codex/config.toml` with a custom
|
|
// `model_provider` + matching `[model_providers.<name>]` entry are fully
|
|
// functional from the Codex CLI, but `codex login status` doesn't see them
|
|
// because it only reports on `~/.codex/auth.json` (populated by `codex login`).
|
|
// We read and minimally parse the config file so we can surface this as a
|
|
// valid "ready" state and skip the ChatGPT login prompt in the UI.
|
|
|
|
/** Find `#` outside quoted regions. Tracks escape state via a flag rather
|
|
* than peeking at the previous character, so even runs of backslashes like
|
|
* `"C:\\path\\"` close the string correctly. Literal (single-quoted) TOML
|
|
* strings don't recognize `\` as an escape, so only honor escapes inside
|
|
* basic (double-quoted) strings. */
|
|
function findUnquotedHash(value) {
|
|
let inStr = false;
|
|
let quote = "";
|
|
let escaped = false;
|
|
for (let i = 0; i < value.length; i++) {
|
|
const ch = value[i];
|
|
if (inStr) {
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (quote === '"' && ch === "\\") {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (ch === quote) {
|
|
inStr = false;
|
|
quote = "";
|
|
}
|
|
continue;
|
|
}
|
|
if (ch === '"' || ch === "'") {
|
|
inStr = true;
|
|
quote = ch;
|
|
continue;
|
|
}
|
|
if (ch === "#") return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* Parse the narrow subset of TOML we need from Codex's config.toml:
|
|
* - top-level string keys (e.g. `model_provider = "my_provider"`)
|
|
* - `[model_providers.<name>]` tables with string-valued keys
|
|
* Unsupported TOML features (arrays, inline tables, multi-line strings, etc.)
|
|
* are ignored — Codex's config.toml doesn't use them for provider definitions.
|
|
*/
|
|
function parseCodexConfigToml(text) {
|
|
const result = { model_providers: {} };
|
|
let currentProvider = null;
|
|
let atTopLevel = true;
|
|
|
|
// Strip UTF-8 BOM so the first key still matches the regex on Windows-edited files.
|
|
const normalized = String(text || "").replace(/^\uFEFF/, "");
|
|
const lines = normalized.split(/\r?\n/);
|
|
for (const rawLine of lines) {
|
|
let line = rawLine;
|
|
const hashIdx = findUnquotedHash(line);
|
|
if (hashIdx >= 0) line = line.slice(0, hashIdx);
|
|
line = line.trim();
|
|
if (!line) continue;
|
|
|
|
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
|
|
if (sectionMatch) {
|
|
const section = sectionMatch[1].trim();
|
|
if (section.startsWith("model_providers.")) {
|
|
currentProvider = section.slice("model_providers.".length);
|
|
if (!result.model_providers[currentProvider]) {
|
|
result.model_providers[currentProvider] = {};
|
|
}
|
|
atTopLevel = false;
|
|
} else {
|
|
currentProvider = null;
|
|
atTopLevel = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const kvMatch = line.match(/^([A-Za-z_][\w.-]*)\s*=\s*(.+)$/);
|
|
if (!kvMatch) continue;
|
|
const key = kvMatch[1];
|
|
let raw = kvMatch[2].trim();
|
|
let value;
|
|
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
|
|
value = raw.slice(1, -1);
|
|
} else {
|
|
value = raw;
|
|
}
|
|
|
|
if (atTopLevel) {
|
|
result[key] = value;
|
|
} else if (currentProvider) {
|
|
result.model_providers[currentProvider][key] = value;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Inspect `~/.codex/config.toml` to determine whether the user has
|
|
* configured a custom `model_provider` that isn't the built-in OpenAI/ChatGPT
|
|
* path.
|
|
*
|
|
* Returns null when:
|
|
* - the config file doesn't exist or can't be read
|
|
* - no `model_provider` is set, or it points to the default `openai` preset
|
|
* - the referenced provider entry is missing (config is malformed)
|
|
*
|
|
* Returns a summary object otherwise — even if the env_key isn't currently
|
|
* exported in the shell environment. That case is surfaced via
|
|
* `envKeyPresent: false` so the UI can warn the user; we don't want the
|
|
* absence of an env var to silently fall back to the ChatGPT login flow,
|
|
* because the config.toml is a strong signal the user doesn't want that.
|
|
*/
|
|
function readCodexCustomProviderConfig(shellEnv) {
|
|
const home = shellEnv?.HOME || shellEnv?.USERPROFILE || os.homedir();
|
|
if (!home) return null;
|
|
const configPath = path.join(home, ".codex", "config.toml");
|
|
if (!existsSync(configPath)) return null;
|
|
|
|
let text;
|
|
try {
|
|
text = readFileSync(configPath, "utf8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = parseCodexConfigToml(text);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const activeName = typeof parsed.model_provider === "string"
|
|
? parsed.model_provider.trim()
|
|
: "";
|
|
if (!activeName) return null;
|
|
// The built-in "openai" provider still goes through ChatGPT/API-key auth
|
|
// managed by `codex login`, so treating it as "custom" would be wrong.
|
|
if (activeName === "openai") return null;
|
|
|
|
const providerEntry = parsed.model_providers?.[activeName];
|
|
if (!providerEntry) return null;
|
|
|
|
const envKeyName = typeof providerEntry.env_key === "string" ? providerEntry.env_key.trim() : "";
|
|
const envKeyValue = envKeyName && shellEnv ? String(shellEnv[envKeyName] || "").trim() : "";
|
|
const hardcodedApiKey = typeof providerEntry.api_key === "string" ? providerEntry.api_key.trim() : "";
|
|
const activeModel = typeof parsed.model === "string" ? parsed.model.trim() : "";
|
|
|
|
// Hash the actual auth material (either the hardcoded api_key or the
|
|
// resolved env_key value) so the SDK backend fingerprint changes when
|
|
// the user rotates their key — without ever returning the raw value
|
|
// across the IPC boundary.
|
|
const authMaterial = hardcodedApiKey || envKeyValue;
|
|
const authHash = authMaterial
|
|
? createHash("sha256").update(authMaterial).digest("hex")
|
|
: null;
|
|
|
|
return {
|
|
providerName: activeName,
|
|
displayName: providerEntry.name || activeName,
|
|
baseUrl: providerEntry.base_url || null,
|
|
envKey: envKeyName || null,
|
|
envKeyPresent: Boolean(envKeyValue),
|
|
hasHardcodedApiKey: Boolean(hardcodedApiKey),
|
|
model: activeModel || null,
|
|
authHash,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns a user-facing error message when a Codex config.toml custom
|
|
* provider references an env_key that isn't exported in the shell env and
|
|
* doesn't have a hardcoded api_key either — otherwise returns null. Shared
|
|
* by every spawn path (stream handler, list-models handler) so users get
|
|
* the same actionable message regardless of which one hits first.
|
|
*/
|
|
function getCodexCustomConfigPreflightError(customConfig) {
|
|
if (!customConfig) return null;
|
|
if (!customConfig.envKey) return null;
|
|
if (customConfig.envKeyPresent || customConfig.hasHardcodedApiKey) return null;
|
|
return `Codex is configured to use the "${customConfig.displayName}" provider from ~/.codex/config.toml, but the environment variable ${customConfig.envKey} is not set. Export it in your shell (e.g. add to ~/.zshrc) and click "Refresh Status" in Settings.`;
|
|
}
|
|
|
|
// ── Integration state ──
|
|
|
|
function normalizeCodexIntegrationState(rawOutput) {
|
|
const normalizedOutput = String(rawOutput || "").toLowerCase();
|
|
|
|
if (normalizedOutput.includes("logged in using chatgpt")) {
|
|
return "connected_chatgpt";
|
|
}
|
|
if (
|
|
normalizedOutput.includes("logged in using an api key") ||
|
|
normalizedOutput.includes("logged in using api key")
|
|
) {
|
|
return "connected_api_key";
|
|
}
|
|
if (normalizedOutput.includes("not logged in")) {
|
|
return "not_logged_in";
|
|
}
|
|
return "unknown";
|
|
}
|
|
|
|
function appendCodexChatGptValidationFailure(rawOutput, validationError) {
|
|
return [
|
|
String(rawOutput || "").trim(),
|
|
"",
|
|
"ChatGPT auth validation failed:",
|
|
validationError || "Unknown validation error",
|
|
].join("\n").trim();
|
|
}
|
|
|
|
// ── Error helpers ──
|
|
|
|
function safeJsonStringify(value) {
|
|
const seen = new WeakSet();
|
|
try {
|
|
return JSON.stringify(value, (_key, nestedValue) => {
|
|
if (typeof nestedValue !== "object" || nestedValue === null) {
|
|
return nestedValue;
|
|
}
|
|
if (seen.has(nestedValue)) {
|
|
return "[Circular]";
|
|
}
|
|
seen.add(nestedValue);
|
|
return nestedValue;
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function stringifyErrorValue(value, seen = new WeakSet()) {
|
|
if (value == null) return "";
|
|
if (typeof value === "string") return value;
|
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
if (value instanceof Error) return value.message || value.name || String(value);
|
|
if (typeof value !== "object") return String(value);
|
|
if (seen.has(value)) return "[Circular error]";
|
|
seen.add(value);
|
|
|
|
const candidates = [
|
|
value?.data?.message,
|
|
value?.data?.error,
|
|
value?.errorText,
|
|
value?.message,
|
|
value?.error,
|
|
value?.cause,
|
|
value?.data,
|
|
];
|
|
for (const candidate of candidates) {
|
|
const message = stringifyErrorValue(candidate, seen).trim();
|
|
if (message && message !== "{}") {
|
|
return message;
|
|
}
|
|
}
|
|
|
|
return safeJsonStringify(value) || String(value);
|
|
}
|
|
|
|
function extractCodexError(error) {
|
|
const message = stringifyErrorValue(error) || "Unknown Codex error";
|
|
const code = error?.data?.code || error?.code || error?.error?.code || error?.data?.error?.code;
|
|
return {
|
|
message,
|
|
code: typeof code === "string" ? code : undefined,
|
|
};
|
|
}
|
|
|
|
function isCodexAuthError(params) {
|
|
const searchableText = `${params?.code || ""} ${params?.message || ""} ${params?.error || ""}`.toLowerCase();
|
|
return CODEX_AUTH_HINTS.some((hint) => searchableText.includes(hint));
|
|
}
|
|
|
|
// ── Fingerprints ──
|
|
|
|
function getCodexAuthFingerprint(apiKey) {
|
|
const normalized = String(apiKey || "").trim();
|
|
if (!normalized) return null;
|
|
return createHash("sha256").update(normalized).digest("hex");
|
|
}
|
|
|
|
function getCodexMcpFingerprint(mcpServers) {
|
|
return createHash("sha256").update(JSON.stringify(mcpServers || [])).digest("hex");
|
|
}
|
|
|
|
// ── Validation cache ──
|
|
|
|
function invalidateCodexValidationCache() {
|
|
codexValidationCache = null;
|
|
}
|
|
|
|
function getCodexValidationCache() {
|
|
return codexValidationCache;
|
|
}
|
|
|
|
function setCodexValidationCache(value) {
|
|
codexValidationCache = value;
|
|
}
|
|
|
|
module.exports = {
|
|
MAX_CODEX_LOGIN_OUTPUT_BYTES,
|
|
MAX_CODEX_LOGIN_OUTPUT_CHARS,
|
|
MAX_CODEX_LOGIN_TERMINAL_SESSIONS,
|
|
CODEX_LOGIN_KILL_GRACE_MS,
|
|
codexLoginSessions,
|
|
appendCodexLoginOutput,
|
|
createCodexLoginOutputDecoder,
|
|
pruneCodexLoginSessions,
|
|
recordCodexLoginSession,
|
|
clearCodexLoginKillTimer,
|
|
stopCodexLoginProcess,
|
|
toCodexLoginSessionResponse,
|
|
getActiveCodexLoginSession,
|
|
normalizeCodexIntegrationState,
|
|
appendCodexChatGptValidationFailure,
|
|
readCodexCustomProviderConfig,
|
|
getCodexCustomConfigPreflightError,
|
|
extractCodexError,
|
|
isCodexAuthError,
|
|
getCodexAuthFingerprint,
|
|
getCodexMcpFingerprint,
|
|
invalidateCodexValidationCache,
|
|
getCodexValidationCache,
|
|
setCodexValidationCache,
|
|
};
|