417 lines
14 KiB
JavaScript
417 lines
14 KiB
JavaScript
|
|
/* eslint-disable no-undef */
|
||
|
|
const { StringDecoder } = require("node:string_decoder");
|
||
|
|
|
||
|
|
const DEFAULT_CODEX_CLI_TIMEOUT_MS = 10_000;
|
||
|
|
const CODEX_AUTH_VALIDATION_TIMEOUT_MS = 10_000;
|
||
|
|
const MAX_AGENT_CLI_BUFFER_CHARS = 10 * 1024 * 1024;
|
||
|
|
|
||
|
|
function createAgentCliHelpers(ctx) {
|
||
|
|
with (ctx) {
|
||
|
|
const codexAuthValidationInFlight = new Map();
|
||
|
|
async function runCommand(command, args, options) {
|
||
|
|
return await new Promise((resolve, reject) => {
|
||
|
|
let settled = false;
|
||
|
|
let closed = false;
|
||
|
|
let timeoutId = null;
|
||
|
|
let killId = null;
|
||
|
|
function clearTimers() {
|
||
|
|
if (timeoutId) {
|
||
|
|
clearTimeout(timeoutId);
|
||
|
|
timeoutId = null;
|
||
|
|
}
|
||
|
|
if (killId) {
|
||
|
|
clearTimeout(killId);
|
||
|
|
killId = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const spawnSpec = prepareCommandForSpawn(command, args || []);
|
||
|
|
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
||
|
|
stdio: ["ignore", "pipe", "pipe"],
|
||
|
|
cwd: options?.cwd || undefined,
|
||
|
|
env: options?.env || process.env,
|
||
|
|
shell: spawnSpec.shell,
|
||
|
|
windowsHide: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
let stdout = "";
|
||
|
|
let stderr = "";
|
||
|
|
let stdoutBytes = 0;
|
||
|
|
let stderrBytes = 0;
|
||
|
|
let stdoutTruncated = false;
|
||
|
|
let stderrTruncated = false;
|
||
|
|
const stdoutDecoder = new StringDecoder("utf8");
|
||
|
|
const stderrDecoder = new StringDecoder("utf8");
|
||
|
|
const timeoutMs = Number.isFinite(options?.timeoutMs) ? Number(options.timeoutMs) : 0;
|
||
|
|
|
||
|
|
child.stdout.on("data", (chunk) => {
|
||
|
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
||
|
|
const remaining = Math.max(0, MAX_AGENT_CLI_BUFFER_CHARS - stdoutBytes);
|
||
|
|
const accepted = buffer.length <= remaining ? buffer : buffer.subarray(0, remaining);
|
||
|
|
if (accepted.length > 0) stdout += stdoutDecoder.write(accepted);
|
||
|
|
stdoutBytes += accepted.length;
|
||
|
|
if (accepted.length < buffer.length) stdoutTruncated = true;
|
||
|
|
});
|
||
|
|
|
||
|
|
child.stderr.on("data", (chunk) => {
|
||
|
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
||
|
|
const remaining = Math.max(0, MAX_AGENT_CLI_BUFFER_CHARS - stderrBytes);
|
||
|
|
const accepted = buffer.length <= remaining ? buffer : buffer.subarray(0, remaining);
|
||
|
|
if (accepted.length > 0) stderr += stderrDecoder.write(accepted);
|
||
|
|
stderrBytes += accepted.length;
|
||
|
|
if (accepted.length < buffer.length) stderrTruncated = true;
|
||
|
|
});
|
||
|
|
|
||
|
|
child.once("error", (error) => {
|
||
|
|
closed = true;
|
||
|
|
if (settled) return;
|
||
|
|
settled = true;
|
||
|
|
clearTimers();
|
||
|
|
reject(error);
|
||
|
|
});
|
||
|
|
|
||
|
|
child.once("close", (exitCode) => {
|
||
|
|
closed = true;
|
||
|
|
clearTimers();
|
||
|
|
if (settled) return;
|
||
|
|
settled = true;
|
||
|
|
if (!stdoutTruncated || stdoutDecoder.lastNeed === 0) stdout += stdoutDecoder.end();
|
||
|
|
if (!stderrTruncated || stderrDecoder.lastNeed === 0) stderr += stderrDecoder.end();
|
||
|
|
resolve({
|
||
|
|
stdout: stripAnsi(stdout),
|
||
|
|
stderr: stripAnsi(stderr),
|
||
|
|
exitCode,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
if (timeoutMs > 0) {
|
||
|
|
timeoutId = setTimeout(() => {
|
||
|
|
if (settled) return;
|
||
|
|
settled = true;
|
||
|
|
const error = new Error(`Command timed out after ${timeoutMs}ms`);
|
||
|
|
error.code = "ETIMEDOUT";
|
||
|
|
try {
|
||
|
|
if (!closed) child.kill("SIGTERM");
|
||
|
|
} catch {}
|
||
|
|
killId = setTimeout(() => {
|
||
|
|
try {
|
||
|
|
if (!closed) child.kill("SIGKILL");
|
||
|
|
} catch {}
|
||
|
|
}, 750);
|
||
|
|
if (typeof killId.unref === "function") killId.unref();
|
||
|
|
reject(error);
|
||
|
|
}, timeoutMs);
|
||
|
|
if (typeof timeoutId.unref === "function") timeoutId.unref();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function getCommandOutput(result) {
|
||
|
|
return [result?.stdout, result?.stderr]
|
||
|
|
.filter((chunk) => typeof chunk === "string" && chunk.length > 0)
|
||
|
|
.join("\n")
|
||
|
|
.trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
function getFirstCommandOutputLine(result) {
|
||
|
|
return getCommandOutput(result).split(/\r?\n/)[0] || "";
|
||
|
|
}
|
||
|
|
|
||
|
|
async function probeCliVersion(probeCmd, probeArgs, env) {
|
||
|
|
try {
|
||
|
|
const result = await runCommand(probeCmd, probeArgs, { env, timeoutMs: 5000 });
|
||
|
|
return {
|
||
|
|
launched: true,
|
||
|
|
exitCode: result.exitCode,
|
||
|
|
output: getCommandOutput(result),
|
||
|
|
version: getFirstCommandOutputLine(result),
|
||
|
|
};
|
||
|
|
} catch {
|
||
|
|
return {
|
||
|
|
launched: false,
|
||
|
|
exitCode: null,
|
||
|
|
output: "",
|
||
|
|
version: "",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function runCodexCli(args, options) {
|
||
|
|
const shellEnv = await getShellEnv();
|
||
|
|
const requestedPath = String(options?.codexPath || "").trim();
|
||
|
|
const configuredPath = requestedPath ? normalizeCliPathForPlatform?.(requestedPath) : null;
|
||
|
|
if (requestedPath && !configuredPath) {
|
||
|
|
throw new Error(`Codex CLI path not found: ${requestedPath}`);
|
||
|
|
}
|
||
|
|
const codexCliPath = configuredPath || await resolveCliFromPathAsync("codex", shellEnv) || "codex";
|
||
|
|
return await runCommand(codexCliPath, args, {
|
||
|
|
cwd: options?.cwd?.trim() || undefined,
|
||
|
|
env: shellEnv,
|
||
|
|
timeoutMs: Number.isFinite(options?.timeoutMs)
|
||
|
|
? Number(options.timeoutMs)
|
||
|
|
: DEFAULT_CODEX_CLI_TIMEOUT_MS,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function runCodexCliChecked(args, options) {
|
||
|
|
const result = await runCodexCli(args, options);
|
||
|
|
if (result.exitCode === 0) {
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
const errorText =
|
||
|
|
result.stderr.trim() ||
|
||
|
|
result.stdout.trim() ||
|
||
|
|
`Codex command failed with exit code ${result.exitCode ?? "unknown"}`;
|
||
|
|
throw new Error(errorText);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function validateCodexChatGptAuth(options) {
|
||
|
|
const maxAgeMs = options?.maxAgeMs ?? 30000;
|
||
|
|
const now = Date.now();
|
||
|
|
const rawRequestedCodexPath = String(options?.codexPath || "").trim();
|
||
|
|
const requestedCodexPath = rawRequestedCodexPath ? normalizeCliPathForPlatform?.(rawRequestedCodexPath) : null;
|
||
|
|
if (rawRequestedCodexPath && !requestedCodexPath) {
|
||
|
|
const result = {
|
||
|
|
ok: false,
|
||
|
|
checkedAt: now,
|
||
|
|
codexPath: null,
|
||
|
|
error: `Codex CLI path not found: ${rawRequestedCodexPath}`,
|
||
|
|
code: "ENOENT",
|
||
|
|
};
|
||
|
|
setCodexValidationCache(result);
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
const cached = getCodexValidationCache();
|
||
|
|
if (cached && now - cached.checkedAt < maxAgeMs && (cached.codexPath || null) === requestedCodexPath) return cached;
|
||
|
|
const inFlightKey = requestedCodexPath || "__auto__";
|
||
|
|
const existingValidation = codexAuthValidationInFlight.get(inFlightKey);
|
||
|
|
if (existingValidation) return existingValidation;
|
||
|
|
|
||
|
|
const validationPromise = (async () => {
|
||
|
|
const shellEnv = await getShellEnv();
|
||
|
|
const rawCodexPath = requestedCodexPath || await resolveSdkBinPathAsync("codex", shellEnv);
|
||
|
|
const codexPath = rawCodexPath && typeof resolveCodexExecutableForSdk === "function"
|
||
|
|
? resolveCodexExecutableForSdk(rawCodexPath) || null
|
||
|
|
: rawCodexPath;
|
||
|
|
if (!codexPath) {
|
||
|
|
const result = { ok: false, checkedAt: now, codexPath: requestedCodexPath, error: "codex binary not found", code: "ENOENT" };
|
||
|
|
setCodexValidationCache(result);
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
const abortController = new AbortController();
|
||
|
|
let timeoutId = null;
|
||
|
|
let iterator = null;
|
||
|
|
try {
|
||
|
|
const timeoutPromise = new Promise((_, reject) => {
|
||
|
|
timeoutId = setTimeout(() => {
|
||
|
|
const error = new Error(
|
||
|
|
`Codex ChatGPT auth validation timed out after ${CODEX_AUTH_VALIDATION_TIMEOUT_MS}ms`,
|
||
|
|
);
|
||
|
|
error.code = "ETIMEDOUT";
|
||
|
|
try { abortController.abort(error); } catch {}
|
||
|
|
reject(error);
|
||
|
|
}, CODEX_AUTH_VALIDATION_TIMEOUT_MS);
|
||
|
|
if (typeof timeoutId?.unref === "function") timeoutId.unref();
|
||
|
|
});
|
||
|
|
|
||
|
|
const probePromise = (async () => {
|
||
|
|
// Minimal read-only probe turn through the SDK to confirm auth works.
|
||
|
|
const { Codex } = await (typeof loadCodexSdk === "function"
|
||
|
|
? loadCodexSdk()
|
||
|
|
: import("@openai/codex-sdk"));
|
||
|
|
const codexOptions = { env: addCodexExecutableEnvForSdk(shellEnv, codexPath) };
|
||
|
|
if (codexPath) codexOptions.codexPathOverride = codexPath;
|
||
|
|
const codex = new Codex(codexOptions);
|
||
|
|
const thread = codex.startThread({ skipGitRepoCheck: true });
|
||
|
|
const { events } = await thread.runStreamed("ping", {
|
||
|
|
sandbox: "read-only",
|
||
|
|
signal: abortController.signal,
|
||
|
|
});
|
||
|
|
iterator = events?.[Symbol.asyncIterator]?.();
|
||
|
|
if (!iterator) throw new Error("Codex auth validation returned no event stream");
|
||
|
|
let failed = null;
|
||
|
|
while (true) {
|
||
|
|
const next = await iterator.next();
|
||
|
|
if (next.done) break;
|
||
|
|
const event = next.value;
|
||
|
|
if (event?.type === "turn.failed") { failed = event.error; break; }
|
||
|
|
if (event?.type === "turn.completed") break;
|
||
|
|
if (event?.type === "item.completed") break;
|
||
|
|
}
|
||
|
|
if (failed) throw failed;
|
||
|
|
})();
|
||
|
|
|
||
|
|
await Promise.race([probePromise, timeoutPromise]);
|
||
|
|
const result = { ok: true, checkedAt: now, codexPath, error: null };
|
||
|
|
setCodexValidationCache(result);
|
||
|
|
return result;
|
||
|
|
} catch (error) {
|
||
|
|
const normalized = extractCodexError(error);
|
||
|
|
const result = { ok: false, checkedAt: now, codexPath, error: normalized.message, code: normalized.code };
|
||
|
|
setCodexValidationCache(result);
|
||
|
|
return result;
|
||
|
|
} finally {
|
||
|
|
if (timeoutId) clearTimeout(timeoutId);
|
||
|
|
try { abortController.abort(); } catch {}
|
||
|
|
try { void Promise.resolve(iterator?.return?.()).catch(() => {}); } catch {}
|
||
|
|
}
|
||
|
|
})();
|
||
|
|
|
||
|
|
codexAuthValidationInFlight.set(inFlightKey, validationPromise);
|
||
|
|
try {
|
||
|
|
return await validationPromise;
|
||
|
|
} finally {
|
||
|
|
if (codexAuthValidationInFlight.get(inFlightKey) === validationPromise) {
|
||
|
|
codexAuthValidationInFlight.delete(inFlightKey);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function objectToPairs(value) {
|
||
|
|
if (!value || typeof value !== "object") return [];
|
||
|
|
return Object.entries(value)
|
||
|
|
.filter(([name, val]) => typeof name === "string" && typeof val === "string")
|
||
|
|
.map(([name, val]) => ({ name, value: val }));
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveCodexStdioEnv(transport, shellEnv) {
|
||
|
|
const merged = {};
|
||
|
|
|
||
|
|
if (transport?.env && typeof transport.env === "object") {
|
||
|
|
for (const [name, value] of Object.entries(transport.env)) {
|
||
|
|
if (typeof name === "string" && typeof value === "string") {
|
||
|
|
merged[name] = value;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Array.isArray(transport?.env_vars)) {
|
||
|
|
for (const envName of transport.env_vars) {
|
||
|
|
const value = shellEnv[envName] || process.env[envName];
|
||
|
|
if (typeof value === "string" && value.length > 0 && !merged[envName]) {
|
||
|
|
merged[envName] = value;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return merged;
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveCodexHttpHeaders(transport, shellEnv) {
|
||
|
|
const merged = {};
|
||
|
|
|
||
|
|
if (transport?.http_headers && typeof transport.http_headers === "object") {
|
||
|
|
for (const [name, value] of Object.entries(transport.http_headers)) {
|
||
|
|
if (typeof name === "string" && typeof value === "string") {
|
||
|
|
merged[name] = value;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (transport?.env_http_headers && typeof transport.env_http_headers === "object") {
|
||
|
|
for (const [headerName, envName] of Object.entries(transport.env_http_headers)) {
|
||
|
|
if (typeof headerName !== "string" || typeof envName !== "string") continue;
|
||
|
|
const value = shellEnv[envName] || process.env[envName];
|
||
|
|
if (typeof value === "string" && value.length > 0) {
|
||
|
|
merged[headerName] = value;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const bearerEnvVar = typeof transport?.bearer_token_env_var === "string"
|
||
|
|
? transport.bearer_token_env_var.trim()
|
||
|
|
: "";
|
||
|
|
if (bearerEnvVar && !merged.Authorization) {
|
||
|
|
const token = shellEnv[bearerEnvVar] || process.env[bearerEnvVar];
|
||
|
|
if (typeof token === "string" && token.trim()) {
|
||
|
|
merged.Authorization = `Bearer ${token.trim()}`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return merged;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function resolveCodexMcpSnapshot(cwd) {
|
||
|
|
const empty = { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
|
||
|
|
|
||
|
|
try {
|
||
|
|
const result = await runCodexCliChecked(["mcp", "list", "--json"], {
|
||
|
|
cwd: cwd || undefined,
|
||
|
|
});
|
||
|
|
const parsed = JSON.parse(result.stdout);
|
||
|
|
if (!Array.isArray(parsed)) {
|
||
|
|
return empty;
|
||
|
|
}
|
||
|
|
|
||
|
|
const shellEnv = await getShellEnv();
|
||
|
|
const mcpServers = [];
|
||
|
|
|
||
|
|
for (const entry of parsed) {
|
||
|
|
if (!entry?.enabled || !entry?.transport || typeof entry?.name !== "string") {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const transportType = String(entry.transport.type || "").trim().toLowerCase();
|
||
|
|
|
||
|
|
if (transportType === "stdio") {
|
||
|
|
const command = String(entry.transport.command || "").trim();
|
||
|
|
if (!command) continue;
|
||
|
|
mcpServers.push({
|
||
|
|
name: entry.name,
|
||
|
|
type: "stdio",
|
||
|
|
command,
|
||
|
|
args: Array.isArray(entry.transport.args)
|
||
|
|
? entry.transport.args.filter((arg) => typeof arg === "string")
|
||
|
|
: [],
|
||
|
|
env: objectToPairs(resolveCodexStdioEnv(entry.transport, shellEnv)),
|
||
|
|
});
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (transportType === "streamable_http" || transportType === "http" || transportType === "sse") {
|
||
|
|
const url = String(entry.transport.url || "").trim();
|
||
|
|
if (!url) continue;
|
||
|
|
mcpServers.push({
|
||
|
|
name: entry.name,
|
||
|
|
type: "http",
|
||
|
|
url,
|
||
|
|
headers: objectToPairs(resolveCodexHttpHeaders(entry.transport, shellEnv)),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
mcpServers,
|
||
|
|
fingerprint: getCodexMcpFingerprint(mcpServers),
|
||
|
|
};
|
||
|
|
} catch (err) {
|
||
|
|
console.error("[Codex] Failed to resolve MCP servers:", err?.message || err);
|
||
|
|
return empty;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
return {
|
||
|
|
runCommand,
|
||
|
|
getCommandOutput,
|
||
|
|
getFirstCommandOutputLine,
|
||
|
|
probeCliVersion,
|
||
|
|
runCodexCli,
|
||
|
|
runCodexCliChecked,
|
||
|
|
validateCodexChatGptAuth,
|
||
|
|
objectToPairs,
|
||
|
|
resolveCodexStdioEnv,
|
||
|
|
resolveCodexHttpHeaders,
|
||
|
|
resolveCodexMcpSnapshot,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
createAgentCliHelpers,
|
||
|
|
CODEX_AUTH_VALIDATION_TIMEOUT_MS,
|
||
|
|
DEFAULT_CODEX_CLI_TIMEOUT_MS,
|
||
|
|
MAX_AGENT_CLI_BUFFER_CHARS,
|
||
|
|
};
|