[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
416
electron/bridges/aiBridge/agentCliHelpers.cjs
Normal file
416
electron/bridges/aiBridge/agentCliHelpers.cjs
Normal file
@@ -0,0 +1,416 @@
|
||||
/* 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,
|
||||
};
|
||||
Reference in New Issue
Block a user