Files
NetMesh/electron/bridges/aiBridge/agentAuthProbes.cjs

296 lines
10 KiB
JavaScript
Raw Normal View History

"use strict";
/**
* Layer-3 (authentication) CLI probes for the managed backends.
* Each probe is dependency-injected (runners / fileExists) for unit testing;
* the discovery handler wires the real implementations.
*
* Returns: { authenticated: boolean, authSource: string|null }
*/
const { existsSync, readFileSync } = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { resolveCursorCliSpawnSpec } = require("./cursorCliSpawn.cjs");
function defaultFileExists(p) {
try { return existsSync(p); } catch { return false; }
}
function defaultReadFile(p) {
try { return readFileSync(p, "utf-8"); } catch { return null; }
}
// ── Claude ──
function defaultRunSecurity() {
// macOS keychain lookup for the Claude Code OAuth credentials entry.
try {
const stdout = execFileSync(
"security",
["find-generic-password", "-s", "Claude Code-credentials", "-w"],
{ encoding: "utf8", timeout: 4000, stdio: ["pipe", "pipe", "pipe"] },
);
return { exitCode: 0, stdout };
} catch (err) {
return { exitCode: err?.status ?? 1, stdout: "" };
}
}
function probeClaudeAuth({ env, platform, runSecurity, fileExists, homeDir } = {}) {
const e = env || process.env;
const plat = platform || process.platform;
const fx = fileExists || defaultFileExists;
const home = homeDir || os.homedir();
const apiKey = typeof e.ANTHROPIC_API_KEY === "string" ? e.ANTHROPIC_API_KEY.trim() : "";
const oauthToken = typeof e.CLAUDE_CODE_OAUTH_TOKEN === "string" ? e.CLAUDE_CODE_OAUTH_TOKEN.trim() : "";
const authToken = typeof e.ANTHROPIC_AUTH_TOKEN === "string" ? e.ANTHROPIC_AUTH_TOKEN.trim() : "";
if (apiKey || oauthToken || authToken) return { authenticated: true, authSource: "env" };
if (plat === "darwin") {
const sec = (runSecurity || defaultRunSecurity)();
if (sec && sec.exitCode === 0 && String(sec.stdout || "").trim()) {
return { authenticated: true, authSource: "keychain" };
}
}
const configDir = typeof e.CLAUDE_CONFIG_DIR === "string" && e.CLAUDE_CONFIG_DIR.trim()
? e.CLAUDE_CONFIG_DIR.trim()
: path.join(home, ".claude");
if (fx(path.join(configDir, ".credentials.json"))) {
return { authenticated: true, authSource: "credentials-file" };
}
return { authenticated: false, authSource: null };
}
// ── Copilot ──
function defaultRunGhAuthStatus() {
try {
const out = execFileSync("gh", ["auth", "status"], {
encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
});
return { exitCode: 0, stdout: out, stderr: "" };
} catch (err) {
return { exitCode: err?.status ?? 1, stdout: "", stderr: String(err?.stderr || err?.message || "") };
}
}
function probeCopilotAuth({ runGhAuthStatus } = {}) {
const res = (runGhAuthStatus || defaultRunGhAuthStatus)();
if (res && res.exitCode === 0) return { authenticated: true, authSource: "gh" };
return { authenticated: false, authSource: null };
}
// ── Codex ──
function probeCodexAuth({ runLoginStatus, fileExists, homeDir } = {}) {
const fx = fileExists || defaultFileExists;
const home = homeDir || os.homedir();
const res = runLoginStatus ? runLoginStatus() : { exitCode: 1, stdout: "" };
const out = String((res && (res.stdout || res.stderr)) || "").toLowerCase();
if (out.includes("logged in using chatgpt")) return { authenticated: true, authSource: "chatgpt" };
if (out.includes("logged in using an api key") || out.includes("logged in using api key")) {
return { authenticated: true, authSource: "api-key" };
}
if (fx(path.join(home, ".codex", "auth.json"))) {
return { authenticated: true, authSource: "auth-file" };
}
return { authenticated: false, authSource: null };
}
// ── CodeBuddy ──
// SDK supports CODEBUDDY_API_KEY, CODEBUDDY_AUTH_TOKEN (OAuth), and CLI login
// state (~/.codebuddy/settings.json with authToken/apiKeyHelper).
function probeCodebuddyAuth({ env, fileExists, readFile, homeDir } = {}) {
const e = env || process.env;
const fx = fileExists || defaultFileExists;
const rf = readFile || defaultReadFile;
const home = homeDir || os.homedir();
const apiKey = typeof e.CODEBUDDY_API_KEY === "string" ? e.CODEBUDDY_API_KEY.trim() : "";
const authToken = typeof e.CODEBUDDY_AUTH_TOKEN === "string" ? e.CODEBUDDY_AUTH_TOKEN.trim() : "";
if (apiKey) return { authenticated: true, authSource: "api-key" };
if (authToken) return { authenticated: true, authSource: "auth-token" };
// Check CLI login state in settings.json (authToken / apiKeyHelper fields).
const settingsPath = path.join(home, ".codebuddy", "settings.json");
const content = rf(settingsPath);
if (content !== null) {
try {
const parsed = JSON.parse(content);
if (parsed && typeof parsed === "object") {
if (typeof parsed.authToken === "string" && parsed.authToken.trim()) {
return { authenticated: true, authSource: "settings-file" };
}
if (typeof parsed.apiKeyHelper === "string" && parsed.apiKeyHelper.trim()) {
return { authenticated: true, authSource: "settings-file" };
}
}
} catch { /* Malformed JSON — treat as no auth */ }
}
return { authenticated: false, authSource: null };
}
// ── Cursor CLI login (cursor-agent only) ──
// Do not probe bare `agent` — it collides with other CLIs on PATH (e.g. Grok).
const CURSOR_CLI_BINARY_CANDIDATES = ["cursor-agent"];
function stripCursorApiKeyFromProbeEnv(env) {
const out = { ...(env || {}) };
delete out.CURSOR_API_KEY;
return out;
}
function defaultResolveCursorCliBinary(name, env) {
try {
const whichCmd = process.platform === "win32" ? "where" : "which";
const out = execFileSync(whichCmd, [name], {
encoding: "utf8",
timeout: 4000,
env: env || process.env,
stdio: ["pipe", "pipe", "pipe"],
});
const first = String(out || "").split(/\r?\n/).map((l) => l.trim()).find(Boolean);
return first || null;
} catch {
return null;
}
}
function defaultRunCursorStatus(binPath, env) {
const spec = resolveCursorCliSpawnSpec(binPath, ["status", "--format", "json"]);
try {
const stdout = execFileSync(spec.command, spec.args, {
encoding: "utf8",
timeout: 8000,
env: env || process.env,
stdio: ["pipe", "pipe", "pipe"],
shell: spec.shell,
windowsHide: true,
});
return { exitCode: 0, stdout: String(stdout || ""), stderr: "" };
} catch (err) {
return {
exitCode: err?.status ?? 1,
stdout: String(err?.stdout || ""),
stderr: String(err?.stderr || err?.message || ""),
};
}
}
function extractFirstJsonObject(text) {
const raw = String(text || "").replace(/^\uFEFF/, "").trim();
if (!raw) return null;
const candidates = [raw];
const start = raw.indexOf("{");
const end = raw.lastIndexOf("}");
if (start >= 0 && end > start) {
const sliced = raw.slice(start, end + 1);
if (sliced !== raw) candidates.push(sliced);
}
for (const candidate of candidates) {
try {
const parsed = JSON.parse(candidate);
if (parsed && typeof parsed === "object") return parsed;
} catch { /* try next */ }
}
return null;
}
function parseCursorStatusJson(stdout) {
const parsed = extractFirstJsonObject(stdout);
if (!parsed) return null;
// Real Cursor status always exposes isAuthenticated and/or status.
// Reject unrelated CLIs that accept unknown flags or emit other JSON.
if (typeof parsed.isAuthenticated !== "boolean" && typeof parsed.status !== "string") {
return null;
}
return parsed;
}
/**
* Probe local Cursor Agent CLI login (subscription session).
* Resolves only `cursor-agent` (not bare `agent`) to avoid PATH collisions.
* Strips CURSOR_API_KEY so "cli-login" is not proven by a metered API key alone.
*
* @returns {{ authenticated: boolean, authSource: string|null, email: string|null, binPath: string|null }}
*/
function probeCursorCliAuth({ env, resolveBinary, runStatus } = {}) {
const e = stripCursorApiKeyFromProbeEnv(env || process.env);
const resolve = resolveBinary || ((name) => defaultResolveCursorCliBinary(name, e));
const run = runStatus || ((bin) => defaultRunCursorStatus(bin, e));
// A resolved cursor-agent path is a user install even when status JSON is
// missing, unrecognized, or the status command throws.
let resolvedBinPath = null;
for (const name of CURSOR_CLI_BINARY_CANDIDATES) {
let binPath = null;
try {
binPath = resolve(name);
} catch {
continue;
}
if (!binPath) continue;
if (!resolvedBinPath) resolvedBinPath = binPath;
let res = null;
try {
res = run(binPath);
} catch {
continue;
}
if (!res) continue;
// Accept JSON even on non-zero exit if present (some CLIs exit 1 when logged out).
const parsed = parseCursorStatusJson(res.stdout);
if (!parsed) continue;
const authenticated = Boolean(
parsed.isAuthenticated === true || parsed.status === "authenticated",
);
if (!authenticated) continue;
const email = typeof parsed?.userInfo?.email === "string" ? parsed.userInfo.email : null;
return { authenticated: true, authSource: "cli-login", email, binPath };
}
return {
authenticated: false,
authSource: null,
email: null,
binPath: resolvedBinPath,
};
}
// ── Grok Build ──
function probeGrokAuth({ env, fileExists, homeDir } = {}) {
const e = env || process.env;
const fx = fileExists || defaultFileExists;
const home = homeDir || os.homedir();
const apiKey = typeof e.XAI_API_KEY === "string" ? e.XAI_API_KEY.trim() : "";
if (apiKey) return { authenticated: true, authSource: "env" };
// OAuth / account login persists under ~/.grok/auth.json (or GROK_CONFIG_DIR).
const configDir = typeof e.GROK_CONFIG_DIR === "string" && e.GROK_CONFIG_DIR.trim()
? e.GROK_CONFIG_DIR.trim()
: path.join(home, ".grok");
if (fx(path.join(configDir, "auth.json"))) {
return { authenticated: true, authSource: "auth-file" };
}
return { authenticated: false, authSource: null };
}
module.exports = {
probeClaudeAuth,
probeCopilotAuth,
probeCodexAuth,
probeCodebuddyAuth,
probeCursorCliAuth,
probeGrokAuth,
CURSOR_CLI_BINARY_CANDIDATES,
defaultRunSecurity,
defaultRunGhAuthStatus,
parseCursorStatusJson,
resolveCursorCliSpawnSpec,
};