Files
NetMesh/electron/bridges/ai/codexHelpers.cjs

478 lines
15 KiB
JavaScript
Raw Permalink Normal View History

/**
* 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,
};